Phase 14 구현: 회의 모드 (Meeting Mode)

실시간 녹음 + 타임스탬프 메모 + STT 전사 + LLM 구조화 회의록 + PDF/MD 내보내기.
CaptionService 연동, 동시 사용 충돌 방지, 긴 회의 2-pass 요약 지원.
DB 2테이블, IPC 15채널, UI 3뷰(목록/녹음/상세), 12개 locale i18n.
This commit is contained in:
Yun Chan 2026-04-08 01:16:52 +09:00
parent dd68fc9ccd
commit d0c4f9ee53
38 changed files with 2550 additions and 769 deletions

View file

@ -58,6 +58,7 @@ export async function bootstrap(): Promise<void> {
{ name: 'voice-mode', critical: false, fn: initVoiceMode },
{ name: 'llm-polling', critical: false, fn: initLLMPolling },
{ name: 'meeting-summary-wiring', critical: false, fn: initMeetingSummaryWiring },
{ name: 'meeting-mode', critical: false, fn: initMeetingMode },
]
for (const step of steps) {
@ -273,6 +274,12 @@ async function initMeetingSummaryWiring(): Promise<void> {
const summaryService = getMeetingSummaryService()
captionService.on('session-saved', (summary: { sessionId: string }) => {
// 회의 모드가 CaptionService를 사용 중이면 MeetingSummary 자동 생성 건너뛰기
const { getMeetingModeService } = require('./services/MeetingModeService') as { getMeetingModeService: () => { isMeetingModeActive: () => boolean } }
if (getMeetingModeService().isMeetingModeActive()) {
logger.info('Skipping auto meeting summary: meeting mode active')
return
}
summaryService.onCaptionSessionSaved(summary).catch((err) => {
logger.warn('Auto meeting summary failed:', err)
})
@ -282,6 +289,12 @@ async function initMeetingSummaryWiring(): Promise<void> {
}
}
async function initMeetingMode(): Promise<void> {
// MeetingModeService 싱글톤 초기화 (첫 접근 시 인스턴스 생성)
const { getMeetingModeService } = await import('./services/MeetingModeService')
getMeetingModeService()
}
// ── HistoryPopup IPC 연동 ────────────────────────────
function setupHistoryPopupIPC(): void {

View file

@ -129,6 +129,40 @@ export function initDatabase(): BetterSQLite3Database<typeof schema> {
CREATE INDEX IF NOT EXISTS idx_rag_chunks_document_id ON rag_chunks(document_id);
`)
// Phase 14: Meeting Mode 테이블 생성
sqlite.exec(`
CREATE TABLE IF NOT EXISTS meeting_sessions (
id TEXT PRIMARY KEY,
title TEXT,
status TEXT NOT NULL DEFAULT 'recording',
started_at INTEGER NOT NULL,
ended_at INTEGER,
duration_ms INTEGER,
raw_transcript TEXT,
minutes_markdown TEXT,
minutes_json TEXT,
stt_model TEXT,
llm_model TEXT,
stt_latency_ms INTEGER,
llm_latency_ms INTEGER,
error_message TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_created_at ON meeting_sessions(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_meeting_sessions_status ON meeting_sessions(status);
CREATE TABLE IF NOT EXISTS meeting_memos (
id TEXT PRIMARY KEY,
session_id TEXT NOT NULL,
content TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_meeting_memos_session_id ON meeting_memos(session_id);
CREATE INDEX IF NOT EXISTS idx_meeting_memos_timestamp ON meeting_memos(timestamp_ms);
`)
// Phase 12.2: history 테이블에 summary_text 컬럼 마이그레이션
try {
const columns = sqlite.pragma('table_info(history)') as Array<{ name: string }>

View file

@ -152,6 +152,56 @@ export type NewRAGDocumentRow = typeof ragDocuments.$inferInsert
export type RAGChunkRow = typeof ragChunks.$inferSelect
export type NewRAGChunkRow = typeof ragChunks.$inferInsert
// ── meetingSessions (Phase 14) ──────────────────────────
export const meetingSessions = sqliteTable(
'meeting_sessions',
{
id: text('id').primaryKey(),
title: text('title'),
status: text('status', {
enum: ['recording', 'processing', 'completed', 'error'],
}).notNull().default('recording'),
startedAt: integer('started_at').notNull(),
endedAt: integer('ended_at'),
durationMs: integer('duration_ms'),
rawTranscript: text('raw_transcript'),
minutesMarkdown: text('minutes_markdown'),
minutesJson: text('minutes_json'),
sttModel: text('stt_model'),
llmModel: text('llm_model'),
sttLatencyMs: integer('stt_latency_ms'),
llmLatencyMs: integer('llm_latency_ms'),
errorMessage: text('error_message'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
index('idx_meeting_sessions_created_at').on(table.createdAt),
index('idx_meeting_sessions_status').on(table.status),
]
)
// ── meetingMemos (Phase 14) ─────────────────────────────
export const meetingMemos = sqliteTable(
'meeting_memos',
{
id: text('id').primaryKey(),
sessionId: text('session_id').notNull(),
content: text('content').notNull(),
timestampMs: integer('timestamp_ms').notNull(),
createdAt: integer('created_at').notNull(),
},
(table) => [
index('idx_meeting_memos_session_id').on(table.sessionId),
index('idx_meeting_memos_timestamp').on(table.timestampMs),
]
)
export type MeetingSessionRow = typeof meetingSessions.$inferSelect
export type NewMeetingSessionRow = typeof meetingSessions.$inferInsert
export type MeetingMemoRow = typeof meetingMemos.$inferSelect
export type NewMeetingMemoRow = typeof meetingMemos.$inferInsert
// ── 타입 추출 ────────────────────────────────────────────
export type History = typeof history.$inferSelect
export type NewHistory = typeof history.$inferInsert

View file

@ -23,6 +23,7 @@ import { registerTemplateHandlers } from './template-handlers'
import { registerVoiceConversationHandlers } from './voice-conversation-handlers'
import { registerRAGHandlers } from './rag-handlers'
import { registerVoiceActionHandlers } from './voice-action-handlers'
import { registerMeetingModeHandlers } from './meeting-mode-handlers'
import { getLogger } from '../services/LoggerService'
const logger = getLogger('ipc')
@ -51,5 +52,6 @@ export function registerAllIpcHandlers(): void {
registerVoiceConversationHandlers()
registerRAGHandlers()
registerVoiceActionHandlers()
registerMeetingModeHandlers()
logger.info('All IPC handlers registered')
}

View file

@ -0,0 +1,115 @@
// src/main/ipc/meeting-mode-handlers.ts
// Phase 14: Meeting Mode IPC 핸들러
import { ipcMain } from 'electron'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { ErrorCode, ipcSuccess, ipcError } from '@shared/errors'
import { getMeetingModeService } from '../services/MeetingModeService'
import { getLogger } from '../services/LoggerService'
import type {
MeetingAddMemoParams,
MeetingGetSessionsParams,
MeetingGetSessionParams,
MeetingDeleteSessionParams,
MeetingExportParams,
} from '@shared/types'
const logger = getLogger('meeting-mode-handlers')
export function registerMeetingModeHandlers(): void {
const ch = IPC_CHANNELS.MEETING_MODE
ipcMain.handle(ch.START_RECORDING, async () => {
try {
const result = await getMeetingModeService().startRecording()
return ipcSuccess(result)
} catch (err) {
logger.error(`startRecording 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(ErrorCode.MeetingAlreadyRecording, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.STOP_RECORDING, async () => {
try {
await getMeetingModeService().stopRecording()
return ipcSuccess(undefined)
} catch (err) {
logger.error(`stopRecording 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(ErrorCode.MeetingNotRecording, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.ADD_MEMO, async (_event, params: MeetingAddMemoParams) => {
try {
const memo = await getMeetingModeService().addMemo(params.content)
return ipcSuccess(memo)
} catch (err) {
logger.error(`addMemo 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(ErrorCode.MeetingNotRecording, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.GET_STATE, () => {
try {
return ipcSuccess(getMeetingModeService().getStateInfo())
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.GET_SESSIONS, (_event, params: MeetingGetSessionsParams) => {
try {
return ipcSuccess(getMeetingModeService().getSessions(params.page, params.pageSize))
} catch (err) {
return ipcError(ErrorCode.UnknownError, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.GET_SESSION, (_event, params: MeetingGetSessionParams) => {
try {
return ipcSuccess(getMeetingModeService().getSession(params.sessionId))
} catch (err) {
return ipcError(ErrorCode.MeetingSessionNotFound, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.DELETE_SESSION, (_event, params: MeetingDeleteSessionParams) => {
try {
getMeetingModeService().deleteSession(params.sessionId)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(ErrorCode.MeetingSessionNotFound, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.UPDATE_TITLE, (_event, params: { sessionId: string; title: string }) => {
try {
getMeetingModeService().updateTitle(params.sessionId, params.title)
return ipcSuccess(undefined)
} catch (err) {
return ipcError(ErrorCode.MeetingSessionNotFound, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.EXPORT_PDF, async (_event, params: MeetingExportParams) => {
try {
const filePath = await getMeetingModeService().exportPdf(params.sessionId)
return ipcSuccess(filePath)
} catch (err) {
logger.error(`exportPdf 실패: ${err instanceof Error ? err.message : String(err)}`)
return ipcError(ErrorCode.MeetingPdfFailed, err instanceof Error ? err.message : String(err))
}
})
ipcMain.handle(ch.EXPORT_MARKDOWN, async (_event, params: MeetingExportParams) => {
try {
const filePath = await getMeetingModeService().exportMarkdown(params.sessionId)
return ipcSuccess(filePath)
} catch (err) {
logger.error(`exportMarkdown 실패: ${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')
}

View file

@ -0,0 +1,750 @@
// src/main/services/MeetingModeService.ts
// Phase 14: Meeting Mode — 실시간 녹음 + 타임스탬프 메모 + LLM 구조화 회의록
// 싱글톤 + EventEmitter 패턴. CaptionService 연동.
import { EventEmitter } from 'events'
import { BrowserWindow, Notification, dialog } from 'electron'
import fs from 'fs'
import { nanoid } from 'nanoid'
import { eq, desc, sql } from 'drizzle-orm'
import { getLogger } from './LoggerService'
import { configGet } from './ConfigService'
import { getMainWindow } from '../windows/WindowManager'
import { getDatabase } from '../db'
import { meetingSessions, meetingMemos } from '../db/schema'
import { IPC_CHANNELS } from '@shared/ipc-channels'
import { D3ROError, ErrorCode } from '@shared/errors'
import type {
MeetingModeState,
MeetingMemo,
MeetingSessionSummary,
MeetingSessionDetail,
MeetingMinutes,
MeetingSessionPage,
MeetingStartResult,
MeetingModeStateInfo,
MeetingProcessingStep,
MeetingProcessingProgress,
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
'memo-added': (memo: MeetingMemo) => void
'processing-progress': (progress: MeetingProcessingProgress) => void
'session-completed': (detail: MeetingSessionDetail) => void
'error': (error: D3ROError) => void
}
class MeetingModeService extends EventEmitter {
private _state: MeetingModeState = 'idle'
private _sessionId: string | null = null
private _sessionStartedAt: number | null = null
private _segments: CaptionSegment[] = []
private _memos: MeetingMemo[] = []
private _segmentHandler: ((segment: CaptionSegment) => void) | null = null
/** CaptionService session-saved 방지 플래그 */
private _meetingModeActive = false
// ── 공개 접근자 ──
getState(): MeetingModeState {
return this._state
}
getStateInfo(): MeetingModeStateInfo {
return {
state: this._state,
sessionId: this._sessionId,
elapsedMs: this._sessionStartedAt ? Date.now() - this._sessionStartedAt : 0,
memoCount: this._memos.length,
segmentCount: this._segments.length,
}
}
isMeetingModeActive(): boolean {
return this._meetingModeActive
}
// ── 녹음 시작 ──
async startRecording(): Promise<MeetingStartResult> {
if (this._state !== 'idle') {
throw new D3ROError(
ErrorCode.MeetingAlreadyRecording,
`회의 모드가 이미 활성 상태입니다: ${this._state}`,
)
}
// CaptionService 상태 확인
const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService()
const captionState = captionService.getState()
if (captionState !== 'inactive') {
throw new D3ROError(
ErrorCode.MeetingAlreadyRecording,
'자막 모드가 활성 상태입니다. 먼저 자막을 종료해주세요.',
)
}
this._setState('recording')
this._meetingModeActive = true
const sessionId = nanoid()
const now = Date.now()
this._sessionId = sessionId
this._sessionStartedAt = now
this._segments = []
this._memos = []
// DB에 세션 생성
const db = getDatabase()
db.insert(meetingSessions).values({
id: sessionId,
status: 'recording',
startedAt: now,
createdAt: now,
updatedAt: now,
}).run()
// CaptionService 시작 (오버레이 숨김 상태로 — 회의 모드 UI에서 자체 표시)
this._segmentHandler = (segment: CaptionSegment) => {
this._segments.push(segment)
this.emit('segment', segment)
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SEGMENT, segment)
}
captionService.on('segment', this._segmentHandler)
try {
await captionService.start()
} catch (err) {
// 시작 실패 시 복원
captionService.off('segment', this._segmentHandler)
this._segmentHandler = null
this._meetingModeActive = false
this._sessionId = null
this._sessionStartedAt = null
db.update(meetingSessions).set({
status: 'error',
errorMessage: err instanceof Error ? err.message : String(err),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
this._setState('idle')
throw err
}
logger.info(`회의 녹음 시작: sessionId=${sessionId}`)
this._sendStateToRenderer()
return { sessionId }
}
// ── 메모 추가 ──
async addMemo(content: string): Promise<MeetingMemo> {
if (this._state !== 'recording' || !this._sessionId || !this._sessionStartedAt) {
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
}
const memo: MeetingMemo = {
id: nanoid(),
sessionId: this._sessionId,
content,
timestampMs: Date.now() - this._sessionStartedAt,
createdAt: Date.now(),
}
this._memos.push(memo)
// DB 저장
const db = getDatabase()
db.insert(meetingMemos).values({
id: memo.id,
sessionId: memo.sessionId,
content: memo.content,
timestampMs: memo.timestampMs,
createdAt: memo.createdAt,
}).run()
this.emit('memo-added', memo)
logger.debug(`메모 추가: [${this._formatTime(memo.timestampMs)}] ${content}`)
return memo
}
// ── 녹음 종료 ──
async stopRecording(): Promise<void> {
if (this._state !== 'recording') {
throw new D3ROError(ErrorCode.MeetingNotRecording, '녹음 중이 아닙니다')
}
const sessionId = this._sessionId!
const now = Date.now()
// CaptionService 종료
const { getCaptionService } = await import('./CaptionService')
const captionService = getCaptionService()
if (this._segmentHandler) {
captionService.off('segment', this._segmentHandler)
this._segmentHandler = null
}
await captionService.stop()
// 세션 종료 시간 기록
const db = getDatabase()
db.update(meetingSessions).set({
endedAt: now,
durationMs: this._sessionStartedAt ? now - this._sessionStartedAt : 0,
updatedAt: now,
}).where(eq(meetingSessions.id, sessionId)).run()
logger.info(`회의 녹음 종료: sessionId=${sessionId}, segments=${this._segments.length}`)
// 후처리 시작 (비동기)
this._runPostProcessing().catch((err: unknown) => {
logger.error(`후처리 실패: ${err instanceof Error ? err.message : String(err)}`)
})
}
// ── 후처리 파이프라인 ──
private async _runPostProcessing(): Promise<void> {
this._setState('processing')
this._sendStateToRenderer()
const sessionId = this._sessionId!
const db = getDatabase()
try {
db.update(meetingSessions).set({
status: 'processing',
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
// Step 1: 전사 텍스트 합산
this._sendProgress(sessionId, 'merging', 20)
const rawTranscript = this._segments
.map((s) => `[${this._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)
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 알림
this._sendProgress(sessionId, 'notifying', 95)
new Notification({ title: '회의록 준비 완료', body: '회의록이 생성되었습니다.' }).show()
// 완료 이벤트
this._sendProgress(sessionId, 'notifying', 100)
const detail = this._loadSessionDetail(sessionId)
if (detail) {
this.emit('session-completed', detail)
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, detail)
}
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
logger.info(`회의록 생성 완료: sessionId=${sessionId}`)
} catch (err) {
db.update(meetingSessions).set({
status: 'error',
errorMessage: err instanceof Error ? err.message : String(err),
updatedAt: Date.now(),
}).where(eq(meetingSessions.id, sessionId)).run()
this._resetSession()
this._setState('idle')
this._sendStateToRenderer()
const d3roErr = new D3ROError(
ErrorCode.MeetingProcessingFailed,
`회의록 생성 실패: ${err instanceof Error ? err.message : String(err)}`,
)
this.emit('error', d3roErr)
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
code: d3roErr.code,
message: d3roErr.message,
})
logger.error(`후처리 실패: ${d3roErr.message}`)
}
}
// ── 세션 조회 ──
getSessions(page: number, pageSize: number): MeetingSessionPage {
const db = getDatabase()
const offset = (page - 1) * pageSize
const countResult = db.select({ count: sql<number>`count(*)` })
.from(meetingSessions)
.get()
const total = countResult?.count ?? 0
const rows = db.select()
.from(meetingSessions)
.orderBy(desc(meetingSessions.createdAt))
.limit(pageSize)
.offset(offset)
.all()
const sessions: MeetingSessionSummary[] = rows.map((row) => {
const memoCount = db.select({ count: sql<number>`count(*)` })
.from(meetingMemos)
.where(eq(meetingMemos.sessionId, row.id))
.get()?.count ?? 0
return {
id: row.id,
title: row.title,
status: row.status as MeetingSessionSummary['status'],
startedAt: row.startedAt,
endedAt: row.endedAt,
durationMs: row.durationMs,
memoCount,
}
})
return {
sessions,
total,
page,
pageSize,
totalPages: Math.ceil(total / pageSize),
}
}
getSession(sessionId: string): MeetingSessionDetail {
const detail = this._loadSessionDetail(sessionId)
if (!detail) {
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
}
return detail
}
deleteSession(sessionId: 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}`)
}
db.delete(meetingMemos).where(eq(meetingMemos.sessionId, sessionId)).run()
db.delete(meetingSessions).where(eq(meetingSessions.id, sessionId)).run()
logger.info(`회의 세션 삭제: ${sessionId}`)
}
updateTitle(sessionId: string, title: 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}`)
}
db.update(meetingSessions).set({ title, updatedAt: Date.now() })
.where(eq(meetingSessions.id, sessionId)).run()
}
// ── 내보내기 ──
async exportPdf(sessionId: string): Promise<string> {
const session = this._loadSessionDetail(sessionId)
if (!session) {
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
}
const html = this._buildPdfHtml(session)
const { filePath } = await dialog.showSaveDialog({
title: 'PDF로 내보내기',
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.pdf`,
filters: [{ name: 'PDF', extensions: ['pdf'] }],
})
if (!filePath) return ''
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 },
})
fs.writeFileSync(filePath, pdfBuffer)
logger.info(`PDF 내보내기 완료: ${filePath}`)
return filePath
} finally {
win.close()
}
}
async exportMarkdown(sessionId: string): Promise<string> {
const session = this._loadSessionDetail(sessionId)
if (!session) {
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
}
const { filePath } = await dialog.showSaveDialog({
title: '마크다운으로 내보내기',
defaultPath: `회의록_${this._formatDateFile(session.startedAt)}.md`,
filters: [{ name: 'Markdown', extensions: ['md'] }],
})
if (!filePath) return ''
const md = this._buildExportMarkdown(session)
fs.writeFileSync(filePath, md, 'utf-8')
logger.info(`마크다운 내보내기 완료: ${filePath}`)
return filePath
}
// ── 내부 헬퍼 ──
private _setState(state: MeetingModeState): void {
this._state = state
this.emit('state-changed', state)
}
private _resetSession(): void {
this._sessionId = null
this._sessionStartedAt = null
this._segments = []
this._memos = []
this._meetingModeActive = false
}
private _sendToRenderer(channel: string, data: unknown): void {
const win = getMainWindow()
if (win && !win.isDestroyed()) {
win.webContents.send(channel, data)
}
}
private _sendStateToRenderer(): void {
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, this.getStateInfo())
}
private _sendProgress(sessionId: string, step: MeetingProcessingStep, percent: number): void {
const progress: MeetingProcessingProgress = { sessionId, step, percent }
this.emit('processing-progress', progress)
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')}`
}
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')}`
}
if (!endedAt) return fmt(startedAt)
return `${fmt(startedAt)} ~ ${new Date(endedAt).getHours()}:${String(new Date(endedAt).getMinutes()).padStart(2, '0')}`
}
private _buildPrompt(rawTranscript: string, formattedMemos: string): string {
return `## 전사록\n${rawTranscript}\n\n## 참석자 메모 (📝 표시)\n${formattedMemos}\n\n위 전사록과 메모를 바탕으로 회의록을 작성해주세요.`
}
private _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
}
private _loadSessionDetail(sessionId: string): MeetingSessionDetail | null {
const db = getDatabase()
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
if (!row) return null
const memoRows = db.select().from(meetingMemos)
.where(eq(meetingMemos.sessionId, sessionId))
.orderBy(meetingMemos.timestampMs)
.all()
const memos: MeetingMemo[] = memoRows.map((m) => ({
id: m.id,
sessionId: m.sessionId,
content: m.content,
timestampMs: m.timestampMs,
createdAt: m.createdAt,
}))
let minutes: MeetingMinutes | null = null
if (row.minutesJson) {
try {
minutes = JSON.parse(row.minutesJson) as MeetingMinutes
} catch {
logger.warn(`회의록 JSON 파싱 실패: sessionId=${sessionId}`)
}
}
return {
id: row.id,
title: row.title,
status: row.status as MeetingSessionDetail['status'],
startedAt: row.startedAt,
endedAt: row.endedAt,
durationMs: row.durationMs,
memoCount: memos.length,
rawTranscript: row.rawTranscript,
minutesMarkdown: row.minutesMarkdown,
minutes,
memos,
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)
: '<p>회의록이 생성되지 않았습니다.</p>'
return `<!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; }
.memo-badge { background: #fff3cd; padding: 2px 6px; border-radius: 4px; font-size: 11px; }
.meta { color: #888; font-size: 12px; margin-bottom: 20px; }
ul { padding-left: 20px; }
li { margin: 4px 0; }
</style>
</head>
<body>
<h1>${session.title ?? '무제 회의'}</h1>
<p class="meta">일시: ${this._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, '')
}
}
// ── 싱글톤 ──
let instance: MeetingModeService | null = null
export function getMeetingModeService(): MeetingModeService {
if (!instance) {
instance = new MeetingModeService()
}
return instance
}

View file

@ -821,6 +821,12 @@ class VoiceModeService extends EventEmitter {
logger.warn('Cannot start caption: dictation session active')
return
}
// 회의 모드 녹음 중이면 자막 시작 불가 (상호 배제)
const { getMeetingModeService } = await import('./MeetingModeService')
if (getMeetingModeService().getState() === 'recording') {
logger.warn('Cannot start caption: meeting mode recording active')
return
}
await caption.start()
logger.info('Caption started via hotkey')
}

View file

@ -136,6 +136,18 @@ import type {
TemplateSessionInfo,
TemplateFieldCompletedEvent,
TemplateSessionCompletedEvent,
MeetingStartResult,
MeetingMemo,
MeetingAddMemoParams,
MeetingModeStateInfo,
MeetingSessionPage,
MeetingGetSessionsParams,
MeetingSessionDetail,
MeetingGetSessionParams,
MeetingDeleteSessionParams,
MeetingExportParams,
MeetingProcessingProgress,
CaptionSegment,
} from '@shared/types'
import { Feature } from '@shared/types'
import type { IPCResult } from '@shared/errors'
@ -553,6 +565,40 @@ const electronAPI = {
onError: (cb: (data: ConversationError) => void): Unsubscribe =>
on(IPC_CHANNELS.VOICE_CONVERSATION.ERROR, cb),
},
// ── Meeting Mode (Phase 14) ───────────────────────────
meetingMode: {
startRecording: () =>
invoke<MeetingStartResult>(IPC_CHANNELS.MEETING_MODE.START_RECORDING),
stopRecording: () =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.STOP_RECORDING),
addMemo: (params: MeetingAddMemoParams) =>
invoke<MeetingMemo>(IPC_CHANNELS.MEETING_MODE.ADD_MEMO, params),
getState: () =>
invoke<MeetingModeStateInfo>(IPC_CHANNELS.MEETING_MODE.GET_STATE),
getSessions: (params: MeetingGetSessionsParams) =>
invoke<MeetingSessionPage>(IPC_CHANNELS.MEETING_MODE.GET_SESSIONS, params),
getSession: (params: MeetingGetSessionParams) =>
invoke<MeetingSessionDetail>(IPC_CHANNELS.MEETING_MODE.GET_SESSION, params),
deleteSession: (params: MeetingDeleteSessionParams) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.DELETE_SESSION, params),
updateTitle: (params: { sessionId: string; title: string }) =>
invoke<void>(IPC_CHANNELS.MEETING_MODE.UPDATE_TITLE, params),
exportPdf: (params: MeetingExportParams) =>
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_PDF, params),
exportMarkdown: (params: MeetingExportParams) =>
invoke<string>(IPC_CHANNELS.MEETING_MODE.EXPORT_MARKDOWN, params),
onStateChanged: (cb: (e: MeetingModeStateInfo) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_MODE.STATE_CHANGED, cb),
onSegment: (cb: (e: CaptionSegment) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_MODE.SEGMENT, cb),
onProcessingProgress: (cb: (e: MeetingProcessingProgress) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_MODE.PROCESSING_PROGRESS, cb),
onSessionCompleted: (cb: (e: MeetingSessionDetail) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_MODE.SESSION_COMPLETED, cb),
onError: (cb: (e: { code: number; message: string }) => void): Unsubscribe =>
on(IPC_CHANNELS.MEETING_MODE.ERROR, cb),
},
} as const
contextBridge.exposeInMainWorld('electronAPI', electronAPI)

View file

@ -9,6 +9,7 @@ import MenuBookIcon from '@mui/icons-material/MenuBook'
import ExtensionIcon from '@mui/icons-material/Extension'
import RecordVoiceOverIcon from '@mui/icons-material/RecordVoiceOver'
import AutoStoriesIcon from '@mui/icons-material/AutoStories'
import GroupsIcon from '@mui/icons-material/Groups'
import SettingsIcon from '@mui/icons-material/Settings'
import { Led } from './ds'
import { DashboardPage } from '../pages/DashboardPage'
@ -17,6 +18,7 @@ import { DictionaryPage } from '../pages/DictionaryPage'
import { CommandsPage } from '../pages/CommandsPage'
import { VoiceConversationPage } from '../pages/VoiceConversationPage'
import { KnowledgeBasePage } from '../pages/KnowledgeBasePage'
import { MeetingModePage } from '../pages/MeetingModePage'
import { SettingsModal } from './SettingsModal'
import { LicenseModal } from './LicenseModal'
import { OnboardingModal } from './OnboardingModal'
@ -26,7 +28,7 @@ import { useI18n } from '../i18n'
import type { TranslationKey } from '../i18n'
import type { LicenseTier } from '@shared/types'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge'
type Route = 'dashboard' | 'history' | 'dictionary' | 'commands' | 'conversation' | 'knowledge' | 'meeting'
interface NavItem {
route: Route
@ -42,6 +44,7 @@ const NAV_ITEMS: NavItem[] = [
{ route: 'commands', labelKey: 'nav.commands', abbr: 'CMD', icon: <ExtensionIcon sx={{ fontSize: 20 }} /> },
{ route: 'conversation', labelKey: 'nav.conversation', abbr: 'TALK', icon: <RecordVoiceOverIcon sx={{ fontSize: 20 }} /> },
{ route: 'knowledge', labelKey: 'nav.knowledge', abbr: 'RAG', icon: <AutoStoriesIcon sx={{ fontSize: 20 }} /> },
{ route: 'meeting', labelKey: 'nav.meeting', abbr: 'MTG', icon: <GroupsIcon sx={{ fontSize: 20 }} /> },
]
function tierToLedColor(tier: LicenseTier): 'amber' | 'green' {
@ -225,6 +228,7 @@ export function AppLayout(): React.ReactElement {
{currentRoute === 'commands' && <CommandsPage />}
{currentRoute === 'conversation' && <VoiceConversationPage />}
{currentRoute === 'knowledge' && <KnowledgeBasePage />}
{currentRoute === 'meeting' && <MeetingModePage />}
</Box>
</Box>
<StatusBar />

View file

@ -455,10 +455,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
<MenuItem value="auto">{t('settings.theme.system')}</MenuItem>
<MenuItem value="light">{t('settings.theme.light')}</MenuItem>
<MenuItem value="dark">{t('settings.theme.dark')}</MenuItem>
<MenuItem value="nord">Nord</MenuItem>
<MenuItem value="solarized">Solarized</MenuItem>
<MenuItem value="catppuccin">Catppuccin</MenuItem>
<MenuItem value="dracula">Dracula</MenuItem>
<MenuItem value="nord">{t('settings.theme.nord')}</MenuItem>
<MenuItem value="solarized">{t('settings.theme.solarized')}</MenuItem>
<MenuItem value="catppuccin">{t('settings.theme.catppuccin')}</MenuItem>
<MenuItem value="dracula">{t('settings.theme.dracula')}</MenuItem>
</Select>
</FormControl>
@ -627,10 +627,10 @@ export function SettingsModal({ open, onClose }: SettingsModalProps): React.Reac
onChange={(e) => updateConfig('sttLanguage', e.target.value)}
>
<MenuItem value="auto">{t('settings.sttLang.auto')}</MenuItem>
<MenuItem value="ko"></MenuItem>
<MenuItem value="en">English</MenuItem>
<MenuItem value="ja"></MenuItem>
<MenuItem value="zh"></MenuItem>
<MenuItem value="ko">{t('settings.sttLang.ko')}</MenuItem>
<MenuItem value="en">{t('settings.sttLang.en')}</MenuItem>
<MenuItem value="ja">{t('settings.sttLang.ja')}</MenuItem>
<MenuItem value="zh">{t('settings.sttLang.zh')}</MenuItem>
</Select>
</FormControl>
</Box>

View file

@ -277,7 +277,7 @@ export function TemplateSection(): React.ReactElement {
fullWidth
multiline
rows={3}
helperText="Use {{fieldName}} for placeholders"
helperText={t('template.outputHelperText')}
/>
</DialogContent>
<DialogActions sx={{ px: 3, pb: 2 }}>

View file

@ -56,10 +56,10 @@ export function MetalDial({
background: `
repeating-radial-gradient(
circle at 50% 50%,
#e6e7e9 0px,
#e6e7e9 1px,
#b0b2b5 1.5px,
#b0b2b5 2.5px
${d3roPalette.text.disabled} 0px,
${d3roPalette.text.disabled} 1px,
${d3roPalette.text.label} 1.5px,
${d3roPalette.text.label} 2.5px
)
`,
transform: `rotate(${rotation}deg)`,

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Lokaler KI-Sprachassistent",
"nav.dashboard": "Dashboard",
"nav.history": "Verlauf",
"nav.dictionary": "Wörterbuch",
"nav.commands": "Befehle",
"nav.settings": "Einstellungen",
"dashboard.sessionOverview": "Sitzungsübersicht",
"dashboard.systemStatus": "Systemstatus",
"dashboard.sessionsToday": "Sitzungen heute",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} Einträge",
"dashboard.stat": "Statistiken",
"dashboard.sys": "System",
"history.title": "Transkriptionsverlauf",
"history.search": "Suchen...",
"history.entries": "{{count}} Einträge",
@ -34,7 +31,6 @@
"history.noResults": "Keine Ergebnisse",
"history.noHistory": "Kein Verlauf — starte eine Aufnahme",
"history.count": "{{count}} Einträge",
"dictionary.title": "Benutzerwörterbuch",
"dictionary.words": "{{count}} Wörter",
"dictionary.add": "Hinzufügen",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Wort hinzufügen",
"dictionary.word": "Wort",
"dictionary.pronunciation": "Aussprache (optional)",
"commands.title": "LLM-Befehle",
"commands.count": "{{count}} Befehle",
"commands.add": "Hinzufügen",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Prompt-Vorlage",
"commands.promptHelp": "{{text}} wird durch den transkribierten Text ersetzt",
"commands.defaultPrompt": "Bitte verbessere {{text}}.",
"settings.title": "Einstellungen",
"settings.tabs.general": "Allgemein",
"settings.tabs.audio": "Audio",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "Über",
"settings.shortcuts": "Tastenkürzel",
"settings.dictation": "Diktat",
"settings.dictation.desc": "Gedrückt halten zum Sprechen. Die Transkription beginnt beim Loslassen.",
@ -82,20 +75,17 @@
"settings.notSet": "Nicht festgelegt",
"settings.enabled": "Aktiviert",
"settings.disabled": "Deaktiviert",
"settings.interface": "Oberfläche",
"settings.theme": "Design",
"settings.theme.system": "System",
"settings.theme.light": "Hell",
"settings.theme.dark": "Dunkel",
"settings.language": "Sprache",
"settings.appBehavior": "App-Verhalten",
"settings.closeToTray": "In Systembereich minimieren",
"settings.autoLaunch": "Beim Systemstart automatisch starten",
"settings.autoInsert": "Text nach der Transkription automatisch einfügen",
"settings.soundEffects": "Soundeffekte",
"settings.microphone": "Mikrofon",
"settings.inputDevice": "Eingabegerät",
"settings.deviceDefault": "(Standard)",
@ -103,7 +93,6 @@
"settings.insertMethod": "Einfügemethode",
"settings.insertClipboard": "Zwischenablage (Ctrl+V)",
"settings.insertKeyboard": "Tastatureingabe",
"settings.whisperModel": "Whisper-Modell",
"settings.model.tiny": "tiny (39 MB, am schnellsten)",
"settings.model.base": "base (74 MB, ausgewogen)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1,5 GB, beste Qualität)",
"settings.sttLanguage": "Erkennungssprache",
"settings.sttLang.auto": "Automatische Erkennung",
"settings.ollamaServer": "Ollama-Server",
"settings.ollamaUrl": "Ollama-Server-URL",
"settings.ollamaHint": "D3RO Voice verbindet sich automatisch, wenn Ollama läuft. Lade Modelle direkt über Ollama herunter (z. B.: ollama pull qwen3:4b).",
@ -125,7 +113,6 @@
"settings.action.grammar": "Grammatikkorrektur",
"settings.action.custom": "Benutzerdefinierter Prompt",
"settings.actionHint": "Die gewählte LLM-Nachbearbeitung wird auf den transkribierten Text angewendet, nachdem mit dem Tastenkürzel aufgenommen wurde. Funktioniert nur, wenn Ollama verbunden ist.",
"settings.about.version": "Version",
"settings.about.techStack": "Tech-Stack",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (lokal) / LLM: Ollama (lokal)",
"settings.about.description": "Vollständig lokaler KI-Sprachassistent, basierend auf Reverse Engineering von Speakly. Funktioniert ohne Cloud-Abhängigkeiten.",
"settings.about.restartOnboarding": "Einrichtungsanleitung erneut anzeigen",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama läuft nicht",
"status.nudge.desc": "Ollama wird für die LLM-Nachbearbeitung (Übersetzung, Zusammenfassung usw.) benötigt.",
"status.nudge.guide": "Installationsanleitung anzeigen →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "Ohne Tippen, per Sprache. Dein lokaler KI-Sprachassistent.",
"onboarding.welcome.start": "Loslegen",
@ -160,7 +145,6 @@
"onboarding.done.start": "Loslegen",
"onboarding.back": "Zurück",
"onboarding.next": "Weiter",
"hotkey.title": "Tastenkürzel einrichten",
"hotkey.dictationTitle": "Diktat-Tastenkürzel einrichten",
"hotkey.oneTouchTitle": "Tastenkürzel für Ein-Tasten-Modus einrichten",
@ -171,7 +155,6 @@
"hotkey.current": "Aktuell: {{keys}}",
"hotkey.hint": "Gib eine Kombination (z. B. Ctrl+Shift+Q) oder eine einzelne Taste (z. B. F5) ein",
"hotkey.reset": "Erneut eingeben",
"ollama.title": "OLLAMA EINRICHTUNGSANLEITUNG",
"ollama.step1.title": "SCHRITT 1 — Ollama installieren",
"ollama.step1.desc": "Ollama ist ein kostenloses Tool zum lokalen Ausführen von LLMs.",
@ -180,7 +163,6 @@
"ollama.step2.alt": "Oder ein größeres Modell: ollama pull qwen3:8b (genauer, langsamer)",
"ollama.step3.title": "SCHRITT 3 — Automatische Verbindung",
"ollama.step3.desc": "D3RO-VOICE erkennt automatisch, wenn Ollama läuft. Wenn die LED in der Statusleiste von Rot auf Grün wechselt, ist alles bereit!",
"service.sttEngine": "STT-ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "TASTENKÜRZEL",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "AKTIV",
"service.standby": "BEREITSCHAFT",
"common.cancel": "Abbrechen",
"common.save": "Speichern",
"common.delete": "Löschen",
@ -202,7 +183,51 @@
"common.copy": "Kopieren",
"common.test": "Testen",
"common.stop": "Stoppen",
"date.today": "Heute",
"date.yesterday": "Gestern"
"date.yesterday": "Gestern",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "Koreanisch",
"settings.sttLang.en": "Englisch",
"settings.sttLang.ja": "Japanisch",
"settings.sttLang.zh": "Chinesisch",
"template.outputHelperText": "Verwenden Sie {{Feldname}} für Platzhalter",
"nav.meeting": "Meeting",
"meeting.title": "Meeting-Modus",
"meeting.newMeeting": "Neues Meeting",
"meeting.startRecording": "Aufnahme starten",
"meeting.stopRecording": "Aufnahme beenden",
"meeting.recording": "Aufnahme läuft",
"meeting.processing": "Protokoll wird erstellt...",
"meeting.completed": "Abgeschlossen",
"meeting.error": "Fehler",
"meeting.elapsed": "Vergangen",
"meeting.memoPlaceholder": "Memo eingeben (Enter zum Hinzufügen)",
"meeting.memoAdded": "Memo hinzugefügt",
"meeting.noSessions": "Keine Meeting-Aufnahmen",
"meeting.noSessionsDesc": "Drücke «Neues Meeting», um die Aufnahme zu starten",
"meeting.summary": "Zusammenfassung",
"meeting.decisions": "Beschlüsse",
"meeting.actionItems": "Aufgaben",
"meeting.timeline": "Zeitlinie",
"meeting.transcript": "Rohtranskript",
"meeting.memos": "Memos",
"meeting.exportPdf": "Als PDF exportieren",
"meeting.exportMarkdown": "Als Markdown exportieren",
"meeting.delete": "Löschen",
"meeting.deleteConfirm": "Diesen Meeting-Eintrag löschen?",
"meeting.backToList": "Zurück zur Liste",
"meeting.notificationTitle": "Protokoll fertig",
"meeting.notificationBody": "Das Meeting-Protokoll wurde erstellt.",
"meeting.sessions": "{{count}} Sitzungen",
"meeting.duration": "{{minutes}} Min.",
"meeting.editTitle": "Titel bearbeiten",
"meeting.untitled": "Meeting ohne Titel",
"meeting.processingStep.merging": "Transkript zusammenführen...",
"meeting.processingStep.generating": "Protokoll mit LLM erstellen...",
"meeting.processingStep.parsing": "Protokoll analysieren...",
"meeting.processingStep.saving": "Speichern...",
"meeting.processingStep.notifying": "Benachrichtigung senden..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Local AI Voice Assistant",
"nav.dashboard": "Dashboard",
"nav.history": "History",
"nav.dictionary": "Dictionary",
"nav.commands": "Commands",
"nav.settings": "Settings",
"dashboard.sessionOverview": "Session Overview",
"dashboard.systemStatus": "System Status",
"dashboard.sessionsToday": "Today's Sessions",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} entries",
"dashboard.stat": "Stats",
"dashboard.sys": "System",
"history.title": "Transcription History",
"history.search": "Search...",
"history.entries": "{{count}} entries",
@ -34,7 +31,6 @@
"history.noResults": "No results found",
"history.noHistory": "No history — start recording",
"history.count": "{{count}} entries",
"dictionary.title": "Custom Dictionary",
"dictionary.words": "{{count}} words",
"dictionary.add": "Add",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Add Word",
"dictionary.word": "Word",
"dictionary.pronunciation": "Pronunciation (optional)",
"commands.title": "LLM Commands",
"commands.count": "{{count}} commands",
"commands.add": "Add",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Prompt Template",
"commands.promptHelp": "{{text}} will be replaced with the transcribed text",
"commands.defaultPrompt": "Please refine the following: {{text}}",
"settings.title": "Settings",
"settings.tabs.general": "General",
"settings.tabs.audio": "Audio",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "About",
"settings.shortcuts": "Shortcuts",
"settings.dictation": "Dictation",
"settings.dictation.desc": "Hold to speak. Transcription begins when you release the key.",
@ -89,20 +82,17 @@
"settings.notSet": "Not set",
"settings.enabled": "Enabled",
"settings.disabled": "Disabled",
"settings.interface": "Interface",
"settings.theme": "Theme",
"settings.theme.system": "System",
"settings.theme.light": "Light",
"settings.theme.dark": "Dark",
"settings.language": "Language",
"settings.appBehavior": "App Behavior",
"settings.closeToTray": "Minimize to tray on close",
"settings.autoLaunch": "Launch at system startup",
"settings.autoInsert": "Auto-insert text after transcription",
"settings.soundEffects": "Sound Effects",
"settings.microphone": "Microphone",
"settings.inputDevice": "Input Device",
"settings.deviceDefault": "(Default)",
@ -110,7 +100,6 @@
"settings.insertMethod": "Insert Method",
"settings.insertClipboard": "Clipboard (Ctrl+V)",
"settings.insertKeyboard": "Keyboard Typing",
"settings.whisperModel": "Whisper Model",
"settings.model.tiny": "tiny (39 MB, fastest)",
"settings.model.base": "base (74 MB, balanced)",
@ -119,7 +108,6 @@
"settings.model.large": "large-v3 (1.5 GB, best)",
"settings.sttLanguage": "Recognition Language",
"settings.sttLang.auto": "Auto-detect",
"settings.ollamaServer": "Ollama Server",
"settings.ollamaUrl": "Ollama Server URL",
"settings.ollamaHint": "Connects automatically when Ollama is running. Pull models directly in Ollama (e.g. ollama pull qwen3:4b).",
@ -132,7 +120,6 @@
"settings.action.grammar": "Grammar Correction",
"settings.action.custom": "Custom Prompt",
"settings.actionHint": "The selected LLM post-processing will be applied to your transcription after recording. Only works when Ollama is connected.",
"settings.about.version": "Version",
"settings.about.techStack": "Tech Stack",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -140,13 +127,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)",
"settings.about.description": "A fully local AI voice assistant built on Speakly reverse-engineering insights. Runs entirely offline with no cloud dependencies.",
"settings.about.restartOnboarding": "Restart Setup Wizard",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama is not running",
"status.nudge.desc": "Ollama is required for LLM post-processing (translation, summarization, etc.).",
"status.nudge.guide": "View setup guide →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "No typing needed — just speak. Your local AI voice assistant.",
"onboarding.welcome.start": "Get Started",
@ -167,7 +152,6 @@
"onboarding.done.start": "Start Using",
"onboarding.back": "Back",
"onboarding.next": "Next",
"hotkey.title": "Set Shortcut",
"hotkey.dictationTitle": "Set Dictation Shortcut",
"hotkey.oneTouchTitle": "Set One-Touch Mode Shortcut",
@ -179,7 +163,6 @@
"hotkey.current": "Current: {{keys}}",
"hotkey.hint": "Enter a combination (e.g. Ctrl+Shift+Q) or a single key (e.g. F5)",
"hotkey.reset": "Clear",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "STEP 1 — Install Ollama",
"ollama.step1.desc": "Ollama is a free tool for running LLMs locally.",
@ -188,7 +171,6 @@
"ollama.step2.alt": "Or a larger model: ollama pull qwen3:8b (more accurate, slower)",
"ollama.step3.title": "STEP 3 — Auto-Connect",
"ollama.step3.desc": "Once Ollama is running, D3RO-VOICE will detect it automatically. When the status bar LED turns from red to green, you're ready!",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -198,7 +180,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "Cancel",
"common.save": "Save",
"common.delete": "Delete",
@ -210,7 +191,6 @@
"common.copy": "Copy",
"common.test": "Test",
"common.stop": "Stop",
"memo.tags": "Tags",
"memo.addTag": "Add Tag",
"memo.removeTag": "Remove Tag",
@ -221,7 +201,6 @@
"memo.filterByTag": "Filter by tag",
"memo.allTags": "All Tags",
"memo.clearFilter": "Clear filter",
"voiceCommand.title": "Voice Commands",
"voiceCommand.enabled": "Voice Keyword Recognition",
"voiceCommand.enabledDesc": "Detects keywords at the start of transcription to auto-select commands.",
@ -232,7 +211,6 @@
"voiceCommand.matchMode.prefix": "Starts with",
"voiceCommand.matchMode.suffix": "Ends with",
"voiceCommand.matchMode.contains": "Contains",
"chain.title": "LLM Chains",
"chain.count": "{{count}} chains",
"chain.add": "Add Chain",
@ -251,24 +229,19 @@
"chain.noChains": "No chains — create a pipeline to run multiple commands in sequence",
"chain.step": "Step {{n}}",
"chain.progress": "Processing step {{current}}/{{total}}",
"context.title": "Screen Context",
"context.enabled": "Screen Context",
"context.enabledDesc": "Captures active app and selected text when recording starts, and sends it to the LLM.",
"dashboard.caption": "Live Caption",
"dashboard.captionStart": "Start Caption",
"dashboard.captionStop": "Stop Caption",
"dashboard.captionActive": "Caption Active",
"settings.voiceCommands": "Voice Commands",
"settings.voiceCommands.desc": "Detects keywords in transcription to auto-select commands.",
"settings.screenContext": "Screen Context",
"settings.screenContext.desc": "Sends active app info and selected text to the LLM during recording.",
"date.today": "Today",
"date.yesterday": "Yesterday",
"license.title": "License",
"license.currentTier": "Current Tier",
"license.free": "FREE",
@ -343,7 +316,6 @@
"license.unlimited": "Unlimited",
"license.locked": "Locked",
"license.nav": "License",
"fileTranscription.title": "File Transcription",
"fileTranscription.dropZone": "Drop audio/video file here",
"fileTranscription.dropZoneHint": "Supports MP3, WAV, M4A, MP4, MKV, WEBM",
@ -356,7 +328,6 @@
"fileTranscription.retry": "Retry",
"fileTranscription.error.invalidFormat": "Unsupported file format",
"fileTranscription.error.unknown": "Unknown error",
"meetingSummary.title": "Meeting Summary",
"meetingSummary.generating": "Generating summary...",
"meetingSummary.summary": "Summary",
@ -365,7 +336,6 @@
"meetingSummary.exportMarkdown": "Export as Markdown",
"meetingSummary.noSummary": "No summary available",
"meetingSummary.generate": "Generate Summary",
"template.title": "Dictation Templates",
"template.create": "New Template",
"template.edit": "Edit Template",
@ -381,7 +351,6 @@
"template.startSession": "Start",
"template.cancelSession": "Cancel Session",
"template.empty": "No templates",
"nav.conversation": "Talk",
"conversation.title": "Voice Conversation",
"conversation.idle": "Idle",
@ -393,7 +362,6 @@
"conversation.inputPlaceholder": "Type a message...",
"conversation.end": "End",
"conversation.clearHistory": "Clear history",
"nav.knowledge": "Knowledge",
"rag.title": "Knowledge Base",
"rag.addDocument": "Add Document",
@ -410,12 +378,56 @@
"rag.sources": "Sources",
"rag.adding": "Adding...",
"rag.parsing": "Parsing document...",
"voiceAction.title": "Voice Actions",
"voiceAction.execute": "Execute",
"voiceAction.presets": "Preset Commands",
"voiceAction.history": "Action History",
"voiceAction.noHistory": "No action history",
"voiceAction.blocked": "Blocked (unsafe)",
"voiceAction.executed": "Executed"
"voiceAction.executed": "Executed",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "한국어 (Korean)",
"settings.sttLang.en": "English",
"settings.sttLang.ja": "日本語 (Japanese)",
"settings.sttLang.zh": "中文 (Chinese)",
"template.outputHelperText": "Use {{fieldName}} for placeholders",
"nav.meeting": "Meeting",
"meeting.title": "Meeting Mode",
"meeting.newMeeting": "New Meeting",
"meeting.startRecording": "Start Recording",
"meeting.stopRecording": "Stop Recording",
"meeting.recording": "Recording",
"meeting.processing": "Generating minutes...",
"meeting.completed": "Completed",
"meeting.error": "Error",
"meeting.elapsed": "Elapsed",
"meeting.memoPlaceholder": "Type a memo (Enter to add)",
"meeting.memoAdded": "Memo added",
"meeting.noSessions": "No meeting recordings",
"meeting.noSessionsDesc": "Press New Meeting to start recording",
"meeting.summary": "Summary",
"meeting.decisions": "Decisions",
"meeting.actionItems": "Action Items",
"meeting.timeline": "Timeline",
"meeting.transcript": "Raw Transcript",
"meeting.memos": "Memos",
"meeting.exportPdf": "Export PDF",
"meeting.exportMarkdown": "Export Markdown",
"meeting.delete": "Delete",
"meeting.deleteConfirm": "Delete this meeting record?",
"meeting.backToList": "Back to List",
"meeting.notificationTitle": "Minutes Ready",
"meeting.notificationBody": "Meeting minutes have been generated.",
"meeting.sessions": "{{count}} sessions",
"meeting.duration": "{{minutes}} min",
"meeting.editTitle": "Edit Title",
"meeting.untitled": "Untitled Meeting",
"meeting.processingStep.merging": "Merging transcript...",
"meeting.processingStep.generating": "Generating minutes with LLM...",
"meeting.processingStep.parsing": "Parsing minutes...",
"meeting.processingStep.saving": "Saving...",
"meeting.processingStep.notifying": "Sending notification..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Asistente de voz con IA local",
"nav.dashboard": "Panel",
"nav.history": "Historial",
"nav.dictionary": "Diccionario",
"nav.commands": "Comandos",
"nav.settings": "Ajustes",
"dashboard.sessionOverview": "Resumen de sesión",
"dashboard.systemStatus": "Estado del sistema",
"dashboard.sessionsToday": "Sesiones hoy",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} entradas",
"dashboard.stat": "estadísticas",
"dashboard.sys": "sistema",
"history.title": "Historial de transcripciones",
"history.search": "Buscar...",
"history.entries": "{{count}} entradas",
@ -34,7 +31,6 @@
"history.noResults": "Sin resultados",
"history.noHistory": "Sin historial — empieza a grabar",
"history.count": "{{count}} entradas",
"dictionary.title": "Diccionario personalizado",
"dictionary.words": "{{count}} palabras",
"dictionary.add": "Añadir",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Añadir palabra",
"dictionary.word": "Palabra",
"dictionary.pronunciation": "Pronunciación (opcional)",
"commands.title": "Comandos LLM",
"commands.count": "{{count}} comandos",
"commands.add": "Añadir",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Plantilla de prompt",
"commands.promptHelp": "{{text}} se reemplazará por el texto transcrito",
"commands.defaultPrompt": "Por favor, mejora {{text}}.",
"settings.title": "Ajustes",
"settings.tabs.general": "General",
"settings.tabs.audio": "Audio",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "Acerca de",
"settings.shortcuts": "Atajos",
"settings.dictation": "Dictado",
"settings.dictation.desc": "Mantén pulsado para hablar. Al soltar, comienza la transcripción.",
@ -82,20 +75,17 @@
"settings.notSet": "No configurado",
"settings.enabled": "Activado",
"settings.disabled": "Desactivado",
"settings.interface": "Interfaz",
"settings.theme": "Tema",
"settings.theme.system": "Sistema",
"settings.theme.light": "Claro",
"settings.theme.dark": "Oscuro",
"settings.language": "Idioma",
"settings.appBehavior": "Comportamiento de la app",
"settings.closeToTray": "Minimizar a la bandeja",
"settings.autoLaunch": "Iniciar con el sistema",
"settings.autoInsert": "Insertar texto automáticamente tras transcripción",
"settings.soundEffects": "Efectos de sonido",
"settings.microphone": "Micrófono",
"settings.inputDevice": "Dispositivo de entrada",
"settings.deviceDefault": "(predeterminado)",
@ -103,7 +93,6 @@
"settings.insertMethod": "Método de inserción",
"settings.insertClipboard": "Portapapeles (Ctrl+V)",
"settings.insertKeyboard": "Escritura con teclado",
"settings.whisperModel": "Modelo Whisper",
"settings.model.tiny": "tiny (39 MB, más rápido)",
"settings.model.base": "base (74 MB, equilibrado)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1.5 GB, mejor)",
"settings.sttLanguage": "Idioma de reconocimiento",
"settings.sttLang.auto": "Detección automática",
"settings.ollamaServer": "Servidor Ollama",
"settings.ollamaUrl": "URL del servidor Ollama",
"settings.ollamaHint": "D3RO Voice se conecta automáticamente cuando Ollama está en ejecución. Descarga modelos directamente desde Ollama (ej: ollama pull qwen3:4b).",
@ -125,7 +113,6 @@
"settings.action.grammar": "Corrección gramatical",
"settings.action.custom": "Prompt personalizado",
"settings.actionHint": "El postprocesado LLM seleccionado se aplica al texto transcrito tras grabar con el atajo. Solo funciona cuando Ollama está conectado.",
"settings.about.version": "Versión",
"settings.about.techStack": "Stack tecnológico",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)",
"settings.about.description": "Asistente de voz con IA completamente local, basado en ingeniería inversa de Speakly. Funciona sin dependencias en la nube.",
"settings.about.restartOnboarding": "Ver guía de configuración inicial de nuevo",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama no está en ejecución",
"status.nudge.desc": "Se necesita Ollama para el postprocesado LLM (traducción, resumen, etc.).",
"status.nudge.guide": "Ver guía de instalación →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "Sin escribir, con voz. Tu asistente de voz con IA local.",
"onboarding.welcome.start": "Comenzar",
@ -160,7 +145,6 @@
"onboarding.done.start": "Comenzar",
"onboarding.back": "Atrás",
"onboarding.next": "Siguiente",
"hotkey.title": "Configurar atajo",
"hotkey.dictationTitle": "Configurar atajo de dictado",
"hotkey.oneTouchTitle": "Configurar atajo de modo un toque",
@ -171,7 +155,6 @@
"hotkey.current": "Actual: {{keys}}",
"hotkey.hint": "Introduce una combinación (ej: Ctrl+Shift+Q) o una tecla sola (ej: F5)",
"hotkey.reset": "Volver a introducir",
"ollama.title": "GUÍA DE CONFIGURACIÓN DE OLLAMA",
"ollama.step1.title": "PASO 1 — Instalar Ollama",
"ollama.step1.desc": "Ollama es una herramienta gratuita para ejecutar LLMs de forma local.",
@ -180,7 +163,6 @@
"ollama.step2.alt": "O un modelo más grande: ollama pull qwen3:8b (más preciso, más lento)",
"ollama.step3.title": "PASO 3 — Conexión automática",
"ollama.step3.desc": "D3RO-VOICE detecta automáticamente cuando Ollama está en ejecución. Cuando el LED de la barra de estado pase de rojo a verde, ¡estará listo!",
"service.sttEngine": "MOTOR STT",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "ATAJO DE TECLADO",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVO",
"service.standby": "EN ESPERA",
"common.cancel": "Cancelar",
"common.save": "Guardar",
"common.delete": "Eliminar",
@ -202,7 +183,51 @@
"common.copy": "Copiar",
"common.test": "Probar",
"common.stop": "Detener",
"date.today": "Hoy",
"date.yesterday": "Ayer"
"date.yesterday": "Ayer",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "Coreano",
"settings.sttLang.en": "Inglés",
"settings.sttLang.ja": "Japonés",
"settings.sttLang.zh": "Chino",
"template.outputHelperText": "Use {{nombreCampo}} para marcadores de posición",
"nav.meeting": "Reunión",
"meeting.title": "Modo Reunión",
"meeting.newMeeting": "Nueva Reunión",
"meeting.startRecording": "Iniciar Grabación",
"meeting.stopRecording": "Detener Grabación",
"meeting.recording": "Grabando",
"meeting.processing": "Generando actas...",
"meeting.completed": "Completado",
"meeting.error": "Error",
"meeting.elapsed": "Tiempo transcurrido",
"meeting.memoPlaceholder": "Escribe un memo (Enter para añadir)",
"meeting.memoAdded": "Memo añadido",
"meeting.noSessions": "Sin grabaciones de reuniones",
"meeting.noSessionsDesc": "Pulsa Nueva Reunión para empezar a grabar",
"meeting.summary": "Resumen",
"meeting.decisions": "Decisiones",
"meeting.actionItems": "Tareas",
"meeting.timeline": "Línea de tiempo",
"meeting.transcript": "Transcripción bruta",
"meeting.memos": "Memos",
"meeting.exportPdf": "Exportar PDF",
"meeting.exportMarkdown": "Exportar Markdown",
"meeting.delete": "Eliminar",
"meeting.deleteConfirm": "¿Eliminar este registro de reunión?",
"meeting.backToList": "Volver a la lista",
"meeting.notificationTitle": "Actas listas",
"meeting.notificationBody": "Las actas de la reunión han sido generadas.",
"meeting.sessions": "{{count}} sesiones",
"meeting.duration": "{{minutes}} min",
"meeting.editTitle": "Editar título",
"meeting.untitled": "Reunión sin título",
"meeting.processingStep.merging": "Fusionando transcripción...",
"meeting.processingStep.generating": "Generando actas con LLM...",
"meeting.processingStep.parsing": "Analizando actas...",
"meeting.processingStep.saving": "Guardando...",
"meeting.processingStep.notifying": "Enviando notificación..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Assistant vocal IA local",
"nav.dashboard": "Tableau de bord",
"nav.history": "Historique",
"nav.dictionary": "Dictionnaire",
"nav.commands": "Commandes",
"nav.settings": "Paramètres",
"dashboard.sessionOverview": "Aperçu de session",
"dashboard.systemStatus": "État du système",
"dashboard.sessionsToday": "Sessions aujourd'hui",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} entrées",
"dashboard.stat": "statistiques",
"dashboard.sys": "système",
"history.title": "Historique des transcriptions",
"history.search": "Rechercher...",
"history.entries": "{{count}} entrées",
@ -34,7 +31,6 @@
"history.noResults": "Aucun résultat",
"history.noHistory": "Aucun historique — commencez à enregistrer",
"history.count": "{{count}} entrées",
"dictionary.title": "Dictionnaire personnalisé",
"dictionary.words": "{{count}} mots",
"dictionary.add": "Ajouter",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Ajouter un mot",
"dictionary.word": "Mot",
"dictionary.pronunciation": "Prononciation (facultatif)",
"commands.title": "Commandes LLM",
"commands.count": "{{count}} commandes",
"commands.add": "Ajouter",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Modèle de prompt",
"commands.promptHelp": "{{text}} sera remplacé par le texte transcrit",
"commands.defaultPrompt": "Veuillez améliorer {{text}}.",
"settings.title": "Paramètres",
"settings.tabs.general": "Général",
"settings.tabs.audio": "Audio",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "À propos",
"settings.shortcuts": "Raccourcis",
"settings.dictation": "Dictée",
"settings.dictation.desc": "Maintenez enfoncé pour parler. La transcription démarre au relâchement.",
@ -82,20 +75,17 @@
"settings.notSet": "Non configuré",
"settings.enabled": "Activé",
"settings.disabled": "Désactivé",
"settings.interface": "Interface",
"settings.theme": "Thème",
"settings.theme.system": "Système",
"settings.theme.light": "Clair",
"settings.theme.dark": "Sombre",
"settings.language": "Langue",
"settings.appBehavior": "Comportement de l'application",
"settings.closeToTray": "Réduire dans la barre système",
"settings.autoLaunch": "Lancer au démarrage du système",
"settings.autoInsert": "Insérer le texte automatiquement après la transcription",
"settings.soundEffects": "Effets sonores",
"settings.microphone": "Microphone",
"settings.inputDevice": "Périphérique d'entrée",
"settings.deviceDefault": "(par défaut)",
@ -103,7 +93,6 @@
"settings.insertMethod": "Méthode d'insertion",
"settings.insertClipboard": "Presse-papiers (Ctrl+V)",
"settings.insertKeyboard": "Frappe au clavier",
"settings.whisperModel": "Modèle Whisper",
"settings.model.tiny": "tiny (39 Mo, le plus rapide)",
"settings.model.base": "base (74 Mo, équilibré)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1,5 Go, meilleur)",
"settings.sttLanguage": "Langue de reconnaissance",
"settings.sttLang.auto": "Détection automatique",
"settings.ollamaServer": "Serveur Ollama",
"settings.ollamaUrl": "URL du serveur Ollama",
"settings.ollamaHint": "D3RO Voice se connecte automatiquement quand Ollama est en cours d'exécution. Téléchargez les modèles directement depuis Ollama (ex : ollama pull qwen3:4b).",
@ -125,7 +113,6 @@
"settings.action.grammar": "Correction grammaticale",
"settings.action.custom": "Prompt personnalisé",
"settings.actionHint": "Le post-traitement LLM sélectionné est appliqué au texte transcrit après l'enregistrement avec le raccourci. Ne fonctionne que lorsque Ollama est connecté.",
"settings.about.version": "Version",
"settings.about.techStack": "Stack technologique",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT : faster-whisper (local) / LLM : Ollama (local)",
"settings.about.description": "Assistant vocal IA entièrement local, basé sur l'ingénierie inverse de Speakly. Fonctionne sans dépendance au cloud.",
"settings.about.restartOnboarding": "Revoir le guide de configuration initiale",
"status.ollama": "OLLAMA",
"status.offline": "HORS LIGNE",
"status.nudge.title": "Ollama n'est pas en cours d'exécution",
"status.nudge.desc": "Ollama est nécessaire pour le post-traitement LLM (traduction, résumé, etc.).",
"status.nudge.guide": "Voir le guide d'installation →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "Sans taper, par la voix. Votre assistant vocal IA local.",
"onboarding.welcome.start": "Commencer",
@ -160,7 +145,6 @@
"onboarding.done.start": "Commencer",
"onboarding.back": "Retour",
"onboarding.next": "Suivant",
"hotkey.title": "Configurer le raccourci",
"hotkey.dictationTitle": "Configurer le raccourci de dictée",
"hotkey.oneTouchTitle": "Configurer le raccourci du mode une touche",
@ -171,7 +155,6 @@
"hotkey.current": "Actuel : {{keys}}",
"hotkey.hint": "Saisissez une combinaison (ex : Ctrl+Shift+Q) ou une touche seule (ex : F5)",
"hotkey.reset": "Ressaisir",
"ollama.title": "GUIDE DE CONFIGURATION OLLAMA",
"ollama.step1.title": "ÉTAPE 1 — Installer Ollama",
"ollama.step1.desc": "Ollama est un outil gratuit pour exécuter des LLM en local.",
@ -180,7 +163,6 @@
"ollama.step2.alt": "Ou un modèle plus grand : ollama pull qwen3:8b (plus précis, plus lent)",
"ollama.step3.title": "ÉTAPE 3 — Connexion automatique",
"ollama.step3.desc": "D3RO-VOICE détecte automatiquement quand Ollama est en cours d'exécution. Quand la LED de la barre d'état passe du rouge au vert, c'est prêt !",
"service.sttEngine": "MOTEUR STT",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "RACCOURCI CLAVIER",
@ -190,7 +172,6 @@
"service.offline": "HORS LIGNE",
"service.active": "ACTIF",
"service.standby": "EN VEILLE",
"common.cancel": "Annuler",
"common.save": "Enregistrer",
"common.delete": "Supprimer",
@ -202,7 +183,51 @@
"common.copy": "Copier",
"common.test": "Tester",
"common.stop": "Arrêter",
"date.today": "Aujourd'hui",
"date.yesterday": "Hier"
"date.yesterday": "Hier",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "Coréen",
"settings.sttLang.en": "Anglais",
"settings.sttLang.ja": "Japonais",
"settings.sttLang.zh": "Chinois",
"template.outputHelperText": "Utilisez {{nomChamp}} pour les espaces réservés",
"nav.meeting": "Réunion",
"meeting.title": "Mode Réunion",
"meeting.newMeeting": "Nouvelle Réunion",
"meeting.startRecording": "Démarrer l'enregistrement",
"meeting.stopRecording": "Arrêter l'enregistrement",
"meeting.recording": "Enregistrement en cours",
"meeting.processing": "Génération du compte-rendu...",
"meeting.completed": "Terminé",
"meeting.error": "Erreur",
"meeting.elapsed": "Temps écoulé",
"meeting.memoPlaceholder": "Saisir un mémo (Entrée pour ajouter)",
"meeting.memoAdded": "Mémo ajouté",
"meeting.noSessions": "Aucun enregistrement de réunion",
"meeting.noSessionsDesc": "Appuyez sur Nouvelle Réunion pour commencer l'enregistrement",
"meeting.summary": "Résumé",
"meeting.decisions": "Décisions",
"meeting.actionItems": "Actions à mener",
"meeting.timeline": "Chronologie",
"meeting.transcript": "Transcription brute",
"meeting.memos": "Mémos",
"meeting.exportPdf": "Exporter en PDF",
"meeting.exportMarkdown": "Exporter en Markdown",
"meeting.delete": "Supprimer",
"meeting.deleteConfirm": "Supprimer cet enregistrement de réunion ?",
"meeting.backToList": "Retour à la liste",
"meeting.notificationTitle": "Compte-rendu prêt",
"meeting.notificationBody": "Le compte-rendu de la réunion a été généré.",
"meeting.sessions": "{{count}} séances",
"meeting.duration": "{{minutes}} min",
"meeting.editTitle": "Modifier le titre",
"meeting.untitled": "Réunion sans titre",
"meeting.processingStep.merging": "Fusion de la transcription...",
"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..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "ローカルAI音声アシスタント",
"nav.dashboard": "ダッシュボード",
"nav.history": "履歴",
"nav.dictionary": "辞書",
"nav.commands": "コマンド",
"nav.settings": "設定",
"dashboard.sessionOverview": "セッション概要",
"dashboard.systemStatus": "システム状態",
"dashboard.sessionsToday": "本日のセッション",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}件",
"dashboard.stat": "統計",
"dashboard.sys": "システム",
"history.title": "文字起こし履歴",
"history.search": "検索...",
"history.entries": "{{count}}件",
@ -34,7 +31,6 @@
"history.noResults": "検索結果なし",
"history.noHistory": "履歴なし — 録音を開始してください",
"history.count": "{{count}}件",
"dictionary.title": "カスタム辞書",
"dictionary.words": "{{count}}語",
"dictionary.add": "追加",
@ -47,7 +43,6 @@
"dictionary.addTitle": "単語を追加",
"dictionary.word": "単語",
"dictionary.pronunciation": "読み仮名 (任意)",
"commands.title": "LLMコマンド",
"commands.count": "{{count}}個",
"commands.add": "追加",
@ -62,14 +57,12 @@
"commands.promptTemplate": "プロンプトテンプレート",
"commands.promptHelp": "{{text}} は文字起こしされたテキストに置換されます",
"commands.defaultPrompt": "{{text}} を整えてください。",
"settings.title": "設定",
"settings.tabs.general": "一般",
"settings.tabs.audio": "オーディオ",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "情報",
"settings.shortcuts": "ショートカット",
"settings.dictation": "ディクテーション",
"settings.dictation.desc": "押しながら話します。キーを離すと文字起こしが始まります。",
@ -82,20 +75,17 @@
"settings.notSet": "未設定",
"settings.enabled": "有効",
"settings.disabled": "無効",
"settings.interface": "インターフェース",
"settings.theme": "テーマ",
"settings.theme.system": "システム",
"settings.theme.light": "ライト",
"settings.theme.dark": "ダーク",
"settings.language": "言語",
"settings.appBehavior": "アプリの動作",
"settings.closeToTray": "トレイに最小化",
"settings.autoLaunch": "システム起動時に自動起動",
"settings.autoInsert": "文字起こし後に自動テキスト挿入",
"settings.soundEffects": "効果音",
"settings.microphone": "マイク",
"settings.inputDevice": "入力デバイス",
"settings.deviceDefault": "(デフォルト)",
@ -103,7 +93,6 @@
"settings.insertMethod": "挿入方式",
"settings.insertClipboard": "クリップボード (Ctrl+V)",
"settings.insertKeyboard": "キーボード入力",
"settings.whisperModel": "Whisperモデル",
"settings.model.tiny": "tiny (39 MB, 最速)",
"settings.model.base": "base (74 MB, バランス)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1.5 GB, 最高)",
"settings.sttLanguage": "認識言語",
"settings.sttLang.auto": "自動検出",
"settings.ollamaServer": "Ollamaサーバー",
"settings.ollamaUrl": "OllamaサーバーURL",
"settings.ollamaHint": "Ollamaが起動していれば自動的に接続されます。モデルはOllamaから直接pullしてください例: ollama pull qwen3:4b。",
@ -125,7 +113,6 @@
"settings.action.grammar": "文法修正",
"settings.action.custom": "カスタムプロンプト",
"settings.actionHint": "ホットキーで録音後、文字起こしされたテキストに選択したLLM後処理が適用されます。Ollamaが接続されている場合のみ動作します。",
"settings.about.version": "バージョン",
"settings.about.techStack": "技術スタック",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (ローカル) / LLM: Ollama (ローカル)",
"settings.about.description": "Speaklyリバースエンジニアリングのウハウをもとに構築したローカルAI音声アシスタント。クラウドに依存せず完全ローカルで動作します。",
"settings.about.restartOnboarding": "初期設定ガイドをもう一度見る",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollamaが起動していません",
"status.nudge.desc": "LLM後処理翻訳、要約などを使用するにはOllamaが必要です。",
"status.nudge.guide": "インストールガイドを見る →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "タイピング不要、音声で。ローカルAI音声アシスタントです。",
"onboarding.welcome.start": "始める",
@ -160,7 +145,6 @@
"onboarding.done.start": "始める",
"onboarding.back": "戻る",
"onboarding.next": "次へ",
"hotkey.title": "ショートカット設定",
"hotkey.dictationTitle": "ディクテーションショートカットの設定",
"hotkey.oneTouchTitle": "ワンタッチモードショートカットの設定",
@ -171,7 +155,6 @@
"hotkey.current": "現在: {{keys}}",
"hotkey.hint": "組み合わせキー(例: Ctrl+Shift+Qまたは単一キー例: F5を入力してください",
"hotkey.reset": "再入力",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "STEP 1 — Ollamaのインストール",
"ollama.step1.desc": "OllamaはローカルでLLMを実行するための無料ツールです。",
@ -180,7 +163,6 @@
"ollama.step2.alt": "または大きいモデル: ollama pull qwen3:8b (より正確、より遅い)",
"ollama.step3.title": "STEP 3 — 自動接続",
"ollama.step3.desc": "Ollamaが起動するとD3RO-VOICEが自動的に検出します。下部ステータスバーのLEDが赤から緑に変われば準備完了",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "キャンセル",
"common.save": "保存",
"common.delete": "削除",
@ -202,7 +183,51 @@
"common.copy": "コピー",
"common.test": "テスト",
"common.stop": "停止",
"date.today": "今日",
"date.yesterday": "昨日"
"date.yesterday": "昨日",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "한국語",
"settings.sttLang.en": "English",
"settings.sttLang.ja": "日本語",
"settings.sttLang.zh": "中文",
"template.outputHelperText": "{{フィールド名}} でプレースホルダーを使用",
"nav.meeting": "ミーティング",
"meeting.title": "ミーティングモード",
"meeting.newMeeting": "新規ミーティング",
"meeting.startRecording": "録音開始",
"meeting.stopRecording": "録音停止",
"meeting.recording": "録音中",
"meeting.processing": "議事録を生成中...",
"meeting.completed": "完了",
"meeting.error": "エラー",
"meeting.elapsed": "経過時間",
"meeting.memoPlaceholder": "メモを入力Enterで追加",
"meeting.memoAdded": "メモを追加しました",
"meeting.noSessions": "ミーティングの録音がありません",
"meeting.noSessionsDesc": "「新規ミーティング」を押して録音を開始してください",
"meeting.summary": "要約",
"meeting.decisions": "決定事項",
"meeting.actionItems": "アクションアイテム",
"meeting.timeline": "タイムライン",
"meeting.transcript": "生のトランスクリプト",
"meeting.memos": "メモ",
"meeting.exportPdf": "PDFエクスポート",
"meeting.exportMarkdown": "Markdownエクスポート",
"meeting.delete": "削除",
"meeting.deleteConfirm": "このミーティング記録を削除しますか?",
"meeting.backToList": "一覧に戻る",
"meeting.notificationTitle": "議事録が完成しました",
"meeting.notificationBody": "ミーティングの議事録が生成されました。",
"meeting.sessions": "{{count}} セッション",
"meeting.duration": "{{minutes}} 分",
"meeting.editTitle": "タイトルを編集",
"meeting.untitled": "無題のミーティング",
"meeting.processingStep.merging": "トランスクリプトをマージ中...",
"meeting.processingStep.generating": "LLMで議事録を生成中...",
"meeting.processingStep.parsing": "議事録を解析中...",
"meeting.processingStep.saving": "保存中...",
"meeting.processingStep.notifying": "通知を送信中..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "로컬 AI 음성 어시스턴트",
"nav.dashboard": "대시보드",
"nav.history": "히스토리",
"nav.dictionary": "사전",
"nav.commands": "명령어",
"nav.settings": "설정",
"dashboard.sessionOverview": "세션 개요",
"dashboard.systemStatus": "시스템 상태",
"dashboard.sessionsToday": "오늘 세션",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}건",
"dashboard.stat": "통계",
"dashboard.sys": "시스템",
"history.title": "전사 기록",
"history.search": "검색...",
"history.entries": "{{count}}건",
@ -34,7 +31,6 @@
"history.noResults": "검색 결과 없음",
"history.noHistory": "히스토리 없음 — 녹음을 시작하세요",
"history.count": "{{count}}건",
"dictionary.title": "커스텀 사전",
"dictionary.words": "{{count}}개",
"dictionary.add": "추가",
@ -47,7 +43,6 @@
"dictionary.addTitle": "단어 추가",
"dictionary.word": "단어",
"dictionary.pronunciation": "발음 (선택)",
"commands.title": "LLM 명령어",
"commands.count": "{{count}}개",
"commands.add": "추가",
@ -62,14 +57,12 @@
"commands.promptTemplate": "프롬프트 템플릿",
"commands.promptHelp": "{{text}}는 전사된 텍스트로 치환됩니다",
"commands.defaultPrompt": "{{text}}를 다듬어주세요.",
"settings.title": "설정",
"settings.tabs.general": "일반",
"settings.tabs.audio": "오디오",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "정보",
"settings.shortcuts": "단축키",
"settings.dictation": "받아쓰기",
"settings.dictation.desc": "누른 상태에서 말하기. 키를 놓으면 전사가 시작됩니다.",
@ -89,20 +82,17 @@
"settings.notSet": "미설정",
"settings.enabled": "활성화됨",
"settings.disabled": "비활성화",
"settings.interface": "인터페이스",
"settings.theme": "테마",
"settings.theme.system": "시스템",
"settings.theme.light": "라이트",
"settings.theme.dark": "다크",
"settings.language": "언어",
"settings.appBehavior": "앱 동작",
"settings.closeToTray": "트레이로 최소화",
"settings.autoLaunch": "시스템 시작 시 자동 실행",
"settings.autoInsert": "전사 후 자동 텍스트 삽입",
"settings.soundEffects": "효과음",
"settings.microphone": "마이크",
"settings.inputDevice": "입력 장치",
"settings.deviceDefault": "(기본)",
@ -110,7 +100,6 @@
"settings.insertMethod": "삽입 방식",
"settings.insertClipboard": "클립보드 (Ctrl+V)",
"settings.insertKeyboard": "키보드 타이핑",
"settings.whisperModel": "Whisper 모델",
"settings.model.tiny": "tiny (39 MB, 가장 빠름)",
"settings.model.base": "base (74 MB, 균형)",
@ -119,7 +108,6 @@
"settings.model.large": "large-v3 (1.5 GB, 최고)",
"settings.sttLanguage": "인식 언어",
"settings.sttLang.auto": "자동 감지",
"settings.ollamaServer": "Ollama 서버",
"settings.ollamaUrl": "Ollama 서버 URL",
"settings.ollamaHint": "Ollama가 실행 중이면 자동으로 연결됩니다. 모델은 Ollama에서 직접 pull하세요 (예: ollama pull qwen3:4b).",
@ -132,7 +120,6 @@
"settings.action.grammar": "문법 교정",
"settings.action.custom": "커스텀 프롬프트",
"settings.actionHint": "핫키로 녹음 후 전사된 텍스트에 선택한 LLM 후처리가 적용됩니다. Ollama가 연결되어 있을 때만 동작합니다.",
"settings.about.version": "버전",
"settings.about.techStack": "기술 스택",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -140,13 +127,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (로컬) / LLM: Ollama (로컬)",
"settings.about.description": "Speakly 리버스엔지니어링 노하우 기반 로컬 AI 음성 어시스턴트. 클라우드 의존성 없이 완전 로컬로 동작합니다.",
"settings.about.restartOnboarding": "초기 설정 안내 다시 보기",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama가 실행되지 않고 있어요",
"status.nudge.desc": "LLM 후처리(번역, 요약 등)를 사용하려면 Ollama가 필요합니다.",
"status.nudge.guide": "설치 안내 보기 →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "타이핑 없이, 음성으로. 로컬 AI 음성 어시스턴트입니다.",
"onboarding.welcome.start": "시작하기",
@ -167,7 +152,6 @@
"onboarding.done.start": "시작하기",
"onboarding.back": "뒤로",
"onboarding.next": "다음",
"hotkey.title": "단축키 설정",
"hotkey.dictationTitle": "받아쓰기 단축키 설정",
"hotkey.oneTouchTitle": "원터치 모드 단축키 설정",
@ -179,7 +163,6 @@
"hotkey.current": "현재: {{keys}}",
"hotkey.hint": "조합키(예: Ctrl+Shift+Q) 또는 단일키(예: F5)를 입력하세요",
"hotkey.reset": "다시 입력",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "STEP 1 — Ollama 설치",
"ollama.step1.desc": "Ollama는 로컬에서 LLM을 실행하는 무료 도구입니다.",
@ -188,7 +171,6 @@
"ollama.step2.alt": "또는 더 큰 모델: ollama pull qwen3:8b (더 정확, 더 느림)",
"ollama.step3.title": "STEP 3 — 자동 연결",
"ollama.step3.desc": "Ollama가 실행되면 D3RO-VOICE가 자동으로 감지합니다. 하단 상태 바의 LED가 빨간색에서 초록색으로 바뀌면 준비 완료!",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -198,7 +180,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "취소",
"common.save": "저장",
"common.delete": "삭제",
@ -210,7 +191,6 @@
"common.copy": "복사",
"common.test": "테스트",
"common.stop": "중지",
"memo.tags": "태그",
"memo.addTag": "태그 추가",
"memo.removeTag": "태그 제거",
@ -221,7 +201,6 @@
"memo.filterByTag": "태그로 필터",
"memo.allTags": "전체 태그",
"memo.clearFilter": "필터 해제",
"voiceCommand.title": "음성 명령어",
"voiceCommand.enabled": "음성 키워드 인식",
"voiceCommand.enabledDesc": "전사 시작 시 키워드를 감지하여 명령어를 자동 선택합니다.",
@ -232,7 +211,6 @@
"voiceCommand.matchMode.prefix": "앞부분 일치",
"voiceCommand.matchMode.suffix": "뒷부분 일치",
"voiceCommand.matchMode.contains": "포함",
"chain.title": "LLM 체인",
"chain.count": "{{count}}개",
"chain.add": "체인 추가",
@ -251,24 +229,19 @@
"chain.noChains": "체인 없음 — 여러 명령어를 순차 실행하는 파이프라인을 만들어 보세요",
"chain.step": "{{n}}단계",
"chain.progress": "{{current}}/{{total}} 단계 처리 중",
"context.title": "화면 컨텍스트",
"context.enabled": "스크린 컨텍스트",
"context.enabledDesc": "녹음 시작 시 활성 앱과 선택된 텍스트를 캡처하여 LLM에 전달합니다.",
"dashboard.caption": "실시간 자막",
"dashboard.captionStart": "자막 시작",
"dashboard.captionStop": "자막 종료",
"dashboard.captionActive": "자막 활성",
"settings.voiceCommands": "음성 명령어",
"settings.voiceCommands.desc": "전사된 텍스트에서 키워드를 감지하여 명령어를 자동 선택합니다.",
"settings.screenContext": "화면 컨텍스트",
"settings.screenContext.desc": "녹음 시 활성 앱 정보와 선택된 텍스트를 LLM에 함께 전달합니다.",
"date.today": "오늘",
"date.yesterday": "어제",
"license.title": "라이선스",
"license.currentTier": "현재 등급",
"license.free": "FREE",
@ -343,7 +316,6 @@
"license.unlimited": "무제한",
"license.locked": "잠금",
"license.nav": "라이선스",
"fileTranscription.title": "파일 전사",
"fileTranscription.dropZone": "오디오/비디오 파일을 여기에 드래그하세요",
"fileTranscription.dropZoneHint": "MP3, WAV, M4A, MP4, MKV, WEBM 지원",
@ -356,7 +328,6 @@
"fileTranscription.retry": "다시 시도",
"fileTranscription.error.invalidFormat": "지원하지 않는 파일 형식입니다",
"fileTranscription.error.unknown": "알 수 없는 오류",
"meetingSummary.title": "회의록 요약",
"meetingSummary.generating": "요약 생성 중...",
"meetingSummary.summary": "요약",
@ -365,7 +336,6 @@
"meetingSummary.exportMarkdown": "마크다운 내보내기",
"meetingSummary.noSummary": "요약이 없습니다",
"meetingSummary.generate": "요약 생성",
"template.title": "딕테이션 템플릿",
"template.create": "새 템플릿",
"template.edit": "템플릿 편집",
@ -381,7 +351,6 @@
"template.startSession": "시작",
"template.cancelSession": "세션 취소",
"template.empty": "템플릿이 없습니다",
"nav.conversation": "대화",
"conversation.title": "음성 대화",
"conversation.idle": "대기",
@ -393,7 +362,6 @@
"conversation.inputPlaceholder": "메시지 입력...",
"conversation.end": "종료",
"conversation.clearHistory": "대화 초기화",
"nav.knowledge": "지식 베이스",
"rag.title": "지식 베이스",
"rag.addDocument": "문서 추가",
@ -410,12 +378,56 @@
"rag.sources": "참조 문서",
"rag.adding": "추가 중...",
"rag.parsing": "문서 분석 중...",
"voiceAction.title": "음성 액션",
"voiceAction.execute": "실행",
"voiceAction.presets": "프리셋 명령",
"voiceAction.history": "실행 이력",
"voiceAction.noHistory": "실행 이력이 없습니다",
"voiceAction.blocked": "차단됨 (위험 명령)",
"voiceAction.executed": "실행됨"
"voiceAction.executed": "실행됨",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "한국어",
"settings.sttLang.en": "English",
"settings.sttLang.ja": "日本語",
"settings.sttLang.zh": "中文",
"template.outputHelperText": "{{필드명}} 형식으로 플레이스홀더를 사용하세요",
"nav.meeting": "회의",
"meeting.title": "회의 모드",
"meeting.newMeeting": "새 회의",
"meeting.startRecording": "녹음 시작",
"meeting.stopRecording": "녹음 종료",
"meeting.recording": "녹음 중",
"meeting.processing": "회의록 생성 중...",
"meeting.completed": "완료",
"meeting.error": "오류",
"meeting.elapsed": "경과 시간",
"meeting.memoPlaceholder": "메모 입력 (Enter로 추가)",
"meeting.memoAdded": "메모 추가됨",
"meeting.noSessions": "회의 녹음이 없습니다",
"meeting.noSessionsDesc": "새 회의 버튼을 눌러 녹음을 시작하세요",
"meeting.summary": "요약",
"meeting.decisions": "결정사항",
"meeting.actionItems": "할 일",
"meeting.timeline": "타임라인",
"meeting.transcript": "원문 전사",
"meeting.memos": "메모",
"meeting.exportPdf": "PDF 내보내기",
"meeting.exportMarkdown": "마크다운 내보내기",
"meeting.delete": "삭제",
"meeting.deleteConfirm": "이 회의록을 삭제하시겠습니까?",
"meeting.backToList": "목록으로",
"meeting.notificationTitle": "회의록 준비 완료",
"meeting.notificationBody": "회의록이 생성되었습니다. 클릭하여 확인하세요.",
"meeting.sessions": "{{count}}건",
"meeting.duration": "{{minutes}}분",
"meeting.editTitle": "제목 편집",
"meeting.untitled": "무제 회의",
"meeting.processingStep.merging": "전사 텍스트 합산 중...",
"meeting.processingStep.generating": "LLM 회의록 생성 중...",
"meeting.processingStep.parsing": "회의록 파싱 중...",
"meeting.processingStep.saving": "저장 중...",
"meeting.processingStep.notifying": "완료 알림 중..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Assistente de voz com IA local",
"nav.dashboard": "Painel",
"nav.history": "Histórico",
"nav.dictionary": "Dicionário",
"nav.commands": "Comandos",
"nav.settings": "Configurações",
"dashboard.sessionOverview": "Visão geral da sessão",
"dashboard.systemStatus": "Status do sistema",
"dashboard.sessionsToday": "Sessões hoje",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} entradas",
"dashboard.stat": "estatísticas",
"dashboard.sys": "sistema",
"history.title": "Histórico de transcrições",
"history.search": "Pesquisar...",
"history.entries": "{{count}} entradas",
@ -34,7 +31,6 @@
"history.noResults": "Sem resultados",
"history.noHistory": "Sem histórico — comece a gravar",
"history.count": "{{count}} entradas",
"dictionary.title": "Dicionário personalizado",
"dictionary.words": "{{count}} palavras",
"dictionary.add": "Adicionar",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Adicionar palavra",
"dictionary.word": "Palavra",
"dictionary.pronunciation": "Pronúncia (opcional)",
"commands.title": "Comandos LLM",
"commands.count": "{{count}} comandos",
"commands.add": "Adicionar",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Modelo de prompt",
"commands.promptHelp": "{{text}} será substituído pelo texto transcrito",
"commands.defaultPrompt": "Por favor, melhore {{text}}.",
"settings.title": "Configurações",
"settings.tabs.general": "Geral",
"settings.tabs.audio": "Áudio",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "Sobre",
"settings.shortcuts": "Atalhos",
"settings.dictation": "Ditado",
"settings.dictation.desc": "Mantenha pressionado para falar. A transcrição começa ao soltar.",
@ -82,20 +75,17 @@
"settings.notSet": "Não configurado",
"settings.enabled": "Ativado",
"settings.disabled": "Desativado",
"settings.interface": "Interface",
"settings.theme": "Tema",
"settings.theme.system": "Sistema",
"settings.theme.light": "Claro",
"settings.theme.dark": "Escuro",
"settings.language": "Idioma",
"settings.appBehavior": "Comportamento do aplicativo",
"settings.closeToTray": "Minimizar para a bandeja",
"settings.autoLaunch": "Iniciar com o sistema",
"settings.autoInsert": "Inserir texto automaticamente após a transcrição",
"settings.soundEffects": "Efeitos sonoros",
"settings.microphone": "Microfone",
"settings.inputDevice": "Dispositivo de entrada",
"settings.deviceDefault": "(padrão)",
@ -103,7 +93,6 @@
"settings.insertMethod": "Método de inserção",
"settings.insertClipboard": "Área de transferência (Ctrl+V)",
"settings.insertKeyboard": "Digitação pelo teclado",
"settings.whisperModel": "Modelo Whisper",
"settings.model.tiny": "tiny (39 MB, mais rápido)",
"settings.model.base": "base (74 MB, equilibrado)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1,5 GB, melhor)",
"settings.sttLanguage": "Idioma de reconhecimento",
"settings.sttLang.auto": "Detecção automática",
"settings.ollamaServer": "Servidor Ollama",
"settings.ollamaUrl": "URL do servidor Ollama",
"settings.ollamaHint": "D3RO Voice conecta-se automaticamente quando o Ollama está em execução. Baixe modelos diretamente pelo Ollama (ex: ollama pull qwen3:4b).",
@ -125,7 +113,6 @@
"settings.action.grammar": "Correção gramatical",
"settings.action.custom": "Prompt personalizado",
"settings.actionHint": "O pós-processamento LLM selecionado é aplicado ao texto transcrito após gravar com o atalho. Só funciona quando o Ollama está conectado.",
"settings.about.version": "Versão",
"settings.about.techStack": "Stack tecnológico",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (local) / LLM: Ollama (local)",
"settings.about.description": "Assistente de voz com IA totalmente local, baseado em engenharia reversa do Speakly. Funciona sem dependências de nuvem.",
"settings.about.restartOnboarding": "Ver guia de configuração inicial novamente",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama não está em execução",
"status.nudge.desc": "O Ollama é necessário para o pós-processamento LLM (tradução, resumo, etc.).",
"status.nudge.guide": "Ver guia de instalação →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "Sem digitar, por voz. Seu assistente de voz com IA local.",
"onboarding.welcome.start": "Começar",
@ -160,7 +145,6 @@
"onboarding.done.start": "Começar",
"onboarding.back": "Voltar",
"onboarding.next": "Próximo",
"hotkey.title": "Configurar atalho",
"hotkey.dictationTitle": "Configurar atalho de ditado",
"hotkey.oneTouchTitle": "Configurar atalho do modo de um toque",
@ -171,7 +155,6 @@
"hotkey.current": "Atual: {{keys}}",
"hotkey.hint": "Insira uma combinação (ex: Ctrl+Shift+Q) ou uma tecla única (ex: F5)",
"hotkey.reset": "Inserir novamente",
"ollama.title": "GUIA DE CONFIGURAÇÃO DO OLLAMA",
"ollama.step1.title": "PASSO 1 — Instalar o Ollama",
"ollama.step1.desc": "O Ollama é uma ferramenta gratuita para executar LLMs localmente.",
@ -180,7 +163,6 @@
"ollama.step2.alt": "Ou um modelo maior: ollama pull qwen3:8b (mais preciso, mais lento)",
"ollama.step3.title": "PASSO 3 — Conexão automática",
"ollama.step3.desc": "D3RO-VOICE detecta automaticamente quando o Ollama está em execução. Quando o LED da barra de status mudar de vermelho para verde, está pronto!",
"service.sttEngine": "MOTOR STT",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "ATALHO DE TECLADO",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ATIVO",
"service.standby": "EM ESPERA",
"common.cancel": "Cancelar",
"common.save": "Salvar",
"common.delete": "Excluir",
@ -202,7 +183,51 @@
"common.copy": "Copiar",
"common.test": "Testar",
"common.stop": "Parar",
"date.today": "Hoje",
"date.yesterday": "Ontem"
"date.yesterday": "Ontem",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "Coreano",
"settings.sttLang.en": "Inglês",
"settings.sttLang.ja": "Japonês",
"settings.sttLang.zh": "Chinês",
"template.outputHelperText": "Use {{nomeCampo}} para marcadores de posição",
"nav.meeting": "Reunião",
"meeting.title": "Modo Reunião",
"meeting.newMeeting": "Nova Reunião",
"meeting.startRecording": "Iniciar Gravação",
"meeting.stopRecording": "Parar Gravação",
"meeting.recording": "Gravando",
"meeting.processing": "Gerando ata...",
"meeting.completed": "Concluído",
"meeting.error": "Erro",
"meeting.elapsed": "Tempo decorrido",
"meeting.memoPlaceholder": "Digite um memo (Enter para adicionar)",
"meeting.memoAdded": "Memo adicionado",
"meeting.noSessions": "Nenhuma gravação de reunião",
"meeting.noSessionsDesc": "Pressione Nova Reunião para começar a gravar",
"meeting.summary": "Resumo",
"meeting.decisions": "Decisões",
"meeting.actionItems": "Itens de ação",
"meeting.timeline": "Linha do tempo",
"meeting.transcript": "Transcrição bruta",
"meeting.memos": "Memos",
"meeting.exportPdf": "Exportar PDF",
"meeting.exportMarkdown": "Exportar Markdown",
"meeting.delete": "Excluir",
"meeting.deleteConfirm": "Excluir este registro de reunião?",
"meeting.backToList": "Voltar à lista",
"meeting.notificationTitle": "Ata pronta",
"meeting.notificationBody": "A ata da reunião foi gerada.",
"meeting.sessions": "{{count}} sessões",
"meeting.duration": "{{minutes}} min",
"meeting.editTitle": "Editar título",
"meeting.untitled": "Reunião sem título",
"meeting.processingStep.merging": "Mesclando transcrição...",
"meeting.processingStep.generating": "Gerando ata com LLM...",
"meeting.processingStep.parsing": "Analisando ata...",
"meeting.processingStep.saving": "Salvando...",
"meeting.processingStep.notifying": "Enviando notificação..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Локальный голосовой ИИ-ассистент",
"nav.dashboard": "Панель",
"nav.history": "История",
"nav.dictionary": "Словарь",
"nav.commands": "Команды",
"nav.settings": "Настройки",
"dashboard.sessionOverview": "Обзор сессий",
"dashboard.systemStatus": "Состояние системы",
"dashboard.sessionsToday": "Сессий сегодня",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} записей",
"dashboard.stat": "Статистика",
"dashboard.sys": "Система",
"history.title": "История транскрипций",
"history.search": "Поиск...",
"history.entries": "{{count}} записей",
@ -34,7 +31,6 @@
"history.noResults": "Результаты не найдены",
"history.noHistory": "История пуста — начните запись",
"history.count": "{{count}} записей",
"dictionary.title": "Пользовательский словарь",
"dictionary.words": "{{count}} слов",
"dictionary.add": "Добавить",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Добавить слово",
"dictionary.word": "Слово",
"dictionary.pronunciation": "Произношение (необязательно)",
"commands.title": "Команды LLM",
"commands.count": "{{count}} шт.",
"commands.add": "Добавить",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Шаблон промпта",
"commands.promptHelp": "{{text}} будет заменено на транскрибированный текст",
"commands.defaultPrompt": "Пожалуйста, улучшите {{text}}.",
"settings.title": "Настройки",
"settings.tabs.general": "Общие",
"settings.tabs.audio": "Аудио",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "О программе",
"settings.shortcuts": "Горячие клавиши",
"settings.dictation": "Диктовка",
"settings.dictation.desc": "Говорите, удерживая клавишу. Отпустите для начала транскрипции.",
@ -82,20 +75,17 @@
"settings.notSet": "Не задано",
"settings.enabled": "Включено",
"settings.disabled": "Выключено",
"settings.interface": "Интерфейс",
"settings.theme": "Тема",
"settings.theme.system": "Системная",
"settings.theme.light": "Светлая",
"settings.theme.dark": "Тёмная",
"settings.language": "Язык",
"settings.appBehavior": "Поведение приложения",
"settings.closeToTray": "Сворачивать в трей",
"settings.autoLaunch": "Автозапуск при старте системы",
"settings.autoInsert": "Автоматически вставлять текст после транскрипции",
"settings.soundEffects": "Звуковые эффекты",
"settings.microphone": "Микрофон",
"settings.inputDevice": "Устройство ввода",
"settings.deviceDefault": "(По умолчанию)",
@ -103,7 +93,6 @@
"settings.insertMethod": "Метод вставки",
"settings.insertClipboard": "Буфер обмена (Ctrl+V)",
"settings.insertKeyboard": "Эмуляция клавиатуры",
"settings.whisperModel": "Модель Whisper",
"settings.model.tiny": "tiny (39 МБ, самая быстрая)",
"settings.model.base": "base (74 МБ, баланс)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1.5 ГБ, лучшая)",
"settings.sttLanguage": "Язык распознавания",
"settings.sttLang.auto": "Автоопределение",
"settings.ollamaServer": "Сервер Ollama",
"settings.ollamaUrl": "URL сервера Ollama",
"settings.ollamaHint": "Если Ollama запущена, подключение произойдёт автоматически. Загружайте модели напрямую через Ollama (например: ollama pull qwen3:4b).",
@ -125,7 +113,6 @@
"settings.action.grammar": "Исправить грамматику",
"settings.action.custom": "Пользовательский промпт",
"settings.actionHint": "После записи и транскрипции к тексту будет применена выбранная постобработка LLM. Работает только при подключённом Ollama.",
"settings.about.version": "Версия",
"settings.about.techStack": "Технический стек",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (локально) / LLM: Ollama (локально)",
"settings.about.description": "Локальный голосовой ИИ-ассистент, основанный на методах обратной разработки Speakly. Работает полностью локально без зависимости от облачных сервисов.",
"settings.about.restartOnboarding": "Показать начальную настройку снова",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama не запущена",
"status.nudge.desc": "Для постобработки LLM (перевод, резюмирование и т.д.) требуется Ollama.",
"status.nudge.guide": "Смотреть инструкцию по установке →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "Без набора текста — только голос. Локальный голосовой ИИ-ассистент.",
"onboarding.welcome.start": "Начать",
@ -160,7 +145,6 @@
"onboarding.done.start": "Начать",
"onboarding.back": "Назад",
"onboarding.next": "Далее",
"hotkey.title": "Настройка горячей клавиши",
"hotkey.dictationTitle": "Задать горячую клавишу диктовки",
"hotkey.oneTouchTitle": "Задать горячую клавишу режима одного нажатия",
@ -171,7 +155,6 @@
"hotkey.current": "Текущее: {{keys}}",
"hotkey.hint": "Введите сочетание клавиш (например: Ctrl+Shift+Q) или одну клавишу (например: F5)",
"hotkey.reset": "Ввести снова",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "ШАГ 1 — Установка Ollama",
"ollama.step1.desc": "Ollama — бесплатный инструмент для локального запуска LLM.",
@ -180,7 +163,6 @@
"ollama.step2.alt": "Или более крупная модель: ollama pull qwen3:8b (точнее, медленнее)",
"ollama.step3.title": "ШАГ 3 — Автоподключение",
"ollama.step3.desc": "При запуске Ollama D3RO-VOICE обнаружит её автоматически. Когда индикатор LED на нижней панели сменится с красного на зелёный — готово!",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "Отмена",
"common.save": "Сохранить",
"common.delete": "Удалить",
@ -202,7 +183,51 @@
"common.copy": "Копировать",
"common.test": "Тест",
"common.stop": "Стоп",
"date.today": "Сегодня",
"date.yesterday": "Вчера"
"date.yesterday": "Вчера",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "Корейский",
"settings.sttLang.en": "Английский",
"settings.sttLang.ja": "Японский",
"settings.sttLang.zh": "Китайский",
"template.outputHelperText": "Используйте {{имяПоля}} для заполнителей",
"nav.meeting": "Совещание",
"meeting.title": "Режим совещания",
"meeting.newMeeting": "Новое совещание",
"meeting.startRecording": "Начать запись",
"meeting.stopRecording": "Остановить запись",
"meeting.recording": "Запись",
"meeting.processing": "Создание протокола...",
"meeting.completed": "Завершено",
"meeting.error": "Ошибка",
"meeting.elapsed": "Прошло",
"meeting.memoPlaceholder": "Введите заметку (Enter для добавления)",
"meeting.memoAdded": "Заметка добавлена",
"meeting.noSessions": "Нет записей совещаний",
"meeting.noSessionsDesc": "Нажмите «Новое совещание», чтобы начать запись",
"meeting.summary": "Сводка",
"meeting.decisions": "Решения",
"meeting.actionItems": "Задачи",
"meeting.timeline": "Хронология",
"meeting.transcript": "Исходная расшифровка",
"meeting.memos": "Заметки",
"meeting.exportPdf": "Экспорт в PDF",
"meeting.exportMarkdown": "Экспорт в Markdown",
"meeting.delete": "Удалить",
"meeting.deleteConfirm": "Удалить эту запись совещания?",
"meeting.backToList": "Вернуться к списку",
"meeting.notificationTitle": "Протокол готов",
"meeting.notificationBody": "Протокол совещания сформирован.",
"meeting.sessions": "{{count}} сессий",
"meeting.duration": "{{minutes}} мин",
"meeting.editTitle": "Изменить название",
"meeting.untitled": "Совещание без названия",
"meeting.processingStep.merging": "Объединение расшифровки...",
"meeting.processingStep.generating": "Создание протокола с помощью LLM...",
"meeting.processingStep.parsing": "Разбор протокола...",
"meeting.processingStep.saving": "Сохранение...",
"meeting.processingStep.notifying": "Отправка уведомления..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "ผู้ช่วยเสียง AI ในเครื่อง",
"nav.dashboard": "แดชบอร์ด",
"nav.history": "ประวัติ",
"nav.dictionary": "พจนานุกรม",
"nav.commands": "คำสั่ง",
"nav.settings": "การตั้งค่า",
"dashboard.sessionOverview": "ภาพรวมเซสชัน",
"dashboard.systemStatus": "สถานะระบบ",
"dashboard.sessionsToday": "เซสชันวันนี้",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} รายการ",
"dashboard.stat": "สถิติ",
"dashboard.sys": "ระบบ",
"history.title": "ประวัติการถอดความ",
"history.search": "ค้นหา...",
"history.entries": "{{count}} รายการ",
@ -34,7 +31,6 @@
"history.noResults": "ไม่พบผลลัพธ์",
"history.noHistory": "ไม่มีประวัติ — เริ่มบันทึกเสียง",
"history.count": "{{count}} รายการ",
"dictionary.title": "พจนานุกรมที่กำหนดเอง",
"dictionary.words": "{{count}} คำ",
"dictionary.add": "เพิ่ม",
@ -47,7 +43,6 @@
"dictionary.addTitle": "เพิ่มคำ",
"dictionary.word": "คำ",
"dictionary.pronunciation": "การออกเสียง (ไม่บังคับ)",
"commands.title": "คำสั่ง LLM",
"commands.count": "{{count}} รายการ",
"commands.add": "เพิ่ม",
@ -62,14 +57,12 @@
"commands.promptTemplate": "เทมเพลตพรอมต์",
"commands.promptHelp": "{{text}} จะถูกแทนที่ด้วยข้อความที่ถอดความ",
"commands.defaultPrompt": "กรุณาปรับปรุง {{text}}",
"settings.title": "การตั้งค่า",
"settings.tabs.general": "ทั่วไป",
"settings.tabs.audio": "เสียง",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "เกี่ยวกับ",
"settings.shortcuts": "ปุ่มลัด",
"settings.dictation": "การบอกเล่า",
"settings.dictation.desc": "กดค้างไว้แล้วพูด เมื่อปล่อยจะเริ่มถอดความ",
@ -82,20 +75,17 @@
"settings.notSet": "ยังไม่ได้ตั้งค่า",
"settings.enabled": "เปิดใช้งาน",
"settings.disabled": "ปิดใช้งาน",
"settings.interface": "อินเทอร์เฟซ",
"settings.theme": "ธีม",
"settings.theme.system": "ระบบ",
"settings.theme.light": "สว่าง",
"settings.theme.dark": "มืด",
"settings.language": "ภาษา",
"settings.appBehavior": "พฤติกรรมแอป",
"settings.closeToTray": "ย่อลงถาดระบบ",
"settings.autoLaunch": "เปิดอัตโนมัติเมื่อเริ่มระบบ",
"settings.autoInsert": "แทรกข้อความอัตโนมัติหลังถอดความ",
"settings.soundEffects": "เอฟเฟกต์เสียง",
"settings.microphone": "ไมโครโฟน",
"settings.inputDevice": "อุปกรณ์อินพุต",
"settings.deviceDefault": "(ค่าเริ่มต้น)",
@ -103,7 +93,6 @@
"settings.insertMethod": "วิธีการแทรก",
"settings.insertClipboard": "คลิปบอร์ด (Ctrl+V)",
"settings.insertKeyboard": "จำลองการพิมพ์",
"settings.whisperModel": "โมเดล Whisper",
"settings.model.tiny": "tiny (39 MB, เร็วที่สุด)",
"settings.model.base": "base (74 MB, สมดุล)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1.5 GB, ดีที่สุด)",
"settings.sttLanguage": "ภาษาที่รู้จัก",
"settings.sttLang.auto": "ตรวจจับอัตโนมัติ",
"settings.ollamaServer": "เซิร์ฟเวอร์ Ollama",
"settings.ollamaUrl": "URL เซิร์ฟเวอร์ Ollama",
"settings.ollamaHint": "หาก Ollama กำลังทำงาน จะเชื่อมต่ออัตโนมัติ ดาวน์โหลดโมเดลโดยตรงจาก Ollama (เช่น: ollama pull qwen3:4b)",
@ -125,7 +113,6 @@
"settings.action.grammar": "แก้ไขไวยากรณ์",
"settings.action.custom": "พรอมต์ที่กำหนดเอง",
"settings.actionHint": "การประมวลผล LLM ที่เลือกจะถูกนำไปใช้กับข้อความที่ถอดความหลังจากบันทึก ทำงานเฉพาะเมื่อเชื่อมต่อ Ollama",
"settings.about.version": "เวอร์ชัน",
"settings.about.techStack": "เทคโนโลยีที่ใช้",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (ในเครื่อง) / LLM: Ollama (ในเครื่อง)",
"settings.about.description": "ผู้ช่วยเสียง AI ในเครื่องที่พัฒนาจากความรู้การวิศวกรรมย้อนกลับ Speakly ทำงานได้อย่างสมบูรณ์ในเครื่องโดยไม่พึ่งพาบริการคลาวด์",
"settings.about.restartOnboarding": "ดูคู่มือการตั้งค่าเริ่มต้นอีกครั้ง",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama ยังไม่ได้ทำงาน",
"status.nudge.desc": "ต้องใช้ Ollama สำหรับการประมวลผล LLM (แปล สรุป ฯลฯ)",
"status.nudge.guide": "ดูคำแนะนำการติดตั้ง →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "ไม่ต้องพิมพ์ — แค่พูด ผู้ช่วยเสียง AI ในเครื่อง",
"onboarding.welcome.start": "เริ่มต้น",
@ -160,7 +145,6 @@
"onboarding.done.start": "เริ่มต้น",
"onboarding.back": "ย้อนกลับ",
"onboarding.next": "ถัดไป",
"hotkey.title": "การตั้งค่าปุ่มลัด",
"hotkey.dictationTitle": "ตั้งค่าปุ่มลัดการบอกเล่า",
"hotkey.oneTouchTitle": "ตั้งค่าปุ่มลัดโหมดสัมผัสเดียว",
@ -171,7 +155,6 @@
"hotkey.current": "ปัจจุบัน: {{keys}}",
"hotkey.hint": "กรอกปุ่มลัด (เช่น: Ctrl+Shift+Q) หรือปุ่มเดี่ยว (เช่น: F5)",
"hotkey.reset": "กรอกใหม่",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "ขั้นตอนที่ 1 — ติดตั้ง Ollama",
"ollama.step1.desc": "Ollama เป็นเครื่องมือฟรีสำหรับรัน LLM ในเครื่อง",
@ -180,7 +163,6 @@
"ollama.step2.alt": "หรือโมเดลที่ใหญ่กว่า: ollama pull qwen3:8b (แม่นยำกว่า ช้ากว่า)",
"ollama.step3.title": "ขั้นตอนที่ 3 — เชื่อมต่ออัตโนมัติ",
"ollama.step3.desc": "เมื่อ Ollama ทำงาน D3RO-VOICE จะตรวจพบโดยอัตโนมัติ เมื่อไฟ LED บนแถบสถานะเปลี่ยนจากสีแดงเป็นสีเขียว — พร้อมใช้งาน!",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "ยกเลิก",
"common.save": "บันทึก",
"common.delete": "ลบ",
@ -202,7 +183,51 @@
"common.copy": "คัดลอก",
"common.test": "ทดสอบ",
"common.stop": "หยุด",
"date.today": "วันนี้",
"date.yesterday": "เมื่อวาน"
"date.yesterday": "เมื่อวาน",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "เกาหลี",
"settings.sttLang.en": "อังกฤษ",
"settings.sttLang.ja": "ญี่ปุ่น",
"settings.sttLang.zh": "จีน",
"template.outputHelperText": "ใช้ {{ชื่อฟิลด์}} สำหรับตัวยึดตำแหน่ง",
"nav.meeting": "การประชุม",
"meeting.title": "โหมดการประชุม",
"meeting.newMeeting": "การประชุมใหม่",
"meeting.startRecording": "เริ่มบันทึก",
"meeting.stopRecording": "หยุดบันทึก",
"meeting.recording": "กำลังบันทึก",
"meeting.processing": "กำลังสร้างรายงานการประชุม...",
"meeting.completed": "เสร็จสิ้น",
"meeting.error": "ข้อผิดพลาด",
"meeting.elapsed": "เวลาที่ผ่านไป",
"meeting.memoPlaceholder": "พิมพ์บันทึก (Enter เพื่อเพิ่ม)",
"meeting.memoAdded": "เพิ่มบันทึกแล้ว",
"meeting.noSessions": "ไม่มีการบันทึกการประชุม",
"meeting.noSessionsDesc": "กด «การประชุมใหม่» เพื่อเริ่มบันทึก",
"meeting.summary": "สรุป",
"meeting.decisions": "การตัดสินใจ",
"meeting.actionItems": "รายการดำเนินการ",
"meeting.timeline": "ไทม์ไลน์",
"meeting.transcript": "ถอดความดิบ",
"meeting.memos": "บันทึก",
"meeting.exportPdf": "ส่งออก PDF",
"meeting.exportMarkdown": "ส่งออก Markdown",
"meeting.delete": "ลบ",
"meeting.deleteConfirm": "ลบรายงานการประชุมนี้?",
"meeting.backToList": "กลับไปยังรายการ",
"meeting.notificationTitle": "รายงานการประชุมพร้อมแล้ว",
"meeting.notificationBody": "สร้างรายงานการประชุมเสร็จแล้ว",
"meeting.sessions": "{{count}} เซสชัน",
"meeting.duration": "{{minutes}} นาที",
"meeting.editTitle": "แก้ไขชื่อ",
"meeting.untitled": "การประชุมที่ไม่มีชื่อ",
"meeting.processingStep.merging": "กำลังรวมการถอดความ...",
"meeting.processingStep.generating": "กำลังสร้างรายงานด้วย LLM...",
"meeting.processingStep.parsing": "กำลังวิเคราะห์รายงาน...",
"meeting.processingStep.saving": "กำลังบันทึก...",
"meeting.processingStep.notifying": "กำลังส่งการแจ้งเตือน..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "Trợ lý giọng nói AI cục bộ",
"nav.dashboard": "Bảng điều khiển",
"nav.history": "Lịch sử",
"nav.dictionary": "Từ điển",
"nav.commands": "Lệnh",
"nav.settings": "Cài đặt",
"dashboard.sessionOverview": "Tổng quan phiên",
"dashboard.systemStatus": "Trạng thái hệ thống",
"dashboard.sessionsToday": "Phiên hôm nay",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}} mục",
"dashboard.stat": "Thống kê",
"dashboard.sys": "Hệ thống",
"history.title": "Lịch sử phiên âm",
"history.search": "Tìm kiếm...",
"history.entries": "{{count}} mục",
@ -34,7 +31,6 @@
"history.noResults": "Không tìm thấy kết quả",
"history.noHistory": "Chưa có lịch sử — hãy bắt đầu ghi âm",
"history.count": "{{count}} mục",
"dictionary.title": "Từ điển tùy chỉnh",
"dictionary.words": "{{count}} từ",
"dictionary.add": "Thêm",
@ -47,7 +43,6 @@
"dictionary.addTitle": "Thêm từ",
"dictionary.word": "Từ",
"dictionary.pronunciation": "Phát âm (tùy chọn)",
"commands.title": "Lệnh LLM",
"commands.count": "{{count}} lệnh",
"commands.add": "Thêm",
@ -62,14 +57,12 @@
"commands.promptTemplate": "Mẫu prompt",
"commands.promptHelp": "{{text}} sẽ được thay thế bằng văn bản phiên âm",
"commands.defaultPrompt": "Vui lòng cải thiện {{text}}.",
"settings.title": "Cài đặt",
"settings.tabs.general": "Chung",
"settings.tabs.audio": "Âm thanh",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "Giới thiệu",
"settings.shortcuts": "Phím tắt",
"settings.dictation": "Chính tả",
"settings.dictation.desc": "Giữ phím và nói. Thả phím để bắt đầu phiên âm.",
@ -82,20 +75,17 @@
"settings.notSet": "Chưa đặt",
"settings.enabled": "Đã bật",
"settings.disabled": "Đã tắt",
"settings.interface": "Giao diện",
"settings.theme": "Giao diện",
"settings.theme.system": "Hệ thống",
"settings.theme.light": "Sáng",
"settings.theme.dark": "Tối",
"settings.language": "Ngôn ngữ",
"settings.appBehavior": "Hành vi ứng dụng",
"settings.closeToTray": "Thu nhỏ xuống khay hệ thống",
"settings.autoLaunch": "Tự động khởi động cùng hệ thống",
"settings.autoInsert": "Tự động chèn văn bản sau khi phiên âm",
"settings.soundEffects": "Hiệu ứng âm thanh",
"settings.microphone": "Micrô",
"settings.inputDevice": "Thiết bị đầu vào",
"settings.deviceDefault": "(Mặc định)",
@ -103,7 +93,6 @@
"settings.insertMethod": "Phương thức chèn",
"settings.insertClipboard": "Bộ nhớ tạm (Ctrl+V)",
"settings.insertKeyboard": "Giả lập bàn phím",
"settings.whisperModel": "Mô hình Whisper",
"settings.model.tiny": "tiny (39 MB, nhanh nhất)",
"settings.model.base": "base (74 MB, cân bằng)",
@ -112,7 +101,6 @@
"settings.model.large": "large-v3 (1.5 GB, tốt nhất)",
"settings.sttLanguage": "Ngôn ngữ nhận dạng",
"settings.sttLang.auto": "Tự động phát hiện",
"settings.ollamaServer": "Máy chủ Ollama",
"settings.ollamaUrl": "URL máy chủ Ollama",
"settings.ollamaHint": "Nếu Ollama đang chạy, kết nối sẽ tự động. Tải mô hình trực tiếp từ Ollama (ví dụ: ollama pull qwen3:4b).",
@ -125,7 +113,6 @@
"settings.action.grammar": "Sửa ngữ pháp",
"settings.action.custom": "Prompt tùy chỉnh",
"settings.actionHint": "Hậu xử lý LLM đã chọn sẽ được áp dụng cho văn bản phiên âm sau khi ghi âm. Chỉ hoạt động khi Ollama được kết nối.",
"settings.about.version": "Phiên bản",
"settings.about.techStack": "Ngăn xếp công nghệ",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper (cục bộ) / LLM: Ollama (cục bộ)",
"settings.about.description": "Trợ lý giọng nói AI cục bộ dựa trên kỹ thuật dịch ngược Speakly. Hoạt động hoàn toàn cục bộ, không phụ thuộc vào dịch vụ đám mây.",
"settings.about.restartOnboarding": "Xem lại hướng dẫn cài đặt ban đầu",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama chưa chạy",
"status.nudge.desc": "Cần Ollama để sử dụng hậu xử lý LLM (dịch, tóm tắt, v.v.).",
"status.nudge.guide": "Xem hướng dẫn cài đặt →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "Không cần gõ phím — chỉ cần nói. Trợ lý giọng nói AI cục bộ.",
"onboarding.welcome.start": "Bắt đầu",
@ -160,7 +145,6 @@
"onboarding.done.start": "Bắt đầu",
"onboarding.back": "Quay lại",
"onboarding.next": "Tiếp theo",
"hotkey.title": "Cài đặt phím tắt",
"hotkey.dictationTitle": "Đặt phím tắt chính tả",
"hotkey.oneTouchTitle": "Đặt phím tắt chế độ một chạm",
@ -171,7 +155,6 @@
"hotkey.current": "Hiện tại: {{keys}}",
"hotkey.hint": "Nhập tổ hợp phím (ví dụ: Ctrl+Shift+Q) hoặc phím đơn (ví dụ: F5)",
"hotkey.reset": "Nhập lại",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "BƯỚC 1 — Cài đặt Ollama",
"ollama.step1.desc": "Ollama là công cụ miễn phí để chạy LLM cục bộ.",
@ -180,7 +163,6 @@
"ollama.step2.alt": "Hoặc mô hình lớn hơn: ollama pull qwen3:8b (chính xác hơn, chậm hơn)",
"ollama.step3.title": "BƯỚC 3 — Tự động kết nối",
"ollama.step3.desc": "Khi Ollama chạy, D3RO-VOICE sẽ tự động phát hiện. Khi đèn LED trên thanh trạng thái chuyển từ đỏ sang xanh — đã sẵn sàng!",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "Hủy",
"common.save": "Lưu",
"common.delete": "Xóa",
@ -202,7 +183,51 @@
"common.copy": "Sao chép",
"common.test": "Kiểm tra",
"common.stop": "Dừng",
"date.today": "Hôm nay",
"date.yesterday": "Hôm qua"
"date.yesterday": "Hôm qua",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "Tiếng Hàn",
"settings.sttLang.en": "Tiếng Anh",
"settings.sttLang.ja": "Tiếng Nhật",
"settings.sttLang.zh": "Tiếng Trung",
"template.outputHelperText": "Sử dụng {{tênTrường}} cho trình giữ chỗ",
"nav.meeting": "Cuộc họp",
"meeting.title": "Chế độ Cuộc họp",
"meeting.newMeeting": "Cuộc họp mới",
"meeting.startRecording": "Bắt đầu ghi âm",
"meeting.stopRecording": "Dừng ghi âm",
"meeting.recording": "Đang ghi âm",
"meeting.processing": "Đang tạo biên bản...",
"meeting.completed": "Hoàn thành",
"meeting.error": "Lỗi",
"meeting.elapsed": "Thời gian đã qua",
"meeting.memoPlaceholder": "Nhập ghi chú (Enter để thêm)",
"meeting.memoAdded": "Đã thêm ghi chú",
"meeting.noSessions": "Không có bản ghi cuộc họp",
"meeting.noSessionsDesc": "Nhấn «Cuộc họp mới» để bắt đầu ghi âm",
"meeting.summary": "Tóm tắt",
"meeting.decisions": "Quyết định",
"meeting.actionItems": "Hành động cần thực hiện",
"meeting.timeline": "Dòng thời gian",
"meeting.transcript": "Bản ghi thô",
"meeting.memos": "Ghi chú",
"meeting.exportPdf": "Xuất PDF",
"meeting.exportMarkdown": "Xuất Markdown",
"meeting.delete": "Xóa",
"meeting.deleteConfirm": "Xóa bản ghi cuộc họp này?",
"meeting.backToList": "Quay lại danh sách",
"meeting.notificationTitle": "Biên bản đã sẵn sàng",
"meeting.notificationBody": "Biên bản cuộc họp đã được tạo.",
"meeting.sessions": "{{count}} phiên",
"meeting.duration": "{{minutes}} phút",
"meeting.editTitle": "Chỉnh sửa tiêu đề",
"meeting.untitled": "Cuộc họp không có tiêu đề",
"meeting.processingStep.merging": "Đang ghép bản ghi...",
"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..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "本地AI語音助手",
"nav.dashboard": "儀表板",
"nav.history": "歷史記錄",
"nav.dictionary": "辭典",
"nav.commands": "指令",
"nav.settings": "設定",
"dashboard.sessionOverview": "工作階段總覽",
"dashboard.systemStatus": "系統狀態",
"dashboard.sessionsToday": "今日工作階段",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}筆",
"dashboard.stat": "統計",
"dashboard.sys": "系統",
"history.title": "轉錄歷史",
"history.search": "搜尋...",
"history.entries": "{{count}}筆",
@ -34,7 +31,6 @@
"history.noResults": "無搜尋結果",
"history.noHistory": "尚無歷史記錄 — 請開始錄音",
"history.count": "{{count}}筆",
"dictionary.title": "自訂辭典",
"dictionary.words": "{{count}}個詞",
"dictionary.add": "新增",
@ -47,7 +43,6 @@
"dictionary.addTitle": "新增詞條",
"dictionary.word": "詞彙",
"dictionary.pronunciation": "發音(選填)",
"commands.title": "LLM指令",
"commands.count": "{{count}}個",
"commands.add": "新增",
@ -62,14 +57,12 @@
"commands.promptTemplate": "提示詞範本",
"commands.promptHelp": "{{text}} 將被替換為轉錄的文字",
"commands.defaultPrompt": "請潤飾以下內容:{{text}}",
"settings.title": "設定",
"settings.tabs.general": "一般",
"settings.tabs.audio": "音訊",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "關於",
"settings.shortcuts": "快速鍵",
"settings.dictation": "聽寫",
"settings.dictation.desc": "按住鍵開始說話,放開鍵後開始轉錄。",
@ -82,20 +75,17 @@
"settings.notSet": "未設定",
"settings.enabled": "已啟用",
"settings.disabled": "已停用",
"settings.interface": "介面",
"settings.theme": "主題",
"settings.theme.system": "跟隨系統",
"settings.theme.light": "淺色",
"settings.theme.dark": "深色",
"settings.language": "語言",
"settings.appBehavior": "應用程式行為",
"settings.closeToTray": "最小化至系統匣",
"settings.autoLaunch": "開機時自動啟動",
"settings.autoInsert": "轉錄後自動插入文字",
"settings.soundEffects": "音效",
"settings.microphone": "麥克風",
"settings.inputDevice": "輸入裝置",
"settings.deviceDefault": "(預設)",
@ -103,7 +93,6 @@
"settings.insertMethod": "插入方式",
"settings.insertClipboard": "剪貼簿Ctrl+V",
"settings.insertKeyboard": "鍵盤輸入",
"settings.whisperModel": "Whisper模型",
"settings.model.tiny": "tiny39 MB最快",
"settings.model.base": "base74 MB均衡",
@ -112,7 +101,6 @@
"settings.model.large": "large-v31.5 GB最佳",
"settings.sttLanguage": "識別語言",
"settings.sttLang.auto": "自動偵測",
"settings.ollamaServer": "Ollama伺服器",
"settings.ollamaUrl": "Ollama伺服器URL",
"settings.ollamaHint": "Ollama執行時將自動連線。請直接在Ollama中pull模型例如ollama pull qwen3:4b。",
@ -125,7 +113,6 @@
"settings.action.grammar": "語法校正",
"settings.action.custom": "自訂提示詞",
"settings.actionHint": "透過熱鍵錄音後所選LLM後處理將套用至轉錄文字。僅在Ollama連線時有效。",
"settings.about.version": "版本",
"settings.about.techStack": "技術堆疊",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper本地/ LLM: Ollama本地",
"settings.about.description": "基於Speakly逆向工程經驗打造的本地AI語音助手無需依賴雲端完全在本地端執行。",
"settings.about.restartOnboarding": "重新檢視初始設定引導",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama尚未執行",
"status.nudge.desc": "使用LLM後處理翻譯、摘要等需要Ollama。",
"status.nudge.guide": "查看安裝指南 →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "無需打字用聲音操作。本地AI語音助手。",
"onboarding.welcome.start": "開始",
@ -160,7 +145,6 @@
"onboarding.done.start": "開始",
"onboarding.back": "返回",
"onboarding.next": "下一步",
"hotkey.title": "快速鍵設定",
"hotkey.dictationTitle": "設定聽寫快速鍵",
"hotkey.oneTouchTitle": "設定一鍵模式快速鍵",
@ -171,7 +155,6 @@
"hotkey.current": "目前: {{keys}}",
"hotkey.hint": "請輸入組合鍵例如Ctrl+Shift+Q或單一鍵例如F5",
"hotkey.reset": "重新輸入",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "STEP 1 — 安裝Ollama",
"ollama.step1.desc": "Ollama是一款在本地端執行LLM的免費工具。",
@ -180,7 +163,6 @@
"ollama.step2.alt": "或更大的模型ollama pull qwen3:8b更精準速度較慢",
"ollama.step3.title": "STEP 3 — 自動連線",
"ollama.step3.desc": "Ollama啟動後D3RO-VOICE將自動偵測。底部狀態列的LED從紅色變為綠色即表示準備就緒",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "取消",
"common.save": "儲存",
"common.delete": "刪除",
@ -202,7 +183,51 @@
"common.copy": "複製",
"common.test": "測試",
"common.stop": "停止",
"date.today": "今天",
"date.yesterday": "昨天"
"date.yesterday": "昨天",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "韓語",
"settings.sttLang.en": "English",
"settings.sttLang.ja": "日語",
"settings.sttLang.zh": "中文",
"template.outputHelperText": "使用 {{欄位名稱}} 作為佔位符",
"nav.meeting": "會議",
"meeting.title": "會議模式",
"meeting.newMeeting": "新增會議",
"meeting.startRecording": "開始錄音",
"meeting.stopRecording": "停止錄音",
"meeting.recording": "錄音中",
"meeting.processing": "正在產生會議記錄...",
"meeting.completed": "已完成",
"meeting.error": "錯誤",
"meeting.elapsed": "已用時",
"meeting.memoPlaceholder": "輸入備忘Enter 新增)",
"meeting.memoAdded": "備忘已新增",
"meeting.noSessions": "暫無會議錄音",
"meeting.noSessionsDesc": "點擊「新增會議」開始錄音",
"meeting.summary": "摘要",
"meeting.decisions": "決議",
"meeting.actionItems": "待辦事項",
"meeting.timeline": "時間軸",
"meeting.transcript": "原始逐字稿",
"meeting.memos": "備忘",
"meeting.exportPdf": "匯出 PDF",
"meeting.exportMarkdown": "匯出 Markdown",
"meeting.delete": "刪除",
"meeting.deleteConfirm": "刪除此會議記錄?",
"meeting.backToList": "返回列表",
"meeting.notificationTitle": "會議記錄已就緒",
"meeting.notificationBody": "會議記錄已產生。",
"meeting.sessions": "{{count}} 場會議",
"meeting.duration": "{{minutes}} 分鐘",
"meeting.editTitle": "編輯標題",
"meeting.untitled": "無標題會議",
"meeting.processingStep.merging": "正在合併逐字稿...",
"meeting.processingStep.generating": "正在以 LLM 產生會議記錄...",
"meeting.processingStep.parsing": "正在解析會議記錄...",
"meeting.processingStep.saving": "正在儲存...",
"meeting.processingStep.notifying": "正在發送通知..."
}

View file

@ -1,13 +1,11 @@
{
"app.name": "D3RO Voice",
"app.tagline": "本地AI语音助手",
"nav.dashboard": "仪表板",
"nav.history": "历史记录",
"nav.dictionary": "词典",
"nav.commands": "命令",
"nav.settings": "设置",
"dashboard.sessionOverview": "会话概览",
"dashboard.systemStatus": "系统状态",
"dashboard.sessionsToday": "今日会话",
@ -26,7 +24,6 @@
"dashboard.entries": "{{count}}条",
"dashboard.stat": "统计",
"dashboard.sys": "系统",
"history.title": "转录历史",
"history.search": "搜索...",
"history.entries": "{{count}}条",
@ -34,7 +31,6 @@
"history.noResults": "无搜索结果",
"history.noHistory": "暂无历史记录 — 请开始录音",
"history.count": "{{count}}条",
"dictionary.title": "自定义词典",
"dictionary.words": "{{count}}个词",
"dictionary.add": "添加",
@ -47,7 +43,6 @@
"dictionary.addTitle": "添加词条",
"dictionary.word": "词汇",
"dictionary.pronunciation": "发音(可选)",
"commands.title": "LLM命令",
"commands.count": "{{count}}个",
"commands.add": "添加",
@ -62,14 +57,12 @@
"commands.promptTemplate": "提示词模板",
"commands.promptHelp": "{{text}} 将被替换为转录的文本",
"commands.defaultPrompt": "请润色以下内容:{{text}}",
"settings.title": "设置",
"settings.tabs.general": "通用",
"settings.tabs.audio": "音频",
"settings.tabs.stt": "STT",
"settings.tabs.llm": "LLM",
"settings.tabs.about": "关于",
"settings.shortcuts": "快捷键",
"settings.dictation": "听写",
"settings.dictation.desc": "按住键开始说话,松开键后开始转录。",
@ -82,20 +75,17 @@
"settings.notSet": "未设置",
"settings.enabled": "已启用",
"settings.disabled": "已禁用",
"settings.interface": "界面",
"settings.theme": "主题",
"settings.theme.system": "跟随系统",
"settings.theme.light": "浅色",
"settings.theme.dark": "深色",
"settings.language": "语言",
"settings.appBehavior": "应用行为",
"settings.closeToTray": "最小化到托盘",
"settings.autoLaunch": "开机自动启动",
"settings.autoInsert": "转录后自动插入文本",
"settings.soundEffects": "音效",
"settings.microphone": "麦克风",
"settings.inputDevice": "输入设备",
"settings.deviceDefault": "(默认)",
@ -103,7 +93,6 @@
"settings.insertMethod": "插入方式",
"settings.insertClipboard": "剪贴板Ctrl+V",
"settings.insertKeyboard": "键盘输入",
"settings.whisperModel": "Whisper模型",
"settings.model.tiny": "tiny39 MB最快",
"settings.model.base": "base74 MB均衡",
@ -112,7 +101,6 @@
"settings.model.large": "large-v31.5 GB最佳",
"settings.sttLanguage": "识别语言",
"settings.sttLang.auto": "自动检测",
"settings.ollamaServer": "Ollama服务器",
"settings.ollamaUrl": "Ollama服务器URL",
"settings.ollamaHint": "Ollama运行时将自动连接。请直接在Ollama中pull模型例如ollama pull qwen3:4b。",
@ -125,7 +113,6 @@
"settings.action.grammar": "语法校正",
"settings.action.custom": "自定义提示词",
"settings.actionHint": "通过热键录音后所选LLM后处理将应用于转录文本。仅在Ollama连接时有效。",
"settings.about.version": "版本",
"settings.about.techStack": "技术栈",
"settings.about.techStackValue": "Electron + React 19 + MUI 7 + TypeScript",
@ -133,13 +120,11 @@
"settings.about.voiceEngineValue": "STT: faster-whisper本地/ LLM: Ollama本地",
"settings.about.description": "基于Speakly逆向工程经验构建的本地AI语音助手无需依赖云端完全在本地运行。",
"settings.about.restartOnboarding": "重新查看初始设置引导",
"status.ollama": "OLLAMA",
"status.offline": "OFFLINE",
"status.nudge.title": "Ollama未运行",
"status.nudge.desc": "使用LLM后处理翻译、摘要等需要Ollama。",
"status.nudge.guide": "查看安装指南 →",
"onboarding.welcome.title": "D3RO-VOICE",
"onboarding.welcome.desc": "无需打字用声音操作。本地AI语音助手。",
"onboarding.welcome.start": "开始",
@ -160,7 +145,6 @@
"onboarding.done.start": "开始",
"onboarding.back": "返回",
"onboarding.next": "下一步",
"hotkey.title": "快捷键设置",
"hotkey.dictationTitle": "设置听写快捷键",
"hotkey.oneTouchTitle": "设置一触即发模式快捷键",
@ -171,7 +155,6 @@
"hotkey.current": "当前: {{keys}}",
"hotkey.hint": "请输入组合键例如Ctrl+Shift+Q或单个键例如F5",
"hotkey.reset": "重新输入",
"ollama.title": "OLLAMA SETUP GUIDE",
"ollama.step1.title": "STEP 1 — 安装Ollama",
"ollama.step1.desc": "Ollama是一款在本地运行LLM的免费工具。",
@ -180,7 +163,6 @@
"ollama.step2.alt": "或更大的模型ollama pull qwen3:8b更精准更慢",
"ollama.step3.title": "STEP 3 — 自动连接",
"ollama.step3.desc": "Ollama启动后D3RO-VOICE将自动检测。底部状态栏的LED从红色变为绿色即表示准备就绪",
"service.sttEngine": "STT ENGINE",
"service.ollamaLlm": "OLLAMA LLM",
"service.hotkeyHook": "HOTKEY HOOK",
@ -190,7 +172,6 @@
"service.offline": "OFFLINE",
"service.active": "ACTIVE",
"service.standby": "STANDBY",
"common.cancel": "取消",
"common.save": "保存",
"common.delete": "删除",
@ -202,7 +183,51 @@
"common.copy": "复制",
"common.test": "测试",
"common.stop": "停止",
"date.today": "今天",
"date.yesterday": "昨天"
"date.yesterday": "昨天",
"settings.theme.nord": "Nord",
"settings.theme.solarized": "Solarized",
"settings.theme.catppuccin": "Catppuccin",
"settings.theme.dracula": "Dracula",
"settings.sttLang.ko": "韩语",
"settings.sttLang.en": "English",
"settings.sttLang.ja": "日语",
"settings.sttLang.zh": "中文",
"template.outputHelperText": "使用 {{字段名}} 作为占位符",
"nav.meeting": "会议",
"meeting.title": "会议模式",
"meeting.newMeeting": "新建会议",
"meeting.startRecording": "开始录音",
"meeting.stopRecording": "停止录音",
"meeting.recording": "录音中",
"meeting.processing": "正在生成会议纪要...",
"meeting.completed": "已完成",
"meeting.error": "错误",
"meeting.elapsed": "已用时",
"meeting.memoPlaceholder": "输入备忘Enter 添加)",
"meeting.memoAdded": "备忘已添加",
"meeting.noSessions": "暂无会议录音",
"meeting.noSessionsDesc": "点击「新建会议」开始录音",
"meeting.summary": "摘要",
"meeting.decisions": "决议",
"meeting.actionItems": "待办事项",
"meeting.timeline": "时间轴",
"meeting.transcript": "原始转录",
"meeting.memos": "备忘",
"meeting.exportPdf": "导出 PDF",
"meeting.exportMarkdown": "导出 Markdown",
"meeting.delete": "删除",
"meeting.deleteConfirm": "删除此会议记录?",
"meeting.backToList": "返回列表",
"meeting.notificationTitle": "会议纪要已就绪",
"meeting.notificationBody": "会议纪要已生成。",
"meeting.sessions": "{{count}} 场会议",
"meeting.duration": "{{minutes}} 分钟",
"meeting.editTitle": "编辑标题",
"meeting.untitled": "无标题会议",
"meeting.processingStep.merging": "正在合并转录...",
"meeting.processingStep.generating": "正在用 LLM 生成会议纪要...",
"meeting.processingStep.parsing": "正在解析会议纪要...",
"meeting.processingStep.saving": "正在保存...",
"meeting.processingStep.notifying": "正在发送通知..."
}

View file

@ -0,0 +1,570 @@
// src/renderer/pages/MeetingModePage.tsx
// Phase 14: Meeting Mode — 실시간 녹음 + 메모 + 회의록 UI
import { useState, useEffect, useCallback, useRef } from 'react'
import {
Box,
TextField,
IconButton,
Tooltip,
LinearProgress,
Typography,
} 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 { PageHeader, EmptyStateCard } from '../components/shared'
import { d3roPalette, d3roFontMono, d3roTypo } from '../theme'
import { useI18n } from '../i18n'
import type {
MeetingSessionSummary,
MeetingSessionDetail,
MeetingModeStateInfo,
MeetingProcessingProgress,
CaptionSegment,
MeetingMemo,
} from '@shared/types'
type MeetingView = 'list' | 'recording' | 'detail'
export function MeetingModePage(): React.ReactElement {
const { t } = useI18n()
const [view, setView] = useState<MeetingView>('list')
const [sessions, setSessions] = useState<MeetingSessionSummary[]>([])
const [totalSessions, setTotalSessions] = useState(0)
const [loading, setLoading] = useState(true)
const [page, setPage] = useState(1)
const pageSize = 20
// 녹음 뷰 상태
const [stateInfo, setStateInfo] = useState<MeetingModeStateInfo | null>(null)
const [segments, setSegments] = useState<CaptionSegment[]>([])
const [memos, setMemos] = useState<MeetingMemo[]>([])
const [memoInput, setMemoInput] = useState('')
const [elapsedMs, setElapsedMs] = useState(0)
const [progress, setProgress] = useState<MeetingProcessingProgress | null>(null)
const transcriptRef = useRef<HTMLDivElement>(null)
const elapsedTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
const recordingStartRef = useRef<number>(0)
// 상세 뷰 상태
const [detail, setDetail] = useState<MeetingSessionDetail | null>(null)
const [editingTitle, setEditingTitle] = useState(false)
const [titleDraft, setTitleDraft] = useState('')
// ── 세션 목록 로드 ──
const loadSessions = useCallback(async () => {
setLoading(true)
const resp = await window.electronAPI.meetingMode.getSessions({ page, pageSize })
if (resp.success) {
setSessions(resp.data.sessions)
setTotalSessions(resp.data.total)
}
setLoading(false)
}, [page])
useEffect(() => {
if (view === 'list') loadSessions()
}, [view, loadSessions])
// ── IPC 이벤트 구독 ──
useEffect(() => {
const unsubState = window.electronAPI.meetingMode.onStateChanged((info) => {
setStateInfo(info)
if (info.state === 'idle' && view === 'recording') {
// 후처리 완료 → 목록으로 이동
if (!progress) {
setView('list')
}
}
})
const unsubSegment = window.electronAPI.meetingMode.onSegment((seg) => {
setSegments((prev) => [...prev, seg])
// 자동 스크롤
setTimeout(() => {
if (transcriptRef.current) {
transcriptRef.current.scrollTop = transcriptRef.current.scrollHeight
}
}, 50)
})
const unsubProgress = window.electronAPI.meetingMode.onProcessingProgress((p) => {
setProgress(p)
})
const unsubCompleted = window.electronAPI.meetingMode.onSessionCompleted(() => {
setProgress(null)
setView('list')
})
const unsubError = window.electronAPI.meetingMode.onError(() => {
setProgress(null)
setView('list')
})
return () => {
unsubState()
unsubSegment()
unsubProgress()
unsubCompleted()
unsubError()
}
}, [view, progress])
// ── 경과 시간 타이머 ──
useEffect(() => {
if (view === 'recording') {
recordingStartRef.current = Date.now()
elapsedTimerRef.current = setInterval(() => {
setElapsedMs(Date.now() - recordingStartRef.current)
}, 1000)
}
return () => {
if (elapsedTimerRef.current) {
clearInterval(elapsedTimerRef.current)
elapsedTimerRef.current = null
}
}
}, [view])
// ── 핸들러 ──
const handleStartRecording = useCallback(async () => {
const resp = await window.electronAPI.meetingMode.startRecording()
if (resp.success) {
setSegments([])
setMemos([])
setElapsedMs(0)
setProgress(null)
setView('recording')
}
}, [])
const handleStopRecording = useCallback(async () => {
await window.electronAPI.meetingMode.stopRecording()
}, [])
const handleAddMemo = useCallback(async () => {
if (!memoInput.trim()) return
const resp = await window.electronAPI.meetingMode.addMemo({ content: memoInput.trim() })
if (resp.success) {
setMemos((prev) => [...prev, resp.data])
setMemoInput('')
}
}, [memoInput])
const handleMemoKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleAddMemo()
}
},
[handleAddMemo],
)
const handleViewDetail = useCallback(async (sessionId: string) => {
const resp = await window.electronAPI.meetingMode.getSession({ sessionId })
if (resp.success) {
setDetail(resp.data)
setView('detail')
}
}, [])
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 => {
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')}`
}
const formatDate = (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')}`
}
const statusColor = (status: string): 'green' | 'amber' | 'red' | 'blue' => {
switch (status) {
case 'completed': return 'green'
case 'recording': return 'red'
case 'processing': return 'amber'
case 'error': return 'red'
default: return 'blue'
}
}
// ── 렌더: 목록 뷰 ──
if (view === 'list') {
return (
<Box sx={{ p: 3 }}>
<PageHeader
title={t('meeting.title')}
count={totalSessions > 0 ? t('meeting.sessions', { count: totalSessions }) : undefined}
action={
<PhysicalButton size="small" onClick={handleStartRecording}>
<AddIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.newMeeting')}
</PhysicalButton>
}
/>
{loading ? (
<LinearProgress sx={{ mt: 2 }} />
) : sessions.length === 0 ? (
<EmptyStateCard message={`${t('meeting.noSessions')}\n${t('meeting.noSessionsDesc')}`} />
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{sessions.map((session) => (
<MetalCard
key={session.id}
sx={{ cursor: 'pointer', '&:hover': { opacity: 0.85 } }}
onClick={() => handleViewDetail(session.id)}
>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<PhosphorText variant="label">
{session.title ?? t('meeting.untitled')}
</PhosphorText>
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{session.durationMs != null
? t('meeting.duration', { minutes: Math.round(session.durationMs / 60000) })
: '—'}
{' · '}
{t('meeting.memos')}: {session.memoCount}
</PhosphorText>
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<PhosphorText variant="dim">{formatDate(session.startedAt)}</PhosphorText>
<Led color={statusColor(session.status)} size={8} />
</Box>
</Box>
{/* 프로세싱 중인 세션 표시 */}
{session.status === 'processing' && progress && progress.sessionId === session.id && (
<Box sx={{ mt: 1 }}>
<LinearProgress
variant="determinate"
value={progress.percent}
sx={{ height: 4, borderRadius: 2 }}
/>
<PhosphorText variant="dim" sx={{ mt: 0.5, fontSize: 11 }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])}
</PhosphorText>
</Box>
)}
</MetalCard>
))}
</Box>
)}
</Box>
)
}
// ── 렌더: 녹음 뷰 ──
if (view === 'recording') {
const isProcessing = stateInfo?.state === 'processing'
return (
<Box sx={{ p: 3, height: '100%', display: 'flex', flexDirection: 'column' }}>
{/* 상단 바 */}
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2 }}>
<FiberManualRecordIcon sx={{ color: d3roPalette.indicator.red, fontSize: 16, animation: 'pulse 1s infinite' }} />
<PhosphorText variant="label" sx={{ fontFamily: d3roFontMono }}>
{formatTime(elapsedMs)}
</PhosphorText>
<PhosphorText variant="dim">
{isProcessing ? t('meeting.processing') : t('meeting.recording')}
</PhosphorText>
</Box>
{!isProcessing && (
<PhysicalButton size="small" color="error" onClick={handleStopRecording}>
<StopIcon sx={{ fontSize: 16, mr: 0.5 }} />
{t('meeting.stopRecording')}
</PhysicalButton>
)}
</Box>
{/* 프로세싱 진행률 */}
{isProcessing && progress && (
<Box sx={{ mb: 2 }}>
<LinearProgress variant="determinate" value={progress.percent} sx={{ height: 6, borderRadius: 3 }} />
<PhosphorText variant="dim" sx={{ mt: 0.5 }}>
{t(`meeting.processingStep.${progress.step}` as Parameters<typeof t>[0])} ({progress.percent}%)
</PhosphorText>
</Box>
)}
{/* 메인 패널: 전사 + 메모 */}
<Box sx={{ flex: 1, display: 'flex', gap: 2, minHeight: 0 }}>
{/* 좌: 실시간 전사 */}
<ScreenPanel sx={{ flex: 1, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.transcript')}
</PhosphorText>
<Box
ref={transcriptRef}
sx={{
flex: 1,
overflow: 'auto',
fontFamily: d3roFontMono,
fontSize: d3roTypo.reading.fontSize,
color: d3roPalette.text.primary,
lineHeight: 1.8,
}}
>
{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)}]
</Typography>
<Typography component="span" sx={{ fontSize: 13, fontFamily: d3roFontMono }}>
{seg.text}
</Typography>
</Box>
))}
</Box>
</ScreenPanel>
{/* 우: 메모 입력 */}
<Box sx={{ width: 300, display: 'flex', flexDirection: 'column' }}>
<PhosphorText variant="dim" sx={{ mb: 1, fontSize: 11 }}>
{t('meeting.memos')}
</PhosphorText>
<Box sx={{ flex: 1, overflow: 'auto', mb: 1 }}>
{memos.map((memo) => (
<Box
key={memo.id}
sx={{
mb: 0.5,
p: 1,
borderRadius: 1,
bgcolor: d3roPalette.bg.chassis,
}}
>
<PhosphorText variant="dim" sx={{ fontSize: 10, fontFamily: d3roFontMono }}>
{formatTime(memo.timestampMs)}
</PhosphorText>
<PhosphorText variant="reading" sx={{ fontSize: 12 }}>
{memo.content}
</PhosphorText>
</Box>
))}
</Box>
{!isProcessing && (
<TextField
size="small"
fullWidth
placeholder={t('meeting.memoPlaceholder')}
value={memoInput}
onChange={(e) => setMemoInput(e.target.value)}
onKeyDown={handleMemoKeyDown}
sx={{
'& .MuiInputBase-root': {
fontFamily: d3roFontMono,
fontSize: 13,
},
}}
/>
)}
</Box>
</Box>
</Box>
)
}
// ── 렌더: 상세 뷰 ──
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="reading" 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="reading" 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="reading" 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="reading" 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.indicator.red, mb: 1 }}>
{t('meeting.error')}
</PhosphorText>
<PhosphorText variant="reading">{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="reading">{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>
)
}
return <Box />
}

View file

@ -151,6 +151,14 @@ export enum ErrorCode {
VoiceActionBlocked = 877,
VoiceActionInvalidPlan = 878,
// === Phase 14: Meeting Mode (880-889) ===
MeetingAlreadyRecording = 880,
MeetingNotRecording = 881,
MeetingProcessingFailed = 882,
MeetingSessionNotFound = 883,
MeetingExportFailed = 884,
MeetingPdfFailed = 885,
// === Config (800-849) ===
ConfigReadFailed = 800,
ConfigWriteFailed = 801,

View file

@ -305,6 +305,26 @@ export const IPC_CHANNELS = {
ACTION_ERROR: 'voiceAction:actionError',
},
// ── Phase 14: Meeting Mode ──
MEETING_MODE: {
START_RECORDING: 'meetingMode:startRecording',
STOP_RECORDING: 'meetingMode:stopRecording',
ADD_MEMO: 'meetingMode:addMemo',
GET_STATE: 'meetingMode:getState',
GET_SESSIONS: 'meetingMode:getSessions',
GET_SESSION: 'meetingMode:getSession',
DELETE_SESSION: 'meetingMode:deleteSession',
UPDATE_TITLE: 'meetingMode:updateTitle',
EXPORT_PDF: 'meetingMode:exportPdf',
EXPORT_MARKDOWN: 'meetingMode:exportMarkdown',
// Main → Renderer events
STATE_CHANGED: 'meetingMode:stateChanged',
SEGMENT: 'meetingMode:segment',
PROCESSING_PROGRESS: 'meetingMode:processingProgress',
SESSION_COMPLETED: 'meetingMode:sessionCompleted',
ERROR: 'meetingMode:error',
},
// ── Phase 11: License & Monetization ──
LICENSE: {
GET_INFO: 'license:getInfo',

View file

@ -1262,3 +1262,99 @@ export interface VoiceActionErrorEvent {
message: string
userText: string
}
// ============================================================
// Phase 14: Meeting Mode
// ============================================================
export type MeetingModeState = 'idle' | 'recording' | 'processing' | 'completed' | 'error'
export interface MeetingMemo {
id: string
sessionId: string
content: string
/** 녹음 시작 기준 상대 타임스탬프 (ms) */
timestampMs: number
createdAt: number
}
export interface MeetingSessionSummary {
id: string
title: string | null
status: 'recording' | 'processing' | 'completed' | 'error'
startedAt: number
endedAt: number | null
durationMs: number | null
memoCount: number
}
export interface MeetingSessionDetail extends MeetingSessionSummary {
rawTranscript: string | null
minutesMarkdown: string | null
minutes: MeetingMinutes | null
memos: MeetingMemo[]
sttModel: string | null
llmModel: string | null
errorMessage: string | null
}
export interface MeetingMinutes {
summary: string
decisions: string[]
actionItems: Array<{ task: string; assignee?: string; deadline?: string }>
timeline: Array<{ time: string; content: string; type: 'transcript' | 'memo' }>
}
export interface MeetingSessionPage {
sessions: MeetingSessionSummary[]
total: number
page: number
pageSize: number
totalPages: number
}
export interface MeetingStartResult {
sessionId: string
}
export interface MeetingAddMemoParams {
content: string
}
export interface MeetingGetSessionsParams {
page: number
pageSize: number
}
export interface MeetingGetSessionParams {
sessionId: string
}
export interface MeetingDeleteSessionParams {
sessionId: string
}
export interface MeetingExportParams {
sessionId: string
}
export interface MeetingModeStateInfo {
state: MeetingModeState
sessionId: string | null
elapsedMs: number
memoCount: number
segmentCount: number
}
export type MeetingProcessingStep =
| 'merging'
| 'generating'
| 'parsing'
| 'saving'
| 'notifying'
export interface MeetingProcessingProgress {
sessionId: string
step: MeetingProcessingStep
percent: number
}