Phase 14.5 구현: 회의 모드 고도화 — 다중 문서 생성/편집/내보내기
PLAUD 수준의 기능 확장: - 전사 편집: 인라인 편집 + 원본 보존 + 수정됨 표시(점선 밑줄) - 다중 문서 생성: 회의록/보고서/아이디어노트/커스텀 템플릿 - MD 에디터: 렌더링/편집 토글 (react-markdown + remark-gfm) - 상세 페이지 리디자인: 풀스크린 탭 전환 구조 - 내보내기: MD, PDF, TXT, DOCX 4종 (docx 패키지) - 커스텀 프롬프트 템플릿 저장/재사용 (electron-store) - SSOT: 마크다운 파싱 유틸 추출, 후처리에서 자동 문서 생성 제거 DB: meeting_documents 테이블 + edited_transcript 컬럼 IPC: 8+4 채널, 서비스 2개(MeetingModeService 확장 + MeetingDocTemplateService 신규) UI: 8개 신규 컴포넌트 (meeting/ 디렉토리), 12개 locale i18n
This commit is contained in:
parent
6197ceb132
commit
d4928ffa60
38 changed files with 4791 additions and 438 deletions
|
|
@ -299,6 +299,9 @@ async function initMeetingMode(): Promise<void> {
|
|||
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
|
||||
const { getMeetingModeService } = await import('./services/MeetingModeService')
|
||||
getMeetingModeService()
|
||||
// MeetingDocTemplateService 싱글톤 초기화 (빌트인 템플릿 보장)
|
||||
const { getMeetingDocTemplateService } = await import('./services/MeetingDocTemplateService')
|
||||
getMeetingDocTemplateService()
|
||||
}
|
||||
|
||||
// ── HistoryPopup IPC 연동 ────────────────────────────
|
||||
|
|
|
|||
|
|
@ -163,6 +163,34 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
|
|||
CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
|
||||
`)
|
||||
|
||||
// Phase 14.5: meeting_documents 테이블 + edited_transcript 컬럼
|
||||
sqlite.exec(`
|
||||
CREATE TABLE IF NOT EXISTS meeting_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
template_type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
prompt_used TEXT,
|
||||
llm_model TEXT,
|
||||
llm_latency_ms INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_meeting_documents_session_id ON meeting_documents(session_id);
|
||||
`)
|
||||
|
||||
// Phase 14.5: meeting_sessions에 edited_transcript 컬럼 마이그레이션
|
||||
try {
|
||||
const msCols = sqlite.pragma('table_info(meeting_sessions)') as Array<{ name: string }>
|
||||
if (!msCols.some((c) => c.name === 'edited_transcript')) {
|
||||
sqlite.exec('ALTER TABLE meeting_sessions ADD COLUMN edited_transcript TEXT')
|
||||
logger.info('Migrated: added edited_transcript column to meeting_sessions')
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('edited_transcript migration check failed:', err)
|
||||
}
|
||||
|
||||
// Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션
|
||||
try {
|
||||
const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }>
|
||||
|
|
|
|||
|
|
@ -165,6 +165,7 @@ export const meetingSessions = sqliteTable(
|
|||
endedAt: integer('ended_at'),
|
||||
durationMs: integer('duration_ms'),
|
||||
rawTranscript: text('raw_transcript'),
|
||||
editedTranscript: text('edited_transcript'),
|
||||
minutesMarkdown: text('minutes_markdown'),
|
||||
minutesJson: text('minutes_json'),
|
||||
sttModel: text('stt_model'),
|
||||
|
|
@ -202,6 +203,31 @@ export type NewMeetingSessionRow = typeof meetingSessions.$inferInsert
|
|||
export type MeetingMemoRow = typeof meetingMemos.$inferSelect
|
||||
export type NewMeetingMemoRow = typeof meetingMemos.$inferInsert
|
||||
|
||||
// ── meetingDocuments (Phase 14.5) ───────────────────────
|
||||
export const meetingDocuments = sqliteTable(
|
||||
'meeting_documents',
|
||||
{
|
||||
id: text('id').primaryKey(),
|
||||
sessionId: text('session_id').notNull(),
|
||||
templateType: text('template_type', {
|
||||
enum: ['minutes', 'report', 'idea-note', 'custom'],
|
||||
}).notNull(),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull().default(''),
|
||||
promptUsed: text('prompt_used'),
|
||||
llmModel: text('llm_model'),
|
||||
llmLatencyMs: integer('llm_latency_ms'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
updatedAt: integer('updated_at').notNull(),
|
||||
},
|
||||
(table) => [
|
||||
index('idx_meeting_documents_session_id').on(table.sessionId),
|
||||
]
|
||||
)
|
||||
|
||||
export type MeetingDocumentRow = typeof meetingDocuments.$inferSelect
|
||||
export type NewMeetingDocumentRow = typeof meetingDocuments.$inferInsert
|
||||
|
||||
// ── 타입 추출 ────────────────────────────────────────────
|
||||
export type History = typeof history.$inferSelect
|
||||
export type NewHistory = typeof history.$inferInsert
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import { registerVoiceConversationHandlers } from './voice-conversation-handlers
|
|||
import { registerRAGHandlers } from './rag-handlers'
|
||||
import { registerVoiceActionHandlers } from './voice-action-handlers'
|
||||
import { registerMeetingModeHandlers } from './meeting-mode-handlers'
|
||||
import { registerMeetingDocTemplateHandlers } from './meeting-doc-template-handlers'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
|
||||
const logger = getLogger('ipc')
|
||||
|
|
@ -53,5 +54,6 @@ export function registerAllIpcHandlers(): void {
|
|||
registerRAGHandlers()
|
||||
registerVoiceActionHandlers()
|
||||
registerMeetingModeHandlers()
|
||||
registerMeetingDocTemplateHandlers()
|
||||
logger.info('All IPC handlers registered')
|
||||
}
|
||||
|
|
|
|||
63
src/main/ipc/meeting-doc-template-handlers.ts
Normal file
63
src/main/ipc/meeting-doc-template-handlers.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// src/main/ipc/meeting-doc-template-handlers.ts
|
||||
// Phase 14.5: Meeting Document Template IPC 핸들러
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
import { ErrorCode, ipcSuccess, ipcError } from '@shared/errors'
|
||||
import { getMeetingDocTemplateService } from '../services/MeetingDocTemplateService'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type {
|
||||
CreateMeetingDocTemplateParams,
|
||||
UpdateMeetingDocTemplateParams,
|
||||
DeleteMeetingDocTemplateParams,
|
||||
} from '@shared/types'
|
||||
|
||||
const logger = getLogger('meeting-doc-template-handlers')
|
||||
|
||||
export function registerMeetingDocTemplateHandlers(): void {
|
||||
const ch = IPC_CHANNELS.MEETING_DOC_TEMPLATE
|
||||
|
||||
ipcMain.handle(ch.GET_ALL, () => {
|
||||
try {
|
||||
return ipcSuccess(getMeetingDocTemplateService().getAll())
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.CREATE, (_event, params: CreateMeetingDocTemplateParams) => {
|
||||
try {
|
||||
const result = getMeetingDocTemplateService().create(params)
|
||||
return ipcSuccess(result)
|
||||
} catch (err) {
|
||||
logger.error(`meetingDocTemplate create 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.UPDATE, (_event, params: UpdateMeetingDocTemplateParams) => {
|
||||
try {
|
||||
const result = getMeetingDocTemplateService().update(params)
|
||||
return ipcSuccess(result)
|
||||
} catch (err) {
|
||||
return ipcError(
|
||||
ErrorCode.MeetingDocTemplateNotFound,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.DELETE, (_event, params: DeleteMeetingDocTemplateParams) => {
|
||||
try {
|
||||
getMeetingDocTemplateService().delete(params.id)
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(
|
||||
ErrorCode.MeetingDocTemplateNotFound,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Meeting Doc Template IPC handlers registered')
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// src/main/ipc/meeting-mode-handlers.ts
|
||||
// Phase 14: Meeting Mode IPC 핸들러
|
||||
// Phase 14 / 14.5: Meeting Mode IPC 핸들러
|
||||
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
|
|
@ -12,6 +12,8 @@ import type {
|
|||
MeetingGetSessionParams,
|
||||
MeetingDeleteSessionParams,
|
||||
MeetingExportParams,
|
||||
MeetingGenerateDocParams,
|
||||
MeetingExportFormat,
|
||||
} from '@shared/types'
|
||||
|
||||
const logger = getLogger('meeting-mode-handlers')
|
||||
|
|
@ -111,5 +113,90 @@ export function registerMeetingModeHandlers(): void {
|
|||
}
|
||||
})
|
||||
|
||||
// ── Phase 14.5 핸들러 ──
|
||||
|
||||
ipcMain.handle(ch.UPDATE_TRANSCRIPT, (_event, params: { sessionId: string; editedTranscript: string }) => {
|
||||
try {
|
||||
getMeetingModeService().updateTranscript(params.sessionId, params.editedTranscript)
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(
|
||||
ErrorCode.MeetingTranscriptUpdateFailed,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.GENERATE_DOCUMENT, async (_event, params: MeetingGenerateDocParams) => {
|
||||
try {
|
||||
const doc = await getMeetingModeService().generateDocument(params)
|
||||
return ipcSuccess(doc)
|
||||
} catch (err) {
|
||||
logger.error(`generateDocument 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return ipcError(
|
||||
ErrorCode.MeetingDocGenerationFailed,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.GET_DOCUMENTS, (_event, params: { sessionId: string }) => {
|
||||
try {
|
||||
return ipcSuccess(getMeetingModeService().getDocuments(params.sessionId))
|
||||
} catch (err) {
|
||||
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.UPDATE_DOCUMENT, (_event, params: { documentId: string; content: string }) => {
|
||||
try {
|
||||
getMeetingModeService().updateDocument(params.documentId, params.content)
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(
|
||||
ErrorCode.MeetingDocumentNotFound,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.DELETE_DOCUMENT, (_event, params: { documentId: string }) => {
|
||||
try {
|
||||
getMeetingModeService().deleteDocument(params.documentId)
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
return ipcError(
|
||||
ErrorCode.MeetingDocumentNotFound,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat }) => {
|
||||
try {
|
||||
const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format)
|
||||
return ipcSuccess(filePath)
|
||||
} catch (err) {
|
||||
logger.error(`exportDocument 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return ipcError(
|
||||
ErrorCode.MeetingDocExportFailed,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string }) => {
|
||||
try {
|
||||
const filePath = await getMeetingModeService().exportTranscript(params.sessionId)
|
||||
return ipcSuccess(filePath)
|
||||
} catch (err) {
|
||||
logger.error(`exportTranscript 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
return ipcError(
|
||||
ErrorCode.MeetingExportFailed,
|
||||
err instanceof Error ? err.message : String(err),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
logger.info('Meeting Mode IPC handlers registered')
|
||||
}
|
||||
|
|
|
|||
245
src/main/services/MeetingDocTemplateService.ts
Normal file
245
src/main/services/MeetingDocTemplateService.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// src/main/services/MeetingDocTemplateService.ts
|
||||
// Phase 14.5: 회의 문서 템플릿 서비스 (electron-store 기반)
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import { nanoid } from 'nanoid'
|
||||
import Store from 'electron-store'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import type {
|
||||
MeetingDocTemplate,
|
||||
CreateMeetingDocTemplateParams,
|
||||
UpdateMeetingDocTemplateParams,
|
||||
MeetingDocTemplateType,
|
||||
} from '@shared/types'
|
||||
|
||||
const logger = getLogger('MeetingDocTemplateService')
|
||||
|
||||
// ── 빌트인 템플릿 ──
|
||||
const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
|
||||
{
|
||||
id: 'builtin-minutes',
|
||||
name: '회의록',
|
||||
description: '결정사항, 할 일, 타임라인 구조의 표준 회의록',
|
||||
templateType: 'minutes' as MeetingDocTemplateType,
|
||||
systemPrompt: `당신은 전문 회의록 작성 비서입니다.
|
||||
전사록을 분석하여 구조화된 회의록을 작성합니다.
|
||||
|
||||
규칙:
|
||||
- 전사록에 없는 내용을 추가하지 마세요
|
||||
- 시간 순서를 유지하세요
|
||||
- 한국어로 작성하세요 (원문이 영어면 원어 유지)
|
||||
|
||||
출력 형식:
|
||||
## 요약
|
||||
(3-5줄 핵심 요약)
|
||||
|
||||
## 핵심 결정사항
|
||||
- (결정 1)
|
||||
- (결정 2)
|
||||
|
||||
## 할 일 목록
|
||||
- [ ] (담당자가 있으면 포함) (할 일)
|
||||
|
||||
## 타임라인
|
||||
| 시간 | 내용 |
|
||||
|------|------|
|
||||
| MM:SS | 주요 발언/이벤트 |`,
|
||||
isBuiltin: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'builtin-report',
|
||||
name: '보고서',
|
||||
description: '개요, 핵심 내용, 결론, 제안 구조의 보고서',
|
||||
templateType: 'report' as MeetingDocTemplateType,
|
||||
systemPrompt: `당신은 전문 비즈니스 보고서 작성 비서입니다.
|
||||
회의 전사록을 분석하여 체계적인 보고서를 작성합니다.
|
||||
|
||||
규칙:
|
||||
- 전사록에 없는 내용을 추가하지 마세요
|
||||
- 사실 기반으로 작성하세요
|
||||
- 한국어로 작성하세요
|
||||
|
||||
출력 형식:
|
||||
## 개요
|
||||
(회의 목적과 배경 2-3줄)
|
||||
|
||||
## 핵심 내용
|
||||
- (주요 논의 사항 1)
|
||||
- (주요 논의 사항 2)
|
||||
- (주요 논의 사항 3)
|
||||
|
||||
## 결론
|
||||
(합의된 결론 및 방향 2-3줄)
|
||||
|
||||
## 제안사항
|
||||
- (제안 1)
|
||||
- (제안 2)`,
|
||||
isBuiltin: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
{
|
||||
id: 'builtin-idea-note',
|
||||
name: '아이디어 노트',
|
||||
description: '핵심 아이디어, 장단점, 우선순위, 다음 단계 구조',
|
||||
templateType: 'idea-note' as MeetingDocTemplateType,
|
||||
systemPrompt: `당신은 창의적 아이디어 정리 전문가입니다.
|
||||
회의 전사록에서 아이디어와 제안을 추출하여 정리합니다.
|
||||
|
||||
규칙:
|
||||
- 전사록에 등장한 아이디어만 정리하세요
|
||||
- 실행 가능성 중심으로 평가하세요
|
||||
- 한국어로 작성하세요
|
||||
|
||||
출력 형식:
|
||||
## 핵심 아이디어
|
||||
- (아이디어 1)
|
||||
- (아이디어 2)
|
||||
|
||||
## 장점
|
||||
- (장점 1)
|
||||
- (장점 2)
|
||||
|
||||
## 단점 / 리스크
|
||||
- (단점 1)
|
||||
- (단점 2)
|
||||
|
||||
## 우선순위
|
||||
1. (최우선 항목)
|
||||
2. (차순위 항목)
|
||||
|
||||
## 다음 단계
|
||||
- [ ] (액션 아이템 1)
|
||||
- [ ] (액션 아이템 2)`,
|
||||
isBuiltin: true,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]
|
||||
|
||||
interface MeetingDocTemplateStoreSchema {
|
||||
templates: MeetingDocTemplate[]
|
||||
}
|
||||
|
||||
class MeetingDocTemplateService extends EventEmitter {
|
||||
private _store: Store<MeetingDocTemplateStoreSchema>
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this._store = new Store<MeetingDocTemplateStoreSchema>({
|
||||
name: 'meeting-doc-templates',
|
||||
defaults: {
|
||||
templates: [...BUILTIN_TEMPLATES],
|
||||
},
|
||||
})
|
||||
this._ensureBuiltins()
|
||||
}
|
||||
|
||||
// ── CRUD ──
|
||||
|
||||
getAll(): MeetingDocTemplate[] {
|
||||
return this._store.get('templates', [])
|
||||
}
|
||||
|
||||
getById(id: string): MeetingDocTemplate | null {
|
||||
return this.getAll().find((t) => t.id === id) ?? null
|
||||
}
|
||||
|
||||
create(params: CreateMeetingDocTemplateParams): MeetingDocTemplate {
|
||||
const template: MeetingDocTemplate = {
|
||||
id: nanoid(),
|
||||
name: params.name,
|
||||
description: params.description,
|
||||
templateType: 'custom' as MeetingDocTemplateType,
|
||||
systemPrompt: params.systemPrompt,
|
||||
isBuiltin: false,
|
||||
createdAt: Date.now(),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
const templates = this.getAll()
|
||||
templates.push(template)
|
||||
this._store.set('templates', templates)
|
||||
logger.info(`회의 문서 템플릿 생성: ${template.id} (${template.name})`)
|
||||
return template
|
||||
}
|
||||
|
||||
update(params: UpdateMeetingDocTemplateParams): MeetingDocTemplate {
|
||||
const templates = this.getAll()
|
||||
const idx = templates.findIndex((t) => t.id === params.id)
|
||||
if (idx === -1) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.MeetingDocTemplateNotFound,
|
||||
`템플릿을 찾을 수 없습니다: ${params.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
const existing = templates[idx]
|
||||
const updated: MeetingDocTemplate = {
|
||||
...existing,
|
||||
...(params.name !== undefined && { name: params.name }),
|
||||
...(params.description !== undefined && { description: params.description }),
|
||||
...(params.systemPrompt !== undefined && { systemPrompt: params.systemPrompt }),
|
||||
updatedAt: Date.now(),
|
||||
}
|
||||
|
||||
templates[idx] = updated
|
||||
this._store.set('templates', templates)
|
||||
logger.info(`회의 문서 템플릿 수정: ${params.id}`)
|
||||
return updated
|
||||
}
|
||||
|
||||
delete(id: string): void {
|
||||
const templates = this.getAll()
|
||||
const template = templates.find((t) => t.id === id)
|
||||
if (!template) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.MeetingDocTemplateNotFound,
|
||||
`템플릿을 찾을 수 없습니다: ${id}`,
|
||||
)
|
||||
}
|
||||
if (template.isBuiltin) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.MeetingDocTemplateBuiltinDelete,
|
||||
'빌트인 템플릿은 삭제할 수 없습니다',
|
||||
)
|
||||
}
|
||||
|
||||
this._store.set(
|
||||
'templates',
|
||||
templates.filter((t) => t.id !== id),
|
||||
)
|
||||
logger.info(`회의 문서 템플릿 삭제: ${id}`)
|
||||
}
|
||||
|
||||
private _ensureBuiltins(): void {
|
||||
const templates = this.getAll()
|
||||
let changed = false
|
||||
for (const builtin of BUILTIN_TEMPLATES) {
|
||||
if (!templates.find((t) => t.id === builtin.id)) {
|
||||
templates.push(builtin)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
this._store.set('templates', templates)
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.removeAllListeners()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
let instance: MeetingDocTemplateService | null = null
|
||||
|
||||
export function getMeetingDocTemplateService(): MeetingDocTemplateService {
|
||||
if (!instance) {
|
||||
instance = new MeetingDocTemplateService()
|
||||
}
|
||||
return instance
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
// src/main/services/MeetingModeService.ts
|
||||
// Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록
|
||||
// Phase 14 / 14.5: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + 문서 생성/내보내기
|
||||
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
|
|
@ -11,9 +11,18 @@ import { getLogger } from './LoggerService'
|
|||
import { configGet, configSet } from './ConfigService'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { getDatabase } from '../db'
|
||||
import { meetingSessions, meetingMemos } from '../db/schema'
|
||||
import { meetingSessions, meetingMemos, meetingDocuments } from '../db/schema'
|
||||
import { IPC_CHANNELS } from '@shared/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@shared/errors'
|
||||
import {
|
||||
parseMinutes,
|
||||
buildExportMarkdown,
|
||||
markdownToSimpleHtml,
|
||||
formatTime,
|
||||
formatDateFile,
|
||||
formatDateRange,
|
||||
} from '../utils/meeting-markdown'
|
||||
import { markdownToDocx } from '../utils/markdown-to-docx'
|
||||
import type {
|
||||
MeetingModeState,
|
||||
MeetingMemo,
|
||||
|
|
@ -25,40 +34,15 @@ import type {
|
|||
MeetingModeStateInfo,
|
||||
MeetingProcessingStep,
|
||||
MeetingProcessingProgress,
|
||||
MeetingDocument,
|
||||
MeetingGenerateDocParams,
|
||||
MeetingExportFormat,
|
||||
MeetingDocGeneratingProgress,
|
||||
CaptionSegment,
|
||||
} from '@shared/types'
|
||||
|
||||
const logger = getLogger('MeetingModeService')
|
||||
|
||||
const MEETING_MODE_SYSTEM_PROMPT = `당신은 전문 회의록 작성 비서입니다.
|
||||
전사록과 참석자 메모를 분석하여 구조화된 회의록을 작성합니다.
|
||||
|
||||
규칙:
|
||||
- 전사록/메모에 없는 내용을 추가하지 마세요
|
||||
- 메모(📝)는 참석자가 직접 작성한 핵심 포인트이므로 우선 반영하세요
|
||||
- 시간 순서를 유지하세요
|
||||
- 한국어로 작성하세요 (원문이 영어면 원어 유지)
|
||||
|
||||
출력 형식:
|
||||
## 요약
|
||||
(3-5줄 핵심 요약)
|
||||
|
||||
## 핵심 결정사항
|
||||
- (결정 1)
|
||||
- (결정 2)
|
||||
|
||||
## 할 일 목록
|
||||
- [ ] (담당자가 있으면 포함) (할 일)
|
||||
|
||||
## 타임라인
|
||||
| 시간 | 내용 |
|
||||
|------|------|
|
||||
| MM:SS | 주요 발언/이벤트 |`
|
||||
|
||||
/** 긴 회의 청크 분할 기준 (자) */
|
||||
const LONG_MEETING_THRESHOLD = 20000
|
||||
const CHUNK_SIZE = 5000
|
||||
|
||||
interface MeetingModeServiceEvents {
|
||||
'state-changed': (state: MeetingModeState) => void
|
||||
'segment': (segment: CaptionSegment) => void
|
||||
|
|
@ -234,7 +218,7 @@ class MeetingModeService extends EventEmitter {
|
|||
}).run()
|
||||
|
||||
this.emit('memo-added', memo)
|
||||
logger.debug(`메모 추가: [${this._formatTime(memo.timestampMs)}] ${content}`)
|
||||
logger.debug(`메모 추가: [${formatTime(memo.timestampMs)}] ${content}`)
|
||||
|
||||
return memo
|
||||
}
|
||||
|
|
@ -302,90 +286,27 @@ class MeetingModeService extends EventEmitter {
|
|||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
|
||||
// Step 1: 전사 텍스트 합산
|
||||
this._sendProgress(sessionId, 'merging', 20)
|
||||
this._sendProgress(sessionId, 'merging', 40)
|
||||
const rawTranscript = this._segments
|
||||
.map((s) => `[${this._formatTime(s.timestamp)}] ${s.text}`)
|
||||
.map((s) => `[${formatTime(s.timestamp)}] ${s.text}`)
|
||||
.join('\n')
|
||||
|
||||
// Step 2: 메모 포맷팅
|
||||
const formattedMemos = this._memos
|
||||
.map((m) => `[${this._formatTime(m.timestampMs)}] 📝 ${m.content}`)
|
||||
.join('\n')
|
||||
|
||||
// Step 3: LLM 구조화
|
||||
this._sendProgress(sessionId, 'generating', 40)
|
||||
const { getLocalLLMService } = await import('./LocalLLMService')
|
||||
const llmService = getLocalLLMService()
|
||||
const prompt = this._buildPrompt(rawTranscript, formattedMemos)
|
||||
const sttEnd = Date.now()
|
||||
|
||||
let resultText: string
|
||||
let resultModel: string
|
||||
|
||||
if (rawTranscript.length > LONG_MEETING_THRESHOLD) {
|
||||
// 긴 회의: 2-pass 요약
|
||||
const chunks = this._splitIntoChunks(rawTranscript, CHUNK_SIZE)
|
||||
const chunkResults = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const pct = 40 + Math.floor((i / chunks.length) * 30)
|
||||
this._sendProgress(sessionId, 'generating', pct)
|
||||
const chunkResult = await llmService.generate(
|
||||
`다음 회의 전사 일부를 3줄로 요약하세요:\n\n${chunks[i]}`,
|
||||
{ temperature: 0.3 },
|
||||
)
|
||||
chunkResults.push(chunkResult.text)
|
||||
}
|
||||
const condensed = chunkResults.join('\n\n')
|
||||
const finalResult = await llmService.generate(
|
||||
this._buildPrompt(condensed, formattedMemos),
|
||||
{
|
||||
systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
|
||||
temperature: 0.3,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
)
|
||||
resultText = finalResult.text
|
||||
resultModel = finalResult.model
|
||||
} else {
|
||||
const result = await llmService.generate(prompt, {
|
||||
systemPrompt: MEETING_MODE_SYSTEM_PROMPT,
|
||||
temperature: 0.3,
|
||||
maxTokens: 4096,
|
||||
})
|
||||
resultText = result.text
|
||||
resultModel = result.model
|
||||
}
|
||||
const llmEnd = Date.now()
|
||||
|
||||
// Step 4: 마크다운 파싱
|
||||
this._sendProgress(sessionId, 'parsing', 85)
|
||||
const minutes = this._parseMinutes(resultText)
|
||||
|
||||
// Step 5: DB 저장
|
||||
this._sendProgress(sessionId, 'saving', 90)
|
||||
// Step 2: DB 저장 (rawTranscript + completed)
|
||||
this._sendProgress(sessionId, 'saving', 80)
|
||||
const now = Date.now()
|
||||
db.update(meetingSessions).set({
|
||||
status: 'completed',
|
||||
rawTranscript,
|
||||
minutesMarkdown: resultText,
|
||||
minutesJson: JSON.stringify(minutes),
|
||||
sttModel: configGet('sttModelId') as string | undefined,
|
||||
llmModel: resultModel,
|
||||
sttLatencyMs: sttEnd - (this._sessionStartedAt ?? sttEnd),
|
||||
llmLatencyMs: llmEnd - sttEnd,
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
|
||||
// 자동 제목 생성
|
||||
const autoTitle = minutes.summary.split('\n')[0].slice(0, 50) || '무제 회의'
|
||||
db.update(meetingSessions).set({
|
||||
title: autoTitle,
|
||||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
|
||||
// Step 6: Windows 알림
|
||||
// Step 3: 알림
|
||||
this._sendProgress(sessionId, 'notifying', 95)
|
||||
new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show()
|
||||
new Notification({
|
||||
title: '녹음 완료',
|
||||
body: '전사가 완료되었습니다. 문서 생성 탭에서 회의록을 만들어보세요.',
|
||||
}).show()
|
||||
|
||||
// 완료 이벤트
|
||||
this._sendProgress(sessionId, 'notifying', 100)
|
||||
|
|
@ -398,7 +319,7 @@ class MeetingModeService extends EventEmitter {
|
|||
this._resetSession()
|
||||
this._setState('idle')
|
||||
this._sendStateToRenderer()
|
||||
logger.info(`회의록 생성 완료: sessionId=${sessionId}`)
|
||||
logger.info(`회의 전사 저장 완료: sessionId=${sessionId}`)
|
||||
} catch (err) {
|
||||
db.update(meetingSessions).set({
|
||||
status: 'error',
|
||||
|
|
@ -412,7 +333,7 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
const d3roErr = new D3ROError(
|
||||
ErrorCode.MeetingProcessingFailed,
|
||||
`회의록 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
`후처리 실패: ${err instanceof Error ? err.message : String(err)}`,
|
||||
)
|
||||
this.emit('error', d3roErr)
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
|
||||
|
|
@ -509,7 +430,7 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
const { filePath } = await dialog.showSaveDialog({
|
||||
title: 'PDF로 내보내기',
|
||||
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.pdf`,
|
||||
defaultPath: `회의록_${formatDateFile(session.startedAt)}.pdf`,
|
||||
filters: [{ name: 'PDF', extensions: ['pdf'] }],
|
||||
})
|
||||
if (!filePath) return ''
|
||||
|
|
@ -538,12 +459,12 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
const { filePath } = await dialog.showSaveDialog({
|
||||
title: '마크다운으로 내보내기',
|
||||
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.md`,
|
||||
defaultPath: `회의록_${formatDateFile(session.startedAt)}.md`,
|
||||
filters: [{ name: 'Markdown', extensions: ['md'] }],
|
||||
})
|
||||
if (!filePath) return ''
|
||||
|
||||
const md = this._buildExportMarkdown(session)
|
||||
const md = buildExportMarkdown(session)
|
||||
fs.writeFileSync(filePath, md, 'utf-8')
|
||||
logger.info(`마크다운 내보내기 완료: ${filePath}`)
|
||||
return filePath
|
||||
|
|
@ -581,80 +502,263 @@ class MeetingModeService extends EventEmitter {
|
|||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, progress)
|
||||
}
|
||||
|
||||
private _formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
// ── Phase 14.5: 전사 수정 ──
|
||||
|
||||
private _formatDateFile(epochMs: number): string {
|
||||
const d = new Date(epochMs)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
private _formatDateRange(startedAt: number, endedAt: number | null): string {
|
||||
const fmt = (ts: number): string => {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
updateTranscript(sessionId: string, editedTranscript: string): void {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
if (!row) {
|
||||
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
||||
}
|
||||
if (!endedAt) return fmt(startedAt)
|
||||
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
|
||||
}
|
||||
|
||||
private _buildPrompt(rawTranscript: string, formattedMemos: string): string {
|
||||
return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.`
|
||||
}
|
||||
// ── Phase 14.5: 문서 생성 ──
|
||||
|
||||
private _parseMinutes(markdown: string): MeetingMinutes {
|
||||
const minutes: MeetingMinutes = {
|
||||
summary: '',
|
||||
decisions: [],
|
||||
actionItems: [],
|
||||
timeline: [],
|
||||
async generateDocument(params: MeetingGenerateDocParams): Promise<MeetingDocument> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, params.sessionId)).get()
|
||||
if (!row) {
|
||||
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${params.sessionId}`)
|
||||
}
|
||||
|
||||
const sections = markdown.split(/^## /m)
|
||||
for (const section of sections) {
|
||||
const lines = section.trim().split('\n')
|
||||
const heading = lines[0]?.toLowerCase() ?? ''
|
||||
const body = lines.slice(1).join('\n').trim()
|
||||
const { getMeetingDocTemplateService } = await import('./MeetingDocTemplateService')
|
||||
const templateService = getMeetingDocTemplateService()
|
||||
const template = templateService.getById(params.templateId)
|
||||
if (!template) {
|
||||
throw new D3ROError(ErrorCode.MeetingDocTemplateNotFound, `템플릿을 찾을 수 없습니다: ${params.templateId}`)
|
||||
}
|
||||
|
||||
if (heading.includes('요약') || heading.includes('summary')) {
|
||||
minutes.summary = body
|
||||
} else if (heading.includes('결정') || heading.includes('decision')) {
|
||||
minutes.decisions = body
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
||||
.map((l) => l.replace(/^[-*]\s*/, '').trim())
|
||||
} else if (heading.includes('할 일') || heading.includes('action')) {
|
||||
minutes.actionItems = body
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
||||
.map((l) => {
|
||||
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
|
||||
return { task: text }
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
const systemPrompt = params.customPrompt ?? template.systemPrompt
|
||||
const docTitle = params.customTitle ?? `${template.name} — ${row.title ?? '무제 회의'}`
|
||||
|
||||
// 진행률 전송
|
||||
const sendProgress = (percent: number): void => {
|
||||
const progress: MeetingDocGeneratingProgress = {
|
||||
sessionId: params.sessionId,
|
||||
templateId: params.templateId,
|
||||
percent,
|
||||
}
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, progress)
|
||||
}
|
||||
|
||||
sendProgress(10)
|
||||
|
||||
const { getLocalLLMService } = await import('./LocalLLMService')
|
||||
const llmService = getLocalLLMService()
|
||||
|
||||
sendProgress(30)
|
||||
|
||||
const llmStart = Date.now()
|
||||
const result = await llmService.generate(
|
||||
`다음 회의 전사록을 분석하여 문서를 작성해주세요:\n\n${transcript}`,
|
||||
{
|
||||
systemPrompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
)
|
||||
const llmLatencyMs = Date.now() - llmStart
|
||||
|
||||
sendProgress(85)
|
||||
|
||||
const now = Date.now()
|
||||
const docId = nanoid()
|
||||
db.insert(meetingDocuments).values({
|
||||
id: docId,
|
||||
sessionId: params.sessionId,
|
||||
templateType: template.templateType,
|
||||
title: docTitle,
|
||||
content: result.text,
|
||||
promptUsed: systemPrompt,
|
||||
llmModel: result.model,
|
||||
llmLatencyMs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
|
||||
sendProgress(100)
|
||||
logger.info(`문서 생성 완료: docId=${docId}, sessionId=${params.sessionId}`)
|
||||
|
||||
return {
|
||||
id: docId,
|
||||
sessionId: params.sessionId,
|
||||
templateType: template.templateType,
|
||||
title: docTitle,
|
||||
content: result.text,
|
||||
promptUsed: systemPrompt,
|
||||
llmModel: result.model,
|
||||
llmLatencyMs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 14.5: 문서 조회/수정/삭제 ──
|
||||
|
||||
getDocuments(sessionId: string): MeetingDocument[] {
|
||||
const db = getDatabase()
|
||||
const rows = db.select().from(meetingDocuments)
|
||||
.where(eq(meetingDocuments.sessionId, sessionId))
|
||||
.all()
|
||||
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
sessionId: r.sessionId,
|
||||
templateType: r.templateType,
|
||||
title: r.title,
|
||||
content: r.content,
|
||||
promptUsed: r.promptUsed,
|
||||
llmModel: r.llmModel,
|
||||
llmLatencyMs: r.llmLatencyMs,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
}))
|
||||
}
|
||||
|
||||
updateDocument(documentId: string, content: string): void {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
|
||||
if (!row) {
|
||||
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
|
||||
}
|
||||
db.update(meetingDocuments).set({ content, updatedAt: Date.now() })
|
||||
.where(eq(meetingDocuments.id, documentId)).run()
|
||||
logger.info(`문서 수정: docId=${documentId}`)
|
||||
}
|
||||
|
||||
deleteDocument(documentId: string): void {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
|
||||
if (!row) {
|
||||
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
|
||||
}
|
||||
db.delete(meetingDocuments).where(eq(meetingDocuments.id, documentId)).run()
|
||||
logger.info(`문서 삭제: docId=${documentId}`)
|
||||
}
|
||||
|
||||
// ── Phase 14.5: 문서 내보내기 ──
|
||||
|
||||
async exportDocument(documentId: string, format: MeetingExportFormat): Promise<string> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
|
||||
if (!row) {
|
||||
throw new D3ROError(ErrorCode.MeetingDocumentNotFound, `문서를 찾을 수 없습니다: ${documentId}`)
|
||||
}
|
||||
|
||||
const safeTitle = row.title.replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50)
|
||||
const dateStr = formatDateFile(row.createdAt)
|
||||
|
||||
let filters: Electron.FileFilter[]
|
||||
let defaultName: string
|
||||
|
||||
switch (format) {
|
||||
case 'md':
|
||||
filters = [{ name: 'Markdown', extensions: ['md'] }]
|
||||
defaultName = `${safeTitle}_${dateStr}.md`
|
||||
break
|
||||
case 'txt':
|
||||
filters = [{ name: 'Text', extensions: ['txt'] }]
|
||||
defaultName = `${safeTitle}_${dateStr}.txt`
|
||||
break
|
||||
case 'docx':
|
||||
filters = [{ name: 'Word Document', extensions: ['docx'] }]
|
||||
defaultName = `${safeTitle}_${dateStr}.docx`
|
||||
break
|
||||
case 'pdf':
|
||||
filters = [{ name: 'PDF', extensions: ['pdf'] }]
|
||||
defaultName = `${safeTitle}_${dateStr}.pdf`
|
||||
break
|
||||
}
|
||||
|
||||
const { filePath } = await dialog.showSaveDialog({
|
||||
title: '문서 내보내기',
|
||||
defaultPath: defaultName,
|
||||
filters,
|
||||
})
|
||||
if (!filePath) return ''
|
||||
|
||||
switch (format) {
|
||||
case 'md':
|
||||
case 'txt':
|
||||
fs.writeFileSync(filePath, row.content, 'utf-8')
|
||||
break
|
||||
case 'docx': {
|
||||
const buf = await markdownToDocx(row.content, row.title)
|
||||
fs.writeFileSync(filePath, buf)
|
||||
break
|
||||
}
|
||||
case 'pdf': {
|
||||
const html = `<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<style>
|
||||
body { font-family: 'Malgun Gothic', sans-serif; margin: 40px; color: #222; }
|
||||
h1 { font-size: 22px; border-bottom: 2px solid #f25b29; padding-bottom: 8px; }
|
||||
h2 { font-size: 16px; color: #444; margin-top: 24px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 12px 0; }
|
||||
th, td { border: 1px solid #ddd; padding: 8px; text-align: left; font-size: 13px; }
|
||||
th { background: #f5f5f5; font-weight: 600; }
|
||||
ul { padding-left: 20px; }
|
||||
li { margin: 4px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${row.title}</h1>
|
||||
${markdownToSimpleHtml(row.content)}
|
||||
</body>
|
||||
</html>`
|
||||
const win = new BrowserWindow({ show: false, width: 800, height: 600 })
|
||||
try {
|
||||
await win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`)
|
||||
const pdfBuffer = await win.webContents.printToPDF({
|
||||
printBackground: true,
|
||||
pageSize: 'A4',
|
||||
margins: { top: 20, bottom: 20, left: 20, right: 20 },
|
||||
})
|
||||
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
|
||||
const tableRows = body
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('|') && !l.includes('---'))
|
||||
for (const row of tableRows) {
|
||||
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
|
||||
if (cells.length >= 2) {
|
||||
minutes.timeline.push({
|
||||
time: cells[0],
|
||||
content: cells[1],
|
||||
type: cells[1].includes('📝') ? 'memo' : 'transcript',
|
||||
})
|
||||
}
|
||||
fs.writeFileSync(filePath, pdfBuffer)
|
||||
} finally {
|
||||
win.close()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return minutes
|
||||
logger.info(`문서 내보내기 완료: format=${format}, path=${filePath}`)
|
||||
return filePath
|
||||
}
|
||||
|
||||
async exportTranscript(sessionId: string): Promise<string> {
|
||||
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 dateStr = formatDateFile(row.startedAt)
|
||||
const safeTitle = (row.title ?? '무제_회의').replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50)
|
||||
|
||||
const { filePath } = await dialog.showSaveDialog({
|
||||
title: '전사 내보내기',
|
||||
defaultPath: `전사_${safeTitle}_${dateStr}.txt`,
|
||||
filters: [{ name: 'Text', extensions: ['txt'] }],
|
||||
})
|
||||
if (!filePath) return ''
|
||||
|
||||
fs.writeFileSync(filePath, transcript, 'utf-8')
|
||||
logger.info(`전사 내보내기 완료: path=${filePath}`)
|
||||
return filePath
|
||||
}
|
||||
|
||||
// ── 내부 헬퍼 ──
|
||||
|
||||
private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
|
|
@ -673,6 +777,23 @@ class MeetingModeService extends EventEmitter {
|
|||
createdAt: m.createdAt,
|
||||
}))
|
||||
|
||||
const docRows = db.select().from(meetingDocuments)
|
||||
.where(eq(meetingDocuments.sessionId, sessionId))
|
||||
.all()
|
||||
|
||||
const documents: MeetingDocument[] = docRows.map((d) => ({
|
||||
id: d.id,
|
||||
sessionId: d.sessionId,
|
||||
templateType: d.templateType,
|
||||
title: d.title,
|
||||
content: d.content,
|
||||
promptUsed: d.promptUsed,
|
||||
llmModel: d.llmModel,
|
||||
llmLatencyMs: d.llmLatencyMs,
|
||||
createdAt: d.createdAt,
|
||||
updatedAt: d.updatedAt,
|
||||
}))
|
||||
|
||||
let minutes: MeetingMinutes | null = null
|
||||
if (row.minutesJson) {
|
||||
try {
|
||||
|
|
@ -691,27 +812,21 @@ class MeetingModeService extends EventEmitter {
|
|||
durationMs: row.durationMs,
|
||||
memoCount: memos.length,
|
||||
rawTranscript: row.rawTranscript,
|
||||
editedTranscript: row.editedTranscript,
|
||||
minutesMarkdown: row.minutesMarkdown,
|
||||
minutes,
|
||||
memos,
|
||||
documents,
|
||||
sttModel: row.sttModel,
|
||||
llmModel: row.llmModel,
|
||||
errorMessage: row.errorMessage,
|
||||
}
|
||||
}
|
||||
|
||||
private _splitIntoChunks(text: string, chunkSize: number): string[] {
|
||||
const chunks: string[] = []
|
||||
for (let i = 0; i < text.length; i += chunkSize) {
|
||||
chunks.push(text.slice(i, i + chunkSize))
|
||||
}
|
||||
return chunks
|
||||
}
|
||||
|
||||
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
||||
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
||||
const minutesHtml = session.minutesMarkdown
|
||||
? this._markdownToSimpleHtml(session.minutesMarkdown)
|
||||
? markdownToSimpleHtml(session.minutesMarkdown)
|
||||
: '<p>회의록이 생성되지 않았습니다.</p>'
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
|
|
@ -733,53 +848,11 @@ class MeetingModeService extends EventEmitter {
|
|||
</head>
|
||||
<body>
|
||||
<h1>${session.title ?? '무제 회의'}</h1>
|
||||
<p class="meta">일시: ${this._formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
|
||||
<p class="meta">일시: ${formatDateRange(session.startedAt, session.endedAt)} | 소요: ${durationMin}분</p>
|
||||
${minutesHtml}
|
||||
</body>
|
||||
</html>`
|
||||
}
|
||||
|
||||
private _buildExportMarkdown(session: MeetingSessionDetail): string {
|
||||
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
||||
const memoTable = session.memos.length > 0
|
||||
? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${this._formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
|
||||
: ''
|
||||
|
||||
return `# 회의록 — ${session.title ?? '무제 회의'}
|
||||
|
||||
- **일시**: ${this._formatDateRange(session.startedAt, session.endedAt)}
|
||||
- **소요 시간**: ${durationMin}분
|
||||
- **STT 모델**: ${session.sttModel ?? '-'}
|
||||
- **LLM 모델**: ${session.llmModel ?? '-'}
|
||||
|
||||
---
|
||||
|
||||
${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
|
||||
|
||||
---
|
||||
|
||||
${memoTable}
|
||||
|
||||
---
|
||||
|
||||
## 원문 전사
|
||||
|
||||
${session.rawTranscript ?? '(없음)'}
|
||||
`
|
||||
}
|
||||
|
||||
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
|
||||
private _markdownToSimpleHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/^(?!<[h|l|t|p])/gm, '')
|
||||
}
|
||||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
|
|
|
|||
31
src/main/utils/markdown-to-docx.ts
Normal file
31
src/main/utils/markdown-to-docx.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// src/main/utils/markdown-to-docx.ts
|
||||
// Phase 14.5: 마크다운 → DOCX 변환 유틸리티
|
||||
|
||||
import { Document, Packer, Paragraph, TextRun, HeadingLevel } from 'docx'
|
||||
|
||||
export async function markdownToDocx(markdown: string, title: string): Promise<Buffer> {
|
||||
const paragraphs: Paragraph[] = []
|
||||
|
||||
// 타이틀 추가
|
||||
paragraphs.push(new Paragraph({ text: title, heading: HeadingLevel.TITLE }))
|
||||
|
||||
// 줄 단위 파싱
|
||||
for (const line of markdown.split('\n')) {
|
||||
if (line.startsWith('## ')) {
|
||||
paragraphs.push(new Paragraph({ text: line.slice(3), heading: HeadingLevel.HEADING_2 }))
|
||||
} else if (line.startsWith('### ')) {
|
||||
paragraphs.push(new Paragraph({ text: line.slice(4), heading: HeadingLevel.HEADING_3 }))
|
||||
} else if (line.startsWith('# ')) {
|
||||
paragraphs.push(new Paragraph({ text: line.slice(2), heading: HeadingLevel.HEADING_1 }))
|
||||
} else if (line.startsWith('- [ ] ')) {
|
||||
paragraphs.push(new Paragraph({ text: `\u2610 ${line.slice(6)}`, bullet: { level: 0 } }))
|
||||
} else if (line.startsWith('- ') || line.startsWith('* ')) {
|
||||
paragraphs.push(new Paragraph({ text: line.slice(2), bullet: { level: 0 } }))
|
||||
} else if (line.trim()) {
|
||||
paragraphs.push(new Paragraph({ children: [new TextRun(line)] }))
|
||||
}
|
||||
}
|
||||
|
||||
const doc = new Document({ sections: [{ children: paragraphs }] })
|
||||
return Buffer.from(await Packer.toBuffer(doc))
|
||||
}
|
||||
116
src/main/utils/meeting-markdown.ts
Normal file
116
src/main/utils/meeting-markdown.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// src/main/utils/meeting-markdown.ts
|
||||
// Phase 14.5: MeetingModeService에서 추출한 마크다운 유틸리티
|
||||
|
||||
import type { MeetingMinutes, MeetingSessionDetail } from '@shared/types'
|
||||
|
||||
export function formatTime(ms: number): string {
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const minutes = Math.floor(totalSeconds / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatDateFile(epochMs: number): string {
|
||||
const d = new Date(epochMs)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}_${String(d.getHours()).padStart(2, '0')}${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function formatDateRange(startedAt: number, endedAt: number | null): string {
|
||||
const fmt = (ts: number): string => {
|
||||
const d = new Date(ts)
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
if (!endedAt) return fmt(startedAt)
|
||||
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
export function parseMinutes(markdown: string): MeetingMinutes {
|
||||
const minutes: MeetingMinutes = {
|
||||
summary: '',
|
||||
decisions: [],
|
||||
actionItems: [],
|
||||
timeline: [],
|
||||
}
|
||||
|
||||
const sections = markdown.split(/^## /m)
|
||||
for (const section of sections) {
|
||||
const lines = section.trim().split('\n')
|
||||
const heading = lines[0]?.toLowerCase() ?? ''
|
||||
const body = lines.slice(1).join('\n').trim()
|
||||
|
||||
if (heading.includes('요약') || heading.includes('summary')) {
|
||||
minutes.summary = body
|
||||
} else if (heading.includes('결정') || heading.includes('decision')) {
|
||||
minutes.decisions = body
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
||||
.map((l) => l.replace(/^[-*]\s*/, '').trim())
|
||||
} else if (heading.includes('할 일') || heading.includes('action')) {
|
||||
minutes.actionItems = body
|
||||
.split('\n')
|
||||
.filter((l) => l.startsWith('-') || l.startsWith('*'))
|
||||
.map((l) => {
|
||||
const text = l.replace(/^[-*]\s*(\[.\]\s*)?/, '').trim()
|
||||
return { task: text }
|
||||
})
|
||||
} else if (heading.includes('타임라인') || heading.includes('timeline')) {
|
||||
const tableRows = body
|
||||
.split('\n')
|
||||
.filter((l) => l.includes('|') && !l.includes('---'))
|
||||
for (const row of tableRows) {
|
||||
const cells = row.split('|').map((c) => c.trim()).filter(Boolean)
|
||||
if (cells.length >= 2) {
|
||||
minutes.timeline.push({
|
||||
time: cells[0],
|
||||
content: cells[1],
|
||||
type: cells[1].includes('📝') ? 'memo' : 'transcript',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return minutes
|
||||
}
|
||||
|
||||
export function buildExportMarkdown(session: MeetingSessionDetail): string {
|
||||
const durationMin = session.durationMs ? Math.round(session.durationMs / 60000) : 0
|
||||
const memoTable = session.memos.length > 0
|
||||
? `## 참석자 메모\n\n| 시간 | 메모 |\n|------|------|\n${session.memos.map((m) => `| ${formatTime(m.timestampMs)} | ${m.content} |`).join('\n')}`
|
||||
: ''
|
||||
|
||||
return `# 회의록 — ${session.title ?? '무제 회의'}
|
||||
|
||||
- **일시**: ${formatDateRange(session.startedAt, session.endedAt)}
|
||||
- **소요 시간**: ${durationMin}분
|
||||
- **STT 모델**: ${session.sttModel ?? '-'}
|
||||
- **LLM 모델**: ${session.llmModel ?? '-'}
|
||||
|
||||
---
|
||||
|
||||
${session.minutesMarkdown ?? '회의록이 생성되지 않았습니다.'}
|
||||
|
||||
---
|
||||
|
||||
${memoTable}
|
||||
|
||||
---
|
||||
|
||||
## 원문 전사
|
||||
|
||||
${session.rawTranscript ?? '(없음)'}
|
||||
`
|
||||
}
|
||||
|
||||
/** 간단한 마크다운→HTML 변환 (PDF 생성용) */
|
||||
export function markdownToSimpleHtml(md: string): string {
|
||||
return md
|
||||
.replace(/^### (.+)$/gm, '<h3>$1</h3>')
|
||||
.replace(/^## (.+)$/gm, '<h2>$1</h2>')
|
||||
.replace(/^# (.+)$/gm, '<h1>$1</h1>')
|
||||
.replace(/^\- \[.\] (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\- (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/^\* (.+)$/gm, '<li>$1</li>')
|
||||
.replace(/\n{2,}/g, '</p><p>')
|
||||
.replace(/^(?!<[h|l|t|p])/gm, '')
|
||||
}
|
||||
|
|
@ -148,6 +148,20 @@ import type {
|
|||
MeetingExportParams,
|
||||
MeetingProcessingProgress,
|
||||
CaptionSegment,
|
||||
MeetingDocument,
|
||||
MeetingDocTemplate,
|
||||
MeetingGenerateDocParams,
|
||||
MeetingGenerateDocResult,
|
||||
MeetingUpdateDocParams,
|
||||
MeetingDeleteDocParams,
|
||||
MeetingGetDocsParams,
|
||||
MeetingUpdateTranscriptParams,
|
||||
MeetingExportDocParams,
|
||||
MeetingExportFormat,
|
||||
MeetingDocGeneratingProgress,
|
||||
CreateMeetingDocTemplateParams,
|
||||
UpdateMeetingDocTemplateParams,
|
||||
DeleteMeetingDocTemplateParams,
|
||||
} from '@shared/types'
|
||||
import { Feature } from '@shared/types'
|
||||
import type { IPCResult } from '@shared/errors'
|
||||
|
|
@ -602,6 +616,37 @@ const electronAPI = {
|
|||
on(IPC_CHANNELS.MEETING_MODE.ERROR, cb),
|
||||
onAudioLevel: (cb: (e: { level: number }) => void): Unsubscribe =>
|
||||
on('meetingMode:audioLevel', cb),
|
||||
// Phase 14.5
|
||||
updateTranscript: (params: MeetingUpdateTranscriptParams) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.UPDATE_TRANSCRIPT, params),
|
||||
generateDocument: (params: MeetingGenerateDocParams) =>
|
||||
invoke<MeetingGenerateDocResult>(IPC_CHANNELS.MEETING_MODE.GENERATE_DOCUMENT, params),
|
||||
getDocuments: (params: MeetingGetDocsParams) =>
|
||||
invoke<MeetingDocument[]>(IPC_CHANNELS.MEETING_MODE.GET_DOCUMENTS, params),
|
||||
updateDocument: (params: MeetingUpdateDocParams) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.UPDATE_DOCUMENT, params),
|
||||
deleteDocument: (params: MeetingDeleteDocParams) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_DOCUMENT, params),
|
||||
exportDocument: (params: MeetingExportDocParams) =>
|
||||
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_DOCUMENT, params),
|
||||
exportTranscript: (params: { sessionId: string; format: MeetingExportFormat }) =>
|
||||
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_TRANSCRIPT, params),
|
||||
editSegment: (params: { sessionId: string; segmentId: string; text: string }) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_MODE.EDIT_SEGMENT, params),
|
||||
onDocGeneratingProgress: (cb: (e: MeetingDocGeneratingProgress) => void): Unsubscribe =>
|
||||
on(IPC_CHANNELS.MEETING_MODE.DOC_GENERATING_PROGRESS, cb),
|
||||
},
|
||||
|
||||
// ── Meeting Doc Templates (Phase 14.5) ───────────────
|
||||
meetingDocTemplate: {
|
||||
getAll: () =>
|
||||
invoke<MeetingDocTemplate[]>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.GET_ALL),
|
||||
create: (params: CreateMeetingDocTemplateParams) =>
|
||||
invoke<MeetingDocTemplate>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.CREATE, params),
|
||||
update: (params: UpdateMeetingDocTemplateParams) =>
|
||||
invoke<MeetingDocTemplate>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.UPDATE, params),
|
||||
delete: (params: DeleteMeetingDocTemplateParams) =>
|
||||
invoke<void>(IPC_CHANNELS.MEETING_DOC_TEMPLATE.DELETE, params),
|
||||
},
|
||||
} as const
|
||||
|
||||
|
|
|
|||
190
src/renderer/components/meeting/AddDocumentDialog.tsx
Normal file
190
src/renderer/components/meeting/AddDocumentDialog.tsx
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
// src/renderer/components/meeting/AddDocumentDialog.tsx
|
||||
// Phase 14.5: 문서 생성 다이얼로그 — 템플릿 선택 + 커스텀 프롬프트
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
Box,
|
||||
TextField,
|
||||
LinearProgress,
|
||||
} from '@mui/material'
|
||||
import { MetalCard } from '../ds/MetalCard'
|
||||
import { PhosphorText } from '../ds/PhosphorText'
|
||||
import { PhysicalButton } from '../ds/PhysicalButton'
|
||||
import { d3roPalette, d3roTypo, d3roRadius, d3roShadow } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
|
||||
interface TemplateItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
templateType: string
|
||||
isBuiltin: boolean
|
||||
}
|
||||
|
||||
interface AddDocumentDialogProps {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onGenerate: (templateId: string, customPrompt?: string, customTitle?: string) => void
|
||||
templates: TemplateItem[]
|
||||
generating: boolean
|
||||
}
|
||||
|
||||
export function AddDocumentDialog({
|
||||
open,
|
||||
onClose,
|
||||
onGenerate,
|
||||
templates,
|
||||
generating,
|
||||
}: AddDocumentDialogProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null)
|
||||
const [customPrompt, setCustomPrompt] = useState('')
|
||||
const [customTitle, setCustomTitle] = useState('')
|
||||
|
||||
const selectedTemplate = templates.find((tp) => tp.id === selectedId)
|
||||
const isCustom = selectedTemplate?.templateType === 'custom'
|
||||
|
||||
const handleGenerate = useCallback(() => {
|
||||
if (!selectedId) return
|
||||
onGenerate(
|
||||
selectedId,
|
||||
isCustom && customPrompt.trim() ? customPrompt.trim() : undefined,
|
||||
isCustom && customTitle.trim() ? customTitle.trim() : undefined,
|
||||
)
|
||||
}, [selectedId, isCustom, customPrompt, customTitle, onGenerate])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!generating) {
|
||||
setSelectedId(null)
|
||||
setCustomPrompt('')
|
||||
setCustomTitle('')
|
||||
onClose()
|
||||
}
|
||||
}, [generating, onClose])
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
maxWidth="sm"
|
||||
fullWidth
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.inner,
|
||||
boxShadow: d3roShadow.tooltip,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DialogTitle>
|
||||
<PhosphorText variant="label">{t('meeting.selectTemplate')}</PhosphorText>
|
||||
</DialogTitle>
|
||||
|
||||
<DialogContent>
|
||||
{generating && (
|
||||
<Box sx={{ mb: 2 }}>
|
||||
<LinearProgress
|
||||
sx={{
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
'& .MuiLinearProgress-bar': { bgcolor: d3roPalette.accent.amber },
|
||||
}}
|
||||
/>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.5 }}>
|
||||
{t('meeting.generating')}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 템플릿 목록 */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
{templates.map((tp) => (
|
||||
<MetalCard
|
||||
key={tp.id}
|
||||
onClick={() => !generating && setSelectedId(tp.id)}
|
||||
sx={{
|
||||
cursor: generating ? 'default' : 'pointer',
|
||||
opacity: generating ? 0.6 : 1,
|
||||
border:
|
||||
selectedId === tp.id
|
||||
? `1px solid ${d3roPalette.accent.amber}`
|
||||
: `1px solid ${d3roPalette.border.subtle}`,
|
||||
transition: 'border-color 0.15s',
|
||||
'&:hover': {
|
||||
borderColor: generating ? undefined : d3roPalette.accent.amberDim,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<PhosphorText variant="compact">{tp.name}</PhosphorText>
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 11, mt: 0.25 }}>
|
||||
{tp.description}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{/* 커스텀 템플릿 추가 입력 */}
|
||||
{isCustom && (
|
||||
<Box sx={{ mt: 2, display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<TextField
|
||||
label={t('meeting.templateName')}
|
||||
size="small"
|
||||
fullWidth
|
||||
value={customTitle}
|
||||
onChange={(e) => setCustomTitle(e.target.value)}
|
||||
disabled={generating}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': { fontSize: d3roTypo.compact.size },
|
||||
}}
|
||||
/>
|
||||
<TextField
|
||||
label={t('meeting.customPrompt')}
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
value={customPrompt}
|
||||
onChange={(e) => setCustomPrompt(e.target.value)}
|
||||
disabled={generating}
|
||||
placeholder={t('meeting.templatePrompt')}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': { fontSize: d3roTypo.compact.size },
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
|
||||
<DialogActions sx={{ px: 3, pb: 2, gap: 1 }}>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={handleClose}
|
||||
disabled={generating}
|
||||
sx={{ height: 36, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
{t('common.cancel')}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={handleGenerate}
|
||||
disabled={!selectedId || generating}
|
||||
sx={{
|
||||
height: 36,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
}}
|
||||
>
|
||||
{generating ? t('meeting.generating') : t('meeting.generate')}
|
||||
</PhysicalButton>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
89
src/renderer/components/meeting/DocumentTab.tsx
Normal file
89
src/renderer/components/meeting/DocumentTab.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// src/renderer/components/meeting/DocumentTab.tsx
|
||||
// Phase 14.5: 문서 탭 — MarkdownEditor + 자동저장 + 내보내기
|
||||
|
||||
import { useCallback, useRef } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import { MarkdownEditor } from './MarkdownEditor'
|
||||
import { ExportMenu } from './ExportMenu'
|
||||
import { PhysicalButton } from '../ds/PhysicalButton'
|
||||
import { d3roPalette, d3roTypo } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
import type { MeetingExportFormat } from '@shared/types'
|
||||
|
||||
interface DocumentTabDoc {
|
||||
id: string
|
||||
title: string
|
||||
content: string
|
||||
templateType: string
|
||||
}
|
||||
|
||||
interface DocumentTabProps {
|
||||
document: DocumentTabDoc
|
||||
onContentChange: (content: string) => void
|
||||
onExport: (format: MeetingExportFormat) => void
|
||||
onDelete: () => void
|
||||
}
|
||||
|
||||
const AUTOSAVE_DELAY = 500
|
||||
|
||||
export function DocumentTab({
|
||||
document: doc,
|
||||
onContentChange,
|
||||
onExport,
|
||||
onDelete,
|
||||
}: DocumentTabProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const handleChange = useCallback(
|
||||
(content: string) => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => {
|
||||
onContentChange(content)
|
||||
}, AUTOSAVE_DELAY)
|
||||
},
|
||||
[onContentChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
{/* 콘텐츠 편집기 */}
|
||||
<Box sx={{ flex: 1, overflow: 'hidden', minHeight: 0 }}>
|
||||
<MarkdownEditor
|
||||
content={doc.content}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
{/* 하단 액션 바 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
mt: 1.5,
|
||||
pt: 1.5,
|
||||
borderTop: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<ExportMenu onExport={onExport} />
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
color="error"
|
||||
onClick={onDelete}
|
||||
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
<DeleteIcon sx={{ fontSize: 14, mr: 0.5 }} />
|
||||
{t('common.delete')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
143
src/renderer/components/meeting/EditableSegment.tsx
Normal file
143
src/renderer/components/meeting/EditableSegment.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
// src/renderer/components/meeting/EditableSegment.tsx
|
||||
// Phase 14.5: 전사 세그먼트 인라인 편집 컴포넌트
|
||||
|
||||
import { useState, useRef, useCallback } from 'react'
|
||||
import { Box, TextField, Tooltip } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
|
||||
interface EditableSegmentProps {
|
||||
segmentId: string
|
||||
timestamp: number // ms, 상대 시간
|
||||
text: string
|
||||
edited: boolean // 수정 여부
|
||||
onEdit: (segmentId: string, newText: string) => void
|
||||
}
|
||||
|
||||
function formatTimestamp(ms: number): string {
|
||||
const totalSec = Math.floor(ms / 1000)
|
||||
const min = Math.floor(totalSec / 60)
|
||||
const sec = totalSec % 60
|
||||
return `[${String(min).padStart(2, '0')}:${String(sec).padStart(2, '0')}]`
|
||||
}
|
||||
|
||||
export function EditableSegment({
|
||||
segmentId,
|
||||
timestamp,
|
||||
text,
|
||||
edited,
|
||||
onEdit,
|
||||
}: EditableSegmentProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [draft, setDraft] = useState(text)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
setDraft(text)
|
||||
setEditing(true)
|
||||
}, [text])
|
||||
|
||||
const handleCommit = useCallback(() => {
|
||||
const trimmed = draft.trim()
|
||||
if (trimmed && trimmed !== text) {
|
||||
onEdit(segmentId, trimmed)
|
||||
}
|
||||
setEditing(false)
|
||||
}, [draft, text, segmentId, onEdit])
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleCommit()
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false)
|
||||
}
|
||||
},
|
||||
[handleCommit],
|
||||
)
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.inactive,
|
||||
pt: '7px',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(timestamp)}
|
||||
</Box>
|
||||
<TextField
|
||||
inputRef={inputRef}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleCommit}
|
||||
autoFocus
|
||||
size="small"
|
||||
fullWidth
|
||||
multiline
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
bgcolor: d3roPalette.bg.input,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
const textNode = (
|
||||
<Box
|
||||
component="span"
|
||||
onClick={handleClick}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 13,
|
||||
color: d3roPalette.text.primary,
|
||||
cursor: 'text',
|
||||
borderBottom: edited ? `1px dashed ${d3roPalette.text.inactive}` : 'none',
|
||||
'&:hover': {
|
||||
color: d3roPalette.text.primary,
|
||||
borderBottomColor: edited ? d3roPalette.accent.amber : undefined,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</Box>
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', alignItems: 'flex-start', gap: 1, mb: 0.5 }}>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.inactive,
|
||||
pt: '2px',
|
||||
flexShrink: 0,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{formatTimestamp(timestamp)}
|
||||
</Box>
|
||||
{edited ? (
|
||||
<Tooltip title={t('meeting.modified')} placement="top">
|
||||
{textNode}
|
||||
</Tooltip>
|
||||
) : (
|
||||
textNode
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
96
src/renderer/components/meeting/ExportMenu.tsx
Normal file
96
src/renderer/components/meeting/ExportMenu.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
// src/renderer/components/meeting/ExportMenu.tsx
|
||||
// Phase 14.5: 다운로드 형식 선택 드롭다운
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Menu, MenuItem } from '@mui/material'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
import { PhysicalButton } from '../ds/PhysicalButton'
|
||||
import { d3roPalette, d3roTypo, d3roFontMono, d3roRadius, d3roShadow } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
import type { MeetingExportFormat } from '@shared/types'
|
||||
|
||||
interface ExportMenuProps {
|
||||
onExport: (format: MeetingExportFormat) => void
|
||||
}
|
||||
|
||||
interface FormatItem {
|
||||
format: MeetingExportFormat
|
||||
label: string
|
||||
}
|
||||
|
||||
export function ExportMenu({ onExport }: ExportMenuProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [anchorEl, setAnchorEl] = useState<HTMLElement | null>(null)
|
||||
const open = Boolean(anchorEl)
|
||||
|
||||
const handleOpen = useCallback((e: React.MouseEvent<HTMLElement>) => {
|
||||
setAnchorEl(e.currentTarget)
|
||||
}, [])
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setAnchorEl(null)
|
||||
}, [])
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(format: MeetingExportFormat) => {
|
||||
onExport(format)
|
||||
handleClose()
|
||||
},
|
||||
[onExport, handleClose],
|
||||
)
|
||||
|
||||
const formats: FormatItem[] = [
|
||||
{ format: 'md', label: 'Markdown (.md)' },
|
||||
{ format: 'txt', label: t('meeting.exportTxt') },
|
||||
{ format: 'pdf', label: 'PDF (.pdf)' },
|
||||
{ format: 'docx', label: t('meeting.exportDocx') },
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={handleOpen}
|
||||
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
<FileDownloadIcon sx={{ fontSize: 14, mr: 0.5 }} />
|
||||
{t('meeting.exportFormat')}
|
||||
</PhysicalButton>
|
||||
|
||||
<Menu
|
||||
anchorEl={anchorEl}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
slotProps={{
|
||||
paper: {
|
||||
sx: {
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.small,
|
||||
boxShadow: d3roShadow.tooltip,
|
||||
minWidth: 160,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
{formats.map(({ format, label }) => (
|
||||
<MenuItem
|
||||
key={format}
|
||||
onClick={() => handleSelect(format)}
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
color: d3roPalette.text.primary,
|
||||
'&:hover': {
|
||||
bgcolor: d3roPalette.bg.cardHover,
|
||||
color: d3roPalette.accent.amber,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
)
|
||||
}
|
||||
83
src/renderer/components/meeting/MarkdownEditor.tsx
Normal file
83
src/renderer/components/meeting/MarkdownEditor.tsx
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// src/renderer/components/meeting/MarkdownEditor.tsx
|
||||
// Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { PhysicalButton } from '../ds/PhysicalButton'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roShadow, d3roRadius } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
|
||||
interface MarkdownEditorProps {
|
||||
content: string
|
||||
onChange: (content: string) => void
|
||||
readOnly?: boolean
|
||||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
content,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: MarkdownEditorProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [preview, setPreview] = useState(true)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 상단 토글 */}
|
||||
{!readOnly && (
|
||||
<Box sx={{ display: 'flex', gap: 0.75, mb: 1 }}>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
selected={preview}
|
||||
onClick={() => setPreview(true)}
|
||||
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
{t('meeting.previewMode')}
|
||||
</PhysicalButton>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
selected={!preview}
|
||||
onClick={() => setPreview(false)}
|
||||
sx={{ height: 32, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
{t('meeting.editMode')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* 콘텐츠 영역 */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
{preview || readOnly ? (
|
||||
<MarkdownRenderer content={content} />
|
||||
) : (
|
||||
<Box
|
||||
component="textarea"
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
minHeight: 300,
|
||||
resize: 'none',
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
color: d3roPalette.text.primary,
|
||||
bgcolor: d3roPalette.bg.input,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.small,
|
||||
boxShadow: d3roShadow.inset,
|
||||
p: 1.5,
|
||||
outline: 'none',
|
||||
lineHeight: 1.7,
|
||||
boxSizing: 'border-box',
|
||||
'&:focus': {
|
||||
borderColor: d3roPalette.accent.amber,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
198
src/renderer/components/meeting/MarkdownRenderer.tsx
Normal file
198
src/renderer/components/meeting/MarkdownRenderer.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
// src/renderer/components/meeting/MarkdownRenderer.tsx
|
||||
// Phase 14.5: react-markdown + remark-gfm 래퍼 — d3roPalette 기반 스타일
|
||||
|
||||
import ReactMarkdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
import { Box } from '@mui/material'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo, d3roRadius } from '../../theme'
|
||||
import type { Components } from 'react-markdown'
|
||||
|
||||
interface MarkdownRendererProps {
|
||||
content: string
|
||||
}
|
||||
|
||||
const components: Components = {
|
||||
h1: ({ children }) => (
|
||||
<Box
|
||||
component="h1"
|
||||
sx={{
|
||||
fontSize: d3roTypo.value.size,
|
||||
fontWeight: 600,
|
||||
color: d3roPalette.accent.amber,
|
||||
mt: 2,
|
||||
mb: 1,
|
||||
borderBottom: `1px solid ${d3roPalette.border.default}`,
|
||||
pb: 0.5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<Box
|
||||
component="h2"
|
||||
sx={{
|
||||
fontSize: d3roTypo.heading.size,
|
||||
fontWeight: 600,
|
||||
color: d3roPalette.text.primary,
|
||||
mt: 1.5,
|
||||
mb: 0.75,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<Box
|
||||
component="h3"
|
||||
sx={{
|
||||
fontSize: d3roTypo.body.size,
|
||||
fontWeight: 600,
|
||||
color: d3roPalette.text.secondary,
|
||||
mt: 1,
|
||||
mb: 0.5,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<Box
|
||||
component="p"
|
||||
sx={{
|
||||
fontSize: d3roTypo.body.size,
|
||||
color: d3roPalette.text.primary,
|
||||
lineHeight: 1.7,
|
||||
mb: 0.75,
|
||||
mt: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
code: ({ children, className }) => {
|
||||
const isBlock = className?.startsWith('language-') ?? false
|
||||
if (isBlock) {
|
||||
return (
|
||||
<Box
|
||||
component="pre"
|
||||
sx={{
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.small,
|
||||
p: 1.5,
|
||||
overflowX: 'auto',
|
||||
my: 1,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
component="code"
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.compact.size,
|
||||
color: d3roPalette.accent.amber,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
px: 0.5,
|
||||
py: 0.25,
|
||||
borderRadius: d3roRadius.xs,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
)
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<Box
|
||||
component="table"
|
||||
sx={{
|
||||
width: '100%',
|
||||
borderCollapse: 'collapse',
|
||||
fontSize: d3roTypo.compact.size,
|
||||
mb: 1,
|
||||
'& th, & td': {
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
p: 0.75,
|
||||
textAlign: 'left',
|
||||
},
|
||||
'& th': {
|
||||
bgcolor: d3roPalette.bg.chassis,
|
||||
color: d3roPalette.text.secondary,
|
||||
fontWeight: 600,
|
||||
},
|
||||
'& tr:nth-of-type(even)': {
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<Box
|
||||
component="li"
|
||||
sx={{
|
||||
fontSize: d3roTypo.body.size,
|
||||
color: d3roPalette.text.primary,
|
||||
lineHeight: 1.7,
|
||||
mb: 0.25,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<Box
|
||||
component="ul"
|
||||
sx={{ pl: 2.5, mb: 0.75, mt: 0 }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<Box
|
||||
component="ol"
|
||||
sx={{ pl: 2.5, mb: 0.75, mt: 0 }}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<Box
|
||||
component="blockquote"
|
||||
sx={{
|
||||
borderLeft: `3px solid ${d3roPalette.accent.amber}`,
|
||||
pl: 1.5,
|
||||
ml: 0,
|
||||
my: 0.75,
|
||||
color: d3roPalette.text.secondary,
|
||||
fontStyle: 'italic',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
),
|
||||
}
|
||||
|
||||
export function MarkdownRenderer({ content }: MarkdownRendererProps): React.ReactElement {
|
||||
return (
|
||||
<Box sx={{ color: d3roPalette.text.primary, fontSize: d3roTypo.body.size }}>
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]} components={components}>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
336
src/renderer/components/meeting/MeetingDetailTabs.tsx
Normal file
336
src/renderer/components/meeting/MeetingDetailTabs.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
// src/renderer/components/meeting/MeetingDetailTabs.tsx
|
||||
// Phase 14.5: 풀스크린 탭 전환 컨테이너 — 전사 + 동적 문서 탭
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Tabs,
|
||||
Tab,
|
||||
IconButton,
|
||||
TextField,
|
||||
Tooltip,
|
||||
} from '@mui/material'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import { TranscriptTab } from './TranscriptTab'
|
||||
import { DocumentTab } from './DocumentTab'
|
||||
import { AddDocumentDialog } from './AddDocumentDialog'
|
||||
import { PhosphorText } from '../ds/PhosphorText'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
import type {
|
||||
MeetingSessionDetail,
|
||||
MeetingDocument,
|
||||
MeetingDocTemplate,
|
||||
MeetingExportFormat,
|
||||
} from '@shared/types'
|
||||
|
||||
interface MeetingDetailTabsProps {
|
||||
detail: MeetingSessionDetail
|
||||
onBack: () => void
|
||||
}
|
||||
|
||||
const AUTOSAVE_DELAY = 500
|
||||
|
||||
// 세그먼트 파싱: rawTranscript를 줄 단위로 분리
|
||||
function parseSegments(
|
||||
rawTranscript: string | null,
|
||||
editedTranscript: string | null,
|
||||
): Array<{ id: string; timestamp: number; text: string; edited: boolean }> {
|
||||
const source = rawTranscript ?? ''
|
||||
const edited = editedTranscript ?? ''
|
||||
// [MM:SS] 텍스트 패턴 파싱 시도
|
||||
const regex = /^\[(\d{2}):(\d{2})\]\s*(.+)$/
|
||||
return source
|
||||
.split('\n')
|
||||
.filter((line) => line.trim())
|
||||
.map((line, idx) => {
|
||||
const m = regex.exec(line.trim())
|
||||
if (m) {
|
||||
const min = parseInt(m[1], 10)
|
||||
const sec = parseInt(m[2], 10)
|
||||
const text = m[3]
|
||||
// 수정본이 있으면 edited 판단
|
||||
const editedLine = edited.split('\n')[idx]?.trim() ?? ''
|
||||
return {
|
||||
id: `seg-${idx}`,
|
||||
timestamp: (min * 60 + sec) * 1000,
|
||||
text,
|
||||
edited: Boolean(editedLine) && editedLine !== line.trim(),
|
||||
}
|
||||
}
|
||||
// 타임스탬프 없는 라인: 인덱스 기반
|
||||
return {
|
||||
id: `seg-${idx}`,
|
||||
timestamp: idx * 5000,
|
||||
text: line.trim(),
|
||||
edited: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function MeetingDetailTabs({
|
||||
detail: initialDetail,
|
||||
onBack,
|
||||
}: MeetingDetailTabsProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [detail, setDetail] = useState<MeetingSessionDetail>(initialDetail)
|
||||
const [documents, setDocuments] = useState<MeetingDocument[]>(initialDetail.documents)
|
||||
const [templates, setTemplates] = useState<MeetingDocTemplate[]>([])
|
||||
const [tabIndex, setTabIndex] = useState(0)
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState(detail.title ?? '')
|
||||
const autoSaveTimers = useRef<Record<string, ReturnType<typeof setTimeout>>>({})
|
||||
|
||||
// 세그먼트 파싱 (rawTranscript 기반)
|
||||
const segments = parseSegments(detail.rawTranscript, detail.editedTranscript)
|
||||
|
||||
// 템플릿 로드
|
||||
useEffect(() => {
|
||||
window.electronAPI.meetingDocTemplate.getAll().then((resp) => {
|
||||
if (resp.success) setTemplates(resp.data)
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── 제목 저장 ──
|
||||
const handleSaveTitle = useCallback(async () => {
|
||||
if (!titleDraft.trim()) return
|
||||
await window.electronAPI.meetingMode.updateTitle({
|
||||
sessionId: detail.id,
|
||||
title: titleDraft.trim(),
|
||||
})
|
||||
setDetail((prev) => ({ ...prev, title: titleDraft.trim() }))
|
||||
setEditingTitle(false)
|
||||
}, [detail.id, titleDraft])
|
||||
|
||||
// ── 세그먼트 편집 ──
|
||||
const handleEditSegment = useCallback(
|
||||
async (segmentId: string, newText: string) => {
|
||||
await window.electronAPI.meetingMode.editSegment({
|
||||
sessionId: detail.id,
|
||||
segmentId,
|
||||
text: newText,
|
||||
})
|
||||
// 로컬 editedTranscript 갱신 (단순 반영)
|
||||
setDetail((prev) => ({ ...prev, editedTranscript: prev.rawTranscript }))
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// ── 전사 저장 ──
|
||||
const handleSaveTranscript = useCallback(
|
||||
async (editedTranscript: string) => {
|
||||
await window.electronAPI.meetingMode.updateTranscript({
|
||||
sessionId: detail.id,
|
||||
editedTranscript,
|
||||
})
|
||||
setDetail((prev) => ({ ...prev, editedTranscript }))
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// ── 문서 내용 자동저장 ──
|
||||
const handleDocContentChange = useCallback(
|
||||
(docId: string, content: string) => {
|
||||
if (autoSaveTimers.current[docId]) clearTimeout(autoSaveTimers.current[docId])
|
||||
autoSaveTimers.current[docId] = setTimeout(async () => {
|
||||
await window.electronAPI.meetingMode.updateDocument({
|
||||
documentId: docId,
|
||||
content,
|
||||
})
|
||||
setDocuments((prev) =>
|
||||
prev.map((d) => (d.id === docId ? { ...d, content } : d)),
|
||||
)
|
||||
}, AUTOSAVE_DELAY)
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 문서 내보내기 ──
|
||||
const handleExportDocument = useCallback(
|
||||
async (docId: string, format: MeetingExportFormat) => {
|
||||
await window.electronAPI.meetingMode.exportDocument({
|
||||
documentId: docId,
|
||||
format,
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 문서 삭제 ──
|
||||
const handleDeleteDocument = useCallback(
|
||||
async (docId: string) => {
|
||||
await window.electronAPI.meetingMode.deleteDocument({ documentId: docId })
|
||||
setDocuments((prev) => {
|
||||
const docTabIndex = prev.findIndex((d) => d.id === docId) + 1 // +1: 전사 탭
|
||||
setTabIndex((currentTab) => {
|
||||
if (currentTab === docTabIndex) return 0
|
||||
if (currentTab > docTabIndex) return currentTab - 1
|
||||
return currentTab
|
||||
})
|
||||
return prev.filter((d) => d.id !== docId)
|
||||
})
|
||||
},
|
||||
[],
|
||||
)
|
||||
|
||||
// ── 문서 생성 ──
|
||||
const handleGenerate = useCallback(
|
||||
async (templateId: string, customPrompt?: string, customTitle?: string) => {
|
||||
setGenerating(true)
|
||||
const resp = await window.electronAPI.meetingMode.generateDocument({
|
||||
sessionId: detail.id,
|
||||
templateId,
|
||||
customPrompt,
|
||||
customTitle,
|
||||
})
|
||||
setGenerating(false)
|
||||
if (resp.success) {
|
||||
const newDoc = resp.data.document
|
||||
setDocuments((prev) => {
|
||||
// 새 문서 탭으로 이동: 전사(0) + 기존문서수 + 1
|
||||
setTabIndex(prev.length + 1)
|
||||
return [...prev, newDoc]
|
||||
})
|
||||
setDialogOpen(false)
|
||||
}
|
||||
},
|
||||
[detail.id],
|
||||
)
|
||||
|
||||
// 탭 수: 전사(1) + 문서들 + [+] 버튼(1)
|
||||
const totalTabCount = 1 + documents.length + 1
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%', overflow: 'hidden' }}>
|
||||
{/* 헤더 */}
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 1,
|
||||
px: 2,
|
||||
py: 1,
|
||||
borderBottom: `1px solid ${d3roPalette.border.subtle}`,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
<IconButton size="small" onClick={onBack}>
|
||||
<ArrowBackIcon sx={{ fontSize: 18, color: d3roPalette.text.secondary }} />
|
||||
</IconButton>
|
||||
|
||||
{editingTitle ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
|
||||
sx={{
|
||||
flex: 1,
|
||||
'& .MuiInputBase-root': { fontFamily: d3roFontMono, fontSize: d3roTypo.compact.size },
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<CheckIcon sx={{ fontSize: 16, color: d3roPalette.accent.amber }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>
|
||||
{detail.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<Tooltip title={t('meeting.editTitle')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 15, color: d3roPalette.text.inactive }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 탭 바 */}
|
||||
<Box sx={{ borderBottom: `1px solid ${d3roPalette.border.subtle}`, flexShrink: 0 }}>
|
||||
<Tabs
|
||||
value={tabIndex}
|
||||
onChange={(_e, v: number) => {
|
||||
// 마지막 탭([+])은 다이얼로그 열기
|
||||
if (v === totalTabCount - 1) {
|
||||
setDialogOpen(true)
|
||||
return
|
||||
}
|
||||
setTabIndex(v)
|
||||
}}
|
||||
sx={{
|
||||
minHeight: 40,
|
||||
'& .MuiTab-root': {
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: d3roTypo.engrave.size,
|
||||
minHeight: 40,
|
||||
py: 0,
|
||||
color: d3roPalette.text.inactive,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '1px',
|
||||
'&.Mui-selected': { color: d3roPalette.accent.amber },
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.accent.amber, height: 2 },
|
||||
}}
|
||||
>
|
||||
<Tab label={t('meeting.transcriptTab')} />
|
||||
{documents.map((doc) => (
|
||||
<Tab key={doc.id} label={doc.title} />
|
||||
))}
|
||||
<Tab
|
||||
icon={<AddIcon sx={{ fontSize: 16 }} />}
|
||||
label={t('meeting.addDocument')}
|
||||
iconPosition="start"
|
||||
sx={{ minWidth: 120 }}
|
||||
/>
|
||||
</Tabs>
|
||||
</Box>
|
||||
|
||||
{/* 탭 콘텐츠 */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', p: 2, minHeight: 0 }}>
|
||||
{tabIndex === 0 && (
|
||||
<TranscriptTab
|
||||
sessionId={detail.id}
|
||||
segments={segments}
|
||||
rawTranscript={detail.rawTranscript}
|
||||
editedTranscript={detail.editedTranscript}
|
||||
memos={detail.memos}
|
||||
onEditSegment={handleEditSegment}
|
||||
onSaveTranscript={handleSaveTranscript}
|
||||
/>
|
||||
)}
|
||||
{documents.map((doc, idx) =>
|
||||
tabIndex === idx + 1 ? (
|
||||
<DocumentTab
|
||||
key={doc.id}
|
||||
document={doc}
|
||||
onContentChange={(content) => handleDocContentChange(doc.id, content)}
|
||||
onExport={(format) => handleExportDocument(doc.id, format)}
|
||||
onDelete={() => handleDeleteDocument(doc.id)}
|
||||
/>
|
||||
) : null,
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 문서 생성 다이얼로그 */}
|
||||
<AddDocumentDialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
onGenerate={handleGenerate}
|
||||
templates={templates}
|
||||
generating={generating}
|
||||
/>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
173
src/renderer/components/meeting/TranscriptTab.tsx
Normal file
173
src/renderer/components/meeting/TranscriptTab.tsx
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
// src/renderer/components/meeting/TranscriptTab.tsx
|
||||
// Phase 14.5: 전사 편집 탭 (상세 페이지용)
|
||||
|
||||
import { useState, useCallback } from 'react'
|
||||
import { Box, Switch, FormControlLabel } from '@mui/material'
|
||||
import { EditableSegment } from './EditableSegment'
|
||||
import { PhysicalButton } from '../ds/PhysicalButton'
|
||||
import { PhosphorText } from '../ds/PhosphorText'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../../theme'
|
||||
import { useI18n } from '../../i18n'
|
||||
import FileDownloadIcon from '@mui/icons-material/FileDownload'
|
||||
|
||||
interface TranscriptTabProps {
|
||||
sessionId: string
|
||||
segments: Array<{ id: string; timestamp: number; text: string; edited: boolean }>
|
||||
rawTranscript: string | null
|
||||
editedTranscript: string | null
|
||||
memos: Array<{ id: string; timestampMs: number; content: string }>
|
||||
onEditSegment: (segmentId: string, newText: string) => void
|
||||
onSaveTranscript: (editedTranscript: string) => void
|
||||
}
|
||||
|
||||
type TimelineItem =
|
||||
| { kind: 'segment'; id: string; timestamp: number; text: string; edited: boolean }
|
||||
| { kind: 'memo'; id: string; timestamp: number; content: string }
|
||||
|
||||
export function TranscriptTab({
|
||||
sessionId: _sessionId,
|
||||
segments,
|
||||
rawTranscript,
|
||||
editedTranscript,
|
||||
memos,
|
||||
onEditSegment,
|
||||
onSaveTranscript,
|
||||
}: TranscriptTabProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [showEdited, setShowEdited] = useState(true)
|
||||
|
||||
const handleDownloadTxt = useCallback(() => {
|
||||
const content = showEdited && editedTranscript ? editedTranscript : (rawTranscript ?? '')
|
||||
const blob = new Blob([content], { type: 'text/plain;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'transcript.txt'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}, [showEdited, editedTranscript, rawTranscript])
|
||||
|
||||
// 세그먼트 + 메모를 타임스탬프 순으로 인터리브
|
||||
const timeline: TimelineItem[] = [
|
||||
...segments.map((s) => ({
|
||||
kind: 'segment' as const,
|
||||
id: s.id,
|
||||
timestamp: s.timestamp,
|
||||
text: s.text,
|
||||
edited: s.edited,
|
||||
})),
|
||||
...memos.map((m) => ({
|
||||
kind: 'memo' as const,
|
||||
id: m.id,
|
||||
timestamp: m.timestampMs,
|
||||
content: m.content,
|
||||
})),
|
||||
].sort((a, b) => a.timestamp - b.timestamp)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
{/* 상단 컨트롤 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
size="small"
|
||||
checked={showEdited}
|
||||
onChange={(e) => {
|
||||
setShowEdited(e.target.checked)
|
||||
if (!e.target.checked && rawTranscript) {
|
||||
onSaveTranscript(rawTranscript)
|
||||
}
|
||||
}}
|
||||
sx={{
|
||||
'& .MuiSwitch-thumb': { bgcolor: d3roPalette.accent.amber },
|
||||
'& .Mui-checked + .MuiSwitch-track': { bgcolor: d3roPalette.accent.amberDim },
|
||||
}}
|
||||
/>
|
||||
}
|
||||
label={
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 11 }}>
|
||||
{showEdited ? t('meeting.editedText') : t('meeting.originalText')}
|
||||
</PhosphorText>
|
||||
}
|
||||
/>
|
||||
<PhysicalButton
|
||||
size="small"
|
||||
onClick={handleDownloadTxt}
|
||||
sx={{ height: 30, fontSize: d3roTypo.engrave.size }}
|
||||
>
|
||||
<FileDownloadIcon sx={{ fontSize: 13, mr: 0.5 }} />
|
||||
{t('meeting.downloadTranscript')}
|
||||
</PhysicalButton>
|
||||
</Box>
|
||||
|
||||
{/* 타임라인 스크롤 영역 */}
|
||||
<Box
|
||||
sx={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
fontFamily: d3roFontMono,
|
||||
lineHeight: 1.8,
|
||||
}}
|
||||
>
|
||||
{timeline.length === 0 ? (
|
||||
<PhosphorText variant="dim" sx={{ fontSize: 12 }}>
|
||||
{t('meeting.noSessions')}
|
||||
</PhosphorText>
|
||||
) : (
|
||||
timeline.map((item) => {
|
||||
if (item.kind === 'segment') {
|
||||
return (
|
||||
<EditableSegment
|
||||
key={item.id}
|
||||
segmentId={item.id}
|
||||
timestamp={item.timestamp}
|
||||
text={item.text}
|
||||
edited={item.edited}
|
||||
onEdit={onEditSegment}
|
||||
/>
|
||||
)
|
||||
}
|
||||
// memo
|
||||
return (
|
||||
<Box
|
||||
key={item.id}
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'flex-start',
|
||||
gap: 1,
|
||||
mb: 0.5,
|
||||
pl: 0.5,
|
||||
borderLeft: `2px solid ${d3roPalette.accent.amberDim}`,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontFamily: d3roFontMono,
|
||||
fontSize: 11,
|
||||
color: d3roPalette.text.inactive,
|
||||
flexShrink: 0,
|
||||
pt: '2px',
|
||||
}}
|
||||
>
|
||||
{`[M ${String(Math.floor(item.timestamp / 60000)).padStart(2, '0')}:${String(Math.floor((item.timestamp % 60000) / 1000)).padStart(2, '0')}]`}
|
||||
</Box>
|
||||
<Box
|
||||
component="span"
|
||||
sx={{
|
||||
fontSize: 12,
|
||||
color: d3roPalette.accent.amber,
|
||||
fontFamily: d3roFontMono,
|
||||
}}
|
||||
>
|
||||
{item.content}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "Protokoll mit LLM erstellen...",
|
||||
"meeting.processingStep.parsing": "Protokoll analysieren...",
|
||||
"meeting.processingStep.saving": "Speichern...",
|
||||
"meeting.processingStep.notifying": "Benachrichtigung senden..."
|
||||
"meeting.processingStep.notifying": "Benachrichtigung senden...",
|
||||
"meeting.transcriptTab": "Transkript",
|
||||
"meeting.addDocument": "Dokument hinzufügen",
|
||||
"meeting.selectTemplate": "Vorlage auswählen",
|
||||
"meeting.generate": "Generieren",
|
||||
"meeting.generating": "Dokument wird generiert...",
|
||||
"meeting.editMode": "Bearbeiten",
|
||||
"meeting.previewMode": "Vorschau",
|
||||
"meeting.modified": "Geändert",
|
||||
"meeting.originalText": "Original",
|
||||
"meeting.editedText": "Bearbeitet",
|
||||
"meeting.exportDocx": "DOCX exportieren",
|
||||
"meeting.exportTxt": "TXT exportieren",
|
||||
"meeting.exportFormat": "Exportieren",
|
||||
"meeting.customPrompt": "Benutzerdefinierter Prompt",
|
||||
"meeting.templateName": "Vorlagenname",
|
||||
"meeting.templatePrompt": "KI-Prompt",
|
||||
"meeting.templateSave": "Vorlage speichern",
|
||||
"meeting.documentDeleted": "Dokument gelöscht",
|
||||
"meeting.autoSaved": "Automatisch gespeichert",
|
||||
"meeting.minutesTemplate": "Besprechungsprotokoll",
|
||||
"meeting.reportTemplate": "Bericht",
|
||||
"meeting.ideaNoteTemplate": "Ideennotiz",
|
||||
"meeting.customTemplate": "Benutzerdefiniert",
|
||||
"meeting.downloadTranscript": "Transkript herunterladen"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,5 +429,29 @@
|
|||
"meeting.processingStep.generating": "Generating minutes with LLM...",
|
||||
"meeting.processingStep.parsing": "Parsing minutes...",
|
||||
"meeting.processingStep.saving": "Saving...",
|
||||
"meeting.processingStep.notifying": "Sending notification..."
|
||||
"meeting.processingStep.notifying": "Sending notification...",
|
||||
"meeting.transcriptTab": "Transcript",
|
||||
"meeting.addDocument": "Add Document",
|
||||
"meeting.selectTemplate": "Select Template",
|
||||
"meeting.generate": "Generate",
|
||||
"meeting.generating": "Generating document...",
|
||||
"meeting.editMode": "Edit",
|
||||
"meeting.previewMode": "Preview",
|
||||
"meeting.modified": "Modified",
|
||||
"meeting.originalText": "Original",
|
||||
"meeting.editedText": "Edited",
|
||||
"meeting.exportDocx": "Export DOCX",
|
||||
"meeting.exportTxt": "Export TXT",
|
||||
"meeting.exportFormat": "Export",
|
||||
"meeting.customPrompt": "Custom Prompt",
|
||||
"meeting.templateName": "Template Name",
|
||||
"meeting.templatePrompt": "AI Prompt",
|
||||
"meeting.templateSave": "Save Template",
|
||||
"meeting.documentDeleted": "Document deleted",
|
||||
"meeting.autoSaved": "Auto-saved",
|
||||
"meeting.minutesTemplate": "Meeting Minutes",
|
||||
"meeting.reportTemplate": "Report",
|
||||
"meeting.ideaNoteTemplate": "Idea Note",
|
||||
"meeting.customTemplate": "Custom",
|
||||
"meeting.downloadTranscript": "Download Transcript"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "Generando actas con LLM...",
|
||||
"meeting.processingStep.parsing": "Analizando actas...",
|
||||
"meeting.processingStep.saving": "Guardando...",
|
||||
"meeting.processingStep.notifying": "Enviando notificación..."
|
||||
"meeting.processingStep.notifying": "Enviando notificación...",
|
||||
"meeting.transcriptTab": "Transcripción",
|
||||
"meeting.addDocument": "Agregar documento",
|
||||
"meeting.selectTemplate": "Seleccionar plantilla",
|
||||
"meeting.generate": "Generar",
|
||||
"meeting.generating": "Generando documento...",
|
||||
"meeting.editMode": "Editar",
|
||||
"meeting.previewMode": "Vista previa",
|
||||
"meeting.modified": "Modificado",
|
||||
"meeting.originalText": "Original",
|
||||
"meeting.editedText": "Editado",
|
||||
"meeting.exportDocx": "Exportar DOCX",
|
||||
"meeting.exportTxt": "Exportar TXT",
|
||||
"meeting.exportFormat": "Exportar",
|
||||
"meeting.customPrompt": "Prompt personalizado",
|
||||
"meeting.templateName": "Nombre de plantilla",
|
||||
"meeting.templatePrompt": "Prompt de IA",
|
||||
"meeting.templateSave": "Guardar plantilla",
|
||||
"meeting.documentDeleted": "Documento eliminado",
|
||||
"meeting.autoSaved": "Guardado automáticamente",
|
||||
"meeting.minutesTemplate": "Actas de reunión",
|
||||
"meeting.reportTemplate": "Informe",
|
||||
"meeting.ideaNoteTemplate": "Nota de ideas",
|
||||
"meeting.customTemplate": "Personalizado",
|
||||
"meeting.downloadTranscript": "Descargar transcripción"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "Génération du compte-rendu avec LLM...",
|
||||
"meeting.processingStep.parsing": "Analyse du compte-rendu...",
|
||||
"meeting.processingStep.saving": "Enregistrement...",
|
||||
"meeting.processingStep.notifying": "Envoi de la notification..."
|
||||
"meeting.processingStep.notifying": "Envoi de la notification...",
|
||||
"meeting.transcriptTab": "Transcription",
|
||||
"meeting.addDocument": "Ajouter un document",
|
||||
"meeting.selectTemplate": "Choisir un modèle",
|
||||
"meeting.generate": "Générer",
|
||||
"meeting.generating": "Génération du document...",
|
||||
"meeting.editMode": "Éditer",
|
||||
"meeting.previewMode": "Aperçu",
|
||||
"meeting.modified": "Modifié",
|
||||
"meeting.originalText": "Original",
|
||||
"meeting.editedText": "Édité",
|
||||
"meeting.exportDocx": "Exporter DOCX",
|
||||
"meeting.exportTxt": "Exporter TXT",
|
||||
"meeting.exportFormat": "Exporter",
|
||||
"meeting.customPrompt": "Invite personnalisée",
|
||||
"meeting.templateName": "Nom du modèle",
|
||||
"meeting.templatePrompt": "Invite IA",
|
||||
"meeting.templateSave": "Sauvegarder le modèle",
|
||||
"meeting.documentDeleted": "Document supprimé",
|
||||
"meeting.autoSaved": "Sauvegarde automatique",
|
||||
"meeting.minutesTemplate": "Compte-rendu",
|
||||
"meeting.reportTemplate": "Rapport",
|
||||
"meeting.ideaNoteTemplate": "Note d'idées",
|
||||
"meeting.customTemplate": "Personnalisé",
|
||||
"meeting.downloadTranscript": "Télécharger la transcription"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "LLMで議事録を生成中...",
|
||||
"meeting.processingStep.parsing": "議事録を解析中...",
|
||||
"meeting.processingStep.saving": "保存中...",
|
||||
"meeting.processingStep.notifying": "通知を送信中..."
|
||||
"meeting.processingStep.notifying": "通知を送信中...",
|
||||
"meeting.transcriptTab": "文字起こし",
|
||||
"meeting.addDocument": "ドキュメント追加",
|
||||
"meeting.selectTemplate": "テンプレートを選択",
|
||||
"meeting.generate": "生成",
|
||||
"meeting.generating": "ドキュメントを生成中...",
|
||||
"meeting.editMode": "編集",
|
||||
"meeting.previewMode": "プレビュー",
|
||||
"meeting.modified": "編集済み",
|
||||
"meeting.originalText": "原文",
|
||||
"meeting.editedText": "編集版",
|
||||
"meeting.exportDocx": "DOCXエクスポート",
|
||||
"meeting.exportTxt": "TXTエクスポート",
|
||||
"meeting.exportFormat": "エクスポート",
|
||||
"meeting.customPrompt": "カスタムプロンプト",
|
||||
"meeting.templateName": "テンプレート名",
|
||||
"meeting.templatePrompt": "AIプロンプト",
|
||||
"meeting.templateSave": "テンプレートを保存",
|
||||
"meeting.documentDeleted": "ドキュメントが削除されました",
|
||||
"meeting.autoSaved": "自動保存済み",
|
||||
"meeting.minutesTemplate": "議事録",
|
||||
"meeting.reportTemplate": "レポート",
|
||||
"meeting.ideaNoteTemplate": "アイデアノート",
|
||||
"meeting.customTemplate": "カスタム",
|
||||
"meeting.downloadTranscript": "文字起こしをダウンロード"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,5 +429,29 @@
|
|||
"meeting.processingStep.generating": "LLM 회의록 생성 중...",
|
||||
"meeting.processingStep.parsing": "회의록 파싱 중...",
|
||||
"meeting.processingStep.saving": "저장 중...",
|
||||
"meeting.processingStep.notifying": "완료 알림 중..."
|
||||
"meeting.processingStep.notifying": "완료 알림 중...",
|
||||
"meeting.transcriptTab": "전사",
|
||||
"meeting.addDocument": "문서 추가",
|
||||
"meeting.selectTemplate": "템플릿 선택",
|
||||
"meeting.generate": "생성",
|
||||
"meeting.generating": "문서 생성 중...",
|
||||
"meeting.editMode": "편집",
|
||||
"meeting.previewMode": "미리보기",
|
||||
"meeting.modified": "수정됨",
|
||||
"meeting.originalText": "원본 보기",
|
||||
"meeting.editedText": "수정본 보기",
|
||||
"meeting.exportDocx": "DOCX 내보내기",
|
||||
"meeting.exportTxt": "TXT 내보내기",
|
||||
"meeting.exportFormat": "내보내기",
|
||||
"meeting.customPrompt": "커스텀 프롬프트",
|
||||
"meeting.templateName": "템플릿 이름",
|
||||
"meeting.templatePrompt": "AI 프롬프트",
|
||||
"meeting.templateSave": "템플릿 저장",
|
||||
"meeting.documentDeleted": "문서가 삭제되었습니다",
|
||||
"meeting.autoSaved": "자동 저장됨",
|
||||
"meeting.minutesTemplate": "회의록",
|
||||
"meeting.reportTemplate": "보고서",
|
||||
"meeting.ideaNoteTemplate": "아이디어 노트",
|
||||
"meeting.customTemplate": "커스텀",
|
||||
"meeting.downloadTranscript": "전사 다운로드"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "Gerando ata com LLM...",
|
||||
"meeting.processingStep.parsing": "Analisando ata...",
|
||||
"meeting.processingStep.saving": "Salvando...",
|
||||
"meeting.processingStep.notifying": "Enviando notificação..."
|
||||
"meeting.processingStep.notifying": "Enviando notificação...",
|
||||
"meeting.transcriptTab": "Transcrição",
|
||||
"meeting.addDocument": "Adicionar documento",
|
||||
"meeting.selectTemplate": "Selecionar modelo",
|
||||
"meeting.generate": "Gerar",
|
||||
"meeting.generating": "Gerando documento...",
|
||||
"meeting.editMode": "Editar",
|
||||
"meeting.previewMode": "Visualizar",
|
||||
"meeting.modified": "Modificado",
|
||||
"meeting.originalText": "Original",
|
||||
"meeting.editedText": "Editado",
|
||||
"meeting.exportDocx": "Exportar DOCX",
|
||||
"meeting.exportTxt": "Exportar TXT",
|
||||
"meeting.exportFormat": "Exportar",
|
||||
"meeting.customPrompt": "Prompt personalizado",
|
||||
"meeting.templateName": "Nome do modelo",
|
||||
"meeting.templatePrompt": "Prompt de IA",
|
||||
"meeting.templateSave": "Salvar modelo",
|
||||
"meeting.documentDeleted": "Documento excluído",
|
||||
"meeting.autoSaved": "Salvo automaticamente",
|
||||
"meeting.minutesTemplate": "Ata de reunião",
|
||||
"meeting.reportTemplate": "Relatório",
|
||||
"meeting.ideaNoteTemplate": "Nota de ideias",
|
||||
"meeting.customTemplate": "Personalizado",
|
||||
"meeting.downloadTranscript": "Baixar transcrição"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "Создание протокола с помощью LLM...",
|
||||
"meeting.processingStep.parsing": "Разбор протокола...",
|
||||
"meeting.processingStep.saving": "Сохранение...",
|
||||
"meeting.processingStep.notifying": "Отправка уведомления..."
|
||||
"meeting.processingStep.notifying": "Отправка уведомления...",
|
||||
"meeting.transcriptTab": "Транскрипция",
|
||||
"meeting.addDocument": "Добавить документ",
|
||||
"meeting.selectTemplate": "Выбрать шаблон",
|
||||
"meeting.generate": "Создать",
|
||||
"meeting.generating": "Создание документа...",
|
||||
"meeting.editMode": "Редактировать",
|
||||
"meeting.previewMode": "Предпросмотр",
|
||||
"meeting.modified": "Изменено",
|
||||
"meeting.originalText": "Оригинал",
|
||||
"meeting.editedText": "Отредактировано",
|
||||
"meeting.exportDocx": "Экспорт DOCX",
|
||||
"meeting.exportTxt": "Экспорт TXT",
|
||||
"meeting.exportFormat": "Экспорт",
|
||||
"meeting.customPrompt": "Пользовательский запрос",
|
||||
"meeting.templateName": "Название шаблона",
|
||||
"meeting.templatePrompt": "ИИ-запрос",
|
||||
"meeting.templateSave": "Сохранить шаблон",
|
||||
"meeting.documentDeleted": "Документ удалён",
|
||||
"meeting.autoSaved": "Автосохранение",
|
||||
"meeting.minutesTemplate": "Протокол встречи",
|
||||
"meeting.reportTemplate": "Отчёт",
|
||||
"meeting.ideaNoteTemplate": "Заметка с идеями",
|
||||
"meeting.customTemplate": "Пользовательский",
|
||||
"meeting.downloadTranscript": "Скачать транскрипцию"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "กำลังสร้างรายงานด้วย LLM...",
|
||||
"meeting.processingStep.parsing": "กำลังวิเคราะห์รายงาน...",
|
||||
"meeting.processingStep.saving": "กำลังบันทึก...",
|
||||
"meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน..."
|
||||
"meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน...",
|
||||
"meeting.transcriptTab": "การถอดความ",
|
||||
"meeting.addDocument": "เพิ่มเอกสาร",
|
||||
"meeting.selectTemplate": "เลือกแม่แบบ",
|
||||
"meeting.generate": "สร้าง",
|
||||
"meeting.generating": "กำลังสร้างเอกสาร...",
|
||||
"meeting.editMode": "แก้ไข",
|
||||
"meeting.previewMode": "ดูตัวอย่าง",
|
||||
"meeting.modified": "แก้ไขแล้ว",
|
||||
"meeting.originalText": "ต้นฉบับ",
|
||||
"meeting.editedText": "แก้ไขแล้ว",
|
||||
"meeting.exportDocx": "ส่งออก DOCX",
|
||||
"meeting.exportTxt": "ส่งออก TXT",
|
||||
"meeting.exportFormat": "ส่งออก",
|
||||
"meeting.customPrompt": "พรอมต์กำหนดเอง",
|
||||
"meeting.templateName": "ชื่อแม่แบบ",
|
||||
"meeting.templatePrompt": "พรอมต์ AI",
|
||||
"meeting.templateSave": "บันทึกแม่แบบ",
|
||||
"meeting.documentDeleted": "ลบเอกสารแล้ว",
|
||||
"meeting.autoSaved": "บันทึกอัตโนมัติ",
|
||||
"meeting.minutesTemplate": "รายงานการประชุม",
|
||||
"meeting.reportTemplate": "รายงาน",
|
||||
"meeting.ideaNoteTemplate": "บันทึกไอเดีย",
|
||||
"meeting.customTemplate": "กำหนดเอง",
|
||||
"meeting.downloadTranscript": "ดาวน์โหลดการถอดความ"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "Đang tạo biên bản với LLM...",
|
||||
"meeting.processingStep.parsing": "Đang phân tích biên bản...",
|
||||
"meeting.processingStep.saving": "Đang lưu...",
|
||||
"meeting.processingStep.notifying": "Đang gửi thông báo..."
|
||||
"meeting.processingStep.notifying": "Đang gửi thông báo...",
|
||||
"meeting.transcriptTab": "Bản ghi",
|
||||
"meeting.addDocument": "Thêm tài liệu",
|
||||
"meeting.selectTemplate": "Chọn mẫu",
|
||||
"meeting.generate": "Tạo",
|
||||
"meeting.generating": "Đang tạo tài liệu...",
|
||||
"meeting.editMode": "Chỉnh sửa",
|
||||
"meeting.previewMode": "Xem trước",
|
||||
"meeting.modified": "Đã chỉnh sửa",
|
||||
"meeting.originalText": "Bản gốc",
|
||||
"meeting.editedText": "Đã chỉnh sửa",
|
||||
"meeting.exportDocx": "Xuất DOCX",
|
||||
"meeting.exportTxt": "Xuất TXT",
|
||||
"meeting.exportFormat": "Xuất",
|
||||
"meeting.customPrompt": "Lệnh tùy chỉnh",
|
||||
"meeting.templateName": "Tên mẫu",
|
||||
"meeting.templatePrompt": "Lệnh AI",
|
||||
"meeting.templateSave": "Lưu mẫu",
|
||||
"meeting.documentDeleted": "Đã xóa tài liệu",
|
||||
"meeting.autoSaved": "Đã lưu tự động",
|
||||
"meeting.minutesTemplate": "Biên bản họp",
|
||||
"meeting.reportTemplate": "Báo cáo",
|
||||
"meeting.ideaNoteTemplate": "Ghi chú ý tưởng",
|
||||
"meeting.customTemplate": "Tùy chỉnh",
|
||||
"meeting.downloadTranscript": "Tải bản ghi"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "正在以 LLM 產生會議記錄...",
|
||||
"meeting.processingStep.parsing": "正在解析會議記錄...",
|
||||
"meeting.processingStep.saving": "正在儲存...",
|
||||
"meeting.processingStep.notifying": "正在發送通知..."
|
||||
"meeting.processingStep.notifying": "正在發送通知...",
|
||||
"meeting.transcriptTab": "轉錄",
|
||||
"meeting.addDocument": "新增文件",
|
||||
"meeting.selectTemplate": "選擇範本",
|
||||
"meeting.generate": "生成",
|
||||
"meeting.generating": "正在生成文件...",
|
||||
"meeting.editMode": "編輯",
|
||||
"meeting.previewMode": "預覽",
|
||||
"meeting.modified": "已修改",
|
||||
"meeting.originalText": "原文",
|
||||
"meeting.editedText": "編輯版",
|
||||
"meeting.exportDocx": "匯出 DOCX",
|
||||
"meeting.exportTxt": "匯出 TXT",
|
||||
"meeting.exportFormat": "匯出",
|
||||
"meeting.customPrompt": "自訂提示",
|
||||
"meeting.templateName": "範本名稱",
|
||||
"meeting.templatePrompt": "AI 提示",
|
||||
"meeting.templateSave": "儲存範本",
|
||||
"meeting.documentDeleted": "文件已刪除",
|
||||
"meeting.autoSaved": "已自動儲存",
|
||||
"meeting.minutesTemplate": "會議記錄",
|
||||
"meeting.reportTemplate": "報告",
|
||||
"meeting.ideaNoteTemplate": "創意筆記",
|
||||
"meeting.customTemplate": "自訂",
|
||||
"meeting.downloadTranscript": "下載轉錄"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -229,5 +229,29 @@
|
|||
"meeting.processingStep.generating": "正在用 LLM 生成会议纪要...",
|
||||
"meeting.processingStep.parsing": "正在解析会议纪要...",
|
||||
"meeting.processingStep.saving": "正在保存...",
|
||||
"meeting.processingStep.notifying": "正在发送通知..."
|
||||
"meeting.processingStep.notifying": "正在发送通知...",
|
||||
"meeting.transcriptTab": "转录",
|
||||
"meeting.addDocument": "添加文档",
|
||||
"meeting.selectTemplate": "选择模板",
|
||||
"meeting.generate": "生成",
|
||||
"meeting.generating": "正在生成文档...",
|
||||
"meeting.editMode": "编辑",
|
||||
"meeting.previewMode": "预览",
|
||||
"meeting.modified": "已修改",
|
||||
"meeting.originalText": "原文",
|
||||
"meeting.editedText": "编辑版",
|
||||
"meeting.exportDocx": "导出 DOCX",
|
||||
"meeting.exportTxt": "导出 TXT",
|
||||
"meeting.exportFormat": "导出",
|
||||
"meeting.customPrompt": "自定义提示",
|
||||
"meeting.templateName": "模板名称",
|
||||
"meeting.templatePrompt": "AI 提示",
|
||||
"meeting.templateSave": "保存模板",
|
||||
"meeting.documentDeleted": "文档已删除",
|
||||
"meeting.autoSaved": "已自动保存",
|
||||
"meeting.minutesTemplate": "会议记录",
|
||||
"meeting.reportTemplate": "报告",
|
||||
"meeting.ideaNoteTemplate": "创意笔记",
|
||||
"meeting.customTemplate": "自定义",
|
||||
"meeting.downloadTranscript": "下载转录"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,15 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
Box,
|
||||
TextField,
|
||||
IconButton,
|
||||
Tooltip,
|
||||
LinearProgress,
|
||||
Typography,
|
||||
TextField,
|
||||
} from '@mui/material'
|
||||
import AddIcon from '@mui/icons-material/Add'
|
||||
import StopIcon from '@mui/icons-material/Stop'
|
||||
import ArrowBackIcon from '@mui/icons-material/ArrowBack'
|
||||
import DeleteIcon from '@mui/icons-material/Delete'
|
||||
import PictureAsPdfIcon from '@mui/icons-material/PictureAsPdf'
|
||||
import DescriptionIcon from '@mui/icons-material/Description'
|
||||
import EditIcon from '@mui/icons-material/Edit'
|
||||
import CheckIcon from '@mui/icons-material/Check'
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton, ScreenPanel } from '../components/ds'
|
||||
import { MeetingDetailTabs } from '../components/meeting/MeetingDetailTabs'
|
||||
import { EditableSegment } from '../components/meeting/EditableSegment'
|
||||
import { PageHeader, EmptyStateCard } from '../components/shared'
|
||||
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
|
||||
import { useI18n } from '../i18n'
|
||||
|
|
@ -57,8 +50,6 @@ export function MeetingModePage(): React.ReactElement {
|
|||
|
||||
// 상세 뷰 상태
|
||||
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
|
||||
const [editingTitle, setEditingTitle] = useState(false)
|
||||
const [titleDraft, setTitleDraft] = useState('')
|
||||
|
||||
// ── 세션 목록 로드 ──
|
||||
const loadSessions = useCallback(async () => {
|
||||
|
|
@ -183,32 +174,6 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!detail) return
|
||||
await window.electronAPI.meetingMode.deleteSession({ sessionId: detail.id })
|
||||
setDetail(null)
|
||||
setView('list')
|
||||
}, [detail])
|
||||
|
||||
const handleExportPdf = useCallback(async () => {
|
||||
if (!detail) return
|
||||
await window.electronAPI.meetingMode.exportPdf({ sessionId: detail.id })
|
||||
}, [detail])
|
||||
|
||||
const handleExportMarkdown = useCallback(async () => {
|
||||
if (!detail) return
|
||||
await window.electronAPI.meetingMode.exportMarkdown({ sessionId: detail.id })
|
||||
}, [detail])
|
||||
|
||||
const handleSaveTitle = useCallback(async () => {
|
||||
if (!detail || !titleDraft.trim()) return
|
||||
await window.electronAPI.meetingMode.updateTitle({
|
||||
sessionId: detail.id,
|
||||
title: titleDraft.trim(),
|
||||
})
|
||||
setDetail({ ...detail, title: titleDraft.trim() })
|
||||
setEditingTitle(false)
|
||||
}, [detail, titleDraft])
|
||||
|
||||
// ── 유틸 ──
|
||||
const formatTime = (ms: number): string => {
|
||||
|
|
@ -355,17 +320,14 @@ export function MeetingModePage(): React.ReactElement {
|
|||
}}
|
||||
>
|
||||
{segments.map((seg) => (
|
||||
<Box key={seg.id} sx={{ mb: 0.5 }}>
|
||||
<Typography
|
||||
component="span"
|
||||
sx={{ color: d3roPalette.text.inactive, fontSize: 11, mr: 1, fontFamily: d3roFontMono }}
|
||||
>
|
||||
[{formatTime(seg.timestamp - (recordingStartRef.current || seg.timestamp))}]
|
||||
</Typography>
|
||||
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
|
||||
{seg.text}
|
||||
</Typography>
|
||||
</Box>
|
||||
<EditableSegment
|
||||
key={seg.id}
|
||||
segmentId={seg.id}
|
||||
timestamp={Math.max(0, seg.timestamp - (recordingStartRef.current || seg.timestamp))}
|
||||
text={seg.text}
|
||||
edited={false}
|
||||
onEdit={() => { /* 녹음 중 편집 비활성 */ }}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</ScreenPanel>
|
||||
|
|
@ -445,156 +407,11 @@ export function MeetingModePage(): React.ReactElement {
|
|||
|
||||
// ── 렌더: 상세 뷰 ──
|
||||
if (view === 'detail' && detail) {
|
||||
const durationMin = detail.durationMs ? Math.round(detail.durationMs / 60000) : 0
|
||||
|
||||
return (
|
||||
<Box sx={{ p: 3, overflow: 'auto' }}>
|
||||
{/* 헤더 */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
|
||||
<IconButton size="small" onClick={() => { setDetail(null); setView('list') }}>
|
||||
<ArrowBackIcon sx={{ fontSize: 18 }} />
|
||||
</IconButton>
|
||||
{editingTitle ? (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1 }}>
|
||||
<TextField
|
||||
size="small"
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSaveTitle()}
|
||||
sx={{ flex: 1 }}
|
||||
autoFocus
|
||||
/>
|
||||
<IconButton size="small" onClick={handleSaveTitle}>
|
||||
<CheckIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<PhosphorText variant="label" sx={{ flex: 1 }}>
|
||||
{detail.title ?? t('meeting.untitled')}
|
||||
</PhosphorText>
|
||||
<Tooltip title={t('meeting.editTitle')}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => { setEditingTitle(true); setTitleDraft(detail.title ?? '') }}
|
||||
>
|
||||
<EditIcon sx={{ fontSize: 16 }} />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 메타 */}
|
||||
<PhosphorText variant="dim" sx={{ mb: 3 }}>
|
||||
{formatDate(detail.startedAt)}
|
||||
{detail.endedAt ? ` ~ ${new Date(detail.endedAt).getHours()}:${String(new Date(detail.endedAt).getMinutes()).padStart(2, '0')}` : ''}
|
||||
{' · '}
|
||||
{t('meeting.duration', { minutes: durationMin })}
|
||||
</PhosphorText>
|
||||
|
||||
{/* 회의록 본문 */}
|
||||
{detail.minutes && (
|
||||
<>
|
||||
{/* 요약 */}
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.summary')}</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ whiteSpace: 'pre-wrap' }}>
|
||||
{detail.minutes.summary}
|
||||
</PhosphorText>
|
||||
</MetalCard>
|
||||
|
||||
{/* 결정사항 */}
|
||||
{detail.minutes.decisions.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.decisions')}</PhosphorText>
|
||||
{detail.minutes.decisions.map((d, i) => (
|
||||
<PhosphorText key={i} variant="body" sx={{ mb: 0.5 }}>
|
||||
{'• '}{d}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 할 일 */}
|
||||
{detail.minutes.actionItems.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.actionItems')}</PhosphorText>
|
||||
{detail.minutes.actionItems.map((item, i) => (
|
||||
<PhosphorText key={i} variant="body" sx={{ mb: 0.5 }}>
|
||||
{'☐ '}{item.assignee ? `${item.assignee}: ` : ''}{item.task}
|
||||
{item.deadline ? ` (${item.deadline})` : ''}
|
||||
</PhosphorText>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 타임라인 */}
|
||||
{detail.minutes.timeline.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.timeline')}</PhosphorText>
|
||||
{detail.minutes.timeline.map((tl, i) => (
|
||||
<Box key={i} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{tl.time}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body" sx={{ flex: 1 }}>
|
||||
{tl.type === 'memo' ? '📝 ' : ''}{tl.content}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{detail.status === 'error' && detail.errorMessage && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ color: d3roPalette.tag.red, mb: 1 }}>
|
||||
{t('meeting.error')}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body">{detail.errorMessage}</PhosphorText>
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 메모 */}
|
||||
{detail.memos.length > 0 && (
|
||||
<MetalCard sx={{ mb: 2 }}>
|
||||
<PhosphorText variant="label" sx={{ mb: 1 }}>{t('meeting.memos')}</PhosphorText>
|
||||
{detail.memos.map((m) => (
|
||||
<Box key={m.id} sx={{ display: 'flex', gap: 2, mb: 0.5 }}>
|
||||
<PhosphorText variant="dim" sx={{ fontFamily: d3roFontMono, minWidth: 50 }}>
|
||||
{formatTime(m.timestampMs)}
|
||||
</PhosphorText>
|
||||
<PhosphorText variant="body">{m.content}</PhosphorText>
|
||||
</Box>
|
||||
))}
|
||||
</MetalCard>
|
||||
)}
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<Box sx={{ display: 'flex', gap: 1, mt: 3 }}>
|
||||
<Tooltip title={t('meeting.exportPdf')}>
|
||||
<PhysicalButton size="small" onClick={handleExportPdf}>
|
||||
<PictureAsPdfIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportPdf')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.exportMarkdown')}>
|
||||
<PhysicalButton size="small" onClick={handleExportMarkdown}>
|
||||
<DescriptionIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.exportMarkdown')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('meeting.delete')}>
|
||||
<PhysicalButton size="small" color="error" onClick={handleDelete}>
|
||||
<DeleteIcon sx={{ fontSize: 16, mr: 0.5 }} />
|
||||
{t('meeting.delete')}
|
||||
</PhysicalButton>
|
||||
</Tooltip>
|
||||
</Box>
|
||||
</Box>
|
||||
<MeetingDetailTabs
|
||||
detail={detail}
|
||||
onBack={() => { setDetail(null); setView('list') }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -158,6 +158,13 @@ export enum ErrorCode {
|
|||
MeetingSessionNotFound = 883,
|
||||
MeetingExportFailed = 884,
|
||||
MeetingPdfFailed = 885,
|
||||
MeetingDocumentNotFound = 886,
|
||||
MeetingDocGenerationFailed = 887,
|
||||
MeetingDocExportFailed = 888,
|
||||
MeetingTranscriptUpdateFailed = 889,
|
||||
MeetingDocTemplateNotFound = 890,
|
||||
MeetingDocTemplateBuiltinDelete = 891,
|
||||
MeetingDocxExportFailed = 892,
|
||||
|
||||
// === Config (800-849) ===
|
||||
ConfigReadFailed = 800,
|
||||
|
|
|
|||
|
|
@ -317,14 +317,32 @@ export const IPC_CHANNELS = {
|
|||
UPDATE_TITLE: 'meetingMode:updateTitle',
|
||||
EXPORT_PDF: 'meetingMode:exportPdf',
|
||||
EXPORT_MARKDOWN: 'meetingMode:exportMarkdown',
|
||||
// Phase 14.5: 문서 생성/편집/내보내기
|
||||
UPDATE_TRANSCRIPT: 'meetingMode:updateTranscript',
|
||||
GENERATE_DOCUMENT: 'meetingMode:generateDocument',
|
||||
GET_DOCUMENTS: 'meetingMode:getDocuments',
|
||||
UPDATE_DOCUMENT: 'meetingMode:updateDocument',
|
||||
DELETE_DOCUMENT: 'meetingMode:deleteDocument',
|
||||
EXPORT_DOCUMENT: 'meetingMode:exportDocument',
|
||||
EXPORT_TRANSCRIPT: 'meetingMode:exportTranscript',
|
||||
EDIT_SEGMENT: 'meetingMode:editSegment',
|
||||
// Main → Renderer events
|
||||
STATE_CHANGED: 'meetingMode:stateChanged',
|
||||
SEGMENT: 'meetingMode:segment',
|
||||
PROCESSING_PROGRESS: 'meetingMode:processingProgress',
|
||||
SESSION_COMPLETED: 'meetingMode:sessionCompleted',
|
||||
DOC_GENERATING_PROGRESS: 'meetingMode:docGeneratingProgress',
|
||||
ERROR: 'meetingMode:error',
|
||||
},
|
||||
|
||||
// ── Phase 14.5: Meeting Document Templates ──
|
||||
MEETING_DOC_TEMPLATE: {
|
||||
GET_ALL: 'meetingDocTemplate:getAll',
|
||||
CREATE: 'meetingDocTemplate:create',
|
||||
UPDATE: 'meetingDocTemplate:update',
|
||||
DELETE: 'meetingDocTemplate:delete',
|
||||
},
|
||||
|
||||
// ── Phase 11: License & Monetization ──
|
||||
LICENSE: {
|
||||
GET_INFO: 'license:getInfo',
|
||||
|
|
|
|||
|
|
@ -1290,9 +1290,11 @@ export interface MeetingSessionSummary {
|
|||
|
||||
export interface MeetingSessionDetail extends MeetingSessionSummary {
|
||||
rawTranscript: string | null
|
||||
editedTranscript: string | null
|
||||
minutesMarkdown: string | null
|
||||
minutes: MeetingMinutes | null
|
||||
memos: MeetingMemo[]
|
||||
documents: MeetingDocument[]
|
||||
sttModel: string | null
|
||||
llmModel: string | null
|
||||
errorMessage: string | null
|
||||
|
|
@ -1358,3 +1360,96 @@ export interface MeetingProcessingProgress {
|
|||
step: MeetingProcessingStep
|
||||
percent: number
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Phase 14.5: Meeting Document Types
|
||||
// ============================================================
|
||||
|
||||
export type MeetingDocTemplateType = 'minutes' | 'report' | 'idea-note' | 'custom'
|
||||
|
||||
export interface MeetingDocTemplate {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
templateType: MeetingDocTemplateType
|
||||
systemPrompt: string
|
||||
isBuiltin: boolean
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface MeetingDocument {
|
||||
id: string
|
||||
sessionId: string
|
||||
templateType: MeetingDocTemplateType
|
||||
title: string
|
||||
content: string
|
||||
promptUsed: string | null
|
||||
llmModel: string | null
|
||||
llmLatencyMs: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export interface MeetingGenerateDocParams {
|
||||
sessionId: string
|
||||
templateId: string
|
||||
customPrompt?: string
|
||||
customTitle?: string
|
||||
}
|
||||
|
||||
export interface MeetingGenerateDocResult {
|
||||
document: MeetingDocument
|
||||
}
|
||||
|
||||
export interface MeetingUpdateDocParams {
|
||||
documentId: string
|
||||
content: string
|
||||
}
|
||||
|
||||
export interface MeetingDeleteDocParams {
|
||||
documentId: string
|
||||
}
|
||||
|
||||
export interface MeetingGetDocsParams {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export interface MeetingUpdateTranscriptParams {
|
||||
sessionId: string
|
||||
editedTranscript: string
|
||||
}
|
||||
|
||||
export type MeetingExportFormat = 'md' | 'pdf' | 'txt' | 'docx'
|
||||
|
||||
export interface MeetingExportDocParams {
|
||||
documentId: string
|
||||
format: MeetingExportFormat
|
||||
}
|
||||
|
||||
export interface MeetingExportTranscriptParams {
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export interface MeetingDocGeneratingProgress {
|
||||
sessionId: string
|
||||
templateId: string
|
||||
percent: number
|
||||
}
|
||||
|
||||
export interface CreateMeetingDocTemplateParams {
|
||||
name: string
|
||||
description: string
|
||||
systemPrompt: string
|
||||
}
|
||||
|
||||
export interface UpdateMeetingDocTemplateParams {
|
||||
id: string
|
||||
name?: string
|
||||
description?: string
|
||||
systemPrompt?: string
|
||||
}
|
||||
|
||||
export interface DeleteMeetingDocTemplateParams {
|
||||
id: string
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue