fix(desktop): surface configuration and provider failures instead of hiding them
Several desktop paths quietly substituted defaults or partial results: a config write could fall back to a throwaway in-memory store, speech provider errors were absorbed into empty transcriptions, and meeting exports built file names from raw titles. Writes now fail explicitly when the store is unavailable, provider and model failures reach the UI as errors, and export names pass through one sanitizer. Settings, license, ad, and support surfaces use the shared theme tokens, unused hotkey helpers are gone, and the package gains strict node/renderer typecheck configs plus red-team e2e scenarios for these flows.
This commit is contained in:
parent
c8d802d78f
commit
6ba25f53b7
43 changed files with 1721 additions and 204 deletions
|
|
@ -33,9 +33,14 @@ export function registerInstructionHandlers(): void {
|
|||
|
||||
ipcMain.handle(
|
||||
IPC_CHANNELS.INSTRUCTION.UPDATE,
|
||||
async (_event, params: { id: string; data: Partial<CustomInstruction> }) => {
|
||||
async (
|
||||
_event,
|
||||
params: { id: string; data?: Partial<CustomInstruction>; name?: string; description?: string; prompt?: string; icon?: string }
|
||||
) => {
|
||||
try {
|
||||
const result = getCustomInstructionService().update(params.id, params.data)
|
||||
const { id, data, ...rest } = params
|
||||
const updatePayload = data ?? rest
|
||||
const result = getCustomInstructionService().update(id, updatePayload)
|
||||
return ipcSuccess(result)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
import { ipcMain } from 'electron'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors'
|
||||
import { ErrorCode, ipcSuccess, ipcError, D3ROError } from '@d3ro/core/errors'
|
||||
import { getMeetingModeService } from '../services/MeetingModeService'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type {
|
||||
|
|
@ -188,9 +188,9 @@ export function registerMeetingModeHandlers(): void {
|
|||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat }) => {
|
||||
ipcMain.handle(ch.EXPORT_DOCUMENT, async (_event, params: { documentId: string; format: MeetingExportFormat; targetPath?: string }) => {
|
||||
try {
|
||||
const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format)
|
||||
const filePath = await getMeetingModeService().exportDocument(params.documentId, params.format, params.targetPath)
|
||||
return ipcSuccess(filePath)
|
||||
} catch (err) {
|
||||
logger.error(`exportDocument 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
|
|
@ -201,9 +201,9 @@ export function registerMeetingModeHandlers(): void {
|
|||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string }) => {
|
||||
ipcMain.handle(ch.EXPORT_TRANSCRIPT, async (_event, params: { sessionId: string; targetPath?: string }) => {
|
||||
try {
|
||||
const filePath = await getMeetingModeService().exportTranscript(params.sessionId)
|
||||
const filePath = await getMeetingModeService().exportTranscript(params.sessionId, params.targetPath)
|
||||
return ipcSuccess(filePath)
|
||||
} catch (err) {
|
||||
logger.error(`exportTranscript 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
|
|
|
|||
|
|
@ -3,12 +3,13 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import type { AppConfig, ConfigChangedEvent } from '@d3ro/core/types'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getLogger } from './LoggerService'
|
||||
|
||||
const logger = getLogger('ConfigService')
|
||||
|
||||
// electron-store v10은 ESM 전용이므로 동적 import 필요
|
||||
interface ElectronStore<T extends Record<string, unknown>> {
|
||||
interface ElectronStore<T> {
|
||||
get<K extends keyof T>(key: K): T[K]
|
||||
set<K extends keyof T>(key: K, value: T[K]): void
|
||||
store: T
|
||||
|
|
@ -25,6 +26,7 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
sttProvider: 'local' as const,
|
||||
sttProviderConfigs: {
|
||||
local: { modelId: 'large-v3-turbo' },
|
||||
'd3ro-cloud': { modelId: 'default', apiKey: '', baseUrl: 'http://localhost:5000' },
|
||||
openai: { modelId: 'whisper-1', apiKey: '', baseUrl: 'https://api.openai.com/v1' },
|
||||
groq: { modelId: 'whisper-large-v3-turbo', apiKey: '', baseUrl: 'https://api.groq.com/openai/v1' },
|
||||
deepgram: { modelId: 'nova-3', apiKey: '', baseUrl: 'https://api.deepgram.com' },
|
||||
|
|
@ -106,6 +108,9 @@ const CONFIG_DEFAULTS: AppConfig = {
|
|||
activeInstructionId: '',
|
||||
activeChainId: null,
|
||||
captionAudioSource: 'mic',
|
||||
updateChannel: 'latest',
|
||||
updateDeviceId: '',
|
||||
skippedUpdateVersion: null,
|
||||
}
|
||||
|
||||
let store: ElectronStore<AppConfig> | null = null
|
||||
|
|
@ -164,8 +169,12 @@ export function configSet<K extends keyof AppConfig>(key: K, value: AppConfig[K]
|
|||
logger.warn(`ConfigService not initialized — using in-memory store for "${key}"`)
|
||||
initInMemoryConfig()
|
||||
}
|
||||
const previousValue = store.get(key)
|
||||
store.set(key, value)
|
||||
const activeStore = store
|
||||
if (!activeStore) {
|
||||
throw new D3ROError(ErrorCode.ConfigWriteFailed, 'Config store unavailable')
|
||||
}
|
||||
const previousValue = activeStore.get(key)
|
||||
activeStore.set(key, value)
|
||||
|
||||
const event: ConfigChangedEvent = {
|
||||
key,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,10 @@ import type { CustomInstruction } from '@d3ro/core/types'
|
|||
|
||||
const logger = getLogger('CustomInstructionService')
|
||||
|
||||
type CreateInput = Omit<CustomInstruction, 'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt'>
|
||||
type CreateInput = Omit<
|
||||
CustomInstruction,
|
||||
'id' | 'isBuiltin' | 'order' | 'createdAt' | 'updatedAt' | 'icon'
|
||||
> & { icon?: string }
|
||||
|
||||
// ============================================================
|
||||
// 프리셋 명령어 (Phase 6 설계)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,7 @@ const BUILTIN_TEMPLATES: DictationTemplate[] = [
|
|||
},
|
||||
]
|
||||
|
||||
interface TemplateStoreSchema {
|
||||
type TemplateStoreSchema = {
|
||||
templates: DictationTemplate[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ class HistoryService {
|
|||
db.insert(history).values(entry).run()
|
||||
|
||||
// stats 싱글톤 업데이트
|
||||
this._updateStats(input.duration, input.wordCount)
|
||||
this._updateStats(input.duration ?? 0, input.wordCount ?? 0)
|
||||
|
||||
logger.info(`History entry created: ${id}`)
|
||||
|
||||
|
|
|
|||
|
|
@ -653,16 +653,6 @@ class HotkeyService extends EventEmitter {
|
|||
return null
|
||||
}
|
||||
|
||||
/** 주어진 uiohook 키코드가 수정자 키(Ctrl/Alt/Shift/Meta)인지 확인 */
|
||||
private _isModifierKeyCode(keyCode: number): boolean {
|
||||
return (
|
||||
this._isCtrlKeyCode(keyCode) ||
|
||||
this._isAltKeyCode(keyCode) ||
|
||||
this._isShiftKeyCode(keyCode) ||
|
||||
this._isMetaKeyCode(keyCode)
|
||||
)
|
||||
}
|
||||
|
||||
private _isCtrlKeyCode(keyCode: number): boolean {
|
||||
return keyCode === UiohookKey.Ctrl || keyCode === UiohookKey.CtrlRight
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import type {
|
|||
TierComparison,
|
||||
} from '@d3ro/core/types'
|
||||
import { Feature } from '@d3ro/core/types'
|
||||
import { normalizeEntitlementTier } from '@d3ro/core/entitlement'
|
||||
import { verifySignedLicenseKey, createDefaultTrialPayload } from '@d3ro/core/utils/crypto-license'
|
||||
|
||||
const logger = getLogger('license')
|
||||
|
|
@ -147,7 +148,6 @@ function getTomorrowMidnight(): string {
|
|||
// ── LicenseService 싱글톤 ──────────────────────────────────
|
||||
class LicenseService extends EventEmitter {
|
||||
private _info: LicenseInfo
|
||||
private _initialized = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
|
@ -216,7 +216,6 @@ class LicenseService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
this._initialized = true
|
||||
logger.info(`LicenseService initialized: tier=${this._info.tier}, trial=${this._info.isTrial ?? false}, machineId=${this._info.machineId.substring(0, 8)}...`)
|
||||
}
|
||||
|
||||
|
|
@ -316,14 +315,15 @@ class LicenseService extends EventEmitter {
|
|||
* CloudSyncService._onAuthenticated에서 호출.
|
||||
*/
|
||||
syncFromCloud(tier: LicenseTier): void {
|
||||
if (this._info.tier === tier) return
|
||||
const normalized = normalizeEntitlementTier(tier)
|
||||
if (this._info.tier === normalized) return
|
||||
const previous = this._info.tier
|
||||
this._info.tier = tier
|
||||
this._info.tier = normalized
|
||||
this._info.lastVerifiedAt = Date.now()
|
||||
this._writeStoredField('licenseTier', tier)
|
||||
this._writeStoredField('licenseTier', normalized)
|
||||
this._writeStoredField('licenseLastVerifiedAt', this._info.lastVerifiedAt)
|
||||
this.emit('tier-changed', this.getInfo())
|
||||
logger.info(`License tier synced from cloud: ${previous} → ${tier}`)
|
||||
logger.info(`License tier synced from cloud: ${previous} → ${normalized}`)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@ const BUILTIN_TEMPLATES: MeetingDocTemplate[] = [
|
|||
},
|
||||
]
|
||||
|
||||
interface MeetingDocTemplateStoreSchema {
|
||||
type MeetingDocTemplateStoreSchema = {
|
||||
templates: MeetingDocTemplate[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -59,7 +59,6 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
/** Phase 15.5-2: 녹음 중 오디오 WAV 파일 보존 */
|
||||
private _audioBuffersForFile: Buffer[] = []
|
||||
private _audioFilePath: string | null = null
|
||||
|
||||
// Phase 15: Meeting AI Chat
|
||||
private _chatHistory: Array<{ role: 'user' | 'assistant'; content: string }> = []
|
||||
|
|
@ -369,7 +368,6 @@ class MeetingModeService extends EventEmitter {
|
|||
if (!fs.existsSync(audioDir)) fs.mkdirSync(audioDir, { recursive: true })
|
||||
const wavPath = path.join(audioDir, `${sessionId}.wav`)
|
||||
this._saveWav(wavPath, Buffer.concat(this._audioBuffersForFile))
|
||||
this._audioFilePath = wavPath
|
||||
logger.info(`회의 오디오 저장: ${wavPath}`)
|
||||
} catch (err) {
|
||||
logger.warn(`오디오 저장 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
|
|
@ -560,7 +558,6 @@ class MeetingModeService extends EventEmitter {
|
|||
this._memos = []
|
||||
this._meetingModeActive = false
|
||||
this._audioBuffersForFile = []
|
||||
this._audioFilePath = null
|
||||
}
|
||||
|
||||
/** PCM16 버퍼를 WAV 파일로 저장 (16kHz mono 16-bit) */
|
||||
|
|
@ -745,7 +742,7 @@ class MeetingModeService extends EventEmitter {
|
|||
title: docTitle,
|
||||
content: result.text,
|
||||
promptUsed: systemPrompt,
|
||||
llmModel: result.model,
|
||||
llmModel: result.model ?? null,
|
||||
llmLatencyMs,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
|
|
@ -797,7 +794,7 @@ class MeetingModeService extends EventEmitter {
|
|||
|
||||
// ── Phase 14.5: 문서 내보내기 ──
|
||||
|
||||
async exportDocument(documentId: string, format: MeetingExportFormat): Promise<string> {
|
||||
async exportDocument(documentId: string, format: MeetingExportFormat, targetPath?: string): Promise<string> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingDocuments).where(eq(meetingDocuments.id, documentId)).get()
|
||||
if (!row) {
|
||||
|
|
@ -829,25 +826,27 @@ class MeetingModeService extends EventEmitter {
|
|||
break
|
||||
}
|
||||
|
||||
if (isShowingMeetingSaveDialog) {
|
||||
return ''
|
||||
}
|
||||
isShowingMeetingSaveDialog = true
|
||||
let filePath: string | undefined
|
||||
try {
|
||||
const { getMainWindow } = await import('../windows/WindowManager')
|
||||
const mainWindow = getMainWindow()
|
||||
const dialogOptions = {
|
||||
title: '문서 내보내기',
|
||||
defaultPath: defaultName,
|
||||
filters,
|
||||
let filePath: string | undefined = targetPath
|
||||
if (!filePath) {
|
||||
if (isShowingMeetingSaveDialog) {
|
||||
return ''
|
||||
}
|
||||
isShowingMeetingSaveDialog = true
|
||||
try {
|
||||
const { getMainWindow } = await import('../windows/WindowManager')
|
||||
const mainWindow = getMainWindow()
|
||||
const dialogOptions = {
|
||||
title: '문서 내보내기',
|
||||
defaultPath: defaultName,
|
||||
filters,
|
||||
}
|
||||
const res = mainWindow
|
||||
? await dialog.showSaveDialog(mainWindow, dialogOptions)
|
||||
: await dialog.showSaveDialog(dialogOptions)
|
||||
filePath = res.filePath
|
||||
} finally {
|
||||
isShowingMeetingSaveDialog = false
|
||||
}
|
||||
const res = mainWindow
|
||||
? await dialog.showSaveDialog(mainWindow, dialogOptions)
|
||||
: await dialog.showSaveDialog(dialogOptions)
|
||||
filePath = res.filePath
|
||||
} finally {
|
||||
isShowingMeetingSaveDialog = false
|
||||
}
|
||||
if (!filePath) return ''
|
||||
|
||||
|
|
@ -900,7 +899,7 @@ class MeetingModeService extends EventEmitter {
|
|||
return filePath
|
||||
}
|
||||
|
||||
async exportTranscript(sessionId: string): Promise<string> {
|
||||
async exportTranscript(sessionId: string, targetPath?: string): Promise<string> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
if (!row) {
|
||||
|
|
@ -911,11 +910,15 @@ class MeetingModeService extends EventEmitter {
|
|||
const dateStr = formatDateFile(row.startedAt)
|
||||
const safeTitle = (row.title ?? '무제_회의').replace(/[/\\?%*:|"<>]/g, '-').slice(0, 50)
|
||||
|
||||
const { filePath } = await dialog.showSaveDialog({
|
||||
title: '전사 내보내기',
|
||||
defaultPath: `전사_${safeTitle}_${dateStr}.txt`,
|
||||
filters: [{ name: 'Text', extensions: ['txt'] }],
|
||||
})
|
||||
let filePath: string | undefined = targetPath
|
||||
if (!filePath) {
|
||||
const res = await dialog.showSaveDialog({
|
||||
title: '전사 내보내기',
|
||||
defaultPath: `전사_${safeTitle}_${dateStr}.txt`,
|
||||
filters: [{ name: 'Text', extensions: ['txt'] }],
|
||||
})
|
||||
filePath = res.filePath
|
||||
}
|
||||
if (!filePath) return ''
|
||||
|
||||
fs.writeFileSync(filePath, transcript, 'utf-8')
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { getCloudSyncService } from './CloudSyncService'
|
|||
import { getDatabase } from '../db'
|
||||
import { history } from '../db/schema'
|
||||
import { getMainWindow } from '../windows/WindowManager'
|
||||
import { toSafeFilenameSegment } from '../utils/safe-filename'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { MeetingSummaryResult, MeetingSummaryProgress } from '@d3ro/core/types'
|
||||
|
|
@ -153,6 +154,8 @@ class MeetingSummaryService extends EventEmitter {
|
|||
|
||||
const date = new Date(entry.createdAt)
|
||||
const dateStr = date.toISOString().slice(0, 10)
|
||||
const safeTitle = toSafeFilenameSegment(entry.title, 'meeting-summary')
|
||||
const defaultName = `d3ro-summary-${safeTitle}-${dateStr}.md`
|
||||
if (isShowingSummarySaveDialog) {
|
||||
throw new D3ROError(ErrorCode.MeetingSummaryExportFailed, 'Export dialog already active')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@ class MemoService {
|
|||
const rows = db
|
||||
.select({
|
||||
id: history.id,
|
||||
title: history.title,
|
||||
originalText: history.originalText,
|
||||
polishedText: history.polishedText,
|
||||
focusedApp: history.focusedApp,
|
||||
|
|
@ -167,7 +168,8 @@ class MemoService {
|
|||
llmLatencyMs: history.llmLatencyMs,
|
||||
createdAt: history.createdAt,
|
||||
updatedAt: history.updatedAt,
|
||||
appVersion: history.appVersion
|
||||
appVersion: history.appVersion,
|
||||
summaryText: history.summaryText
|
||||
})
|
||||
.from(memoTags)
|
||||
.innerJoin(history, eq(memoTags.historyId, history.id))
|
||||
|
|
@ -309,6 +311,7 @@ class MemoService {
|
|||
|
||||
private _toHistoryEntry(row: {
|
||||
id: string
|
||||
title: string | null
|
||||
originalText: string
|
||||
polishedText: string | null
|
||||
focusedApp: string | null
|
||||
|
|
@ -329,9 +332,11 @@ class MemoService {
|
|||
createdAt: number
|
||||
updatedAt: number
|
||||
appVersion: string
|
||||
summaryText: string | null
|
||||
}): HistoryEntry {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
originalText: row.originalText,
|
||||
polishedText: row.polishedText,
|
||||
focusedApp: row.focusedApp,
|
||||
|
|
@ -351,7 +356,8 @@ class MemoService {
|
|||
llmLatencyMs: row.llmLatencyMs,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
appVersion: row.appVersion
|
||||
appVersion: row.appVersion,
|
||||
summaryText: row.summaryText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -353,7 +353,8 @@ ${context}`
|
|||
if (chunk.length > 10) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
start = end - CHUNK_OVERLAP
|
||||
if (end >= safeText.length) break
|
||||
start = Math.max(start + 1, end - CHUNK_OVERLAP)
|
||||
if (start >= safeText.length) break
|
||||
}
|
||||
return chunks
|
||||
|
|
|
|||
|
|
@ -14,10 +14,12 @@ const logger = getLogger('screen-context')
|
|||
// nut-js 타입 (lazy import)
|
||||
// ============================================================
|
||||
|
||||
type NutModule = typeof import('@nut-tree-fork/nut-js')
|
||||
|
||||
interface NutKeyboard {
|
||||
pressKey: (...keys: number[]) => Promise<void>
|
||||
releaseKey: (...keys: number[]) => Promise<void>
|
||||
Key: Record<string, number>
|
||||
pressKey: NutModule['keyboard']['pressKey']
|
||||
releaseKey: NutModule['keyboard']['releaseKey']
|
||||
Key: NutModule['Key']
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
|
|
@ -45,7 +45,6 @@ interface TextInsertEvents {
|
|||
|
||||
class TextInsertService extends EventEmitter {
|
||||
private _nutKeyboard: NutKeyboard | null = null
|
||||
private _nutLoaded = false
|
||||
private _nutLoadPromise: Promise<void> | null = null
|
||||
|
||||
/**
|
||||
|
|
@ -64,7 +63,6 @@ class TextInsertService extends EventEmitter {
|
|||
type: nut.keyboard.type.bind(nut.keyboard),
|
||||
Key: nut.Key
|
||||
}
|
||||
this._nutLoaded = true
|
||||
logger.info('nut-js loaded successfully')
|
||||
} catch (error) {
|
||||
logger.error(`Failed to load nut-js: ${error instanceof Error ? error.message : String(error)}`)
|
||||
|
|
@ -247,11 +245,13 @@ class TextInsertService extends EventEmitter {
|
|||
|
||||
// ── nut-js 타입 (lazy import용) ──────────────────────────
|
||||
|
||||
type NutModule = typeof import('@nut-tree-fork/nut-js')
|
||||
|
||||
interface NutKeyboard {
|
||||
pressKey: (...keys: number[]) => Promise<void>
|
||||
releaseKey: (...keys: number[]) => Promise<void>
|
||||
type: (text: string) => Promise<void>
|
||||
Key: Record<string, number>
|
||||
pressKey: NutModule['keyboard']['pressKey']
|
||||
releaseKey: NutModule['keyboard']['releaseKey']
|
||||
type: NutModule['keyboard']['type']
|
||||
Key: NutModule['Key']
|
||||
}
|
||||
|
||||
// ── 싱글톤 ─────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { getLocalLLMService } from './LocalLLMService'
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { TIMING } from '@d3ro/core/constants'
|
||||
import { RecognitionState, AudioState } from '@d3ro/core/types'
|
||||
import type { VoiceMode, VoiceState } from '@d3ro/core/types'
|
||||
import type { VoiceMode, VoiceState, LLMAction } from '@d3ro/core/types'
|
||||
import {
|
||||
showRecordingTip,
|
||||
hideRecordingTip,
|
||||
|
|
@ -122,9 +122,6 @@ class VoiceModeService extends EventEmitter {
|
|||
private _errorHideTimer: NodeJS.Timeout | null = null
|
||||
/** 녹음 종료 후 STT 준비 대기 타이머 — 전사 시작 시 반드시 해제 */
|
||||
private _sttWaitTimer: NodeJS.Timeout | null = null
|
||||
/** 실시간 부분 전사 루프 */
|
||||
private _interimTimer: NodeJS.Timeout | null = null
|
||||
private _interimBusy = false
|
||||
|
||||
// Action Queue (이벤트 직렬화)
|
||||
private _actionQueue: VoiceAction[] = []
|
||||
|
|
@ -314,9 +311,6 @@ class VoiceModeService extends EventEmitter {
|
|||
// (_initSTT는 내부에서 에러를 _handleError로 처리하므로 fire-and-forget 안전)
|
||||
void this._initSTT()
|
||||
await this._startAudio()
|
||||
|
||||
// 실시간 부분 전사 루프 시작 (STT 준비 + 스트리밍 중일 때만 동작)
|
||||
this._startInterimLoop()
|
||||
}
|
||||
|
||||
async stopSession(): Promise<void> {
|
||||
|
|
@ -482,7 +476,6 @@ class VoiceModeService extends EventEmitter {
|
|||
}
|
||||
|
||||
private async _stopAudio(): Promise<void> {
|
||||
this._stopInterimLoop()
|
||||
this._setAudioState(AudioState.STOPPED)
|
||||
|
||||
const audio = getAudioCaptureService()
|
||||
|
|
@ -503,30 +496,6 @@ class VoiceModeService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
// ── 실시간 부분 전사 (interim) ──────────────────────────
|
||||
// 녹음 중 1.5초 간격으로 현재 버퍼(최근 12초 윈도우)를 전사해
|
||||
// RecordingTip에 "말하는 대로 적히는" 미리보기를 제공한다.
|
||||
// 최종 전사는 release 후 전체 버퍼로 다시 수행 (기존 경로 그대로).
|
||||
|
||||
private _startInterimLoop(): void {
|
||||
this._stopInterimLoop()
|
||||
this._interimTimer = setInterval(() => {
|
||||
void this._runInterimTranscribe()
|
||||
}, 1500)
|
||||
}
|
||||
|
||||
private _stopInterimLoop(): void {
|
||||
if (this._interimTimer) {
|
||||
clearInterval(this._interimTimer)
|
||||
this._interimTimer = null
|
||||
}
|
||||
this._interimBusy = false
|
||||
}
|
||||
|
||||
private async _runInterimTranscribe(): Promise<void> {
|
||||
// Cloud STT does not support interim streaming yet
|
||||
}
|
||||
|
||||
// ── 이중 조건 플러시 ───────────────────────────────────
|
||||
|
||||
private _tryFlushAll(): void {
|
||||
|
|
@ -713,6 +682,10 @@ class VoiceModeService extends EventEmitter {
|
|||
|
||||
try {
|
||||
const action = configGet('defaultLLMAction')
|
||||
if (action === 'none') {
|
||||
this._completeSession(transcribedText)
|
||||
return
|
||||
}
|
||||
|
||||
// Phase 10.2: 스크린 컨텍스트를 LLM 프롬프트에 주입
|
||||
let contextPrefix = ''
|
||||
|
|
@ -890,7 +863,6 @@ class VoiceModeService extends EventEmitter {
|
|||
}
|
||||
|
||||
private _resetToIdle(): void {
|
||||
this._stopInterimLoop()
|
||||
this._clearSttWaitTimer()
|
||||
this._session = null
|
||||
this._audioBuffer = []
|
||||
|
|
|
|||
|
|
@ -75,3 +75,14 @@ export function createProbeWav(durationMs: number = 500, sampleRate: number = 16
|
|||
|
||||
return pcmToWav(pcmBuffer, sampleRate, 1, 16)
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer를 fetch 요청 본문으로 넘긴다.
|
||||
*
|
||||
* 런타임에서는 Buffer가 Uint8Array이므로 그대로 본문으로 사용할 수 있지만,
|
||||
* DOM `BodyInit`과 @types/node의 `Buffer<ArrayBufferLike>` 사이에 제네릭
|
||||
* 불일치가 있어 타입 단언이 필요하다.
|
||||
*/
|
||||
export function bufferToBody(data: Buffer): BodyInit {
|
||||
return data as unknown as BodyInit
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,11 +5,21 @@ import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|||
import type { ISTTDriver } from '../types'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import type { STTProviderConfig } from '@d3ro/core/types'
|
||||
import { pcmToWav, createProbeWav } from '../audio-utils'
|
||||
import { pcmToWav, createProbeWav, bufferToBody } from '../audio-utils'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
|
||||
const logger = getLogger('AssemblyAIDriver')
|
||||
|
||||
/** AssemblyAI 전사 폴링 응답 (필요한 필드만) */
|
||||
interface AssemblyAITranscriptResponse {
|
||||
status: string
|
||||
text?: string
|
||||
words?: Array<{ text: string; start: number; end: number; confidence: number }>
|
||||
language_code?: string
|
||||
audio_duration?: number
|
||||
error?: string
|
||||
}
|
||||
|
||||
export class AssemblyAIDriver implements ISTTDriver {
|
||||
readonly id = 'assemblyai' as const
|
||||
readonly name = 'AssemblyAI (Universal-2)'
|
||||
|
|
@ -38,7 +48,7 @@ export class AssemblyAIDriver implements ISTTDriver {
|
|||
Authorization: apiKey,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: wavBuffer,
|
||||
body: bufferToBody(wavBuffer),
|
||||
signal: AbortSignal.timeout(20000),
|
||||
})
|
||||
|
||||
|
|
@ -90,14 +100,7 @@ export class AssemblyAIDriver implements ISTTDriver {
|
|||
|
||||
// 3. Poll for result (최대 30초 대기)
|
||||
let attempts = 0
|
||||
let fullResult: {
|
||||
status: string
|
||||
text?: string
|
||||
words?: Array<{ text: string; start: number; end: number; confidence: number }>
|
||||
language_code?: string
|
||||
audio_duration?: number
|
||||
error?: string
|
||||
} | null = null
|
||||
let fullResult: AssemblyAITranscriptResponse | null = null
|
||||
|
||||
while (attempts < 30) {
|
||||
await new Promise((r) => setTimeout(r, 600))
|
||||
|
|
@ -110,7 +113,7 @@ export class AssemblyAIDriver implements ISTTDriver {
|
|||
|
||||
if (!pollRes.ok) continue
|
||||
|
||||
const pollData = (await pollRes.json()) as typeof fullResult
|
||||
const pollData = (await pollRes.json()) as AssemblyAITranscriptResponse
|
||||
if (pollData && (pollData.status === 'completed' || pollData.status === 'error')) {
|
||||
fullResult = pollData
|
||||
break
|
||||
|
|
@ -166,7 +169,7 @@ export class AssemblyAIDriver implements ISTTDriver {
|
|||
Authorization: apiKey,
|
||||
'Content-Type': 'application/octet-stream',
|
||||
},
|
||||
body: probe,
|
||||
body: bufferToBody(probe),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
|||
import type { ISTTDriver } from '../types'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import type { STTProviderConfig } from '@d3ro/core/types'
|
||||
import { pcmToWav, createProbeWav } from '../audio-utils'
|
||||
import { pcmToWav, createProbeWav, bufferToBody } from '../audio-utils'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
|
||||
const logger = getLogger('DeepgramDriver')
|
||||
|
|
@ -54,7 +54,7 @@ export class DeepgramDriver implements ISTTDriver {
|
|||
Authorization: `Token ${apiKey}`,
|
||||
'Content-Type': 'audio/wav',
|
||||
},
|
||||
body: wavBuffer,
|
||||
body: bufferToBody(wavBuffer),
|
||||
signal: AbortSignal.timeout(25000),
|
||||
})
|
||||
|
||||
|
|
@ -137,7 +137,7 @@ export class DeepgramDriver implements ISTTDriver {
|
|||
Authorization: `Token ${apiKey}`,
|
||||
'Content-Type': 'audio/wav',
|
||||
},
|
||||
body: probe,
|
||||
body: bufferToBody(probe),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
|
|
|
|||
30
apps/desktop/src/main/utils/safe-filename.ts
Normal file
30
apps/desktop/src/main/utils/safe-filename.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
// src/main/utils/safe-filename.ts
|
||||
// 사용자 입력(제목 등)을 OS가 허용하는 파일명 조각으로 정규화한다.
|
||||
// Windows 금지 문자, 제어 문자, 경로 구분자를 '-'로 치환하고 길이를 제한한다.
|
||||
|
||||
/** Windows 파일명 금지 문자 (경로 구분자 포함). */
|
||||
const UNSAFE_FILENAME_CHARS = '\\/:*?"<>|%'
|
||||
|
||||
/**
|
||||
* 제목 같은 임의 문자열을 안전한 파일명 조각으로 변환한다.
|
||||
* @param input 원본 문자열 (null/undefined 허용)
|
||||
* @param fallback 비어 있거나 전부 치환된 경우 사용할 기본값
|
||||
* @param maxLength 최대 길이 (기본 60)
|
||||
*/
|
||||
export function toSafeFilenameSegment(
|
||||
input: string | null | undefined,
|
||||
fallback: string,
|
||||
maxLength = 60,
|
||||
): string {
|
||||
const safe = Array.from(input?.trim() || fallback)
|
||||
.map((character) => (isUnsafeFilenameCharacter(character) ? '-' : character))
|
||||
.slice(0, maxLength)
|
||||
.join('')
|
||||
.trim()
|
||||
|
||||
return safe || fallback
|
||||
}
|
||||
|
||||
function isUnsafeFilenameCharacter(character: string): boolean {
|
||||
return character.charCodeAt(0) < 0x20 || UNSAFE_FILENAME_CHARS.includes(character)
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ export function createMainWindow(): BrowserWindow {
|
|||
mainWindow = null
|
||||
})
|
||||
|
||||
mainWindow.webContents.on('console-message', (event, level, message, line, sourceId) => {
|
||||
mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
|
||||
logger.info(`[Renderer] [${level}] ${message} (${sourceId}:${line})`)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -282,9 +282,9 @@ export function AppLayout(): React.ReactElement {
|
|||
bgcolor: d3roPalette.glass.surface,
|
||||
transition: 'all 0.18s ease',
|
||||
'&:hover': {
|
||||
color: d3roPalette.tag.purpleText,
|
||||
color: d3roPalette.tag.purple,
|
||||
bgcolor: d3roPalette.glass.raised,
|
||||
borderColor: d3roPalette.tag.purpleBorder,
|
||||
borderColor: d3roPalette.tag.purple,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
backgroundImage: 'none',
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
boxShadow: d3roShadow.chassis,
|
||||
borderRadius: d3roRadius.md,
|
||||
borderRadius: d3roRadius.inner,
|
||||
},
|
||||
}}
|
||||
>
|
||||
|
|
@ -139,7 +139,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1 }}>
|
||||
|
|
@ -157,7 +157,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
API Keys 페이지 열기
|
||||
|
|
@ -174,7 +174,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
|
|
@ -201,7 +201,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 0.5 }}>
|
||||
|
|
@ -248,7 +248,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
mb: 2,
|
||||
}}
|
||||
>
|
||||
|
|
@ -289,7 +289,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
}}
|
||||
>
|
||||
<Typography variant="body2" sx={{ fontWeight: 500, mb: 1 }}>
|
||||
|
|
@ -320,7 +320,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
|
@ -352,7 +352,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
|
@ -384,7 +384,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
|
@ -416,7 +416,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
|
@ -457,7 +457,7 @@ export function CodexOAuthGuideModal({ open, onClose }: CodexOAuthGuideModalProp
|
|||
fontSize: d3roTypo.label.size,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
확인 및 닫기
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { Box, LinearProgress, IconButton, Tooltip } from '@mui/material'
|
||||
import { Copy, X, FileUp } from 'lucide-react'
|
||||
import { MetalCard, PhosphorText, Led } from '@d3ro/ui/components/ds'
|
||||
import { MetalCard, PhosphorText, Led, PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
import { d3roPalette, d3roTypo, d3roRadius } from '@d3ro/ui/theme'
|
||||
import { useI18n } from '@d3ro/i18n'
|
||||
import type {
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ export function LicenseTab(): React.ReactElement {
|
|||
|
||||
{/* ── 14일 Reverse-Trial 상태 배너 (활성화 시) ── */}
|
||||
{licenseInfo?.isTrial && licenseInfo.trialExpiresAt && (
|
||||
<MetalCard sx={{ bgcolor: 'rgba(255, 170, 0, 0.08)', borderColor: d3roPalette.accent.main }}>
|
||||
<MetalCard sx={{ bgcolor: 'var(--d3-status-warning)', borderColor: d3roPalette.accent.main }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Sparkles size={18} color={d3roPalette.accent.main} />
|
||||
<Box sx={{ flex: 1 }}>
|
||||
|
|
|
|||
|
|
@ -392,7 +392,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
fontWeight: 500,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
{starting ? '실행 중...' : 'Ollama 자동 실행'}
|
||||
|
|
@ -410,7 +410,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
{/* ── Step 1: Ollama 설치 및 기동 ── */}
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="STEP 1" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
|
||||
<Chip label="STEP 1" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.dim, color: d3roPalette.accent.main }} />
|
||||
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||
Ollama 설치 및 백그라운드 실행
|
||||
</Typography>
|
||||
|
|
@ -462,7 +462,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Chip label="STEP 2" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.muted, color: d3roPalette.accent.main }} />
|
||||
<Chip label="STEP 2" size="small" sx={{ fontWeight: 500, fontSize: '10px', height: 20, bgcolor: d3roPalette.accent.dim, color: d3roPalette.accent.main }} />
|
||||
<Typography sx={{ ...typoSx('heading'), color: d3roPalette.text.primary }}>
|
||||
한국어 최적화 추천 모델 1-클릭 다운로드
|
||||
</Typography>
|
||||
|
|
@ -564,7 +564,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
py: 0.25,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
다운로드 (Pull)
|
||||
|
|
@ -624,7 +624,7 @@ export function OllamaGuideModal({ open, onClose }: OllamaGuideModalProps): Reac
|
|||
minWidth: 100,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
{testing ? '처리 중' : '테스트'}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ import {
|
|||
ArrowRight,
|
||||
ShieldCheck,
|
||||
} from 'lucide-react'
|
||||
import { d3roPalette, d3roRadius, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
|
||||
import { d3roPalette, d3roRadius, d3roShadow, typoSx, d3roFontMono, d3roFontSans } from '@d3ro/ui/theme'
|
||||
import { Led } from '@d3ro/ui/components/ds'
|
||||
import type { LLMModel, LLMStatus } from '@d3ro/core/types'
|
||||
|
||||
|
|
@ -311,7 +311,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
로컬 AI 모드로 시작
|
||||
|
|
@ -504,7 +504,7 @@ export function OnboardingModal({ open, onClose }: OnboardingModalProps): React.
|
|||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
{hasInstalledLlm ? 'Gemma 2 (2B) 추가 다운로드' : 'Gemma 2 (2B) 1-클릭 다운로드'}
|
||||
|
|
|
|||
|
|
@ -202,10 +202,10 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
sx={{
|
||||
p: 1.5,
|
||||
cursor: 'pointer',
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
bgcolor: isSelected ? d3roPalette.bg.elevated : d3roPalette.bg.inset,
|
||||
border: isSelected ? `1.5px solid ${d3roPalette.accent.main}` : `1px solid ${d3roPalette.border.subtle}`,
|
||||
boxShadow: isSelected ? d3roShadow.elevated : 'none',
|
||||
boxShadow: isSelected ? d3roShadow.card : 'none',
|
||||
transition: 'all 120ms ease-out',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -245,7 +245,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
height: 18,
|
||||
fontSize: '9px',
|
||||
fontWeight: 500,
|
||||
bgcolor: isSelected ? d3roPalette.accent.muted : 'transparent',
|
||||
bgcolor: isSelected ? d3roPalette.accent.dim : 'transparent',
|
||||
color: isSelected ? d3roPalette.accent.main : d3roPalette.text.disabled,
|
||||
border: isSelected ? 'none' : `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
|
|
@ -263,7 +263,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.md,
|
||||
borderRadius: d3roRadius.inner,
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 1.5 }}>
|
||||
|
|
@ -328,7 +328,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
p: 1.5,
|
||||
bgcolor: d3roPalette.bg.elevated,
|
||||
border: `1px solid ${d3roPalette.border.default}`,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
|
|
@ -354,7 +354,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
fontWeight: 500,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: d3roPalette.bg.app,
|
||||
'&:hover': { bgcolor: d3roPalette.accent.hover },
|
||||
'&:hover': { bgcolor: d3roPalette.accent.light },
|
||||
}}
|
||||
>
|
||||
{isDownloading ? `다운로드 중 (${downloadPercent}%)` : '모델 다운로드'}
|
||||
|
|
@ -440,7 +440,7 @@ export function STTTab({ config, updateConfig }: STTTabProps): React.ReactElemen
|
|||
sx={{
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
borderRadius: `${d3roRadius.sm} !important`,
|
||||
borderRadius: `${d3roRadius.small} !important`,
|
||||
'&:before': { display: 'none' },
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// src/renderer/components/meeting/MarkdownEditor.tsx
|
||||
// Phase 14.5: 마크다운 렌더링/편집 토글 컴포넌트
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { Box } from '@mui/material'
|
||||
import { MarkdownRenderer } from './MarkdownRenderer'
|
||||
import { PhysicalButton } from '@d3ro/ui/components/ds'
|
||||
|
|
@ -15,12 +15,26 @@ interface MarkdownEditorProps {
|
|||
}
|
||||
|
||||
export function MarkdownEditor({
|
||||
content,
|
||||
content: initialContent,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
}: MarkdownEditorProps): React.ReactElement {
|
||||
const { t } = useI18n()
|
||||
const [preview, setPreview] = useState(true)
|
||||
const [draft, setDraft] = useState(initialContent)
|
||||
|
||||
useEffect(() => {
|
||||
setDraft(initialContent)
|
||||
}, [initialContent])
|
||||
|
||||
const handleTextChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value
|
||||
setDraft(val)
|
||||
onChange(val)
|
||||
},
|
||||
[onChange],
|
||||
)
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
|
|
@ -49,12 +63,12 @@ export function MarkdownEditor({
|
|||
{/* 콘텐츠 영역 */}
|
||||
<Box sx={{ flex: 1, overflow: 'auto', minHeight: 0 }}>
|
||||
{preview || readOnly ? (
|
||||
<MarkdownRenderer content={content} />
|
||||
<MarkdownRenderer content={draft} />
|
||||
) : (
|
||||
<Box
|
||||
component="textarea"
|
||||
value={content}
|
||||
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => onChange(e.target.value)}
|
||||
value={draft}
|
||||
onChange={handleTextChange}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
|
|
|
|||
|
|
@ -81,11 +81,11 @@ export function CheckoutModal({
|
|||
left: '50%',
|
||||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '90%', sm: 520 },
|
||||
bgcolor: d3roPalette.bg.modal,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
backdropFilter: 'blur(28px)',
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
borderRadius: d3roRadius.outer,
|
||||
boxShadow: d3roShadow.modal,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
p: 3,
|
||||
outline: 'none',
|
||||
display: 'flex',
|
||||
|
|
@ -139,9 +139,9 @@ export function CheckoutModal({
|
|||
gap: 1.5
|
||||
}}
|
||||
>
|
||||
<CheckCircle2 size={48} color={d3roPalette.tag.greenText} />
|
||||
<CheckCircle2 size={48} color={d3roPalette.tag.green} />
|
||||
<Typography
|
||||
sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: '#fff' }}
|
||||
sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: 'var(--d3-text-inverse)' }}
|
||||
>
|
||||
서버에서 구독 활성화를 확인했습니다.
|
||||
</Typography>
|
||||
|
|
@ -155,7 +155,7 @@ export function CheckoutModal({
|
|||
sx={{
|
||||
mt: 2,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: '#fff',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
px: 3,
|
||||
borderRadius: '8px'
|
||||
}}
|
||||
|
|
@ -183,8 +183,8 @@ export function CheckoutModal({
|
|||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${tier === candidate ? d3roPalette.accent.main : d3roPalette.glass.hairline}`,
|
||||
bgcolor:
|
||||
tier === candidate ? 'rgba(59, 130, 246, 0.18)' : 'rgba(255,255,255,0.03)',
|
||||
color: '#fff',
|
||||
tier === candidate ? 'var(--d3-accent-glow)' : 'var(--d3-overlay-strong)',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontFamily: d3roFontSans,
|
||||
fontWeight: 500,
|
||||
textTransform: 'none'
|
||||
|
|
@ -199,7 +199,7 @@ export function CheckoutModal({
|
|||
sx={{
|
||||
p: 2,
|
||||
borderRadius: d3roRadius.inner,
|
||||
bgcolor: 'rgba(17, 26, 48, 0.7)',
|
||||
bgcolor: 'var(--d3-bg-card-soft)',
|
||||
border: `1px solid ${d3roPalette.accent.dim}`,
|
||||
display: 'flex',
|
||||
gap: 1.25,
|
||||
|
|
@ -213,7 +213,7 @@ export function CheckoutModal({
|
|||
fontFamily: d3roFontSans,
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#fff'
|
||||
color: 'var(--d3-text-inverse)'
|
||||
}}
|
||||
>
|
||||
Stripe 보안 결제
|
||||
|
|
@ -252,7 +252,7 @@ export function CheckoutModal({
|
|||
sx={{
|
||||
py: 1.25,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: '#fff',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
borderRadius: '10px',
|
||||
|
|
@ -279,7 +279,7 @@ export function CheckoutModal({
|
|||
sx={{
|
||||
py: 1.25,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: '#fff',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontWeight: 500,
|
||||
fontSize: '13px',
|
||||
borderRadius: '10px'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
Box,
|
||||
Typography,
|
||||
Button,
|
||||
IconButton,
|
||||
Tabs,
|
||||
Tab,
|
||||
TextField,
|
||||
|
|
@ -115,11 +116,11 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
transform: 'translate(-50%, -50%)',
|
||||
width: { xs: '95%', sm: 680 },
|
||||
maxHeight: '90vh',
|
||||
bgcolor: d3roPalette.bg.modal,
|
||||
bgcolor: d3roPalette.bg.card,
|
||||
backdropFilter: 'blur(28px)',
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
borderRadius: d3roRadius.outer,
|
||||
boxShadow: d3roShadow.modal,
|
||||
boxShadow: d3roShadow.dialog,
|
||||
p: 3,
|
||||
outline: 'none',
|
||||
display: 'flex',
|
||||
|
|
@ -131,14 +132,19 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
{/* Header */}
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: `1px solid ${d3roPalette.glass.hairline}`, pb: 1.5 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Headphones size={20} color={d3roPalette.tag.purpleText} />
|
||||
<Headphones size={20} color={d3roPalette.tag.purple} />
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '17px', fontWeight: 600, color: d3roPalette.text.primary }}>
|
||||
D3RO Voice Customer Assistance (CA/CS)
|
||||
</Typography>
|
||||
</Box>
|
||||
<Box onClick={onClose} sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={onClose}
|
||||
aria-label="닫기"
|
||||
sx={{ cursor: 'pointer', color: d3roPalette.text.dimLabel, '&:hover': { color: d3roPalette.text.primary } }}
|
||||
>
|
||||
<X size={18} />
|
||||
</Box>
|
||||
</IconButton>
|
||||
</Box>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
|
|
@ -157,9 +163,9 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
fontWeight: 600,
|
||||
color: d3roPalette.text.secondary,
|
||||
textTransform: 'none',
|
||||
'&.Mui-selected': { color: d3roPalette.tag.purpleText },
|
||||
'&.Mui-selected': { color: d3roPalette.tag.purple },
|
||||
},
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purpleText },
|
||||
'& .MuiTabs-indicator': { bgcolor: d3roPalette.tag.purple },
|
||||
}}
|
||||
>
|
||||
<Tab icon={<Bot size={14} />} iconPosition="start" label="AI 어시스턴트" />
|
||||
|
|
@ -177,7 +183,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
overflowY: 'auto',
|
||||
p: 2,
|
||||
borderRadius: d3roRadius.inner,
|
||||
bgcolor: 'rgba(10, 14, 28, 0.7)',
|
||||
bgcolor: 'var(--d3-bg-inset-soft)',
|
||||
border: `1px solid ${d3roPalette.glass.hairline}`,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
@ -192,8 +198,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
maxWidth: '85%',
|
||||
p: 1.5,
|
||||
borderRadius: msg.role === 'user' ? '14px 14px 2px 14px' : '14px 14px 14px 2px',
|
||||
bgcolor: msg.role === 'user' ? d3roPalette.accent.main : 'rgba(26, 38, 68, 0.8)',
|
||||
color: '#ffffff',
|
||||
bgcolor: msg.role === 'user' ? d3roPalette.accent.main : 'var(--d3-bg-raised-soft)',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontSize: '12px',
|
||||
fontFamily: d3roFontSans,
|
||||
lineHeight: 1.5,
|
||||
|
|
@ -216,21 +222,21 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
<Button
|
||||
size="small"
|
||||
onClick={() => handleAskAI('마이크 인식이 안 돼요')}
|
||||
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
|
||||
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'var(--d3-overlay-strong)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
|
||||
>
|
||||
🎤 마이크 인식 오류
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleAskAI('CUDA GPU 가속 설정 방법')}
|
||||
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
|
||||
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'var(--d3-overlay-strong)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
|
||||
>
|
||||
⚡ CUDA GPU 가속
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => handleAskAI('환불 신청 규정')}
|
||||
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'rgba(255,255,255,0.05)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
|
||||
sx={{ fontSize: '10px', textTransform: 'none', color: d3roPalette.text.secondary, bgcolor: 'var(--d3-overlay-strong)', borderRadius: '999px', px: 1.5, flexShrink: 0 }}
|
||||
>
|
||||
💳 환불 신청 규정
|
||||
</Button>
|
||||
|
|
@ -247,8 +253,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
onKeyDown={(e) => e.key === 'Enter' && handleAskAI()}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
bgcolor: 'rgba(10, 14, 28, 0.9)',
|
||||
color: '#fff',
|
||||
bgcolor: 'var(--d3-bg-inset-soft)',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontSize: '12px',
|
||||
fontFamily: d3roFontSans,
|
||||
borderRadius: '10px',
|
||||
|
|
@ -258,14 +264,14 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
<Button
|
||||
onClick={() => handleAskAI()}
|
||||
sx={{
|
||||
bgcolor: d3roPalette.tag.purpleText,
|
||||
color: '#fff',
|
||||
bgcolor: d3roPalette.tag.purple,
|
||||
color: 'var(--d3-text-inverse)',
|
||||
px: 2.5,
|
||||
borderRadius: '10px',
|
||||
fontFamily: d3roFontSans,
|
||||
fontWeight: 500,
|
||||
fontSize: '12px',
|
||||
'&:hover': { bgcolor: '#9333ea' },
|
||||
'&:hover': { bgcolor: 'var(--d3-tag-purple)' },
|
||||
}}
|
||||
>
|
||||
<Send size={14} />
|
||||
|
|
@ -287,8 +293,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
}}
|
||||
sx={{
|
||||
'& .MuiInputBase-root': {
|
||||
bgcolor: 'rgba(10, 14, 28, 0.9)',
|
||||
color: '#fff',
|
||||
bgcolor: 'var(--d3-bg-inset-soft)',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontSize: '12px',
|
||||
borderRadius: '10px',
|
||||
},
|
||||
|
|
@ -297,7 +303,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
|
||||
<Box sx={{ flex: 1, overflowY: 'auto', display: 'flex', flexDirection: 'column', gap: 1.5, pr: 0.5 }}>
|
||||
{filteredFaqs.map((faq, idx) => (
|
||||
<Box key={idx} sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'rgba(17, 26, 48, 0.7)', border: `1px solid ${d3roPalette.glass.hairline}` }}>
|
||||
<Box key={idx} sx={{ p: 2, borderRadius: d3roRadius.inner, bgcolor: 'var(--d3-bg-card-soft)', border: `1px solid ${d3roPalette.glass.hairline}` }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.text.primary, mb: 0.75 }}>
|
||||
Q. {faq.q}
|
||||
</Typography>
|
||||
|
|
@ -315,8 +321,8 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
<Box sx={{ height: 420, overflowY: 'auto' }}>
|
||||
{ticketSubmitted ? (
|
||||
<Box sx={{ textAlign: 'center', py: 6, display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 1.5 }}>
|
||||
<CheckCircle size={48} color={d3roPalette.tag.greenText} />
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: '#fff' }}>
|
||||
<CheckCircle size={48} color={d3roPalette.tag.green} />
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '18px', fontWeight: 600, color: 'var(--d3-text-inverse)' }}>
|
||||
티켓이 성공적으로 접수되었습니다!
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '12px', color: d3roPalette.accent.light }}>
|
||||
|
|
@ -348,7 +354,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
<TextField size="small" label="문의 제목" placeholder="문제를 요약해 주세요" required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
|
||||
<TextField multiline rows={3} size="small" label="상세 내용" placeholder="오류 발생 상황을 기재해 주세요..." required sx={{ '& .MuiInputBase-root': { fontSize: '12px' } }} />
|
||||
|
||||
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: 'rgba(59, 130, 246, 0.1)', border: `1px solid ${d3roPalette.accent.dim}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ p: 1.5, borderRadius: '8px', bgcolor: 'var(--d3-accent-glow)', border: `1px solid ${d3roPalette.accent.dim}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<Activity size={14} color={d3roPalette.accent.light} />
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '11px', color: d3roPalette.accent.light }}>
|
||||
|
|
@ -360,7 +366,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Button type="submit" sx={{ py: 1, bgcolor: d3roPalette.tag.purpleText, color: '#fff', fontWeight: 500, fontSize: '12px', borderRadius: '10px', '&:hover': { bgcolor: '#9333ea' } }}>
|
||||
<Button type="submit" sx={{ py: 1, bgcolor: d3roPalette.tag.purple, color: 'var(--d3-text-inverse)', fontWeight: 500, fontSize: '12px', borderRadius: '10px', '&:hover': { bgcolor: 'var(--d3-tag-purple)' } }}>
|
||||
티켓 접수하기
|
||||
</Button>
|
||||
</form>
|
||||
|
|
@ -370,7 +376,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
|
||||
{/* TAB 3: 7-DAY REFUND CHECKER */}
|
||||
{tabIndex === 3 && (
|
||||
<Box sx={{ height: 420, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', p: 2, bgcolor: 'rgba(10, 14, 28, 0.7)', borderRadius: d3roRadius.inner }}>
|
||||
<Box sx={{ height: 420, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', p: 2, bgcolor: 'var(--d3-bg-inset-soft)', borderRadius: d3roRadius.inner }}>
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '14px', fontWeight: 500, color: d3roPalette.text.primary }}>
|
||||
7일 이내 무조건 100% 자동 환불 확인
|
||||
|
|
@ -380,19 +386,19 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
</Typography>
|
||||
|
||||
{refundStatus === 'eligible' && (
|
||||
<Box sx={{ p: 2, borderRadius: '10px', bgcolor: 'rgba(16, 185, 129, 0.15)', border: '1px solid rgba(16, 185, 129, 0.3)', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.tag.greenText }}>
|
||||
<Box sx={{ p: 2, borderRadius: '10px', bgcolor: 'var(--d3-status-success)', border: '1px solid var(--d3-status-success)', display: 'flex', flexDirection: 'column', gap: 1 }}>
|
||||
<Typography sx={{ fontFamily: d3roFontSans, fontSize: '13px', fontWeight: 500, color: d3roPalette.tag.green }}>
|
||||
✓ 100% 전액 환불 대상입니다!
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: '#fff' }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: 'var(--d3-text-inverse)' }}>
|
||||
• 결제일: 최근 2일 전 (자격 충족)
|
||||
</Typography>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: '#fff' }}>
|
||||
<Typography sx={{ fontFamily: d3roFontMono, fontSize: '11px', color: 'var(--d3-text-inverse)' }}>
|
||||
• 클라우드 사용량: 0% (자격 충족)
|
||||
</Typography>
|
||||
<Button
|
||||
onClick={() => alert('토스페이먼츠 / Stripe 결제 취소가 접수되었습니다. 영업일 기준 2~3일 내 카드사 취소 처리됩니다.')}
|
||||
sx={{ mt: 1, bgcolor: d3roPalette.tag.greenText, color: '#030712', fontWeight: 500, fontSize: '12px', borderRadius: '8px' }}
|
||||
sx={{ mt: 1, bgcolor: d3roPalette.tag.green, color: 'var(--d3-scrim)', fontWeight: 500, fontSize: '12px', borderRadius: '8px' }}
|
||||
>
|
||||
즉시 전액 환불 승인
|
||||
</Button>
|
||||
|
|
@ -406,7 +412,7 @@ export function SupportModal({ open, onClose }: SupportModalProps): React.ReactE
|
|||
sx={{
|
||||
py: 1.2,
|
||||
bgcolor: d3roPalette.accent.main,
|
||||
color: '#fff',
|
||||
color: 'var(--d3-text-inverse)',
|
||||
fontWeight: 500,
|
||||
fontSize: '12px',
|
||||
borderRadius: '10px',
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ export function useLicenseState(): LicenseState {
|
|||
cloud,
|
||||
currentTier,
|
||||
isFree: currentTier === 'free',
|
||||
isPro: currentTier === 'pro',
|
||||
isPro: currentTier !== 'free',
|
||||
handleUpgrade,
|
||||
handleOpenBilling,
|
||||
openCloudSettings,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export function KnowledgeBasePage(): React.ReactElement {
|
|||
const [indexProgress, setIndexProgress] = useState<RAGIndexProgress | null>(null)
|
||||
const [adding, setAdding] = useState(false)
|
||||
const [addError, setAddError] = useState<string | null>(null)
|
||||
const [queryError, setQueryError] = useState<string | null>(null)
|
||||
|
||||
const loadDocuments = useCallback(async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -36,6 +37,9 @@ export function KnowledgeBasePage(): React.ReactElement {
|
|||
useEffect(() => {
|
||||
const unsubProgress = window.electronAPI.rag.onIndexProgress((data) => {
|
||||
setIndexProgress(data)
|
||||
if (data.currentChunk <= 1) {
|
||||
loadDocuments()
|
||||
}
|
||||
})
|
||||
const unsubComplete = window.electronAPI.rag.onIndexComplete(() => {
|
||||
setIndexProgress(null)
|
||||
|
|
@ -85,9 +89,12 @@ export function KnowledgeBasePage(): React.ReactElement {
|
|||
if (!query.trim()) return
|
||||
setQuerying(true)
|
||||
setResult(null)
|
||||
setQueryError(null)
|
||||
const resp = await window.electronAPI.rag.query({ query: query.trim() })
|
||||
if (resp.success) {
|
||||
setResult(resp.data)
|
||||
} else {
|
||||
setQueryError(resp.error?.message || '검색 중 오류가 발생했습니다.')
|
||||
}
|
||||
setQuerying(false)
|
||||
}, [query])
|
||||
|
|
@ -231,6 +238,15 @@ export function KnowledgeBasePage(): React.ReactElement {
|
|||
</Box>
|
||||
)}
|
||||
|
||||
{queryError && (
|
||||
<Box sx={{ mt: 2.5, p: 2, borderRadius: d3roRadius.inner, bgcolor: d3roPalette.bg.inset, border: `1px solid ${d3roPalette.tag.redBg}`, display: 'flex', alignItems: 'center', gap: 1.5 }}>
|
||||
<Led color="red" />
|
||||
<PhosphorText variant="body" sx={{ color: d3roPalette.tag.red, fontSize: '13px' }}>
|
||||
{queryError}
|
||||
</PhosphorText>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<Box sx={{ mt: 3, pt: 2.5, borderTop: `1px solid ${d3roPalette.glass.hairline}` }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1.5 }}>
|
||||
|
|
|
|||
|
|
@ -428,7 +428,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
sx={{
|
||||
p: 2,
|
||||
bgcolor: d3roPalette.bg.inset,
|
||||
borderRadius: d3roRadius.md,
|
||||
borderRadius: d3roRadius.inner,
|
||||
border: `1px solid ${d3roPalette.border.subtle}`,
|
||||
}}
|
||||
>
|
||||
|
|
@ -475,7 +475,7 @@ export function MeetingModePage(): React.ReactElement {
|
|||
sx={{
|
||||
color: d3roPalette.text.secondary,
|
||||
borderColor: d3roPalette.border.default,
|
||||
borderRadius: d3roRadius.sm,
|
||||
borderRadius: d3roRadius.small,
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@
|
|||
|
||||
if (commands.length === 0) {
|
||||
var empty = document.createElement('div')
|
||||
empty.style.cssText = 'color: rgba(255,255,255,0.4); font-size: 13px; text-align: center; padding: 24px 16px;'
|
||||
empty.style.cssText = 'color: var(--d3-overlay-strong); font-size: 13px; text-align: center; padding: 24px 16px;'
|
||||
empty.textContent = i18nStrings.noCommands || 'No commands'
|
||||
itemsContainer.appendChild(empty)
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue