fix: red-team round 3 hardening across desktop, mobile, core and server
Batch of red-team r3 fixes that were in the working tree before the 2026-09-28 design overhaul, committed as one unit with their tests. - desktop main: STT timeouts and sidecar, voice recording store, sync (credentials, audio, knowledge reindex, push gates), runtime provisioner, update policy, AltGr keybindings, voice-command policy, dictionary file codec/limits, meeting transcript condensing and a local recording ledger so interrupted-session recovery only closes meetings this device recorded (a phone's live meeting is left alone). - mobile: login CSRF via implicit token callbacks rejected, account deletion/retention, durable queue retention, knowledge realtime without unfiltered DELETE, meeting re-record failure paths, cloud STT client, preferences store/resync. - core: text chunking splits long unbroken transcripts to fit, template field policy, dictionary limits, meeting markdown inline handling. - server: payple webhook policy and cancellation order scope, meeting document generation quota, team RPC null-role guard, unified LLM quota in-flight accounting, knowledge chunk vector index, meeting re-record failure paths (migrations 20260929*). - ci: portable/runtime feed gates, update-policy schema, Forgejo file delete and alias planning. Four older tests are updated to the new contracts rather than the old behavior: token-pair auth callbacks are rejected, knowledge realtime no longer subscribes to DELETE, long transcript lines are split, and meeting recovery requires the local recording ledger for empty rows.
This commit is contained in:
parent
2428ede03d
commit
ba9ef9741e
161 changed files with 17056 additions and 2379 deletions
|
|
@ -163,6 +163,12 @@ if (!gotTheLock) {
|
|||
void handleDeepLink(url)
|
||||
})
|
||||
|
||||
// macOS: Dock 아이콘 클릭 → 트레이로 숨긴(closeToTray) 또는 닫힌 메인 창을 다시 띄운다.
|
||||
// 부트스트랩 전(launch 직후 첫 activate)에는 showMainWindow 가 창을 만들지 않는다.
|
||||
app.on('activate', () => {
|
||||
showMainWindow()
|
||||
})
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
await bootstrap()
|
||||
setupLifecycle()
|
||||
|
|
|
|||
|
|
@ -55,7 +55,12 @@ export function registerCaptionHandlers(): void {
|
|||
|
||||
ipcMain.handle(IPC_CHANNELS.CAPTION.STOP, async () => {
|
||||
try {
|
||||
await getCaptionService().stop()
|
||||
// 사용자 자막만 멈춘다. 소유자 없이 부르면 강제 정지라, 대시보드의 '정지'가 회의 전사·마이크를
|
||||
// 끄고도 회의는 '녹음 중'으로 남았다(강제 정지는 종료·로그아웃·회의 초기화 전용).
|
||||
const result = await getCaptionService().stop('user')
|
||||
if (result === false) {
|
||||
return ipcError(ErrorCode.CaptionAlreadyActive, 'Caption is in use by meeting recording')
|
||||
}
|
||||
return ipcSuccess(undefined)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ export function registerInputTelemetryHandlers(): void {
|
|||
async (_event, params: SetInputTelemetryEnabledParams) => {
|
||||
try {
|
||||
getInputTelemetryService().setEnabled(params.enabled === true)
|
||||
// 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다.
|
||||
if (params.enabled !== true) getSuggestionService().applyConfig()
|
||||
// 동의를 끄면 제안도 더 이상 입력 맥락을 받을 수 없다 — 떠 있는 세션·채우기·보관 문맥을 정리한다.
|
||||
getSuggestionService().handleInputConsentChanged()
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
|
|
@ -62,6 +62,7 @@ export function registerInputTelemetryHandlers(): void {
|
|||
async (_event, params: SetInputTelemetryPausedParams) => {
|
||||
try {
|
||||
getInputTelemetryService().setPaused(params.paused === true)
|
||||
getSuggestionService().handleInputConsentChanged()
|
||||
return ipcSuccess(getInputTelemetryService().getState())
|
||||
} catch (error) {
|
||||
return ipcError(
|
||||
|
|
|
|||
|
|
@ -98,8 +98,11 @@ export function registerSTTHandlers(): void {
|
|||
ipcMain.handle(IPC_CHANNELS.STT.SET_MODEL, async (_event, params: SetSTTModelParams) => {
|
||||
try {
|
||||
configSet('sttModelId', params.modelId)
|
||||
if (getSTTManager().getActiveProvider() === 'local') {
|
||||
await getLocalSTTService().initialize(params.modelId)
|
||||
// 설치된 모델만 바로 올린다. 미설치 모델을 initialize 하면 사이드카 /load 가 쓰던 모델을 내리고
|
||||
// HF 캐시로 몇 GB 를 몰래 받는다 — 설정만 저장하고, 사용자가 '다운로드' 로 받게 둔다.
|
||||
const stt = getLocalSTTService()
|
||||
if (getSTTManager().getActiveProvider() === 'local' && stt.isModelInstalled(params.modelId)) {
|
||||
await stt.initialize(params.modelId)
|
||||
}
|
||||
return ipcSuccess(undefined)
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { ipcMain } from 'electron'
|
|||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { ErrorCode, ipcSuccess, ipcError } from '@d3ro/core/errors'
|
||||
import { getVoiceConversationService } from '../services/VoiceConversationService'
|
||||
import { getCloudSyncService } from '../services/CloudSyncService'
|
||||
import { cloudSyncCredentials, type CloudCredentials } from '../services/cloud/cloud-credentials'
|
||||
import { getLogger } from '../services/LoggerService'
|
||||
import type {
|
||||
ConversationSendParams,
|
||||
|
|
@ -15,7 +15,7 @@ import type {
|
|||
|
||||
const logger = getLogger('voice-conversation-handlers')
|
||||
|
||||
export function registerVoiceConversationHandlers(): void {
|
||||
export function registerVoiceConversationHandlers(credentials: CloudCredentials = cloudSyncCredentials): void {
|
||||
ipcMain.handle(IPC_CHANNELS.VOICE_CONVERSATION.START_SESSION, async () => {
|
||||
try {
|
||||
await getVoiceConversationService().startSession()
|
||||
|
|
@ -94,7 +94,7 @@ export function registerVoiceConversationHandlers(): void {
|
|||
IPC_CHANNELS.VOICE_CONVERSATION.GET_REALTIME_TOKEN,
|
||||
async (_event, params: RealtimeTokenParams) => {
|
||||
try {
|
||||
const { data, error } = await getCloudSyncService().invokeFunction(
|
||||
const { data, error } = await credentials.invoke(
|
||||
'realtime-token',
|
||||
{ ...params },
|
||||
)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,14 @@ import { getMainWindow } from '../windows/WindowManager'
|
|||
|
||||
const logger = getLogger('CaptionService')
|
||||
|
||||
/** 로딩을 기다리던 start() 가 stop()·새 start 로 무효화됐다 (내부 신호) */
|
||||
class CaptionStartCancelledError extends Error {
|
||||
constructor() {
|
||||
super('caption start cancelled')
|
||||
this.name = 'CaptionStartCancelledError'
|
||||
}
|
||||
}
|
||||
|
||||
/** 트랙 판단 주기 (ms) — 인식 주기는 트랙이 정한다(중간 결과 1초) */
|
||||
const TICK_INTERVAL_MS = 250
|
||||
|
||||
|
|
@ -123,6 +131,11 @@ class CaptionService extends EventEmitter {
|
|||
private _audio: CaptionAudioSources = this._createAudioSources()
|
||||
/** 현재 세션 옵션(소유자·오버레이·히스토리 저장) — 세션이 없으면 기본값 */
|
||||
private _session: ResolvedStartOptions = { ...DEFAULT_START_OPTIONS }
|
||||
/**
|
||||
* 시작 세대. start() 마다 올리고, 'starting' 중 stop() 도 올린다 — 모델 로딩을 기다리던 start 가
|
||||
* 깨어났을 때 세대가 바뀌었으면 마이크를 열지 않고 물러난다(취소한 자막이 오버레이 없이 켜지던 문제).
|
||||
*/
|
||||
private _startEpoch = 0
|
||||
|
||||
// ── 공개 접근자 ──
|
||||
|
||||
|
|
@ -169,6 +182,11 @@ class CaptionService extends EventEmitter {
|
|||
showOverlay: options.showOverlay ?? DEFAULT_START_OPTIONS.showOverlay,
|
||||
persistHistory: options.persistHistory ?? DEFAULT_START_OPTIONS.persistHistory,
|
||||
}
|
||||
const epoch = ++this._startEpoch
|
||||
const assertCurrent = (): void => {
|
||||
if (epoch !== this._startEpoch || this._disposed) throw new CaptionStartCancelledError()
|
||||
}
|
||||
let ownAudio: CaptionAudioSources | null = null
|
||||
this._setState('starting')
|
||||
|
||||
// 오버레이를 즉시 표시 (로딩 상태) — 회의 모드처럼 자체 UI 가 있으면 띄우지 않는다
|
||||
|
|
@ -182,9 +200,11 @@ class CaptionService extends EventEmitter {
|
|||
// STT 초기화 (모델 로딩 — 시간 소요)
|
||||
const sttService = getLocalSTTService()
|
||||
await sttService.initialize()
|
||||
assertCurrent()
|
||||
// 자막 전용 모델을 골랐으면 받아쓰기 모델과 별도로 올려 둔다(보조 자리).
|
||||
this._captionModelId = configGet('captionSttModelId') ?? null
|
||||
if (this._captionModelId) await sttService.ensureAuxModel(this._captionModelId)
|
||||
assertCurrent()
|
||||
|
||||
// 세션 초기화
|
||||
this._sessionId = crypto.randomUUID()
|
||||
|
|
@ -218,8 +238,10 @@ class CaptionService extends EventEmitter {
|
|||
this._systemTrack = this._createTrack('system', initialContext)
|
||||
}
|
||||
// 마이크 캡처(mic/both) + 시스템 오디오 캡처 요청(system/both — 렌더러에 시작 요청)
|
||||
this._audio = this._createAudioSources()
|
||||
await this._audio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) })
|
||||
ownAudio = this._createAudioSources()
|
||||
this._audio = ownAudio
|
||||
await ownAudio.acquire(audioSource, { onMicChunk: (buffer) => this._onAudioData(buffer) })
|
||||
assertCurrent()
|
||||
|
||||
// 트랙 판단 타이머 — 각 트랙은 한 번에 인식 한 건만 돌린다
|
||||
this._tickTimer = setInterval(() => {
|
||||
|
|
@ -231,6 +253,13 @@ class CaptionService extends EventEmitter {
|
|||
getSoundEffectService().play('recording-start')
|
||||
logger.info(`Live Caption 시작: sessionId=${this._sessionId}`)
|
||||
} catch (err) {
|
||||
if (err instanceof CaptionStartCancelledError) {
|
||||
// 기다리는 사이 stop()(또는 새 start)이 세션을 가져갔다 — 공유 상태는 건드리지 않고
|
||||
// 이 시도가 연 자원만 돌려준다(release 는 멱등).
|
||||
await ownAudio?.release()
|
||||
logger.info('캡션 시작 취소: 로딩 중 정지됨')
|
||||
throw new D3ROError(ErrorCode.CaptionStartFailed, '캡션 시작이 취소되었습니다')
|
||||
}
|
||||
if (this._tickTimer) {
|
||||
clearInterval(this._tickTimer)
|
||||
this._tickTimer = null
|
||||
|
|
@ -272,6 +301,8 @@ class CaptionService extends EventEmitter {
|
|||
return false
|
||||
}
|
||||
|
||||
// 로딩 중(start 가 모델을 기다리는 중)이면 그 start 를 무효화한다 — 깨어나도 마이크를 열지 않는다.
|
||||
if (this._state === 'starting') this._startEpoch += 1
|
||||
this._setState('stopping')
|
||||
|
||||
// 타이머 정리
|
||||
|
|
|
|||
|
|
@ -143,32 +143,11 @@ class CloudSyncService extends EventEmitter {
|
|||
if (this._initialized) return
|
||||
this._initialized = true
|
||||
|
||||
this._client = createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: false, // 직접 관리
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: false
|
||||
},
|
||||
// Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이
|
||||
// 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이
|
||||
// 실시간으로 오지 않던 원인.
|
||||
realtime: { transport: nodeRealtimeTransport }
|
||||
})
|
||||
this._client = this._createClient()
|
||||
|
||||
this._lastSyncAt = (configGet('cloudSyncLastAt') as number | undefined) ?? null
|
||||
|
||||
// supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을
|
||||
// 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다.
|
||||
const client = this._client
|
||||
this._unbindAuthEvents = bindAuthEvents<Session>(
|
||||
(callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription,
|
||||
this._tokens(),
|
||||
() => this._session?.user.id ?? null,
|
||||
(session) => {
|
||||
this._session = session
|
||||
},
|
||||
() => this._onRemoteSignedOut()
|
||||
)
|
||||
this._bindClientAuthEvents(this._client)
|
||||
|
||||
// 언어·테마·자동 다듬기·활성 명령이 바뀌면 모바일 user_settings 로 올린다(원격 반영 중엔 제외).
|
||||
onConfigChanged((event) => {
|
||||
|
|
@ -199,6 +178,62 @@ class CloudSyncService extends EventEmitter {
|
|||
logger.info('CloudSyncService initialized')
|
||||
}
|
||||
|
||||
private _createClient(): SupabaseClient {
|
||||
return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, {
|
||||
auth: {
|
||||
persistSession: false, // 직접 관리
|
||||
autoRefreshToken: true,
|
||||
detectSessionInUrl: false
|
||||
},
|
||||
// Electron 33 메인 프로세스(Node 20)에는 전역 WebSocket이 없다. 주입하지 않으면 Realtime이
|
||||
// 연결조차 못 하고 TIMED_OUT/CLOSED만 반복한다(2026-09-16 설치본 로그) — 다른 기기의 변경이
|
||||
// 실시간으로 오지 않던 원인.
|
||||
realtime: { transport: nodeRealtimeTransport }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* supabase-js는 메인 프로세스에서 refresh token을 주기적으로 회전시킨다. 회전된 토큰과 access token을
|
||||
* 매번 저장·갱신해야 재시작 복원과 Realtime 재구독이 이미 쓰인/만료된 토큰을 쓰지 않는다.
|
||||
*/
|
||||
private _bindClientAuthEvents(client: SupabaseClient): void {
|
||||
this._unbindAuthEvents?.()
|
||||
this._unbindAuthEvents = bindAuthEvents<Session>(
|
||||
(callback) => client.auth.onAuthStateChange((event, session) => callback(event, session)).data.subscription,
|
||||
this._tokens(),
|
||||
() => this._session?.user.id ?? null,
|
||||
(session) => {
|
||||
this._session = session
|
||||
},
|
||||
() => this._onRemoteSignedOut()
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 기기의 Supabase 세션을 서버 결과와 무관하게 버린다.
|
||||
* auth-js signOut 은 오프라인·5xx 면 { error } 를 돌려주고 _removeSession 을 건너뛰어, 클라이언트가
|
||||
* 옛 계정 세션을 계속 들고 자동 갱신한다(Cloud STT·realtime-token 이 로그아웃 뒤에도 그 계정으로 돌던 원인).
|
||||
* 공개 API 로는 메모리 세션만 지울 수 없으므로, 실패하면 자동 갱신을 멈추고 클라이언트를 새로 만든다.
|
||||
*/
|
||||
private async _revokeLocalSession(): Promise<void> {
|
||||
const client = this._client
|
||||
if (!client) return
|
||||
let failure: string | null = null
|
||||
try {
|
||||
const { error } = await client.auth.signOut({ scope: 'local' })
|
||||
if (error) failure = error.message
|
||||
} catch (err) {
|
||||
failure = err instanceof Error ? err.message : String(err)
|
||||
}
|
||||
if (failure === null) return
|
||||
logger.warn(`signOut did not clear the client session (${failure}) — discarding the auth client`)
|
||||
await client.auth.stopAutoRefresh().catch(() => undefined)
|
||||
if (this._client !== client) return
|
||||
const fresh = this._createClient()
|
||||
this._client = fresh
|
||||
this._bindClientAuthEvents(fresh)
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 계정 id (없거나 구버전 토큰 파일이면 null). bootstrap 이 세션 복원을 기다리지 않고
|
||||
* 그 계정 DB 를 먼저 열어, 복원 중·오프라인 동안의 기록이 익명 DB 로 새지 않게 한다.
|
||||
|
|
@ -542,12 +577,9 @@ class CloudSyncService extends EventEmitter {
|
|||
|
||||
// 4) Supabase 세션 무효화 — 이 기기의 세션만(scope:'local').
|
||||
// 기본값 'global' 은 계정의 모든 refresh token 을 폐기해 휴대폰·다른 PC 까지 로그아웃시켰다.
|
||||
// 서버가 실패를 돌려줘도 클라이언트의 세션은 반드시 버린다(_revokeLocalSession).
|
||||
if (this._client && this._session) {
|
||||
try {
|
||||
await this._client.auth.signOut({ scope: 'local' })
|
||||
} catch (err) {
|
||||
logger.warn(`signOut warning: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
await this._revokeLocalSession()
|
||||
}
|
||||
|
||||
// 4) in-memory 상태 + 저장된 토큰 clear
|
||||
|
|
@ -611,9 +643,22 @@ class CloudSyncService extends EventEmitter {
|
|||
* refresh된 토큰은 _session에 반영되지 않아 stale JWT가 될 수 있음.
|
||||
*/
|
||||
async getAccessToken(): Promise<string | null> {
|
||||
if (!this._client) return null
|
||||
const { data } = await this._client.auth.getSession()
|
||||
return data.session?.access_token ?? null
|
||||
return this._currentAccessToken()
|
||||
}
|
||||
|
||||
/**
|
||||
* 자격 증명의 단일 관문 — 이 서비스가 로그아웃 상태(_session=null)면 클라이언트에 무엇이 남아 있든 null.
|
||||
* 기다리는 동안 계정이 바뀌거나 로그아웃되면 그 토큰도 버린다.
|
||||
*/
|
||||
private async _currentAccessToken(): Promise<string | null> {
|
||||
const client = this._client
|
||||
const session = this._session
|
||||
if (!client || !session) return null
|
||||
const { data } = await client.auth.getSession()
|
||||
if (this._client !== client || this._session?.user.id !== session.user.id) return null
|
||||
const current = data.session
|
||||
if (!current || current.user?.id !== session.user.id) return null
|
||||
return current.access_token ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -646,10 +691,9 @@ class CloudSyncService extends EventEmitter {
|
|||
return { data: null, error: { message: 'Supabase client not initialized' } }
|
||||
}
|
||||
|
||||
// 최신 세션 확보 (auto-refresh 보장)
|
||||
const { data: sessionData } = await this._client.auth.getSession()
|
||||
const token = sessionData.session?.access_token
|
||||
if (!token) {
|
||||
// 최신 세션 확보 (auto-refresh 보장) — 로그아웃 상태면 클라이언트에 남은 세션을 쓰지 않는다
|
||||
const token = await this._currentAccessToken()
|
||||
if (!token || !this._client) {
|
||||
return { data: null, error: { message: 'No active session — 로그인 필요' } }
|
||||
}
|
||||
|
||||
|
|
@ -689,8 +733,7 @@ class CloudSyncService extends EventEmitter {
|
|||
return { stream: null, error: { message: 'Supabase client not initialized' } }
|
||||
}
|
||||
|
||||
const { data: sessionData } = await this._client.auth.getSession()
|
||||
const token = sessionData.session?.access_token
|
||||
const token = await this._currentAccessToken()
|
||||
if (!token) {
|
||||
return { stream: null, error: { message: 'No active session — 로그인 필요' } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import { getMainWindow } from '../windows/WindowManager'
|
|||
import { registerVoiceTextSink } from '../voice-text-routing'
|
||||
import { dictatedFieldValue, nextTemplateField, renderTemplateOutput } from './dictation-template-policy'
|
||||
import { textInsertTemplateOutputPort, type TemplateOutputPort } from './dictation-template-output'
|
||||
import { findTemplateFieldIdProblem } from '@d3ro/core/template-field-policy'
|
||||
import { IPC_CHANNELS } from '@d3ro/core/ipc-channels'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
|
|
@ -115,6 +116,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
|
||||
create(params: CreateTemplateParams): DictationTemplate {
|
||||
assertValidFieldIds(params.fields)
|
||||
const template: DictationTemplate = {
|
||||
id: crypto.randomUUID(),
|
||||
name: params.name,
|
||||
|
|
@ -135,6 +137,7 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
|
||||
update(params: UpdateTemplateParams): DictationTemplate {
|
||||
if (params.fields !== undefined) assertValidFieldIds(params.fields)
|
||||
const templates = this.getAll()
|
||||
const idx = templates.findIndex((t) => t.id === params.id)
|
||||
if (idx === -1) {
|
||||
|
|
@ -360,6 +363,21 @@ class DictationTemplateService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 필드 id는 받아쓴 값의 키이자 {{id}} 자리표시자 키다. 비었거나 겹치면 나중 필드 값이 앞 필드 값을 덮어써
|
||||
* 출력에서 앞 값이 사라지므로 저장 전에 거부한다.
|
||||
*/
|
||||
function assertValidFieldIds(fields: DictationTemplate['fields']): void {
|
||||
const problem = findTemplateFieldIdProblem(fields)
|
||||
if (!problem) return
|
||||
throw new D3ROError(
|
||||
ErrorCode.TemplateInvalidFormat,
|
||||
problem.problem === 'empty'
|
||||
? `Template field ${problem.index + 1} has an empty id`
|
||||
: `Template field id is not unique: ${problem.id}`,
|
||||
)
|
||||
}
|
||||
|
||||
/** 계정 범위가 생기기 전의 기계 전역 저장소(dictation-templates.json) — 로컬 모드 범위로 한 번 옮긴다. */
|
||||
function readLegacyTemplates(): DictationTemplate[] | null {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -16,11 +16,16 @@ import { containsPattern } from '../db/like-pattern'
|
|||
import {
|
||||
buildDictionaryUpdatePatch,
|
||||
dictionaryIdentityKey,
|
||||
dictionaryWriteProblemError,
|
||||
findDictionaryIdentityConflict,
|
||||
isUniqueConstraintViolation,
|
||||
normalizeDictionaryPronunciation,
|
||||
normalizeDictionaryWord
|
||||
validateDictionaryDraft
|
||||
} from '@d3ro/core/dictionary-policy'
|
||||
import {
|
||||
parseDictionaryFile,
|
||||
serializeDictionary,
|
||||
toDictionaryImportRow
|
||||
} from './dictionary/dictionary-file-codec'
|
||||
import type {
|
||||
DictionaryEntry,
|
||||
DictionaryQueryParams,
|
||||
|
|
@ -37,90 +42,6 @@ const logger = getLogger('DictionaryService')
|
|||
|
||||
let isShowingDictionarySaveDialog = false
|
||||
|
||||
const DICTIONARY_CSV_HEADER = [
|
||||
'word',
|
||||
'pronunciation',
|
||||
'category',
|
||||
'usageCount',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
] as const
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
|
||||
return needsQuotes ? `"${escaped}"` : escaped
|
||||
}
|
||||
|
||||
function parseCsvRows(input: string): string[][] {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (input[i + 1] === '"') {
|
||||
field += '"'
|
||||
i += 1
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = true
|
||||
} else if (char === ',') {
|
||||
row.push(field)
|
||||
field = ''
|
||||
} else if (char === '\n') {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
row = []
|
||||
field = ''
|
||||
} else if (char === '\r') {
|
||||
// skip CR; LF terminates the row
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
}
|
||||
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
|
||||
}
|
||||
|
||||
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickNumber(source: Record<string, unknown>, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string | null): DictionaryEntry['category'] {
|
||||
if (value === 'auto' || value === 'technical' || value === 'user') return value
|
||||
return 'user'
|
||||
}
|
||||
|
||||
type DictionaryDb = Pick<ReturnType<typeof getDatabase>, 'select'>
|
||||
|
||||
/**
|
||||
|
|
@ -160,10 +81,11 @@ function findImportDuplicate(
|
|||
|
||||
class DictionaryService {
|
||||
add(params: DictionaryAddParams): DictionaryEntry {
|
||||
const word = normalizeDictionaryWord(params.word)
|
||||
if (!word) {
|
||||
throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty')
|
||||
}
|
||||
// 빈 단어·서버 길이 제한(단어 120·발음 200자) 초과는 저장 전에 거부한다 — 받아들이면 로컬에만 남고
|
||||
// 서버가 22023 으로 거부해 아웃박스에 영구 보류된다(다른 기기로 동기화되지 않음).
|
||||
const validated = validateDictionaryDraft(params)
|
||||
if (!validated.ok) throw dictionaryWriteProblemError(validated)
|
||||
const { word, pronunciation } = validated.draft
|
||||
|
||||
const db = getDatabase()
|
||||
const now = Date.now()
|
||||
|
|
@ -177,7 +99,7 @@ class DictionaryService {
|
|||
const entry: NewDictionary = {
|
||||
id,
|
||||
word,
|
||||
pronunciation: normalizeDictionaryPronunciation(params.pronunciation) ?? null,
|
||||
pronunciation,
|
||||
category,
|
||||
usageCount: 0,
|
||||
lastUsedAt: null,
|
||||
|
|
@ -201,9 +123,7 @@ class DictionaryService {
|
|||
|
||||
update(params: DictionaryUpdateParams): DictionaryEntry | null {
|
||||
const result = buildDictionaryUpdatePatch(params)
|
||||
if (!result.ok) {
|
||||
throw new D3ROError(ErrorCode.DictionaryImportInvalidFormat, 'Dictionary word is empty')
|
||||
}
|
||||
if (!result.ok) throw dictionaryWriteProblemError(result)
|
||||
|
||||
const db = getDatabase()
|
||||
const existing = db.select().from(dictionary).where(eq(dictionary.id, params.id)).get()
|
||||
|
|
@ -334,8 +254,7 @@ class DictionaryService {
|
|||
.all()
|
||||
.map((row) => this._toEntry(row))
|
||||
|
||||
const content =
|
||||
params.format === 'csv' ? this._serializeCsv(entries) : this._serializeJson(entries)
|
||||
const content = serializeDictionary(entries, params.format)
|
||||
const defaultName = `d3ro-dictionary-${new Date().toISOString().slice(0, 10)}.${params.format}`
|
||||
|
||||
if (isShowingDictionarySaveDialog) {
|
||||
|
|
@ -398,7 +317,7 @@ class DictionaryService {
|
|||
|
||||
let parsed: Array<Record<string, unknown>>
|
||||
try {
|
||||
parsed = params.format === 'json' ? this._parseJson(raw) : this._parseCsv(raw)
|
||||
parsed = parseDictionaryFile(raw, params.format)
|
||||
} catch (err) {
|
||||
if (err instanceof D3ROError) throw err
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
|
@ -413,37 +332,25 @@ class DictionaryService {
|
|||
const outcome: DictionaryImportResult = { imported: 0, skipped: 0, errors: 0 }
|
||||
|
||||
db.transaction((tx) => {
|
||||
for (const row of parsed) {
|
||||
const word = pickString(row, ['word'])
|
||||
if (!word) {
|
||||
for (const record of parsed) {
|
||||
// 빈 단어·서버 길이 제한 초과 행은 오류로 센다 — 로컬에만 남고 동기화되지 않는 항목을 만들지 않는다
|
||||
const candidate = toDictionaryImportRow(record, now)
|
||||
if (!candidate.ok) {
|
||||
outcome.errors += 1
|
||||
continue
|
||||
}
|
||||
const category = normalizeCategory(pickString(row, ['category']))
|
||||
const { row } = candidate
|
||||
// 대소문자만 다른 단어도 같은 단어로 본다(서버 유일성과 같은 기준)
|
||||
const existing = findImportDuplicate(tx, word, category)
|
||||
const existing = findImportDuplicate(tx, row.word, row.category)
|
||||
if (existing) {
|
||||
outcome.skipped += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const createdAt = pickNumber(row, ['createdAt', 'created_at'])
|
||||
const updatedAt = pickNumber(row, ['updatedAt', 'updated_at'])
|
||||
const usageCount = pickNumber(row, ['usageCount', 'usage_count'])
|
||||
const lastUsedAt = pickNumber(row, ['lastUsedAt', 'last_used_at'])
|
||||
const id = crypto.randomUUID()
|
||||
try {
|
||||
tx.insert(dictionary)
|
||||
.values({
|
||||
id,
|
||||
word,
|
||||
pronunciation: pickString(row, ['pronunciation']),
|
||||
category,
|
||||
usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0,
|
||||
lastUsedAt,
|
||||
createdAt: createdAt ?? now,
|
||||
updatedAt: updatedAt ?? createdAt ?? now
|
||||
})
|
||||
.values({ id, ...row })
|
||||
.run()
|
||||
outcome.imported += 1
|
||||
void getCloudSyncService().pushOne('dictionary', id)
|
||||
|
|
@ -459,64 +366,6 @@ class DictionaryService {
|
|||
return outcome
|
||||
}
|
||||
|
||||
private _serializeJson(entries: DictionaryEntry[]): string {
|
||||
return JSON.stringify({ entries }, null, 2)
|
||||
}
|
||||
|
||||
private _serializeCsv(entries: DictionaryEntry[]): string {
|
||||
const lines = [DICTIONARY_CSV_HEADER.join(',')]
|
||||
for (const entry of entries) {
|
||||
lines.push(
|
||||
[
|
||||
csvCell(entry.word),
|
||||
csvCell(entry.pronunciation ?? ''),
|
||||
csvCell(entry.category),
|
||||
csvCell(entry.usageCount),
|
||||
csvCell(entry.createdAt),
|
||||
csvCell(entry.updatedAt)
|
||||
].join(',')
|
||||
)
|
||||
}
|
||||
return `\uFEFF${lines.join('\r\n')}\r\n`
|
||||
}
|
||||
|
||||
private _parseJson(raw: string): Array<Record<string, unknown>> {
|
||||
const data: unknown = JSON.parse(raw)
|
||||
let list: unknown
|
||||
if (Array.isArray(data)) {
|
||||
list = data
|
||||
} else if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
Array.isArray((data as { entries?: unknown }).entries)
|
||||
) {
|
||||
list = (data as { entries: unknown[] }).entries
|
||||
} else {
|
||||
throw new Error('expected an array or an object with an "entries" array')
|
||||
}
|
||||
return (list as unknown[]).filter(
|
||||
(item): item is Record<string, unknown> => !!item && typeof item === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
private _parseCsv(raw: string): Array<Record<string, unknown>> {
|
||||
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
|
||||
if (rows.length === 0) {
|
||||
throw new Error('empty CSV')
|
||||
}
|
||||
const header = rows[0].map((cell) => cell.trim())
|
||||
if (!header.includes('word')) {
|
||||
throw new Error('missing "word" column')
|
||||
}
|
||||
return rows.slice(1).map((cells) => {
|
||||
const record: Record<string, unknown> = {}
|
||||
header.forEach((key, index) => {
|
||||
record[key] = cells[index] ?? ''
|
||||
})
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
logger.info('DictionaryService disposed')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -272,7 +272,12 @@ class HistoryService {
|
|||
const title = result.text.trim().replace(/^["']|["']$/g, '').slice(0, 80)
|
||||
if (title) {
|
||||
const db = getDatabase()
|
||||
db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
const result = db.update(history).set({ title, updatedAt: Date.now() }).where(eq(history.id, id)).run()
|
||||
// 제목을 만드는 사이 기록이 지워졌다 — 올리면 대기 중인 원격 삭제를 upsert 로 덮는다.
|
||||
if (result.changes === 0) {
|
||||
logger.info(`Auto title discarded: ${id} was deleted while the title was generating`)
|
||||
return null
|
||||
}
|
||||
logger.info(`Auto title generated: ${id} → "${title}"`)
|
||||
// Phase 3.3: 타이틀 업데이트 후 자동 push
|
||||
void getCloudSyncService().pushOne('history', id)
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ import {
|
|||
calculateFrictionInsight,
|
||||
classifyKeyStroke,
|
||||
computeTypedDelta,
|
||||
isEditAtTextEnd,
|
||||
shouldLearnFromApp,
|
||||
countWords,
|
||||
emptyActivityBucket,
|
||||
extractPhrases,
|
||||
isAppExcluded,
|
||||
isLearnablePhrase,
|
||||
LEARNING_EXCLUDED_APPS,
|
||||
manhattanDistance,
|
||||
mergeActivityBucket,
|
||||
rankFlowWindows,
|
||||
|
|
@ -99,6 +99,11 @@ export interface TypingContext {
|
|||
editedSinceFocus: boolean
|
||||
/** 최근에 실제로 타이핑했는가 (recentTypingWindowMs 이내) */
|
||||
typedRecently: boolean
|
||||
/**
|
||||
* prefix 가 실제 커서 앞 텍스트라고 믿을 수 있는가 — 케어렛 오프셋을 알면 true, 모르면
|
||||
* 마지막 편집이 문서 끝에서 일어났을 때만 true. 생략하면 true 로 본다(호환).
|
||||
*/
|
||||
caretReliable?: boolean
|
||||
}
|
||||
|
||||
interface InputTelemetryEvents {
|
||||
|
|
@ -181,6 +186,10 @@ class InputTelemetryService extends EventEmitter {
|
|||
private _lastFocusKey = ''
|
||||
/** 현재 포커스에 들어왔을 때의 텍스트 (비밀번호는 저장하지 않는다) — 편집 여부 판정 기준선. */
|
||||
private _textAtFocus = ''
|
||||
/** 직전 스냅샷의 (포커스 키, 텍스트) — 케어렛을 모를 때 편집 위치 판정에 쓴다 */
|
||||
private _editProbe: { key: string; text: string } | null = null
|
||||
/** 마지막 편집이 문서 끝에서 일어났는가 (케어렛을 모를 때만 의미가 있다) */
|
||||
private _lastEditAtEnd = true
|
||||
/** 클릭 뒤 첫 스냅샷에서 기준선을 다시 잡는다 */
|
||||
private _rebaseFocusText = false
|
||||
private _lastActiveTickAt = 0
|
||||
|
|
@ -644,6 +653,16 @@ class InputTelemetryService extends EventEmitter {
|
|||
}
|
||||
context.editedSinceFocus = textForFocus !== this._textAtFocus
|
||||
|
||||
// 케어렛을 모르면 "문서 끝 = 커서" 가정이 맞는지 마지막 편집 위치로 확인한다.
|
||||
// 문서 중간을 고쳤다면 접두(문서 끝)는 커서와 무관하다 — 제안·수락을 막는다.
|
||||
if (this._editProbe === null || this._editProbe.key !== focusKey) {
|
||||
this._lastEditAtEnd = true
|
||||
} else if (this._editProbe.text !== textForFocus) {
|
||||
this._lastEditAtEnd = isEditAtTextEnd(this._editProbe.text, textForFocus)
|
||||
}
|
||||
this._editProbe = { key: focusKey, text: textForFocus }
|
||||
context.caretReliable = caretKnown || this._lastEditAtEnd
|
||||
|
||||
if (snapshot.isEditable && !snapshot.isPassword && !snapshot.isComposing) {
|
||||
this._applyTypedDelta(snapshot)
|
||||
}
|
||||
|
|
@ -817,9 +836,8 @@ class InputTelemetryService extends EventEmitter {
|
|||
raw: string,
|
||||
meta: { appName: string | null; windowTitle: string | null; source: PhraseSource }
|
||||
): void {
|
||||
if (meta.source === 'typed' && meta.appName && isAppExcluded(meta.appName, LEARNING_EXCLUDED_APPS)) {
|
||||
return
|
||||
}
|
||||
// 학습 제외 앱은 출처와 무관하다 — 터미널에서 수락한 제안(개발 지시)도 코퍼스에 넣지 않는다.
|
||||
if (!shouldLearnFromApp(meta.appName)) return
|
||||
const text = sanitizeSample(
|
||||
raw
|
||||
.split(/\r?\n/u)
|
||||
|
|
@ -1374,8 +1392,8 @@ class InputTelemetryService extends EventEmitter {
|
|||
* 있었다 — 규칙을 과거 데이터에도 똑같이 적용해 그래프를 되돌린다.
|
||||
*/
|
||||
private _pruneUnlearnableCorpus(): void {
|
||||
const excluded = (source: string, appName: string | null): boolean =>
|
||||
source === 'typed' && appName !== null && isAppExcluded(appName, LEARNING_EXCLUDED_APPS)
|
||||
// 학습과 같은 규칙(출처 무관) — 규칙 이전에 쌓인 제외 앱의 수락 제안도 걷어낸다.
|
||||
const excluded = (_source: string, appName: string | null): boolean => !shouldLearnFromApp(appName)
|
||||
|
||||
try {
|
||||
const db = getDatabase()
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ import {
|
|||
bindingKey,
|
||||
normalizeBinding
|
||||
} from '@d3ro/core/keybinding'
|
||||
import { ChordStateMachine } from '@d3ro/core/keybinding-runtime'
|
||||
import { AltGrPressDetector, ChordStateMachine } from '@d3ro/core/keybinding-runtime'
|
||||
import type {
|
||||
ChordBindingEntry,
|
||||
ChordClock,
|
||||
|
|
@ -266,6 +266,13 @@ class KeyBindingService extends EventEmitter {
|
|||
debug: (message) => logger.debug(message)
|
||||
})
|
||||
|
||||
/**
|
||||
* Windows AltGr(가짜 LCtrl + RAlt) 판별. 가짜 LCtrl 이 켠 Ctrl 을 걷어 RightAlt 단독
|
||||
* 바인딩이 매칭되게 하고, AltGr 눌림은 문자 입력일 수 있으므로 상태 머신에 보류를 요청한다.
|
||||
* 다른 플랫폼에는 가짜 LCtrl 이 없으므로 판정 결과를 쓰지 않는다(_handleKeyDown).
|
||||
*/
|
||||
private readonly _altGr = new AltGrPressDetector()
|
||||
|
||||
/** 이 서비스가 직접 등록한 accelerator만 추적한다 (다른 곳의 등록을 해제하지 않기 위해) */
|
||||
private _ownedAccelerators: Set<string> = new Set()
|
||||
|
||||
|
|
@ -491,6 +498,7 @@ class KeyBindingService extends EventEmitter {
|
|||
/** 눌림 · 더블프레스 · AltGr 보류 타이머를 모두 비운다. */
|
||||
private _clearRuntimeState(): void {
|
||||
this._machine.reset()
|
||||
this._altGr.reset()
|
||||
}
|
||||
|
||||
// ── uiohook 이벤트 → 정본 좌표계 ──────────────────────
|
||||
|
|
@ -499,13 +507,19 @@ class KeyBindingService extends EventEmitter {
|
|||
* uiohook 키보드 이벤트를 정본 좌표계 bindingKey 로 옮긴다.
|
||||
* normalizeBinding 이 "주 키가 수정자 자신"인 경우를 정리하므로
|
||||
* Right Alt 단독 바인딩도 그대로 매칭된다.
|
||||
*
|
||||
* isAltGr 이면 Ctrl 플래그는 AltGr 의 가짜 LCtrl 이 켠 것이므로 걷어낸다.
|
||||
*/
|
||||
private _keyboardEventKey(e: UiohookKeyboardEvent, vk: number | null): string | null {
|
||||
private _keyboardEventKey(
|
||||
e: UiohookKeyboardEvent,
|
||||
vk: number | null,
|
||||
isAltGr = false
|
||||
): string | null {
|
||||
if (vk === null) return null
|
||||
return bindingKey({
|
||||
device: 'keyboard',
|
||||
code: vk,
|
||||
ctrl: e.ctrlKey,
|
||||
ctrl: e.ctrlKey && !isAltGr,
|
||||
alt: e.altKey,
|
||||
shift: e.shiftKey,
|
||||
meta: e.metaKey
|
||||
|
|
@ -525,10 +539,18 @@ class KeyBindingService extends EventEmitter {
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 다운. 바인딩되지 않은 키도 상태 머신에 알린다(noteKeyDown) — AltGr+Q('@') 처럼
|
||||
* 문자 키가 이어지면 보류 중인 AltGr 트리거를 취소해야 하기 때문이다.
|
||||
*/
|
||||
private _handleKeyDown(e: UiohookKeyboardEvent): void {
|
||||
const key = this._keyboardEventKey(e, uiohookCodeToVk(e.keycode))
|
||||
const vk = uiohookCodeToVk(e.keycode)
|
||||
// 판별기는 플랫폼과 무관하게 모든 눌림을 관측해 상태를 일관되게 유지한다.
|
||||
const isAltGr = this._altGr.keyDown(vk, e.time) && process.platform === 'win32'
|
||||
this._machine.noteKeyDown(vk)
|
||||
const key = this._keyboardEventKey(e, vk, isAltGr)
|
||||
if (key === null) return
|
||||
this._machine.keyDown(key)
|
||||
this._machine.keyDown(key, { altGr: isAltGr })
|
||||
}
|
||||
|
||||
private _handleMouseDown(e: UiohookMouseEvent): void {
|
||||
|
|
@ -543,6 +565,7 @@ class KeyBindingService extends EventEmitter {
|
|||
*/
|
||||
private _handleKeyUp(e: UiohookKeyboardEvent): void {
|
||||
const vk = uiohookCodeToVk(e.keycode)
|
||||
this._altGr.keyUp(vk)
|
||||
this._machine.keyUp(this._keyboardEventKey(e, vk), vk)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { resolveSystemPrompt } from './llm-prompts'
|
|||
import { getBundledOllamaPath } from '../utils/paths'
|
||||
import { normalizeLoopbackUrl } from '../utils/loopback'
|
||||
import { readNdjsonLines } from '../utils/ndjson-reader'
|
||||
import { ModelResidencyLedger, type ModelResidencyPort } from './llm/model-residency'
|
||||
import {
|
||||
toChatRequest,
|
||||
toRoleMessages,
|
||||
|
|
@ -160,6 +161,8 @@ function stripReasoningBlocks(text: string): string {
|
|||
|
||||
class LocalLLMService extends EventEmitter {
|
||||
private _state = LLMState.Unavailable
|
||||
/** 모든 로컬 요청의 keep_alive 로 추정한 모델 상주 시각 — 제안 온기 판정이 읽는다 */
|
||||
private readonly _residency = new ModelResidencyLedger()
|
||||
/** 모델별 진행 중 pull — 중복 요청은 기존 promise에 합류 */
|
||||
private _pullInFlight = new Map<string, Promise<void>>()
|
||||
private _pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
|
@ -447,6 +450,7 @@ class LocalLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
const data = (await response.json()) as OllamaGenerateResponse
|
||||
this._residency.noteRequest(model, options?.keepAlive, Date.now())
|
||||
|
||||
const result: GenerateResult = {
|
||||
text: data.response,
|
||||
|
|
@ -558,6 +562,8 @@ class LocalLLMService extends EventEmitter {
|
|||
request.abortCause ??= 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
// 서버가 응답을 시작했다면 모델이 올라왔고 이 요청의 keep_alive 가 만료 시각을 정했다.
|
||||
if (reader) this._residency.noteRequest(model, options?.keepAlive, Date.now())
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
|
@ -900,10 +906,16 @@ class LocalLLMService extends EventEmitter {
|
|||
request.abortCause ??= 'cancelled'
|
||||
request.controller.abort()
|
||||
}
|
||||
if (reader) this._residency.noteRequest(model, options?.keepAlive, Date.now())
|
||||
request.close()
|
||||
}
|
||||
}
|
||||
|
||||
/** 모델 상주 추정 포트 (Ollama 는 가장 최근 요청의 keep_alive 로 만료를 다시 정한다). */
|
||||
get residency(): ModelResidencyPort {
|
||||
return this._residency
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this._disposed = true
|
||||
this.stopPolling()
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import { getSidecarBaseUrl, getWhisperModelsDir } from '../utils/paths'
|
|||
import { getRuntimeProvisioner } from './RuntimeProvisioner'
|
||||
import { SidecarSupervisor, type SidecarSupervisorEvents } from './stt/SidecarSupervisor'
|
||||
import { isSidecarLaunchAvailableOffline, resolveSidecarLaunch } from './stt/sidecarLaunch'
|
||||
import { transcriptionTimeoutMs } from './stt/audio-utils'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type {
|
||||
STTModel,
|
||||
|
|
@ -120,6 +121,11 @@ export interface LocalSTTEvents {
|
|||
// ── 상수 ──────────────────────────────────────────────────
|
||||
|
||||
const SIDECAR_REQUEST_TIMEOUT_MS = 120000
|
||||
/**
|
||||
* 최종 전사는 오디오 1초당 이만큼 더 기다린다 — CPU int8 large-v3(beam 5 + 온도 폴백)는 실시간보다
|
||||
* 느릴 수 있다. 짧은 녹음은 SIDECAR_REQUEST_TIMEOUT_MS 를 하한으로 쓴다.
|
||||
*/
|
||||
const SIDECAR_TRANSCRIBE_MS_PER_AUDIO_SECOND = 3000
|
||||
/** 부분 전사(미리보기) 타임아웃 — 실패해도 무시되므로 짧게 잡는다 */
|
||||
const SIDECAR_PARTIAL_TIMEOUT_MS = 15000
|
||||
|
||||
|
|
@ -378,6 +384,16 @@ class LocalSTTService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이 모델을 내려받기 없이 올릴 수 있는가 (models-dir 에 있거나 이미 올라가 있다).
|
||||
* 설치하지 않은 모델을 /load 하면 사이드카가 HF 에서 몇 GB 를 내려받으며 기존 모델을 먼저 내린다 —
|
||||
* 모델 선택(SET_MODEL) 같은 경로는 이 검사를 통과한 모델만 initialize 해야 한다.
|
||||
*/
|
||||
isModelInstalled(modelId: string): boolean {
|
||||
if (!modelId) return false
|
||||
return modelId === this._currentModelId || existsSync(join(getWhisperModelsDir(), modelId, 'model.bin'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정된 모델과 엔진을 내려받기 없이 쓸 수 있는지 확인한다 (requireInstalled 경로).
|
||||
* 모델이 없으면 사이드카 /load가 HF에서 암묵적으로 내려받고, 엔진이 없으면 런타임을 내려받는다 —
|
||||
|
|
@ -735,7 +751,12 @@ class LocalSTTService extends EventEmitter {
|
|||
method: 'POST',
|
||||
body: formData,
|
||||
signal: AbortSignal.timeout(
|
||||
isPartial ? SIDECAR_PARTIAL_TIMEOUT_MS : SIDECAR_REQUEST_TIMEOUT_MS,
|
||||
isPartial
|
||||
? SIDECAR_PARTIAL_TIMEOUT_MS
|
||||
: transcriptionTimeoutMs(audioBuffer.byteLength, {
|
||||
floorMs: SIDECAR_REQUEST_TIMEOUT_MS,
|
||||
perAudioSecondMs: SIDECAR_TRANSCRIBE_MS_PER_AUDIO_SECOND,
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -15,6 +15,24 @@ import { getDatabase } from '../db'
|
|||
import { onAccountScopeChanged } from './account-scope'
|
||||
import { getLlmGateway } from './llm/LlmGateway'
|
||||
import { MeetingRecordingSession, type RecordingSnapshot } from './meeting/MeetingRecordingSession'
|
||||
import {
|
||||
isRecoverableMeeting,
|
||||
syncStateRecordingLedger,
|
||||
type LocalRecordingLedger,
|
||||
} from './meeting/local-recording-ledger'
|
||||
import {
|
||||
createTranscriptStore,
|
||||
transcriptBase,
|
||||
type MeetingTranscriptStore,
|
||||
type TranscriptRevision,
|
||||
} from './meeting/transcript-revision-store'
|
||||
import {
|
||||
buildDiarizationChunkInput,
|
||||
diarizationContinuityHint,
|
||||
extractSpeakerLabels,
|
||||
lastLines,
|
||||
stripEchoedContext,
|
||||
} from './meeting/diarization-context'
|
||||
import { condenseTranscriptToBudget } from './meeting/transcript-condenser'
|
||||
import { selectTranscriptExcerpts } from '@d3ro/core/meeting-llm-input'
|
||||
import { LLM_PROXY_CHAT_LIMITS } from '@d3ro/core/llm-chat'
|
||||
|
|
@ -61,12 +79,18 @@ const logger = getLogger('MeetingModeService')
|
|||
const REWRITE_CHUNK_CHARS = 1500
|
||||
/** 조각 하나의 출력 토큰 상한 — 로컬(Ollama)·Premium 모두 4096 이하만 허용한다 */
|
||||
const REWRITE_CHUNK_MAX_TOKENS = 2048
|
||||
/** 화자 추정에서 다음 조각에 넘기는 앞 조각의 라벨된 마지막 줄 수 */
|
||||
const DIARIZATION_CONTEXT_LINES = 4
|
||||
/** 회의 채팅 히스토리를 세션마다 이 개수(메시지)까지만 유지한다 */
|
||||
const MAX_CHAT_HISTORY_MESSAGES = 20
|
||||
/** 녹음 중 전사를 DB 에 남기는 주기 — 비정상 종료 시 잃는 양의 상한 */
|
||||
const TRANSCRIPT_CHECKPOINT_MS = 15_000
|
||||
/** 중단된 녹음(전사 없음)의 오류 메시지 — 저장용(UI 는 status 로 표시) */
|
||||
const INTERRUPTED_MESSAGE = 'Recording was interrupted before it was saved'
|
||||
/** 녹음 동안 인식이 계속 실패해 전사가 하나도 없다 */
|
||||
const TRANSCRIPTION_FAILED_MESSAGE = 'Transcription failed during recording'
|
||||
/** 자막 인식 실패를 렌더러에 다시 알리는 최소 간격 */
|
||||
const CAPTION_FAILURE_NOTICE_INTERVAL_MS = 30_000
|
||||
|
||||
/**
|
||||
* 회의 기능이 쓰는 LLM 포트 — 백엔드 선택(local/online, 폴백)은 LlmGateway 한 곳이 정한다.
|
||||
|
|
@ -116,8 +140,16 @@ class MeetingModeService extends EventEmitter {
|
|||
/** 회의마다 진행 중인 채팅 — 한 회의의 취소가 다른 회의 채팅을 끊지 않게 */
|
||||
private _chatAbortControllers = new Map<string, AbortController>()
|
||||
private readonly _unsubscribeScope: () => void
|
||||
/** 회의별 자막 인식 실패 수 — 전사가 비었을 때 'completed' 대신 'error' 로 닫는 근거 */
|
||||
private readonly _captionFailures = new Map<string, number>()
|
||||
private _unsubscribeCaptionFailure: (() => void) | null = null
|
||||
private _lastCaptionFailureNoticeAt = 0
|
||||
|
||||
constructor() {
|
||||
constructor(
|
||||
private readonly _recordingLedger: LocalRecordingLedger = syncStateRecordingLedger,
|
||||
/** 전사 수정본 쓰기의 단일 관문 — 기준본 확인(compare-and-set)과 동기화 예약을 맡는다 */
|
||||
private readonly _transcripts: MeetingTranscriptStore = createSqliteMeetingTranscriptStore(),
|
||||
) {
|
||||
super()
|
||||
// 이 서비스가 내는 'error' 는 알림용이다 — 듣는 쪽이 없어도 throw 되지 않게 기본 수신자를 둔다
|
||||
// (EventEmitter 는 리스너 없는 'error' 를 던져 후처리 catch 를 깨뜨렸다).
|
||||
|
|
@ -255,6 +287,8 @@ class MeetingModeService extends EventEmitter {
|
|||
createdAt: now,
|
||||
updatedAt: now,
|
||||
}).run()
|
||||
// 이 기기에서 시작한 녹음 — 앱이 끊기면 다음 시작 때 이것만 복구한다(다른 기기 회의는 건드리지 않음).
|
||||
this._markLocalRecording(sessionId)
|
||||
|
||||
const { getAudioCaptureService, calculateRMS } = await import('./AudioCaptureService')
|
||||
const audioCaptureService = getAudioCaptureService()
|
||||
|
|
@ -306,6 +340,7 @@ class MeetingModeService extends EventEmitter {
|
|||
session.detach()
|
||||
if (this._current === session) this._current = null
|
||||
this._meetingModeActive = false
|
||||
this._clearLocalRecording(sessionId)
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
status: 'error',
|
||||
|
|
@ -318,6 +353,9 @@ class MeetingModeService extends EventEmitter {
|
|||
throw err
|
||||
}
|
||||
|
||||
// 인식이 계속 실패하면(사이드카 다운·모델 로드 실패) 회의가 조용히 빈 전사로 끝나지 않게 알린다.
|
||||
this._watchCaptionFailures(captionService, sessionId)
|
||||
|
||||
// 오디오 레벨 모니터링 + WAV 저장용 버퍼 (Phase 15.5-2)
|
||||
session.attachAudio()
|
||||
this._audioLevelTimer = setInterval(() => {
|
||||
|
|
@ -439,6 +477,7 @@ class MeetingModeService extends EventEmitter {
|
|||
/**
|
||||
* 이 DB 에 'recording'/'processing' 으로 남은 회의(앱 강제 종료·정전 등)를 닫는다.
|
||||
* 저장된 전사가 있으면 'completed', 없으면 'error'. 진행 중인 녹음은 건드리지 않는다.
|
||||
* 이 기기에서 시작한 녹음만 닫는다 — 동기화로 들어온 폰의 진행 중인 회의는 폰이 끝낸다.
|
||||
* @returns 정리한 회의 수
|
||||
*/
|
||||
recoverInterruptedSessions(): number {
|
||||
|
|
@ -453,6 +492,7 @@ class MeetingModeService extends EventEmitter {
|
|||
let recovered = 0
|
||||
for (const row of rows) {
|
||||
if (row.id === liveId) continue
|
||||
if (!isRecoverableMeeting(row, this._recordingLedger)) continue
|
||||
const now = Date.now()
|
||||
const endedAt = row.endedAt ?? row.updatedAt ?? now
|
||||
const hasTranscript = !!row.rawTranscript && row.rawTranscript.trim().length > 0
|
||||
|
|
@ -464,6 +504,7 @@ class MeetingModeService extends EventEmitter {
|
|||
updatedAt: now,
|
||||
}).where(eq(meetingSessions.id, row.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', row.id)
|
||||
this._clearLocalRecording(row.id)
|
||||
recovered++
|
||||
}
|
||||
if (recovered > 0) logger.warn(`중단된 회의 ${recovered}건을 정리했다`)
|
||||
|
|
@ -550,17 +591,22 @@ class MeetingModeService extends EventEmitter {
|
|||
const db = getDatabase()
|
||||
const rawTranscript = buildTranscriptFromSegments([...snapshot.segments], snapshot.startedAt)
|
||||
const hasTranscript = rawTranscript.trim().length > 0
|
||||
const status = options.interrupted && !hasTranscript ? 'error' : 'completed'
|
||||
// 전사가 비었는데 녹음 동안 인식이 실패했다면 '완료'가 아니다 — 사용자가 끝난 뒤에야 빈 회의를 발견했다.
|
||||
const transcriptionFailed = !hasTranscript && (this._captionFailures.get(snapshot.id) ?? 0) > 0
|
||||
const status = !hasTranscript && (options.interrupted || transcriptionFailed) ? 'error' : 'completed'
|
||||
const errorMessage = options.interrupted ? INTERRUPTED_MESSAGE : TRANSCRIPTION_FAILED_MESSAGE
|
||||
db.update(meetingSessions).set({
|
||||
status,
|
||||
rawTranscript,
|
||||
endedAt: snapshot.endedAt,
|
||||
durationMs: snapshot.durationMs,
|
||||
sttModel: configGet('sttModelId') as string | undefined,
|
||||
...(status === 'error' ? { errorMessage: INTERRUPTED_MESSAGE } : {}),
|
||||
...(status === 'error' ? { errorMessage } : {}),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, snapshot.id)).run()
|
||||
getCloudSyncService().pushOne('meetings', snapshot.id)
|
||||
this._captionFailures.delete(snapshot.id)
|
||||
this._clearLocalRecording(snapshot.id)
|
||||
|
||||
if (snapshot.pcm.length > 0) {
|
||||
try {
|
||||
|
|
@ -593,7 +639,49 @@ class MeetingModeService extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private _watchCaptionFailures(
|
||||
captionService: { on(event: 'failure', listener: (error: D3ROError) => void): unknown; off(event: 'failure', listener: (error: D3ROError) => void): unknown },
|
||||
sessionId: string,
|
||||
): void {
|
||||
this._unsubscribeCaptionFailure?.()
|
||||
this._captionFailures.set(sessionId, 0)
|
||||
this._lastCaptionFailureNoticeAt = 0
|
||||
const listener = (error: D3ROError): void => {
|
||||
if (this._current?.id !== sessionId) return
|
||||
this._captionFailures.set(sessionId, (this._captionFailures.get(sessionId) ?? 0) + 1)
|
||||
const now = Date.now()
|
||||
if (now - this._lastCaptionFailureNoticeAt < CAPTION_FAILURE_NOTICE_INTERVAL_MS) return
|
||||
this._lastCaptionFailureNoticeAt = now
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.ERROR, {
|
||||
code: error.code ?? ErrorCode.MeetingProcessingFailed,
|
||||
message: error.message,
|
||||
})
|
||||
}
|
||||
captionService.on('failure', listener)
|
||||
this._unsubscribeCaptionFailure = () => {
|
||||
captionService.off('failure', listener)
|
||||
}
|
||||
}
|
||||
|
||||
private _markLocalRecording(sessionId: string): void {
|
||||
try {
|
||||
this._recordingLedger.mark(sessionId)
|
||||
} catch (err) {
|
||||
logger.warn(`로컬 녹음 표식 기록 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _clearLocalRecording(sessionId: string): void {
|
||||
try {
|
||||
this._recordingLedger.clear(sessionId)
|
||||
} catch (err) {
|
||||
logger.warn(`로컬 녹음 표식 정리 실패: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
|
||||
private _stopTimers(): void {
|
||||
this._unsubscribeCaptionFailure?.()
|
||||
this._unsubscribeCaptionFailure = null
|
||||
if (this._audioLevelTimer) {
|
||||
clearInterval(this._audioLevelTimer)
|
||||
this._audioLevelTimer = null
|
||||
|
|
@ -831,19 +919,27 @@ class MeetingModeService extends EventEmitter {
|
|||
// ── Phase 14.5: 전사 수정 ──
|
||||
|
||||
updateTranscript(sessionId: string, editedTranscript: string): void {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
if (!row) {
|
||||
// 사용자의 수동 저장(실시간 세그먼트 반영 포함)은 기준본 확인 없이 쓴다 — 렌더러 계약은 그대로다.
|
||||
if (this._transcripts.writeEdited(sessionId, editedTranscript) === 'not-found') {
|
||||
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션을 찾을 수 없습니다: ${sessionId}`)
|
||||
}
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
logger.info(`전사 수정 저장: sessionId=${sessionId}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM 결과(다듬기·화자 구분)를 읽었던 기준본 위에서만 쓴다. 그 사이 사용자가 전사를 고쳤으면
|
||||
* 덮어쓰지 않고 실패시킨다 — 예전엔 마지막 쓰기가 이겨 수동 편집이 조용히 사라졌다.
|
||||
*/
|
||||
private _writeRewrite(sessionId: string, text: string, base: TranscriptRevision, failureCode: ErrorCode): void {
|
||||
const result = this._transcripts.writeEdited(sessionId, text, base.revision)
|
||||
if (result === 'not-found') {
|
||||
throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
}
|
||||
if (result === 'conflict') {
|
||||
throw new D3ROError(failureCode, '작업하는 동안 전사가 수정되어 결과를 저장하지 않았습니다. 다시 시도해 주세요.')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Phase 14.5: 문서 생성 ──
|
||||
|
||||
async generateDocument(params: MeetingGenerateDocParams): Promise<MeetingDocument> {
|
||||
|
|
@ -1181,26 +1277,22 @@ class MeetingModeService extends EventEmitter {
|
|||
// ── Phase 15: Auto Polish ──
|
||||
|
||||
async polishTranscript(sessionId: string): Promise<string> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
const base = this._transcripts.read(sessionId)
|
||||
if (!base) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
|
||||
const transcript = row.rawTranscript
|
||||
// 기준본은 수정본(사용자 편집·화자 라벨) 우선 — 원문으로 다듬으면 편집과 라벨이 통째로 사라졌다.
|
||||
const transcript = transcriptBase(base)
|
||||
if (!transcript) throw new D3ROError(ErrorCode.MeetingPolishFailed, '전사 텍스트가 없습니다')
|
||||
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
const polished = await this._rewriteTranscriptInChunks(
|
||||
llm,
|
||||
transcript,
|
||||
'다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]은 그대로 유지하세요.',
|
||||
'다음 음성 전사 텍스트를 다듬어주세요. 필러 단어(음, 어, 그, 아 등)를 제거하고, 문장 구조를 자연스럽게 교정하되, 원래 의미와 내용은 절대 변경하지 마세요. 타임스탬프 형식 [MM:SS]과 화자 표기([화자 N] 등)는 그대로 유지하세요.',
|
||||
ErrorCode.MeetingPolishFailed,
|
||||
)
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: polished,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
this._writeRewrite(sessionId, polished, base, ErrorCode.MeetingPolishFailed)
|
||||
|
||||
logger.info(`Auto Polish 완료: sessionId=${sessionId}`)
|
||||
return polished
|
||||
|
|
@ -1217,26 +1309,34 @@ class MeetingModeService extends EventEmitter {
|
|||
transcript: string,
|
||||
systemPrompt: string,
|
||||
failureCode: ErrorCode,
|
||||
options: { carrySpeakerContext?: boolean } = {},
|
||||
): Promise<string> {
|
||||
const chunks = chunkTranscriptByLines(transcript, REWRITE_CHUNK_CHARS)
|
||||
if (chunks.length === 0) {
|
||||
throw new D3ROError(failureCode, '전사 텍스트가 없습니다')
|
||||
}
|
||||
const outputs: string[] = []
|
||||
let previousTail: string[] = []
|
||||
for (let i = 0; i < chunks.length; i++) {
|
||||
const chunk = chunks[i]
|
||||
const result = await llm.generate(chunk, {
|
||||
systemPrompt,
|
||||
// 화자 추정은 조각 사이에 화자 번호를 이어 준다 — 앞 조각의 라벨된 끝부분과 지금까지의 화자 목록.
|
||||
const carry = options.carrySpeakerContext === true
|
||||
const input = carry ? buildDiarizationChunkInput(chunk, previousTail) : chunk
|
||||
const prompt = carry ? systemPrompt + diarizationContinuityHint(extractSpeakerLabels(outputs.join('\n'))) : systemPrompt
|
||||
const result = await llm.generate(input, {
|
||||
systemPrompt: prompt,
|
||||
temperature: 0.3,
|
||||
maxTokens: REWRITE_CHUNK_MAX_TOKENS,
|
||||
})
|
||||
if (looksTruncatedRewrite(chunk, result.text)) {
|
||||
const text = carry ? stripEchoedContext(result.text, previousTail) : result.text.trim()
|
||||
if (looksTruncatedRewrite(chunk, text)) {
|
||||
throw new D3ROError(
|
||||
failureCode,
|
||||
`LLM 결과가 잘린 것으로 보여 저장하지 않았습니다 (조각 ${i + 1}/${chunks.length})`,
|
||||
)
|
||||
}
|
||||
outputs.push(result.text.trim())
|
||||
outputs.push(text.trim())
|
||||
if (carry) previousTail = lastLines(text, DIARIZATION_CONTEXT_LINES)
|
||||
}
|
||||
return outputs.join('\n')
|
||||
}
|
||||
|
|
@ -1360,10 +1460,9 @@ ${excerpt.text}`
|
|||
// ── Phase 15.5: 화자 구분 ──
|
||||
|
||||
async diarizeSession(sessionId: string, numSpeakers?: number): Promise<void> {
|
||||
const db = getDatabase()
|
||||
const row = db.select().from(meetingSessions).where(eq(meetingSessions.id, sessionId)).get()
|
||||
const row = this._transcripts.read(sessionId)
|
||||
if (!row) throw new D3ROError(ErrorCode.MeetingSessionNotFound, `세션 없음: ${sessionId}`)
|
||||
if (!row.rawTranscript) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다')
|
||||
if (!row.raw) throw new D3ROError(ErrorCode.DiarizationFailed, '전사 텍스트가 없습니다')
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 10 })
|
||||
|
||||
|
|
@ -1410,7 +1509,7 @@ ${excerpt.text}`
|
|||
|
||||
// 전사 줄과 화자 구간 매칭 — 줄 형식 규칙은 core meeting-transcript 한 곳에서만 해석한다.
|
||||
// 화자는 덧붙이지 않고 교체하므로 다시 돌려도 라벨이 쌓이지 않는다.
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
const transcript = transcriptBase(row) ?? ''
|
||||
const labeledLines = applySpeakerLabels(
|
||||
parseTranscriptLines(transcript),
|
||||
diarResult.segments,
|
||||
|
|
@ -1419,11 +1518,7 @@ ${excerpt.text}`
|
|||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 85 })
|
||||
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: formatTranscriptLines(labeledLines),
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
this._writeRewrite(sessionId, formatTranscriptLines(labeledLines), row, ErrorCode.DiarizationFailed)
|
||||
|
||||
logger.info(`pyannote 화자 구분 완료: ${diarResult.num_speakers}명, sessionId=${sessionId}`)
|
||||
} catch (err) {
|
||||
|
|
@ -1442,14 +1537,14 @@ ${excerpt.text}`
|
|||
|
||||
private async _diarizeLLMFallback(
|
||||
sessionId: string,
|
||||
row: { rawTranscript: string | null; editedTranscript: string | null },
|
||||
row: TranscriptRevision,
|
||||
numSpeakers?: number,
|
||||
): Promise<void> {
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 30 })
|
||||
|
||||
const llm = await this._resolveMeetingLlm()
|
||||
|
||||
const transcript = row.editedTranscript ?? row.rawTranscript ?? ''
|
||||
const transcript = transcriptBase(row) ?? ''
|
||||
const speakerHint = numSpeakers && numSpeakers > 0
|
||||
? `회의에는 총 ${numSpeakers}명의 화자가 있습니다.`
|
||||
: '화자 수는 문맥에서 추정하세요.'
|
||||
|
|
@ -1464,16 +1559,12 @@ ${speakerHint}
|
|||
발언 내용, 어조, 문맥을 기반으로 화자를 추정하세요.
|
||||
원문의 타임스탬프와 내용은 변경하지 마세요.`,
|
||||
ErrorCode.DiarizationFailed,
|
||||
{ carrySpeakerContext: true },
|
||||
)
|
||||
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_MODE.DIARIZATION_PROGRESS, { sessionId, percent: 80 })
|
||||
|
||||
const db = getDatabase()
|
||||
db.update(meetingSessions).set({
|
||||
editedTranscript: labeled,
|
||||
updatedAt: Date.now(),
|
||||
}).where(eq(meetingSessions.id, sessionId)).run()
|
||||
getCloudSyncService().pushOne('meetings', sessionId)
|
||||
this._writeRewrite(sessionId, labeled, row, ErrorCode.DiarizationFailed)
|
||||
}
|
||||
|
||||
private _buildPdfHtml(session: MeetingSessionDetail): string {
|
||||
|
|
@ -1488,6 +1579,26 @@ ${speakerHint}
|
|||
}
|
||||
|
||||
// ── 싱글톤 ──
|
||||
/** 기본 전사 저장소 — meeting_sessions 행을 읽고 쓰며, 쓴 뒤 동기화를 예약한다. */
|
||||
function createSqliteMeetingTranscriptStore(): MeetingTranscriptStore {
|
||||
return createTranscriptStore({
|
||||
readRow: (sessionId) =>
|
||||
getDatabase()
|
||||
.select({ rawTranscript: meetingSessions.rawTranscript, editedTranscript: meetingSessions.editedTranscript })
|
||||
.from(meetingSessions)
|
||||
.where(eq(meetingSessions.id, sessionId))
|
||||
.get(),
|
||||
writeEdited: (sessionId, editedTranscript, now) => {
|
||||
getDatabase()
|
||||
.update(meetingSessions)
|
||||
.set({ editedTranscript, updatedAt: now })
|
||||
.where(eq(meetingSessions.id, sessionId))
|
||||
.run()
|
||||
},
|
||||
onWritten: (sessionId) => getCloudSyncService().pushOne('meetings', sessionId),
|
||||
})
|
||||
}
|
||||
|
||||
let instance: MeetingModeService | null = null
|
||||
|
||||
export function getMeetingModeService(): MeetingModeService {
|
||||
|
|
|
|||
|
|
@ -111,13 +111,14 @@ class MeetingSummaryService extends EventEmitter {
|
|||
}
|
||||
|
||||
// DB에 요약 저장
|
||||
db.update(history)
|
||||
const saved = db.update(history)
|
||||
.set({ summaryText: rawMarkdown, updatedAt: Date.now() })
|
||||
.where(eq(history.id, historyId))
|
||||
.run()
|
||||
|
||||
// Phase 3.3: 요약 저장 후 자동 push (fire-and-forget)
|
||||
void getCloudSyncService().pushOne('history', historyId)
|
||||
// Phase 3.3: 요약 저장 후 자동 push (fire-and-forget).
|
||||
// 요약하는 사이 기록이 지워졌으면 올리지 않는다 — 대기 중인 원격 삭제를 upsert 로 덮는다.
|
||||
if (saved.changes > 0) void getCloudSyncService().pushOne('history', historyId)
|
||||
|
||||
this._sendProgress(historyId, 'done')
|
||||
this._sendToRenderer(IPC_CHANNELS.MEETING_SUMMARY.SUMMARY_READY, summaryResult)
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@
|
|||
|
||||
import { EventEmitter } from 'events'
|
||||
import { getLogger } from './LoggerService'
|
||||
import { getCloudSyncService } from './CloudSyncService'
|
||||
import { cloudSyncCredentials, type CloudCredentials } from './cloud/cloud-credentials'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { LLMAction } from '@d3ro/core/types'
|
||||
import { resolveSystemPrompt } from './llm-prompts'
|
||||
|
|
@ -191,13 +191,17 @@ class PremiumLLMService extends EventEmitter {
|
|||
private _disposed = false
|
||||
private _lastQuota: QuotaUsageSnapshot | null = null
|
||||
|
||||
/** 계정 자격 증명 포트 — CloudSyncService 전체가 아닌 필요한 면만 의존한다(ISP/DIP). */
|
||||
constructor(private readonly _credentials: CloudCredentials = cloudSyncCredentials) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능 여부 — Supabase URL + access token 모두 있어야 true.
|
||||
* (LLMRouter가 local/premium 분기 시 호출)
|
||||
*/
|
||||
isAvailable(): boolean {
|
||||
const cloud = getCloudSyncService()
|
||||
return cloud.isEnabled() && cloud.isAuthenticated()
|
||||
return this._credentials.hasCredentials()
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -216,8 +220,7 @@ class PremiumLLMService extends EventEmitter {
|
|||
if (this._disposed) {
|
||||
throw new D3ROError(ErrorCode.LLMProcessingFailed, 'PremiumLLMService disposed')
|
||||
}
|
||||
const cloud = getCloudSyncService()
|
||||
if (!cloud.isEnabled() || !cloud.isAuthenticated()) {
|
||||
if (!this._credentials.hasCredentials()) {
|
||||
this.emit('upgrade-required', { reason: 'auth_required' })
|
||||
throw new D3ROError(
|
||||
ErrorCode.LLMServerUnreachable,
|
||||
|
|
@ -292,8 +295,7 @@ class PremiumLLMService extends EventEmitter {
|
|||
let completed = false
|
||||
|
||||
try {
|
||||
const cloud = getCloudSyncService()
|
||||
const { stream, error } = await cloud.invokeFunctionStream(
|
||||
const { stream, error } = await this._credentials.invokeStream(
|
||||
'llm-proxy',
|
||||
body as unknown as Record<string, unknown>,
|
||||
call.controller.signal,
|
||||
|
|
@ -403,13 +405,11 @@ class PremiumLLMService extends EventEmitter {
|
|||
}
|
||||
|
||||
private async _invokeProxy(body: LlmProxyRequest, signal?: AbortSignal): Promise<ClaudeMessageResponse> {
|
||||
const cloud = getCloudSyncService()
|
||||
|
||||
// Supabase JS 클라이언트의 functions.invoke() 사용 — auth 헤더를 올바르게 처리.
|
||||
// raw fetch + Authorization: Bearer 방식은 Supabase gateway가 401로 거부.
|
||||
const { data, error } = signal
|
||||
? await cloud.invokeFunction('llm-proxy', body as unknown as Record<string, unknown>, { signal })
|
||||
: await cloud.invokeFunction('llm-proxy', body as unknown as Record<string, unknown>)
|
||||
? await this._credentials.invoke('llm-proxy', body as unknown as Record<string, unknown>, { signal })
|
||||
: await this._credentials.invoke('llm-proxy', body as unknown as Record<string, unknown>)
|
||||
|
||||
if (error) {
|
||||
const msg = error.message ?? 'Edge Function error'
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
// - rag/embedding-port.ts: 임베딩 포트 + Ollama 어댑터(모델 존재 확인 포함)
|
||||
// - rag/chunk-store.ts : 문서·청크 저장소 포트 + SQLite 구현
|
||||
// - rag/retrieval.ts : 유사도 순위·답변 프롬프트 (순수 함수)
|
||||
// - rag/remote-document.ts: 동기화로 받은 문서의 삽입/교체/무시 판정 (순수 함수)
|
||||
|
||||
import { EventEmitter } from 'events'
|
||||
import path from 'path'
|
||||
|
|
@ -31,6 +32,7 @@ import {
|
|||
import { OllamaEmbeddingAdapter, type EmbeddingPort } from './rag/embedding-port'
|
||||
import { SqliteChunkStore, type ChunkStore } from './rag/chunk-store'
|
||||
import { buildAnswerSystemPrompt, rankChunks } from './rag/retrieval'
|
||||
import { planRemoteDocument, type RemoteKnowledgeDocument } from './rag/remote-document'
|
||||
|
||||
const logger = getLogger('RAGService')
|
||||
|
||||
|
|
@ -219,31 +221,32 @@ export class RAGService extends EventEmitter {
|
|||
}
|
||||
|
||||
/**
|
||||
* 동기화: 다른 기기(모바일·웹)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다.
|
||||
* 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 filePath는 비워 둔다.
|
||||
* 동기화: 다른 기기(모바일·웹·데스크톱)의 지식 문서를 원문 청크로 받아 저장하고, 이 기기의 임베딩 모델로 색인한다.
|
||||
* 임베딩 공간이 기기마다 달라 벡터는 옮기지 않는다. 원본 파일은 없으므로 새 문서의 filePath는 비워 둔다.
|
||||
* 이미 있는 문서라도 원문이 달라졌으면(다른 기기에서 재색인) 청크를 바꾸고 다시 임베딩한다.
|
||||
* 반영했으면 true, 바뀐 게 없으면 false.
|
||||
*/
|
||||
applyRemoteDocument(doc: {
|
||||
id: string
|
||||
fileName: string
|
||||
fileType: RAGDocument['fileType']
|
||||
chunks: string[]
|
||||
addedAt: number
|
||||
}): boolean {
|
||||
applyRemoteDocument(doc: RemoteKnowledgeDocument): boolean {
|
||||
const { store } = this.deps
|
||||
if (store.hasDocument(doc.id)) return false
|
||||
const chunks = doc.chunks.filter((c) => c.trim().length > 0)
|
||||
if (chunks.length === 0) return false
|
||||
store.insertDocument({
|
||||
id: doc.id,
|
||||
fileName: doc.fileName,
|
||||
filePath: '',
|
||||
fileType: doc.fileType,
|
||||
chunkCount: chunks.length,
|
||||
indexed: false,
|
||||
indexedAt: null,
|
||||
addedAt: doc.addedAt,
|
||||
})
|
||||
store.replaceChunks(doc.id, chunks)
|
||||
const local = store.hasDocument(doc.id) ? this.getStoredChunks(doc.id) : null
|
||||
const plan = planRemoteDocument(local, doc.chunks)
|
||||
if (plan.kind === 'skip') return false
|
||||
if (plan.kind === 'insert') {
|
||||
store.insertDocument({
|
||||
id: doc.id,
|
||||
fileName: doc.fileName,
|
||||
filePath: '',
|
||||
fileType: doc.fileType,
|
||||
chunkCount: plan.chunks.length,
|
||||
indexed: false,
|
||||
indexedAt: null,
|
||||
addedAt: doc.addedAt,
|
||||
})
|
||||
} else {
|
||||
// 옛 임베딩이 남아 검색에 걸리지 않도록 색인 안 됨으로 되돌린 뒤 청크를 바꾼다.
|
||||
store.updateDocument(doc.id, { chunkCount: plan.chunks.length, indexed: false, indexedAt: null })
|
||||
}
|
||||
store.replaceChunks(doc.id, plan.chunks)
|
||||
this._embedStoredChunks(doc.id, doc.fileName).catch((err) => {
|
||||
logger.warn(`Synced document ${doc.fileName} is stored but not embedded yet:`, err)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import { RUNTIME_FEED_URL, RUNTIME_INDEX_FILENAME } from '../update-feed'
|
|||
import {
|
||||
RUNTIME_COMPONENTS,
|
||||
RUNTIME_MIN_VERSION,
|
||||
isRuntimeIndexRejected,
|
||||
isRuntimeVersionSatisfied,
|
||||
parseRuntimeIndex,
|
||||
type RuntimeComponent,
|
||||
|
|
@ -77,6 +78,8 @@ export interface RuntimeProvisionerDeps {
|
|||
fetchImpl: RuntimeFetch
|
||||
feedUrl: string
|
||||
platform: NodeJS.Platform
|
||||
/** 런타임 번들의 아키텍처 확인용 (인덱스의 arch 와 비교) */
|
||||
arch: string
|
||||
stallTimeoutMs: number
|
||||
partAttempts: number
|
||||
}
|
||||
|
|
@ -94,6 +97,7 @@ function defaultDeps(): RuntimeProvisionerDeps {
|
|||
fetchImpl: defaultFetch,
|
||||
feedUrl: RUNTIME_FEED_URL,
|
||||
platform: process.platform,
|
||||
arch: process.arch,
|
||||
stallTimeoutMs: DEFAULT_STALL_TIMEOUT_MS,
|
||||
partAttempts: DEFAULT_PART_DOWNLOAD_ATTEMPTS,
|
||||
}
|
||||
|
|
@ -197,13 +201,35 @@ export class RuntimeProvisioner extends EventEmitter {
|
|||
return existing
|
||||
}
|
||||
|
||||
const task = this._install(component).finally(() => {
|
||||
this._inFlight.delete(component)
|
||||
})
|
||||
const task = this._install(component)
|
||||
.catch((err: unknown) => this._fallBackToInstalled(component, err, options))
|
||||
.finally(() => {
|
||||
this._inFlight.delete(component)
|
||||
})
|
||||
this._inFlight.set(component, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/**
|
||||
* feed 가 이 앱의 최소 버전보다 낮으면(런타임 게시가 앱 게시보다 늦은 경우) 이미 설치된
|
||||
* 엔진을 계속 쓴다. 예전에는 여기서 예외를 올려, 멀쩡히 돌던 엔진까지 버리고 받아쓰기와
|
||||
* 로컬 전사가 feed 가 따라올 때까지 전부 멈췄다. 새 API 가 필요한 기능만 실패하는 편이 낫다.
|
||||
* 사용자가 명시적으로 "다시 설치"(force)한 경우에는 실패를 그대로 알린다.
|
||||
*/
|
||||
private _fallBackToInstalled(
|
||||
component: RuntimeComponent,
|
||||
err: unknown,
|
||||
options: EnsureRuntimeOptions,
|
||||
): string {
|
||||
if (!options.force && isRuntimeIndexRejected(err, 'feed-outdated') && this.isInstalled(component)) {
|
||||
logger.warn(
|
||||
`런타임 feed가 최소 버전보다 낮아 설치된 ${component} 엔진을 계속 사용합니다 — ${err.message}`,
|
||||
)
|
||||
return this.binaryPath(component)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
private async _install(component: RuntimeComponent): Promise<string> {
|
||||
const started = Date.now()
|
||||
logger.info(`런타임 설치 시작: ${component}`)
|
||||
|
|
@ -261,7 +287,10 @@ export class RuntimeProvisioner extends EventEmitter {
|
|||
`런타임 인덱스를 받을 수 없습니다 (HTTP ${response.status}): ${url}`,
|
||||
)
|
||||
}
|
||||
return parseRuntimeIndex(await response.json(), component)
|
||||
return parseRuntimeIndex(await response.json(), component, RUNTIME_MIN_VERSION[component], {
|
||||
platform: this._deps.platform,
|
||||
arch: this._deps.arch,
|
||||
})
|
||||
}
|
||||
|
||||
private async _downloadParts(
|
||||
|
|
|
|||
|
|
@ -40,22 +40,30 @@ export interface SuggestionRecordInput {
|
|||
}
|
||||
|
||||
export interface SuggestionRepository {
|
||||
/** 제안 후보 1건을 기록한다. */
|
||||
record(input: SuggestionRecordInput): void
|
||||
/** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다. */
|
||||
/**
|
||||
* 제안 세션 1건을 기록한다(첫 후보 · 첫 요청 지연 · 후보 수). 행 id 를 돌려준다(실패하면 null).
|
||||
* 한 세션은 한 행이다 — 채우기 후보를 따로 넣으면 평균 지연이 0 으로 희석되고 수락률이 1/N 로 깎인다.
|
||||
*/
|
||||
record(input: SuggestionRecordInput): string | null | void
|
||||
/** 같은 텍스트의 가장 최근 기록 1건을 수락으로 표시한다 (세션 id 를 모를 때의 호환 경로). */
|
||||
markAccepted(text: string): void
|
||||
/** 세션 행의 후보 수를 갱신한다 (채우기 루프가 후보를 더할 때). */
|
||||
updateSessionCandidates?(id: string, candidateCount: number): void
|
||||
/** 이 세션 행을 수락으로 표시하고, 실제로 고른 후보 원문을 남긴다. */
|
||||
markSessionAccepted?(id: string, acceptedText: string): void
|
||||
/** 최근 이력 (limit 는 1~200 으로 클램프). */
|
||||
list(limit: number): SuggestionHistoryEntry[]
|
||||
}
|
||||
|
||||
export function createSqliteSuggestionRepository(): SuggestionRepository {
|
||||
return {
|
||||
record(input: SuggestionRecordInput): void {
|
||||
record(input: SuggestionRecordInput): string | null {
|
||||
const id = crypto.randomUUID()
|
||||
try {
|
||||
getDatabase()
|
||||
.insert(suggestions)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
id,
|
||||
appName: input.appName,
|
||||
prefixText: input.prefix.slice(-SUGGESTION_CONTEXT_MAX_CHARS),
|
||||
suggestionText: input.text,
|
||||
|
|
@ -66,8 +74,30 @@ export function createSqliteSuggestionRepository(): SuggestionRepository {
|
|||
createdAt: Date.now()
|
||||
})
|
||||
.run()
|
||||
return id
|
||||
} catch (error) {
|
||||
logger.warn(`제안 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
|
||||
updateSessionCandidates(id: string, candidateCount: number): void {
|
||||
try {
|
||||
getDatabase().update(suggestions).set({ candidateCount }).where(eq(suggestions.id, id)).run()
|
||||
} catch (error) {
|
||||
logger.warn(`제안 후보 수 갱신 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
markSessionAccepted(id: string, acceptedText: string): void {
|
||||
try {
|
||||
getDatabase()
|
||||
.update(suggestions)
|
||||
.set({ accepted: true, suggestionText: acceptedText })
|
||||
.where(eq(suggestions.id, id))
|
||||
.run()
|
||||
} catch (error) {
|
||||
logger.warn(`수락 기록 실패: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -86,7 +86,8 @@ export function createDefaultSuggestionServiceDeps(): SuggestionServiceDeps {
|
|||
return {
|
||||
repository: createSqliteSuggestionRepository(),
|
||||
budget: new SuggestionBudget(),
|
||||
warmth: new ModelWarmTracker(),
|
||||
// 상주 추정은 로컬 LLM 의 모든 요청(받아쓰기·음성 대화 포함)을 본다.
|
||||
warmth: new ModelWarmTracker({ residentUntil: (model) => getLocalLLMService().residency?.residentUntil(model) ?? null }),
|
||||
foreground: {
|
||||
currentWindowHandle: () => getForegroundWindowInfo()?.hwnd ?? null
|
||||
},
|
||||
|
|
@ -186,6 +187,8 @@ export class SuggestionService extends EventEmitter {
|
|||
private _provenance: SuggestionProvenance | null = null
|
||||
/** 세션을 만든 입력창의 최상위 창 핸들 — 수락 직전 포커스가 그대로인지 확인한다. */
|
||||
private _windowHandle: number | null = null
|
||||
/** 이 세션의 이력 행 id — 한 세션은 한 행이다(채우기 후보·수락은 이 행을 갱신한다). */
|
||||
private _sessionRecordId: string | null = null
|
||||
|
||||
/** 요청 예산 (간격 · 분/일 카운트 · 실패 쿨다운) */
|
||||
private readonly _budget: SuggestionBudget
|
||||
|
|
@ -350,7 +353,24 @@ export class SuggestionService extends EventEmitter {
|
|||
setEnabled(enabled: boolean): void {
|
||||
configSet('suggestionEnabled', enabled)
|
||||
if (!enabled) this.dismiss('disabled')
|
||||
else void this.warmUp()
|
||||
// 입력 맥락(수집 동의)이 없으면 제안이 뜰 수 없다 — 모델만 데우지 않는다.
|
||||
else if (hasInputContextConsent()) void this.warmUp()
|
||||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
|
||||
/**
|
||||
* 입력 수집 동의·일시정지가 바뀌었다. 입력 맥락이 끊기면 떠 있는 세션·채우기 루프를 닫고
|
||||
* 붙잡고 있던 마지막 문맥을 버린다(동의를 철회한 뒤 수동 요청이 옛 접두로 생성하지 않게).
|
||||
* 다시 켜지면 제안이 켜져 있을 때만 모델을 데운다.
|
||||
*/
|
||||
handleInputConsentChanged(): void {
|
||||
if (!hasInputContextConsent()) {
|
||||
this.dismiss('disabled')
|
||||
this._lastContext = null
|
||||
this._lastRequestedPrefix = ''
|
||||
} else if (this.isEnabled()) {
|
||||
void this.warmUp()
|
||||
}
|
||||
this.emit('state-changed', this.getState())
|
||||
}
|
||||
|
||||
|
|
@ -579,6 +599,7 @@ export class SuggestionService extends EventEmitter {
|
|||
if (this.isPresentationActive) this.dismiss(reason)
|
||||
return { ok: false, reason }
|
||||
}
|
||||
if (context.caretReliable === false) return { ok: false, reason: 'caret-unknown' }
|
||||
const prefix = normalizeRequestPrefix(context.prefix)
|
||||
if (prefix.length < 1) return { ok: false, reason: 'empty-prefix' }
|
||||
|
||||
|
|
@ -619,6 +640,7 @@ export class SuggestionService extends EventEmitter {
|
|||
editedSinceFocus: context.editedSinceFocus,
|
||||
typedRecently: context.typedRecently,
|
||||
prefix: context.prefix,
|
||||
caretReliable: context.caretReliable !== false,
|
||||
idleMs: context.idleMs,
|
||||
triggerDelayMs: config.triggerDelayMs,
|
||||
minPrefixChars: config.minPrefixChars,
|
||||
|
|
@ -696,7 +718,11 @@ export class SuggestionService extends EventEmitter {
|
|||
if (!this._isCurrentFallbackContext(prefix, context, token)) return false
|
||||
const trimmedPrefix = normalizeSessionPrefix(prefix)
|
||||
const localMemory = memory ?? this._collectMemoryHints(trimmedPrefix, context.appName)
|
||||
const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars)
|
||||
// 원문 뒤 공백(단어를 끝냈는가)은 세션 접두 정규화가 지운다 — 최신 원문에서 읽어 넘긴다.
|
||||
const rawPrefix = this._lastContext?.prefix ?? context.prefix
|
||||
const entries = buildLocalSuggestionCandidateEntries(trimmedPrefix, localMemory, maxCandidates, maxChars, {
|
||||
wordFinished: /\s$/u.test(rawPrefix)
|
||||
})
|
||||
if (entries.length === 0) return false
|
||||
|
||||
this._partialText = ''
|
||||
|
|
@ -795,6 +821,7 @@ export class SuggestionService extends EventEmitter {
|
|||
// 후보가 오기 전에 빈 화면으로 기다리게 하지 않는다 — 즉시 자리를 잡고
|
||||
// "생성 중" 을 보여준 뒤 내용으로 채운다. 실측 5초 지연에서 특히 중요하다.
|
||||
this._candidates = []
|
||||
this._sessionRecordId = null
|
||||
this._activeIndex = 0
|
||||
this._anchor = context.anchor
|
||||
this._anchorKind = context.anchorKind
|
||||
|
|
@ -904,6 +931,9 @@ export class SuggestionService extends EventEmitter {
|
|||
if (!timedOut) return
|
||||
|
||||
this._lastSkipReason = 'generation-failed'
|
||||
// warm 이라 믿고 보낸 요청이 시간 초과했다면 모델은 이미 내려갔다(다른 기능의 짧은 keep_alive 등)
|
||||
// — 다음 멈춤은 같은 콜드 요청을 되풀이하지 말고 워밍업("준비 중")부터 한다.
|
||||
this._warmth.noteCold(model)
|
||||
this._noteFailure(`timeout ${timeoutMs}ms`)
|
||||
if (
|
||||
this._publishLocalMemory(
|
||||
|
|
@ -1102,13 +1132,10 @@ export class SuggestionService extends EventEmitter {
|
|||
logger.debug(`제안 후보 추가 ${this._candidates.length}/${SUGGESTION_DEFAULTS.maxCandidatesTotal}`)
|
||||
this._armVisibleTtl()
|
||||
this.emit('updated', this.getState())
|
||||
this._recordSuggestion({
|
||||
prefix,
|
||||
text: candidateText,
|
||||
model,
|
||||
latencyMs: 0,
|
||||
candidateCount: this._candidates.length
|
||||
})
|
||||
// 채우기 후보는 새 행이 아니다 — 세션 행의 후보 수만 늘린다(지연 0 행이 평균·수락률을 망쳤다).
|
||||
if (this._sessionRecordId) {
|
||||
this._repository.updateSessionCandidates?.(this._sessionRecordId, this._candidates.length)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
if (!abort.signal.aborted) {
|
||||
|
|
@ -1320,6 +1347,7 @@ export class SuggestionService extends EventEmitter {
|
|||
this._generatedForPrefix = ''
|
||||
this._targetTotal = 0
|
||||
this._provenance = null
|
||||
this._sessionRecordId = null
|
||||
this._lastSkipReason = reason
|
||||
this._abort?.abort()
|
||||
this._abort = null
|
||||
|
|
@ -1350,12 +1378,21 @@ export class SuggestionService extends EventEmitter {
|
|||
logger.info(`제안 수락 무시: 표시 중인 후보 없음 (index=${index ?? 'active'})`)
|
||||
return { ok: false, reason: 'already-visible' }
|
||||
}
|
||||
// 케어렛을 모르는 필드에서 문서 중간을 고친 뒤다 — 후보는 문서 끝의 이어 쓰기라
|
||||
// 실제 커서(문서 중간)에 붙이면 엉뚱한 곳에 문장이 들어간다.
|
||||
if (this._lastContext?.caretReliable === false) {
|
||||
logger.info('제안 수락 거부: 케어렛 위치를 알 수 없고 마지막 편집이 문서 끝이 아니다')
|
||||
this.dismiss('caret-unknown')
|
||||
this.emit('state-changed', this.getState())
|
||||
return { ok: false, reason: 'caret-unknown' }
|
||||
}
|
||||
// 후보는 정제 과정에서 앞 공백을 잃는다 — 커서 앞 원문의 마지막 글자를 보고 구분 공백을
|
||||
// 붙인다(joinSuggestion). 세션을 닫기 전에(dismiss 가 세션 접두를 지운다) 계산한다.
|
||||
const text = joinSuggestion(this._acceptPrefix(), candidate.text, candidate.joint ?? 'separate')
|
||||
const appName = this._appName
|
||||
const windowTitle = this._windowTitle
|
||||
const targetWindow = this._windowHandle
|
||||
const sessionRecordId = this._sessionRecordId
|
||||
|
||||
// 먼저 창을 닫고 생성을 멈춘다 — 수락은 즉시 반응해야 한다.
|
||||
this.dismiss('accepted')
|
||||
|
|
@ -1401,7 +1438,13 @@ export class SuggestionService extends EventEmitter {
|
|||
|
||||
logger.info(`제안 수락: ${text.length}자 삽입 (method=${method === 'keyboard' ? 'keyboard' : 'clipboard'}, app=${appName ?? '-'})`)
|
||||
// 이력은 후보 원문으로 기록돼 있다 — 구분 공백이 붙은 삽입 문자열로는 찾지 못한다.
|
||||
this._repository.markAccepted(candidate.text)
|
||||
// 세션 행을 알면 그 행을 수락으로 표시한다 — 텍스트로 찾으면 로컬 기억의 2번째 이후 후보는
|
||||
// 행이 없어 사라지거나 같은 문구의 옛 행(다른 앱·다른 날)이 수락으로 잡혔다.
|
||||
if (sessionRecordId && this._repository.markSessionAccepted) {
|
||||
this._repository.markSessionAccepted(sessionRecordId, candidate.text)
|
||||
} else {
|
||||
this._repository.markAccepted(candidate.text)
|
||||
}
|
||||
// 수락한 문장은 사용자 문체의 확실한 표본이다 (학습 동의 시에만 저장됨).
|
||||
this._learning.recordAccepted(candidate.text, { appName, windowTitle })
|
||||
return { ok: true }
|
||||
|
|
@ -1455,7 +1498,8 @@ export class SuggestionService extends EventEmitter {
|
|||
learnTypedText: configGet('inputLearnTypedText') === true,
|
||||
appName: this._appName
|
||||
})
|
||||
this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName })
|
||||
const id = this._repository.record({ ...input, prefix: persistPrefix ? input.prefix : '', appName: this._appName })
|
||||
this._sessionRecordId = typeof id === 'string' ? id : null
|
||||
}
|
||||
|
||||
/** 테스트/진단 — 현재 앱이 제외 대상인지. */
|
||||
|
|
@ -1483,6 +1527,11 @@ function sanitizePartial(raw: string): string {
|
|||
return lines.slice(0, 2).join(' ').slice(0, SUGGESTION_MAX_OUTPUT_CHARS)
|
||||
}
|
||||
|
||||
/** 입력 텔레메트리가 맥락을 보낼 수 있는 상태인가 (수집 동의 + 일시정지 아님). */
|
||||
export function hasInputContextConsent(): boolean {
|
||||
return configGet('inputTelemetryEnabled') === true && configGet('inputTelemetryPaused') !== true
|
||||
}
|
||||
|
||||
let instance: SuggestionService | null = null
|
||||
|
||||
export function getSuggestionService(): SuggestionService {
|
||||
|
|
|
|||
|
|
@ -1,140 +1,38 @@
|
|||
// src/main/services/VoiceCommandService.ts
|
||||
// Phase 10.5: 음성 단축키 — 전사 텍스트에서 키워드를 감지하여 명령어 자동 선택.
|
||||
// electron-store에 VoiceCommandRule[] 저장, 키워드 매칭 엔진 제공.
|
||||
// 규칙 저장/활성 상태는 VoiceCommandStorePort로, 매칭·기본값 정책은 voice-command-policy.ts로 분리.
|
||||
|
||||
import { getLogger } from './LoggerService'
|
||||
import { configGet, configSet } from './ConfigService'
|
||||
import type {
|
||||
VoiceCommandRule,
|
||||
VoiceCommandKeyword,
|
||||
VoiceCommandMatch,
|
||||
KeywordMatchMode
|
||||
VoiceCommandMatch
|
||||
} from '@d3ro/core/types'
|
||||
import {
|
||||
buildDefaultRules,
|
||||
findRuleMatch,
|
||||
migrateLegacyDefaultRules
|
||||
} from './voice-command-policy'
|
||||
|
||||
const logger = getLogger('voice-command')
|
||||
|
||||
// ============================================================
|
||||
// 기본 키워드 (프리셋 명령어용)
|
||||
// 저장소 포트 (기본 어댑터 = electron-store/ConfigService)
|
||||
// ============================================================
|
||||
|
||||
interface DefaultKeywordEntry {
|
||||
instructionId: string
|
||||
keywords: VoiceCommandKeyword[]
|
||||
priority: number
|
||||
export interface VoiceCommandStorePort {
|
||||
loadRules(): VoiceCommandRule[] | undefined
|
||||
saveRules(rules: VoiceCommandRule[]): void
|
||||
loadEnabled(): boolean | undefined
|
||||
saveEnabled(enabled: boolean): void
|
||||
}
|
||||
|
||||
const DEFAULT_KEYWORDS: ReadonlyArray<DefaultKeywordEntry> = [
|
||||
{
|
||||
instructionId: 'builtin-translate',
|
||||
keywords: [
|
||||
{ keyword: '번역해줘', matchMode: 'prefix' },
|
||||
{ keyword: '번역', matchMode: 'prefix' },
|
||||
{ keyword: '영어로', matchMode: 'prefix' },
|
||||
{ keyword: 'translate', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-summarize',
|
||||
keywords: [
|
||||
{ keyword: '요약해줘', matchMode: 'prefix' },
|
||||
{ keyword: '요약', matchMode: 'prefix' },
|
||||
{ keyword: 'summarize', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-formal',
|
||||
keywords: [
|
||||
{ keyword: '다듬어줘', matchMode: 'prefix' },
|
||||
{ keyword: '다듬기', matchMode: 'prefix' },
|
||||
{ keyword: 'polish', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-explain-code',
|
||||
keywords: [
|
||||
{ keyword: '설명해줘', matchMode: 'prefix' },
|
||||
{ keyword: '설명', matchMode: 'prefix' },
|
||||
{ keyword: 'explain', matchMode: 'prefix' }
|
||||
],
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
|
||||
// ============================================================
|
||||
// electron-store 키 (ConfigService와 별도 네임스페이스)
|
||||
// ============================================================
|
||||
|
||||
const STORE_KEY_RULES: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandRules'
|
||||
const STORE_KEY_ENABLED: keyof import('@d3ro/core/types').AppConfig = 'voiceCommandsEnabled'
|
||||
|
||||
// ============================================================
|
||||
// 키워드 매칭 엔진
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다.
|
||||
* 키워드 앞뒤의 공백/구두점 경계를 존중한다.
|
||||
*/
|
||||
function matchKeyword(
|
||||
text: string,
|
||||
keyword: string,
|
||||
mode: KeywordMatchMode
|
||||
): { matched: boolean; cleanedText: string } {
|
||||
const trimmed = text.trim()
|
||||
const lowerText = trimmed.toLowerCase()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
|
||||
if (lowerKeyword.length === 0) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
|
||||
switch (mode) {
|
||||
case 'prefix': {
|
||||
if (!lowerText.startsWith(lowerKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
// 키워드 뒤가 끝이거나 공백/구두점이어야 정확한 prefix 매칭
|
||||
const afterKeyword = trimmed.charAt(keyword.length)
|
||||
if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
const cleaned = trimmed.slice(keyword.length).trimStart()
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
|
||||
case 'suffix': {
|
||||
if (!lowerText.endsWith(lowerKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
// 키워드 앞이 시작이거나 공백/구두점이어야 정확한 suffix 매칭
|
||||
const beforeKeyword = trimmed.charAt(trimmed.length - keyword.length - 1)
|
||||
if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
const cleaned = trimmed.slice(0, trimmed.length - keyword.length).trimEnd()
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
|
||||
case 'contains': {
|
||||
const index = lowerText.indexOf(lowerKeyword)
|
||||
if (index === -1) {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
// contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거
|
||||
const before = trimmed.slice(0, index)
|
||||
const after = trimmed.slice(index + keyword.length)
|
||||
const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim()
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isWordBoundary(char: string): boolean {
|
||||
// 공백, 구두점, 한국어 조사/어미 앞의 경계
|
||||
return /[\s,.!?;:'"()[\]{}\-/]/.test(char)
|
||||
const configVoiceCommandStore: VoiceCommandStorePort = {
|
||||
loadRules: () => configGet('voiceCommandRules'),
|
||||
saveRules: (rules) => configSet('voiceCommandRules', rules),
|
||||
loadEnabled: () => configGet('voiceCommandsEnabled'),
|
||||
saveEnabled: (enabled) => configSet('voiceCommandsEnabled', enabled)
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -146,10 +44,13 @@ class VoiceCommandService {
|
|||
private enabled = false
|
||||
private initialized = false
|
||||
|
||||
constructor(private readonly store: VoiceCommandStorePort = configVoiceCommandStore) {}
|
||||
|
||||
initialize(): void {
|
||||
if (this.initialized) return
|
||||
|
||||
this.loadRules()
|
||||
this.migrateLegacyDefaults()
|
||||
this.loadEnabled()
|
||||
this.initialized = true
|
||||
logger.info(
|
||||
|
|
@ -174,35 +75,21 @@ class VoiceCommandService {
|
|||
return noMatch
|
||||
}
|
||||
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) {
|
||||
const found = findRuleMatch(text, this.rules)
|
||||
if (!found) {
|
||||
return noMatch
|
||||
}
|
||||
|
||||
// priority 오름차순 정렬 (낮은 값 = 높은 우선순위)
|
||||
const sortedRules = [...this.rules]
|
||||
.filter((r) => r.enabled && r.keywords.length > 0)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
for (const kw of rule.keywords) {
|
||||
const result = matchKeyword(trimmed, kw.keyword, kw.matchMode)
|
||||
if (result.matched) {
|
||||
logger.info(
|
||||
`Voice command matched: rule="${rule.id}", keyword="${kw.keyword}", instruction="${rule.instructionId}"`
|
||||
)
|
||||
return {
|
||||
matched: true,
|
||||
ruleId: rule.id,
|
||||
instructionId: rule.instructionId,
|
||||
cleanedText: result.cleanedText,
|
||||
matchedKeyword: kw.keyword
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(
|
||||
`Voice command matched: rule="${found.rule.id}", keyword="${found.keyword}", instruction="${found.rule.instructionId}"`
|
||||
)
|
||||
return {
|
||||
matched: true,
|
||||
ruleId: found.rule.id,
|
||||
instructionId: found.rule.instructionId,
|
||||
cleanedText: found.cleanedText,
|
||||
matchedKeyword: found.keyword
|
||||
}
|
||||
|
||||
return noMatch
|
||||
}
|
||||
|
||||
getAllRules(): VoiceCommandRule[] {
|
||||
|
|
@ -256,15 +143,7 @@ class VoiceCommandService {
|
|||
return
|
||||
}
|
||||
|
||||
for (const entry of DEFAULT_KEYWORDS) {
|
||||
this.rules.push({
|
||||
id: crypto.randomUUID(),
|
||||
instructionId: entry.instructionId,
|
||||
keywords: [...entry.keywords],
|
||||
enabled: true,
|
||||
priority: entry.priority
|
||||
})
|
||||
}
|
||||
this.rules = buildDefaultRules(() => crypto.randomUUID())
|
||||
|
||||
this.saveRules()
|
||||
logger.info(`Default voice command keywords initialized (${this.rules.length} rules)`)
|
||||
|
|
@ -276,9 +155,21 @@ class VoiceCommandService {
|
|||
|
||||
// ── Private ──────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 이전 버전이 저장한 맨 명사 기본 키워드('요약', '번역', 'explain' …)는 일반 받아쓰기
|
||||
* 첫 단어를 삼키므로, 사용자가 손대지 않은 기본값이면 명령형 기본값으로 교체한다.
|
||||
*/
|
||||
private migrateLegacyDefaults(): void {
|
||||
const { rules, migrated } = migrateLegacyDefaultRules(this.rules)
|
||||
if (migrated === 0) return
|
||||
this.rules = rules
|
||||
this.saveRules()
|
||||
logger.info(`Migrated ${migrated} legacy default voice command rule(s) to imperative keywords`)
|
||||
}
|
||||
|
||||
private loadRules(): void {
|
||||
try {
|
||||
const stored = configGet(STORE_KEY_RULES) as VoiceCommandRule[] | undefined
|
||||
const stored = this.store.loadRules()
|
||||
if (Array.isArray(stored) && stored.length > 0) {
|
||||
this.rules = stored
|
||||
return
|
||||
|
|
@ -291,7 +182,7 @@ class VoiceCommandService {
|
|||
|
||||
private saveRules(): void {
|
||||
try {
|
||||
configSet(STORE_KEY_RULES, this.rules)
|
||||
this.store.saveRules(this.rules)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to save voice command rules: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -301,7 +192,7 @@ class VoiceCommandService {
|
|||
|
||||
private loadEnabled(): void {
|
||||
try {
|
||||
const stored = configGet(STORE_KEY_ENABLED) as boolean | undefined
|
||||
const stored = this.store.loadEnabled()
|
||||
this.enabled = stored === true
|
||||
} catch {
|
||||
this.enabled = false
|
||||
|
|
@ -310,7 +201,7 @@ class VoiceCommandService {
|
|||
|
||||
private saveEnabled(): void {
|
||||
try {
|
||||
configSet(STORE_KEY_ENABLED, this.enabled)
|
||||
this.store.saveEnabled(this.enabled)
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
`Failed to save voice command enabled state: ${error instanceof Error ? error.message : String(error)}`
|
||||
|
|
@ -332,6 +223,11 @@ export function getVoiceCommandService(): VoiceCommandService {
|
|||
return instance
|
||||
}
|
||||
|
||||
/** 테스트/조립용: 저장소 포트를 주입한 새 인스턴스를 만든다 (싱글턴과 무관). */
|
||||
export function createVoiceCommandService(store?: VoiceCommandStorePort): VoiceCommandService {
|
||||
return new VoiceCommandService(store)
|
||||
}
|
||||
|
||||
export function resetVoiceCommandServiceForTests(): void {
|
||||
instance = null
|
||||
}
|
||||
|
|
|
|||
|
|
@ -40,6 +40,9 @@ export class StreamingCaptionTrack {
|
|||
private _context = ''
|
||||
/** 버퍼를 자를 때마다 바뀐다 — 자르기 전에 시작한 인식 결과는 버린다 */
|
||||
private _epoch = 0
|
||||
/** 연속 인식 실패 수 — 성공하면 0. core 가 백오프·버퍼 상한을 정한다 */
|
||||
private _consecutiveFailures = 0
|
||||
private _lastFailureAt = 0
|
||||
private readonly _now: () => number
|
||||
|
||||
constructor(private readonly _deps: StreamingCaptionTrackDeps, initialContext = '') {
|
||||
|
|
@ -51,6 +54,11 @@ export class StreamingCaptionTrack {
|
|||
return this._buffer.length / BYTES_PER_MS
|
||||
}
|
||||
|
||||
/** 연속 인식 실패 수 (진단·테스트) */
|
||||
get consecutiveFailures(): number {
|
||||
return this._consecutiveFailures
|
||||
}
|
||||
|
||||
push(chunk: Buffer): void {
|
||||
this._buffer = this._buffer.length === 0 ? chunk : Buffer.concat([this._buffer, chunk])
|
||||
if (this._deps.isVoiced(chunk)) this._lastVoiceAt = this._now()
|
||||
|
|
@ -64,13 +72,21 @@ export class StreamingCaptionTrack {
|
|||
hasVoice: this._lastVoiceAt > 0,
|
||||
sinceVoiceMs: this._lastVoiceAt > 0 ? now - this._lastVoiceAt : Number.MAX_SAFE_INTEGER,
|
||||
sincePartialMs: now - this._lastPartialAt,
|
||||
busy: this._busy
|
||||
busy: this._busy,
|
||||
consecutiveFailures: this._consecutiveFailures,
|
||||
sinceFailureMs: this._consecutiveFailures > 0 ? now - this._lastFailureAt : Number.MAX_SAFE_INTEGER
|
||||
})
|
||||
|
||||
if (action === 'trim-idle') {
|
||||
this._keepTail(CAPTION_STREAMING_DEFAULTS.idleKeepMs)
|
||||
return
|
||||
}
|
||||
if (action === 'drop-overflow') {
|
||||
// 인식이 계속 실패하는 동안 쌓인 앞부분은 버린다 — 그 구간은 자막으로 남지 않는다.
|
||||
this._keepTail(CAPTION_STREAMING_DEFAULTS.failureRetainMs)
|
||||
this._epoch += 1
|
||||
return
|
||||
}
|
||||
if (action === 'partial') return this._run(() => this._partial())
|
||||
if (action === 'finalize') return this._run(() => this._finalize())
|
||||
if (action === 'force-commit') {
|
||||
|
|
@ -92,7 +108,10 @@ export class StreamingCaptionTrack {
|
|||
this._busy = true
|
||||
try {
|
||||
await job()
|
||||
this._consecutiveFailures = 0
|
||||
} catch (error) {
|
||||
this._consecutiveFailures += 1
|
||||
this._lastFailureAt = this._now()
|
||||
// 보고 경로가 던져도 트랙(과 이를 기다리는 flush/stop)은 멈추지 않는다
|
||||
try {
|
||||
this._deps.onError(error)
|
||||
|
|
|
|||
62
apps/desktop/src/main/services/cloud/cloud-credentials.ts
Normal file
62
apps/desktop/src/main/services/cloud/cloud-credentials.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// src/main/services/cloud/cloud-credentials.ts
|
||||
// 계정 자격 증명 포트 — Premium LLM·Cloud STT·음성 대화가 CloudSyncService 전체가 아니라
|
||||
// "지금 로그인한 계정의 자격 증명"만 보게 한다(ISP/DIP).
|
||||
//
|
||||
// 정본은 CloudSyncService 의 세션 상태다. 로그아웃(서버 응답과 무관)하면 이 포트의 모든 접근자가
|
||||
// null / 'no session' 을 돌려준다 — Supabase 클라이언트에 남은 세션으로 옛 계정의 쿼터를 쓰지 않게.
|
||||
|
||||
import { getCloudSyncService } from '../CloudSyncService'
|
||||
|
||||
export interface CloudFunctionResult {
|
||||
data: unknown
|
||||
error: { message: string } | null
|
||||
}
|
||||
|
||||
export interface CloudFunctionStreamResult {
|
||||
stream: ReadableStream<Uint8Array> | null
|
||||
error: { message: string } | null
|
||||
}
|
||||
|
||||
export interface CloudInvokeOptions {
|
||||
signal?: AbortSignal
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
export interface CloudCredentials {
|
||||
/** 클라우드가 설정됐고 로그인한 계정이 있으면 true */
|
||||
hasCredentials(): boolean
|
||||
/** 현재 계정의 access token(필요하면 갱신). 로그아웃 상태면 null */
|
||||
accessToken(): Promise<string | null>
|
||||
/** Edge Function 호출. 로그아웃 상태면 error 를 돌려준다 */
|
||||
invoke(name: string, body: Record<string, unknown> | FormData, options?: CloudInvokeOptions): Promise<CloudFunctionResult>
|
||||
/** Edge Function SSE 호출. 로그아웃 상태면 error 를 돌려준다 */
|
||||
invokeStream(name: string, body: Record<string, unknown>, signal?: AbortSignal): Promise<CloudFunctionStreamResult>
|
||||
}
|
||||
|
||||
/** Cloud STT 드라이버가 쓰는 자격 증명 + 엔드포인트 설정 */
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
|
||||
/** CloudSyncService 싱글톤을 자격 증명 포트로 감싼 기본 어댑터(호출 시점에 해석 — 테스트 모킹과 순환 import 안전). */
|
||||
export const cloudSyncCredentials: CloudCredentials = {
|
||||
hasCredentials: () => {
|
||||
const cloud = getCloudSyncService()
|
||||
return cloud.isEnabled() && cloud.isAuthenticated()
|
||||
},
|
||||
accessToken: () => getCloudSyncService().getAccessToken(),
|
||||
invoke: (name, body, options) =>
|
||||
options ? getCloudSyncService().invokeFunction(name, body, options) : getCloudSyncService().invokeFunction(name, body),
|
||||
invokeStream: (name, body, signal) => getCloudSyncService().invokeFunctionStream(name, body, signal),
|
||||
}
|
||||
|
||||
/** 기본 Cloud STT 게이트웨이 — 토큰은 자격 증명 포트에서, 엔드포인트는 설정에서. */
|
||||
export function createCloudSttGateway(credentials: CloudCredentials = cloudSyncCredentials): CloudSttGateway {
|
||||
return {
|
||||
getAccessToken: () => credentials.accessToken(),
|
||||
getSupabaseUrl: () => getCloudSyncService().getSupabaseUrl(),
|
||||
getAnonKey: () => getCloudSyncService().getAnonKey(),
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,211 @@
|
|||
// src/main/services/dictionary/dictionary-file-codec.ts
|
||||
// 사전 내보내기/가져오기 파일 형식(CSV·JSON) — 순수 함수만 둔다 (fs/dialog/DB/동기화 의존 없음).
|
||||
// DictionaryService 는 파일 IO·DB 쓰기·동기화 push 만 맡고, 형식 변환과 행 검증은 여기서 한다.
|
||||
|
||||
import { validateDictionaryDraft, type DictionaryWriteProblem } from '@d3ro/core/dictionary-policy'
|
||||
import type { DictionaryEntry } from '@d3ro/core/types'
|
||||
|
||||
export type DictionaryFileFormat = 'csv' | 'json'
|
||||
|
||||
export const DICTIONARY_CSV_HEADER = [
|
||||
'word',
|
||||
'pronunciation',
|
||||
'category',
|
||||
'usageCount',
|
||||
'createdAt',
|
||||
'updatedAt'
|
||||
] as const
|
||||
|
||||
function csvCell(value: unknown): string {
|
||||
const text = value === null || value === undefined ? '' : String(value)
|
||||
const escaped = text.replace(/"/g, '""')
|
||||
const needsQuotes = /[",\r\n]/.test(escaped) || /^[=+\-@]/.test(escaped)
|
||||
return needsQuotes ? `"${escaped}"` : escaped
|
||||
}
|
||||
|
||||
function parseCsvRows(input: string): string[][] {
|
||||
const rows: string[][] = []
|
||||
let row: string[] = []
|
||||
let field = ''
|
||||
let inQuotes = false
|
||||
for (let i = 0; i < input.length; i += 1) {
|
||||
const char = input[i]
|
||||
if (inQuotes) {
|
||||
if (char === '"') {
|
||||
if (input[i + 1] === '"') {
|
||||
field += '"'
|
||||
i += 1
|
||||
} else {
|
||||
inQuotes = false
|
||||
}
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (char === '"') {
|
||||
inQuotes = true
|
||||
} else if (char === ',') {
|
||||
row.push(field)
|
||||
field = ''
|
||||
} else if (char === '\n') {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
row = []
|
||||
field = ''
|
||||
} else if (char === '\r') {
|
||||
// skip CR; LF terminates the row
|
||||
} else {
|
||||
field += char
|
||||
}
|
||||
}
|
||||
if (field.length > 0 || row.length > 0) {
|
||||
row.push(field)
|
||||
rows.push(row)
|
||||
}
|
||||
return rows.filter((candidate) => candidate.some((cell) => cell.trim().length > 0))
|
||||
}
|
||||
|
||||
function pickString(source: Record<string, unknown>, keys: string[]): string | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'string' && value.trim().length > 0) return value.trim()
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickNumber(source: Record<string, unknown>, keys: string[]): number | null {
|
||||
for (const key of keys) {
|
||||
const value = source[key]
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function normalizeCategory(value: string | null): DictionaryEntry['category'] {
|
||||
if (value === 'auto' || value === 'technical' || value === 'user') return value
|
||||
return 'user'
|
||||
}
|
||||
|
||||
function serializeJson(entries: readonly DictionaryEntry[]): string {
|
||||
return JSON.stringify({ entries }, null, 2)
|
||||
}
|
||||
|
||||
function serializeCsv(entries: readonly DictionaryEntry[]): string {
|
||||
const lines = [DICTIONARY_CSV_HEADER.join(',')]
|
||||
for (const entry of entries) {
|
||||
lines.push(
|
||||
[
|
||||
csvCell(entry.word),
|
||||
csvCell(entry.pronunciation ?? ''),
|
||||
csvCell(entry.category),
|
||||
csvCell(entry.usageCount),
|
||||
csvCell(entry.createdAt),
|
||||
csvCell(entry.updatedAt)
|
||||
].join(',')
|
||||
)
|
||||
}
|
||||
return `\uFEFF${lines.join('\r\n')}\r\n`
|
||||
}
|
||||
|
||||
/** 사전 항목을 파일 내용으로 직렬화한다. */
|
||||
export function serializeDictionary(
|
||||
entries: readonly DictionaryEntry[],
|
||||
format: DictionaryFileFormat
|
||||
): string {
|
||||
return format === 'csv' ? serializeCsv(entries) : serializeJson(entries)
|
||||
}
|
||||
|
||||
function parseJson(raw: string): Array<Record<string, unknown>> {
|
||||
const data: unknown = JSON.parse(raw)
|
||||
let list: unknown
|
||||
if (Array.isArray(data)) {
|
||||
list = data
|
||||
} else if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
Array.isArray((data as { entries?: unknown }).entries)
|
||||
) {
|
||||
list = (data as { entries: unknown[] }).entries
|
||||
} else {
|
||||
throw new Error('expected an array or an object with an "entries" array')
|
||||
}
|
||||
return (list as unknown[]).filter(
|
||||
(item): item is Record<string, unknown> => !!item && typeof item === 'object'
|
||||
)
|
||||
}
|
||||
|
||||
function parseCsv(raw: string): Array<Record<string, unknown>> {
|
||||
const rows = parseCsvRows(raw.replace(/^\uFEFF/, ''))
|
||||
if (rows.length === 0) {
|
||||
throw new Error('empty CSV')
|
||||
}
|
||||
const header = rows[0].map((cell) => cell.trim())
|
||||
if (!header.includes('word')) {
|
||||
throw new Error('missing "word" column')
|
||||
}
|
||||
return rows.slice(1).map((cells) => {
|
||||
const record: Record<string, unknown> = {}
|
||||
header.forEach((key, index) => {
|
||||
record[key] = cells[index] ?? ''
|
||||
})
|
||||
return record
|
||||
})
|
||||
}
|
||||
|
||||
/** 파일 내용을 레코드 목록으로 파싱한다. 형식이 맞지 않으면 Error 를 던진다. */
|
||||
export function parseDictionaryFile(
|
||||
raw: string,
|
||||
format: DictionaryFileFormat
|
||||
): Array<Record<string, unknown>> {
|
||||
return format === 'json' ? parseJson(raw) : parseCsv(raw)
|
||||
}
|
||||
|
||||
/** 가져올 한 행의 정규화된 쓰기 값 (id 는 서비스가 붙인다) */
|
||||
export interface DictionaryImportRow {
|
||||
word: string
|
||||
pronunciation: string | null
|
||||
category: DictionaryEntry['category']
|
||||
usageCount: number
|
||||
lastUsedAt: number | null
|
||||
createdAt: number
|
||||
updatedAt: number
|
||||
}
|
||||
|
||||
export type DictionaryImportRowResult =
|
||||
| { ok: true; row: DictionaryImportRow }
|
||||
| { ok: false; problem: DictionaryWriteProblem }
|
||||
|
||||
/**
|
||||
* 파싱한 레코드 하나를 저장할 행으로 바꾼다. 빈 단어나 서버 길이 제한을 넘는 단어·발음은 거부한다
|
||||
* (받아들이면 로컬에만 남고 서버가 22023 으로 거부해 다른 기기로 동기화되지 않는다).
|
||||
*/
|
||||
export function toDictionaryImportRow(
|
||||
record: Record<string, unknown>,
|
||||
now: number
|
||||
): DictionaryImportRowResult {
|
||||
const draft = validateDictionaryDraft({
|
||||
word: pickString(record, ['word']) ?? '',
|
||||
pronunciation: pickString(record, ['pronunciation'])
|
||||
})
|
||||
if (!draft.ok) return { ok: false, problem: draft }
|
||||
const createdAt = pickNumber(record, ['createdAt', 'created_at'])
|
||||
const updatedAt = pickNumber(record, ['updatedAt', 'updated_at'])
|
||||
const usageCount = pickNumber(record, ['usageCount', 'usage_count'])
|
||||
return {
|
||||
ok: true,
|
||||
row: {
|
||||
word: draft.draft.word,
|
||||
pronunciation: draft.draft.pronunciation,
|
||||
category: normalizeCategory(pickString(record, ['category'])),
|
||||
usageCount: usageCount !== null && usageCount >= 0 ? Math.floor(usageCount) : 0,
|
||||
lastUsedAt: pickNumber(record, ['lastUsedAt', 'last_used_at']),
|
||||
createdAt: createdAt ?? now,
|
||||
updatedAt: updatedAt ?? createdAt ?? now
|
||||
}
|
||||
}
|
||||
}
|
||||
56
apps/desktop/src/main/services/llm/model-residency.ts
Normal file
56
apps/desktop/src/main/services/llm/model-residency.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// src/main/services/llm/model-residency.ts
|
||||
//
|
||||
// Ollama 모델 상주(keep_alive) 추정 — 로컬 LLM 의 "모든" 요청을 기록한다.
|
||||
//
|
||||
// Ollama 는 모델의 만료 시각을 그 모델에 온 가장 최근 요청의 keep_alive 로 다시 정한다.
|
||||
// 제안 서비스가 자기 요청(10m)만 보고 warm 을 판정하면, 받아쓰기(기본 5m)·음성 대화(2m)가
|
||||
// 같은 모델을 더 짧게 덮어쓴 뒤에도 warm 으로 믿어 콜드 모델로 요청해 타임아웃됐다.
|
||||
|
||||
/** keep_alive 를 생략하면 Ollama 서버 기본값(5분)이 적용된다. */
|
||||
export const OLLAMA_DEFAULT_KEEP_ALIVE_MS = 5 * 60_000
|
||||
|
||||
/**
|
||||
* Ollama keep_alive 값을 ms 로 푼다. 음수는 "무기한"(Infinity), 0 은 즉시 내림, 해석할 수 없으면 기본값.
|
||||
* 문자열은 Go duration 형식('10m', '30s', '1h30m')과 초 단위 숫자 문자열을 받는다.
|
||||
*/
|
||||
export function parseKeepAliveMs(value: string | number | undefined | null): number {
|
||||
if (value === undefined || value === null || value === '') return OLLAMA_DEFAULT_KEEP_ALIVE_MS
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) return OLLAMA_DEFAULT_KEEP_ALIVE_MS
|
||||
return value < 0 ? Number.POSITIVE_INFINITY : value * 1000
|
||||
}
|
||||
const trimmed = value.trim()
|
||||
if (/^-?\d+(?:\.\d+)?$/u.test(trimmed)) return parseKeepAliveMs(Number(trimmed))
|
||||
if (trimmed.startsWith('-')) return Number.POSITIVE_INFINITY
|
||||
const unitMs: Record<string, number> = { ms: 1, s: 1000, m: 60_000, h: 3_600_000 }
|
||||
let total = 0
|
||||
let matched = ''
|
||||
for (const part of trimmed.matchAll(/(\d+(?:\.\d+)?)(ms|s|m|h)/gu)) {
|
||||
total += Number(part[1]) * unitMs[part[2]]
|
||||
matched += part[0]
|
||||
}
|
||||
return matched.length > 0 && matched === trimmed ? total : OLLAMA_DEFAULT_KEEP_ALIVE_MS
|
||||
}
|
||||
|
||||
/** 모델이 언제까지 메모리에 남아 있을지 알려 주는 포트 (제안 온기 판정이 읽는다). */
|
||||
export interface ModelResidencyPort {
|
||||
/** 알려진 만료 시각(ms). 이 앱에서 그 모델로 요청한 적이 없으면 null */
|
||||
residentUntil(model: string): number | null
|
||||
}
|
||||
|
||||
/** 요청 완료마다 (모델, keep_alive, 완료 시각)을 기록한다. 가장 최근 요청이 만료 시각을 정한다. */
|
||||
export class ModelResidencyLedger implements ModelResidencyPort {
|
||||
private readonly _expiresAt = new Map<string, number>()
|
||||
|
||||
noteRequest(model: string, keepAlive: string | number | undefined, completedAt: number): void {
|
||||
this._expiresAt.set(model, completedAt + parseKeepAliveMs(keepAlive))
|
||||
}
|
||||
|
||||
residentUntil(model: string): number | null {
|
||||
return this._expiresAt.get(model) ?? null
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this._expiresAt.clear()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,56 @@
|
|||
// src/main/services/meeting/diarization-context.ts
|
||||
//
|
||||
// LLM 화자 추정을 조각(1500자)마다 나눠 돌릴 때 조각 사이에 화자 번호를 이어 주는 순수 규칙.
|
||||
//
|
||||
// 조각마다 독립적으로 물으면 모델이 조각마다 처음 말한 사람을 '화자 1' 로 다시 매겨, 긴 회의에서
|
||||
// '화자 1' 이 3~5분마다 다른 사람을 가리켰다. 앞 조각의 라벨된 마지막 몇 줄과 지금까지의 화자 목록을
|
||||
// 다음 조각에 참고로 넘긴다. 참고 블록은 사용자 메시지 안에 경계를 두어 넣고(전사 원문을 시스템 권한으로
|
||||
// 올리지 않는다), 모델이 그 줄을 다시 출력하면 걷어낸다.
|
||||
|
||||
const SPEAKER_LABEL = /화자\s*(\d+)/gu
|
||||
|
||||
/** 텍스트에 나온 화자 라벨 ('화자 1' …) — 번호 순, 중복 없음 */
|
||||
export function extractSpeakerLabels(text: string): string[] {
|
||||
const numbers = new Set<number>()
|
||||
for (const match of text.matchAll(SPEAKER_LABEL)) numbers.add(Number(match[1]))
|
||||
return [...numbers].sort((a, b) => a - b).map((n) => `화자 ${n}`)
|
||||
}
|
||||
|
||||
/** 마지막 n 줄 (빈 줄 제외) */
|
||||
export function lastLines(text: string, count: number): string[] {
|
||||
return text
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line.length > 0)
|
||||
.slice(-count)
|
||||
}
|
||||
|
||||
export const DIARIZATION_CONTEXT_OPEN = '[이전 조각 끝부분 — 화자 번호를 이어 쓰기 위한 참고입니다. 다시 출력하지 마세요]'
|
||||
export const DIARIZATION_CONTEXT_CLOSE = '[/이전 조각 끝부분]'
|
||||
|
||||
/** 다음 조각의 사용자 메시지 — 앞 조각의 라벨된 꼬리를 참고 블록으로 앞에 붙인다. 첫 조각은 그대로. */
|
||||
export function buildDiarizationChunkInput(chunk: string, previousTail: readonly string[]): string {
|
||||
if (previousTail.length === 0) return chunk
|
||||
return `${DIARIZATION_CONTEXT_OPEN}\n${previousTail.join('\n')}\n${DIARIZATION_CONTEXT_CLOSE}\n\n${chunk}`
|
||||
}
|
||||
|
||||
/** 시스템 프롬프트에 덧붙일 화자 연속성 지시 — 화자 목록만 담는다(전사 원문은 넣지 않는다). */
|
||||
export function diarizationContinuityHint(knownSpeakers: readonly string[]): string {
|
||||
if (knownSpeakers.length === 0) return ''
|
||||
return `\n앞부분에서 이미 ${knownSpeakers.join(', ')} 로 표시했습니다. 같은 사람에게는 같은 번호를 이어 쓰고, 새 사람에게만 다음 번호를 주세요.`
|
||||
}
|
||||
|
||||
/** 모델이 참고 블록(경계 표시·앞 조각 줄)을 다시 출력했으면 걷어낸다. */
|
||||
export function stripEchoedContext(output: string, previousTail: readonly string[]): string {
|
||||
if (previousTail.length === 0) return output.trim()
|
||||
const echoed = new Set(previousTail.map((line) => line.trim()))
|
||||
return output
|
||||
.split('\n')
|
||||
.filter((line) => {
|
||||
const trimmed = line.trim()
|
||||
if (trimmed === DIARIZATION_CONTEXT_OPEN || trimmed === DIARIZATION_CONTEXT_CLOSE) return false
|
||||
return !echoed.has(trimmed)
|
||||
})
|
||||
.join('\n')
|
||||
.trim()
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
// src/main/services/meeting/local-recording-ledger.ts
|
||||
//
|
||||
// "이 기기에서 시작한 회의 녹음" 기록 (로컬 전용 sync_state, 서버로 가지 않는다).
|
||||
//
|
||||
// 중단 복구(recoverInterruptedSessions)는 이 기기가 녹음하다 끊긴 회의만 닫아야 한다. 동기화로 들어온
|
||||
// 폰의 녹음 중·처리 중 회의까지 '중단됨' 오류로 닫고 push 하면, 폰의 녹음 처리가 깨지고 서버의 완료된
|
||||
// 전사가 오래된 로컬 사본(NULL)으로 덮였다.
|
||||
|
||||
import { deleteSyncState, getSyncState, setSyncState } from '../sync/sync-outbox'
|
||||
|
||||
const KEY_PREFIX = 'meeting:local-recording:'
|
||||
|
||||
export interface LocalRecordingLedger {
|
||||
/** 이 기기에서 녹음을 시작했다 */
|
||||
mark(meetingId: string): void
|
||||
/** 녹음이 저장·종료됐다 (더 복구할 것이 없다) */
|
||||
clear(meetingId: string): void
|
||||
/** 이 기기에서 시작해 아직 끝나지 않은 녹음인가 */
|
||||
isLocal(meetingId: string): boolean
|
||||
}
|
||||
|
||||
export const syncStateRecordingLedger: LocalRecordingLedger = {
|
||||
mark: (meetingId) => setSyncState(`${KEY_PREFIX}${meetingId}`, '1'),
|
||||
clear: (meetingId) => deleteSyncState(`${KEY_PREFIX}${meetingId}`),
|
||||
isLocal: (meetingId) => getSyncState(`${KEY_PREFIX}${meetingId}`) === '1',
|
||||
}
|
||||
|
||||
/**
|
||||
* 중단 복구 대상인가 — 이 기기에서 시작한 녹음이거나, 기록이 없던 구버전 녹음 중 이 기기에 전사
|
||||
* 체크포인트가 남은 것. 전사도 표식도 없는 행은 다른 기기(폰)의 진행 중인 회의일 수 있어 건드리지 않는다.
|
||||
*/
|
||||
export function isRecoverableMeeting(
|
||||
row: { id: string; rawTranscript: string | null },
|
||||
ledger: LocalRecordingLedger
|
||||
): boolean {
|
||||
if (ledger.isLocal(row.id)) return true
|
||||
return !!row.rawTranscript && row.rawTranscript.trim().length > 0
|
||||
}
|
||||
|
|
@ -2,7 +2,8 @@
|
|||
// 긴 회의 전사를 LLM 한 번의 입력 한도 안으로 줄이는 map-reduce 단계.
|
||||
//
|
||||
// 문서 생성·요약은 전사 전체를 메시지 하나로 보냈고, 8,000자를 넘는 회의(대략 25분 이상)는 llm-proxy 가
|
||||
// 400 으로 거부했다. 한도를 넘으면 전사를 줄 경계 조각으로 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을
|
||||
// 400 으로 거부했다. 한도를 넘으면 전사를 한도 이하 조각(줄 경계, 줄바꿈 없는 긴 줄은 문장·공백·글자 수 경계)으로
|
||||
// 나눠 조각마다 핵심을 정리(map)하고, 그 정리본을
|
||||
// 이어 붙여(reduce 입력) 호출자가 한 번에 보낸다. 몇 번 줄여도 넘으면 조용히 자르지 않고 실패시킨다.
|
||||
//
|
||||
// LLM 은 포트(generate)로만 받는다 — 게이트웨이든 가짜든 된다.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,69 @@
|
|||
// src/main/services/meeting/transcript-revision-store.ts
|
||||
//
|
||||
// 회의 전사 수정본(editedTranscript) 쓰기의 단일 관문 (SRP · DIP).
|
||||
//
|
||||
// 수동 편집·실시간 세그먼트 수정·AI 다듬기·화자 구분이 각자 editedTranscript 를 덮어쓰면서 기준본 규칙이
|
||||
// 제각각이었다(다듬기는 원문, 화자 구분은 수정본). 그래서 다듬기가 화자 라벨과 직접 수정을 지웠고,
|
||||
// LLM 이 도는 동안 한 수동 편집이 마지막 쓰기에 덮였다. 이 저장소가 "어떤 기준본 위의 수정인가" 를
|
||||
// revision(원문·수정본 내용 해시)으로 확인한다(compare-and-set).
|
||||
|
||||
import { createHash } from 'crypto'
|
||||
|
||||
export interface TranscriptRevision {
|
||||
raw: string | null
|
||||
edited: string | null
|
||||
/** raw·edited 내용 해시 — 제목 변경·상태 전이·동기화처럼 전사와 무관한 updatedAt 변화에는 흔들리지 않는다 */
|
||||
revision: string
|
||||
}
|
||||
|
||||
export type TranscriptWriteResult = 'ok' | 'conflict' | 'not-found'
|
||||
|
||||
export interface MeetingTranscriptStore {
|
||||
read(sessionId: string): TranscriptRevision | null
|
||||
/**
|
||||
* 수정본을 쓴다. baseRevision 을 주면 그 사이 전사가 바뀌었을 때 쓰지 않고 'conflict' 를 돌려준다
|
||||
* (LLM 작업 경로). 생략하면 무조건 쓴다(사용자의 수동 저장).
|
||||
*/
|
||||
writeEdited(sessionId: string, editedTranscript: string, baseRevision?: string): TranscriptWriteResult
|
||||
}
|
||||
|
||||
export function transcriptRevision(raw: string | null, edited: string | null): string {
|
||||
return createHash('sha256').update(JSON.stringify([raw, edited])).digest('hex')
|
||||
}
|
||||
|
||||
/** 다시 쓰기(다듬기·화자 구분)의 기준본 — 사용자의 수정·화자 라벨이 담긴 수정본이 우선이다. */
|
||||
export function transcriptBase(revision: Pick<TranscriptRevision, 'raw' | 'edited'>): string | null {
|
||||
return revision.edited ?? revision.raw
|
||||
}
|
||||
|
||||
/** SQLite 어댑터가 쓰는 최소 행 접근 — better-sqlite3 는 동기라 read→compare→write 가 원자적이다. */
|
||||
export interface TranscriptRowAccess {
|
||||
readRow(sessionId: string): { rawTranscript: string | null; editedTranscript: string | null } | undefined
|
||||
writeEdited(sessionId: string, editedTranscript: string, now: number): void
|
||||
/** 쓰기 뒤 동기화 예약 */
|
||||
onWritten(sessionId: string): void
|
||||
}
|
||||
|
||||
export function createTranscriptStore(access: TranscriptRowAccess): MeetingTranscriptStore {
|
||||
return {
|
||||
read(sessionId) {
|
||||
const row = access.readRow(sessionId)
|
||||
if (!row) return null
|
||||
return {
|
||||
raw: row.rawTranscript,
|
||||
edited: row.editedTranscript,
|
||||
revision: transcriptRevision(row.rawTranscript, row.editedTranscript),
|
||||
}
|
||||
},
|
||||
writeEdited(sessionId, editedTranscript, baseRevision) {
|
||||
const row = access.readRow(sessionId)
|
||||
if (!row) return 'not-found'
|
||||
if (baseRevision !== undefined && transcriptRevision(row.rawTranscript, row.editedTranscript) !== baseRevision) {
|
||||
return 'conflict'
|
||||
}
|
||||
access.writeEdited(sessionId, editedTranscript, Date.now())
|
||||
access.onWritten(sessionId)
|
||||
return 'ok'
|
||||
},
|
||||
}
|
||||
}
|
||||
44
apps/desktop/src/main/services/rag/remote-document.ts
Normal file
44
apps/desktop/src/main/services/rag/remote-document.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
// src/main/services/rag/remote-document.ts
|
||||
// 다른 기기에서 받은 지식 문서를 로컬에 어떻게 반영할지 정하는 순수 정책.
|
||||
// 원문 청크 자체를 버전으로 쓴다 — 서버에 별도 리비전 컬럼이 없어도 재색인(원문 변경)을 알아챈다.
|
||||
|
||||
import type { RAGDocument } from '@d3ro/core/types'
|
||||
|
||||
/** 동기화로 받은 지식 문서(원문 청크만, 임베딩 없음) */
|
||||
export interface RemoteKnowledgeDocument {
|
||||
id: string
|
||||
fileName: string
|
||||
fileType: RAGDocument['fileType']
|
||||
chunks: readonly string[]
|
||||
addedAt: number
|
||||
}
|
||||
|
||||
export type RemoteDocumentPlan =
|
||||
| { kind: 'skip' }
|
||||
| { kind: 'insert'; chunks: string[] }
|
||||
| { kind: 'replace'; chunks: string[] }
|
||||
|
||||
/** 빈 청크는 저장·임베딩 대상이 아니다 */
|
||||
export function usableChunks(chunks: readonly string[]): string[] {
|
||||
return chunks.filter((c) => c.trim().length > 0)
|
||||
}
|
||||
|
||||
export function sameChunks(a: readonly string[], b: readonly string[]): boolean {
|
||||
return a.length === b.length && a.every((content, i) => content === b[i])
|
||||
}
|
||||
|
||||
/**
|
||||
* @param localChunks 로컬에 저장된 원문 청크(chunkIndex 순). 문서가 로컬에 없으면 null.
|
||||
* @param remoteChunks 서버에서 받은 완전한 원문 청크.
|
||||
* - 로컬에 없으면 insert
|
||||
* - 로컬에 있고 원문이 같으면 skip(자기 push의 에코, 제목만 바뀐 행 등)
|
||||
* - 로컬에 있고 원문이 다르면 replace(다른 기기에서 재색인됨)
|
||||
* 받은 청크가 전부 비어 있으면 로컬을 비우지 않고 skip한다.
|
||||
*/
|
||||
export function planRemoteDocument(localChunks: readonly string[] | null, remoteChunks: readonly string[]): RemoteDocumentPlan {
|
||||
const chunks = usableChunks(remoteChunks)
|
||||
if (chunks.length === 0) return { kind: 'skip' }
|
||||
if (localChunks === null) return { kind: 'insert', chunks }
|
||||
if (sameChunks(usableChunks(localChunks), chunks)) return { kind: 'skip' }
|
||||
return { kind: 'replace', chunks }
|
||||
}
|
||||
55
apps/desktop/src/main/services/recording/recording-store.ts
Normal file
55
apps/desktop/src/main/services/recording/recording-store.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
// src/main/services/recording/recording-store.ts
|
||||
//
|
||||
// 받아쓰기 녹음 저장 포트 (DIP) — 오케스트레이터(VoiceModeService)가 electron 경로·fs·WAV 헤더를
|
||||
// 직접 들지 않게 한다. 경로 규칙의 정본은 history-deletion.recordingsDir(), WAV 인코딩의 정본은
|
||||
// stt/audio-utils.pcmToWav 다.
|
||||
//
|
||||
// 저장은 완료 이벤트보다 먼저 끝나야 한다: 히스토리 행(audioLocalPath)과 history_audio 동기화가
|
||||
// 파일이 생기기 전에 돌면, flush 가 "파일 없음"을 성공으로 처리해 녹음이 영영 올라가지 않았다.
|
||||
|
||||
import path from 'path'
|
||||
import { mkdir, writeFile } from 'fs/promises'
|
||||
import { getLogger } from '../LoggerService'
|
||||
import { pcmToWav } from '../stt/audio-utils'
|
||||
|
||||
const logger = getLogger('RecordingStore')
|
||||
|
||||
export interface RecordingStore {
|
||||
/**
|
||||
* 16kHz 16-bit mono PCM 을 WAV 로 저장하고 실제 경로를 돌려준다.
|
||||
* 오디오가 없거나 저장에 실패하면 null — 호출자는 이 경로만 히스토리에 기록한다. 예외를 던지지 않는다.
|
||||
*/
|
||||
save(sessionId: string, pcm: Buffer | null): Promise<string | null>
|
||||
}
|
||||
|
||||
export interface FsRecordingStoreDeps {
|
||||
/** 녹음 디렉터리 (기본: history-deletion.recordingsDir — 삭제 경로와 같은 정본) */
|
||||
resolveDir(): Promise<string>
|
||||
mkdir(dir: string): Promise<unknown>
|
||||
writeFile(filePath: string, data: Buffer): Promise<void>
|
||||
}
|
||||
|
||||
const defaultDeps: FsRecordingStoreDeps = {
|
||||
resolveDir: async () => (await import('../history-deletion')).recordingsDir(),
|
||||
mkdir: (dir) => mkdir(dir, { recursive: true }),
|
||||
writeFile: (filePath, data) => writeFile(filePath, data),
|
||||
}
|
||||
|
||||
export function createFsRecordingStore(deps: FsRecordingStoreDeps = defaultDeps): RecordingStore {
|
||||
return {
|
||||
async save(sessionId: string, pcm: Buffer | null): Promise<string | null> {
|
||||
if (!pcm || pcm.length === 0) return null
|
||||
try {
|
||||
const dir = await deps.resolveDir()
|
||||
await deps.mkdir(dir)
|
||||
const wavPath = path.join(dir, `${sessionId}.wav`)
|
||||
await deps.writeFile(wavPath, pcmToWav(pcm, 16000, 1, 16))
|
||||
logger.info(`Audio saved: ${wavPath} (${Math.round(pcm.length / 1024)}KB)`)
|
||||
return wavPath
|
||||
} catch (error) {
|
||||
logger.warn(`Audio save failed: ${error instanceof Error ? error.message : String(error)}`)
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -58,6 +58,53 @@ export interface RuntimeComponentIndex {
|
|||
parts: RuntimePart[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 런타임 번들이 만들어진 플랫폼·아키텍처. 사이드카(PyInstaller)와 ffmpeg 는 네이티브
|
||||
* 실행 파일이라 빌드한 호스트에서만 돈다. 인덱스가 이 값을 밝히면 다른 플랫폼 앱은
|
||||
* 부품(~160MB)을 받기 전에 거부한다.
|
||||
*/
|
||||
export interface RuntimeTarget {
|
||||
platform: string
|
||||
arch: string
|
||||
}
|
||||
|
||||
/** 인덱스를 거부한 이유 — 호출 측이 "기존 엔진으로 버틸지" 판단할 때 쓴다 */
|
||||
export type RuntimeIndexRejection = 'feed-outdated' | 'platform-mismatch'
|
||||
|
||||
/**
|
||||
* 형식은 맞지만 이 앱이 쓸 수 없는 인덱스. 형식 오류(invalid)와 달리 이유를 싣는다.
|
||||
* - feed-outdated : feed 버전이 이 앱의 최소 요구 버전보다 낮다 (런타임 게시가 앱보다 늦음)
|
||||
* - platform-mismatch : 다른 플랫폼·아키텍처용 번들이다 (예: macOS 앱이 Windows 엔진을 받음)
|
||||
*/
|
||||
export class RuntimeIndexRejectedError extends D3ROError {
|
||||
readonly reason: RuntimeIndexRejection
|
||||
|
||||
constructor(reason: RuntimeIndexRejection, message: string) {
|
||||
super(ErrorCode.ConfigReadFailed, message, { reason })
|
||||
this.reason = reason
|
||||
}
|
||||
}
|
||||
|
||||
export function isRuntimeIndexRejected(
|
||||
err: unknown,
|
||||
reason?: RuntimeIndexRejection,
|
||||
): err is RuntimeIndexRejectedError {
|
||||
return err instanceof RuntimeIndexRejectedError && (reason === undefined || err.reason === reason)
|
||||
}
|
||||
|
||||
/**
|
||||
* 인덱스가 밝힌 대상 플랫폼. platform/arch 가 없는 예전 인덱스는 null(미지정)로 본다 —
|
||||
* 그런 인덱스는 게시 게이트(scripts/ci/lib/runtime-feed-gate.mjs)가 더는 통과시키지 않는다.
|
||||
*/
|
||||
function parseIndexTarget(raw: Record<string, unknown>): RuntimeTarget | null {
|
||||
const { platform, arch } = raw
|
||||
if (platform === undefined && arch === undefined) return null
|
||||
if (typeof platform !== 'string' || platform.length === 0 || typeof arch !== 'string' || arch.length === 0) {
|
||||
throw invalid('platform/arch 가 비어 있거나 문자열이 아닙니다')
|
||||
}
|
||||
return { platform, arch }
|
||||
}
|
||||
|
||||
/** parseRuntimeIndex 의 결과 — 검증된 구성 요소 항목과 인덱스 버전 */
|
||||
export interface ResolvedRuntimeEntry {
|
||||
version: string
|
||||
|
|
@ -131,11 +178,15 @@ function parsePart(raw: unknown, index: number): RuntimePart {
|
|||
* - 부품·아카이브 이름은 경로 구분자 없는 단순 파일 이름이어야 한다.
|
||||
* - 인덱스 version 이 이 앱의 최소 버전보다 낮으면 거부한다 — 설치해 봐야 곧바로
|
||||
* "낡았다" 고 판단돼 매번 다시 받는 루프가 되기 때문이다.
|
||||
* - `target` 을 주면, 인덱스가 밝힌 platform/arch 가 다를 때 거부한다 — 다른 플랫폼
|
||||
* 엔진은 받아 풀어도 검증에서 떨어져 매번 ~160MB 를 다시 받는 루프가 되기 때문이다.
|
||||
* 두 거부는 RuntimeIndexRejectedError 로 던져 형식 오류와 구분한다.
|
||||
*/
|
||||
export function parseRuntimeIndex(
|
||||
raw: unknown,
|
||||
component: RuntimeComponent,
|
||||
minVersion: string | null = RUNTIME_MIN_VERSION[component],
|
||||
target?: RuntimeTarget,
|
||||
): ResolvedRuntimeEntry {
|
||||
if (!isRecord(raw)) throw invalid('최상위 값이 객체가 아닙니다')
|
||||
|
||||
|
|
@ -144,6 +195,19 @@ export function parseRuntimeIndex(
|
|||
throw invalid('version 이 semver 문자열이 아닙니다')
|
||||
}
|
||||
|
||||
const indexTarget = parseIndexTarget(raw)
|
||||
if (
|
||||
target &&
|
||||
indexTarget &&
|
||||
(indexTarget.platform !== target.platform || indexTarget.arch !== target.arch)
|
||||
) {
|
||||
throw new RuntimeIndexRejectedError(
|
||||
'platform-mismatch',
|
||||
`이 플랫폼(${target.platform}-${target.arch})용 런타임이 feed에 없습니다 ` +
|
||||
`(feed=${indexTarget.platform}-${indexTarget.arch}, version=${version})`,
|
||||
)
|
||||
}
|
||||
|
||||
const components = raw.components
|
||||
if (!isRecord(components)) throw invalid('components 가 없습니다')
|
||||
|
||||
|
|
@ -169,8 +233,8 @@ export function parseRuntimeIndex(
|
|||
}
|
||||
|
||||
if (!isRuntimeVersionSatisfied(version, minVersion)) {
|
||||
throw new D3ROError(
|
||||
ErrorCode.ConfigReadFailed,
|
||||
throw new RuntimeIndexRejectedError(
|
||||
'feed-outdated',
|
||||
`런타임 feed의 ${component} 버전(${version})이 이 앱의 최소 요구 버전(${String(minVersion)})보다 낮습니다`,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,3 +86,20 @@ export function createProbeWav(durationMs: number = 500, sampleRate: number = 16
|
|||
export function bufferToBody(data: Buffer): BodyInit {
|
||||
return data as unknown as BodyInit
|
||||
}
|
||||
|
||||
/** 16kHz 16-bit mono PCM 의 초당 바이트 수 */
|
||||
export const PCM16_MONO_16K_BYTES_PER_SECOND = 16000 * 2
|
||||
|
||||
/**
|
||||
* 최종 전사 요청의 응답 기한 — 오디오 길이에 비례한다.
|
||||
*
|
||||
* 고정 120초는 CUDA 없는 PC(CPU int8)에서 몇 분짜리 핸즈프리 받아쓰기를 항상 끊어, 녹음 전체가
|
||||
* 버려졌다. 오디오 1초당 `perAudioSecondMs` 를 주고, 짧은 녹음은 `floorMs` 를 보장한다.
|
||||
*/
|
||||
export function transcriptionTimeoutMs(
|
||||
pcmBytes: number,
|
||||
options: { floorMs: number; perAudioSecondMs: number }
|
||||
): number {
|
||||
const seconds = Math.max(0, pcmBytes) / PCM16_MONO_16K_BYTES_PER_SECOND
|
||||
return Math.max(options.floorMs, Math.ceil(seconds * options.perAudioSecondMs))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,14 @@
|
|||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import type { STTProviderConfig } from '@d3ro/core/types'
|
||||
import type { TranscriptionResult, TranscribeOptions } from '../../LocalSTTService'
|
||||
import { getCloudSyncService } from '../../CloudSyncService'
|
||||
import { createCloudSttGateway, type CloudSttGateway } from '../../cloud/cloud-credentials'
|
||||
import { getLogger } from '../../LoggerService'
|
||||
import { pcmToWav } from '../audio-utils'
|
||||
import type { ISTTDriver } from '../types'
|
||||
|
||||
const logger = getLogger('D3ROCloudDriver')
|
||||
|
||||
export interface CloudSttGateway {
|
||||
getAccessToken(): Promise<string | null>
|
||||
getSupabaseUrl(): string | null
|
||||
getAnonKey(): string | null
|
||||
}
|
||||
export type { CloudSttGateway }
|
||||
|
||||
interface CloudSttPayload {
|
||||
transcript?: unknown
|
||||
|
|
@ -79,7 +75,7 @@ export class D3ROCloudDriver implements ISTTDriver {
|
|||
readonly id = 'd3ro-cloud' as const
|
||||
readonly name = 'D3RO Cloud STT (Managed)'
|
||||
|
||||
constructor(private readonly cloud: CloudSttGateway = getCloudSyncService()) {}
|
||||
constructor(private readonly cloud: CloudSttGateway = createCloudSttGateway()) {}
|
||||
|
||||
async transcribe(
|
||||
audioBuffer: Buffer,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@
|
|||
//
|
||||
// 워밍업 실패도 여기서 기록한다 — 실패한 워밍업을 매 멈춤마다 다시 쏘지 않도록
|
||||
// 잠시 물러선다(backoff).
|
||||
//
|
||||
// 제안 요청만 보면 안 된다: 같은 모델을 쓰는 받아쓰기(기본 5m)·음성 대화(2m)가 keep_alive 를
|
||||
// 짧게 덮어쓰면 Ollama 가 먼저 내린다. 주입된 상주 포트(로컬 LLM 의 모든 요청 기록)가 더 이른
|
||||
// 만료를 알면 그것을 따른다.
|
||||
|
||||
import type { ModelResidencyPort } from '../llm/model-residency'
|
||||
|
||||
export class ModelWarmTracker {
|
||||
private _warmModel: string | null = null
|
||||
|
|
@ -15,6 +21,8 @@ export class ModelWarmTracker {
|
|||
private _failedModel: string | null = null
|
||||
private _failedAt = 0
|
||||
|
||||
constructor(private readonly _residency: ModelResidencyPort | null = null) {}
|
||||
|
||||
/** 이 모델이 `until` 까지 메모리에 남아 있다고 본다. 같은 모델의 실패 기록은 지운다. */
|
||||
noteWarm(model: string, until: number): void {
|
||||
this._warmModel = model
|
||||
|
|
@ -26,7 +34,16 @@ export class ModelWarmTracker {
|
|||
}
|
||||
|
||||
isWarm(model: string | null, now: number): boolean {
|
||||
return model !== null && model === this._warmModel && now < this._warmUntil
|
||||
if (model === null || model !== this._warmModel || now >= this._warmUntil) return false
|
||||
const residentUntil = this._residency?.residentUntil(model) ?? null
|
||||
return residentUntil === null || now < residentUntil
|
||||
}
|
||||
|
||||
/** 이 모델이 warm 이라 믿고 요청했는데 시간 초과했다 — 이미 내려간 것이다. 다음 멈춤은 워밍업부터. */
|
||||
noteCold(model: string): void {
|
||||
if (this._warmModel !== model) return
|
||||
this._warmModel = null
|
||||
this._warmUntil = 0
|
||||
}
|
||||
|
||||
/** 워밍업이 실패했다 (모델 미설치 · 서버 오류 등). */
|
||||
|
|
|
|||
|
|
@ -22,7 +22,12 @@ import {
|
|||
} from './sync-adapters'
|
||||
import { fetchRemoteMemoTagKeys, listLocalMemoTagKeys, reconcileMemoTags } from './memo-tag-sync'
|
||||
import { fetchRemoteAudioOwners, isAudioSyncEnabled, listLocalAudioOwners } from './audio-sync'
|
||||
import { SETTINGS_ROW_ID, applyRemoteSettings, fetchRemoteSettings } from './settings-sync'
|
||||
import {
|
||||
SETTINGS_ROW_ID,
|
||||
applyRemoteSettings,
|
||||
fetchRemoteSettings,
|
||||
settingsNeedResyncAfterInstructionPush,
|
||||
} from './settings-sync'
|
||||
import { applyRemoteBuiltins, editedBuiltinsDifferingFrom, fetchRemoteBuiltins } from './builtin-instruction-sync'
|
||||
import { deleteOrder, upsertOrder } from './sync-registry'
|
||||
import {
|
||||
|
|
@ -50,6 +55,7 @@ import {
|
|||
type SyncRunResult,
|
||||
} from './sync-types'
|
||||
import { earliestDeletedAt, isRestoredAfter, tombstoneWindowExpired, type TombstoneRef } from './tombstone-policy'
|
||||
import { pushableEntries } from './push-gate-policy'
|
||||
|
||||
const logger = getLogger('SyncEngine')
|
||||
|
||||
|
|
@ -209,9 +215,10 @@ export class SyncEngine extends EventEmitter {
|
|||
runFullSync(): Promise<SyncRunResult> {
|
||||
return this.serialize(async () => {
|
||||
const result = emptyRunResult()
|
||||
await this.pullTombstonesBeforePush(result, true)
|
||||
await this.backfillIfNeeded()
|
||||
await this.flushInner(result, true)
|
||||
const tombstonesSynced = await this.pullTombstonesBeforePush(result, true)
|
||||
// 최초 대조는 원격 삭제가 반영된 뒤에만 한다(지운 행을 다시 올리지 않게).
|
||||
if (tombstonesSynced) await this.backfillIfNeeded()
|
||||
await this.flushInner(result, tombstonesSynced)
|
||||
await this.pullInner(result)
|
||||
return result
|
||||
})
|
||||
|
|
@ -222,9 +229,12 @@ export class SyncEngine extends EventEmitter {
|
|||
if (options.releaseParked) releaseParkedEntries()
|
||||
const result = emptyRunResult()
|
||||
const backfillPending = getSyncState(BACKFILL_FLAG) !== 'done'
|
||||
if (backfillPending) await this.pullTombstonesBeforePush(result, true)
|
||||
await this.backfillIfNeeded()
|
||||
await this.flushInner(result, backfillPending)
|
||||
let tombstonesSynced: boolean | null = null
|
||||
if (backfillPending) {
|
||||
tombstonesSynced = await this.pullTombstonesBeforePush(result, true)
|
||||
if (tombstonesSynced) await this.backfillIfNeeded()
|
||||
}
|
||||
await this.flushInner(result, tombstonesSynced)
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
|
@ -347,24 +357,29 @@ export class SyncEngine extends EventEmitter {
|
|||
/**
|
||||
* push 전에 원격 삭제를 반영한다: 폰에서 지운 행의 로컬 대기 upsert가 서버에 행을 되살리지 않게
|
||||
* outbox 항목을 버리고 로컬 행을 지운다. always=false 면 대기 upsert가 있을 때만 가져온다.
|
||||
* 끝까지 반영했거나 읽을 필요가 없었으면 true, 읽기에 실패했으면 false(이번 flush는 upsert를 보내지 않는다).
|
||||
*/
|
||||
private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise<void> {
|
||||
if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return
|
||||
private async pullTombstonesBeforePush(result: SyncRunResult, always: boolean): Promise<boolean> {
|
||||
if (!always && !listDueEntries(this.now()).some((e) => e.op === 'upsert')) return true
|
||||
const changed = new Set<SyncEntity>(result.changed)
|
||||
let synced = true
|
||||
try {
|
||||
result.deleted += await this.pullTombstones(changed)
|
||||
} catch (err) {
|
||||
if (err instanceof SyncAbortedError) throw err
|
||||
synced = false
|
||||
const message = errorMessage(err)
|
||||
result.errors.push(`tombstones: ${message}`)
|
||||
logger.warn(`Pull tombstones before push failed: ${message}`)
|
||||
logger.warn(`Pull tombstones before push failed; holding upserts until the next flush: ${message}`)
|
||||
}
|
||||
result.changed = [...changed]
|
||||
return synced
|
||||
}
|
||||
|
||||
private async flushInner(result: SyncRunResult, tombstonesPulled: boolean): Promise<void> {
|
||||
if (!tombstonesPulled) await this.pullTombstonesBeforePush(result, false)
|
||||
const due = listDueEntries(this.now())
|
||||
/** @param tombstonesSynced 호출자가 이미 push 전 tombstone을 읽었으면 그 결과, 아니면 null(여기서 읽는다) */
|
||||
private async flushInner(result: SyncRunResult, tombstonesSynced: boolean | null): Promise<void> {
|
||||
const synced = tombstonesSynced ?? (await this.pullTombstonesBeforePush(result, false))
|
||||
const due = pushableEntries(listDueEntries(this.now()), { tombstonesSynced: synced })
|
||||
if (due.length === 0) return
|
||||
const ctx = this.context()
|
||||
const byEntity = new Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>()
|
||||
|
|
@ -383,6 +398,7 @@ export class SyncEngine extends EventEmitter {
|
|||
const outcomes = await handler.push(ctx, bucket.upserts.map((e) => e.rowId))
|
||||
this.checkpoint()
|
||||
networkDown = this.settle(bucket.upserts, outcomes, result, handler.entity)
|
||||
if (handler.entity === 'custom_instructions') this.resyncSettingsIfDeferred(outcomes, byEntity)
|
||||
}
|
||||
// 자식 먼저 delete (서버 cascade가 있어도 순서를 지켜 FK 오류를 피한다)
|
||||
for (const handler of deleteOrder()) {
|
||||
|
|
@ -394,6 +410,21 @@ export class SyncEngine extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 설정 push가 서버에 없던 활성 명령 때문에 활성 명령 RPC를 미뤘고, 그 명령이 방금 올라갔으면
|
||||
* 설정을 다시 대기열에 넣는다. 이번 flush에 설정 항목이 이미 있으면 그 push가 맞추므로 넣지 않는다.
|
||||
*/
|
||||
private resyncSettingsIfDeferred(
|
||||
outcomes: PushOutcome[],
|
||||
byEntity: Map<SyncEntity, { upserts: OutboxEntry[]; deletes: OutboxEntry[] }>
|
||||
): void {
|
||||
if ((byEntity.get('user_settings')?.upserts.length ?? 0) > 0) return
|
||||
const pushed = outcomes.filter((o) => o.error === null).map((o) => o.id)
|
||||
if (pushed.length === 0 || !settingsNeedResyncAfterInstructionPush(pushed)) return
|
||||
enqueueChange('user_settings', SETTINGS_ROW_ID, 'upsert', this.now())
|
||||
logger.info('Deferred active instruction reached the server; settings re-queued')
|
||||
}
|
||||
|
||||
/** outbox를 결과대로 정리. 네트워크 계열 실패가 있었으면 true(이번 flush 중단). */
|
||||
private settle(entries: OutboxEntry[], outcomes: PushOutcome[], result: SyncRunResult, entity: SyncEntity): boolean {
|
||||
const byId = new Map(outcomes.map((o) => [o.id, o.error]))
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
// src/main/services/sync/active-instruction-push-policy.ts
|
||||
// 설정 push의 "활성 명령" 하위 단계 규칙(순수 함수). IO는 settings-sync.ts / SyncEngine.ts가 맡는다.
|
||||
//
|
||||
// 활성 명령 RPC(set_active_custom_instruction)는 서버에 그 사용자 명령 행이 있어야 성공한다(없으면 P0002).
|
||||
// 아직 올라가지 않은(대기·보관 중인) 명령을 가리키면 RPC를 보내지 않고 미룬다 — 그렇지 않으면
|
||||
// user_settings 항목이 P0002로 끝없이 재시도되고, 그동안 다른 기기의 설정 pull이 막힌다.
|
||||
// 미룬 명령이 나중에 서버에 올라가면 설정을 다시 push해 활성 명령을 맞춘다.
|
||||
|
||||
import type { ActiveInstructionPush } from './settings-sync-policy'
|
||||
import type { SyncRemoteError } from './sync-types'
|
||||
|
||||
export type ActiveInstructionStep =
|
||||
/** 서버에 올릴 것이 없다 */
|
||||
| { kind: 'none' }
|
||||
/** 서버에 아직 없는 명령 — RPC를 보내지 않고, 명령이 올라간 뒤 다시 맞춘다 */
|
||||
| { kind: 'defer'; instructionId: string }
|
||||
/** RPC로 서버 활성 명령을 바꾼다(null = 해제) */
|
||||
| { kind: 'rpc'; instructionId: string | null }
|
||||
|
||||
/**
|
||||
* 활성 명령 하위 단계를 정한다.
|
||||
* @param unsyncedInstructionIds 로컬 outbox에 upsert가 남아 있는(= 서버에 없을 수 있는) 사용자 명령 id
|
||||
*/
|
||||
export function planActiveInstructionStep(
|
||||
push: ActiveInstructionPush | null,
|
||||
unsyncedInstructionIds: ReadonlySet<string>
|
||||
): ActiveInstructionStep {
|
||||
if (!push) return { kind: 'none' }
|
||||
if (push.instructionId !== null && unsyncedInstructionIds.has(push.instructionId)) {
|
||||
return { kind: 'defer', instructionId: push.instructionId }
|
||||
}
|
||||
return { kind: 'rpc', instructionId: push.instructionId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 명령 RPC가 "서버에 그 명령이 없다"(P0002)로 실패했는지.
|
||||
* 이 경우 이미 저장된 설정 필드는 완료로 두고 활성 명령만 건너뛴다 — 다시 보내도 같은 결과다.
|
||||
*/
|
||||
export function isActiveInstructionMissing(error: SyncRemoteError): boolean {
|
||||
return error.code === 'P0002'
|
||||
}
|
||||
|
||||
/**
|
||||
* 방금 서버에 올라간 명령 중 미뤄 둔 활성 명령이 있으면 설정을 다시 push해야 한다.
|
||||
* @param deferredInstructionId 설정 push가 미뤄 둔 활성 명령 id('' 또는 null = 없음)
|
||||
*/
|
||||
export function shouldResyncSettingsAfterInstructionPush(
|
||||
deferredInstructionId: string | null,
|
||||
pushedInstructionIds: readonly string[]
|
||||
): boolean {
|
||||
return !!deferredInstructionId && pushedInstructionIds.includes(deferredInstructionId)
|
||||
}
|
||||
|
|
@ -56,16 +56,26 @@ function meetingAudioPath(id: string): string {
|
|||
return path.join(app.getPath('userData'), 'meeting-audio', `${id}.wav`)
|
||||
}
|
||||
|
||||
/** 로컬 녹음 파일 경로. 파일이 없으면 null */
|
||||
/**
|
||||
* 파일 전사 기록의 audioLocalPath 는 사용자가 고른 원본 미디어(동영상 포함)다 — 앱이 녹음한 파일이
|
||||
* 아니므로 '녹음 동기화'로 올리지 않는다(설정 문구도 녹음만 올린다고 안내한다). 로컬 재생용으로만 쓴다.
|
||||
*/
|
||||
const NON_RECORDING_HISTORY_MODES: ReadonlySet<string> = new Set(['file-transcription'])
|
||||
|
||||
function isSyncableHistoryRecording(row: { mode: string | null; audioLocalPath: string | null }): boolean {
|
||||
return row.audioLocalPath !== null && !NON_RECORDING_HISTORY_MODES.has(row.mode ?? '')
|
||||
}
|
||||
|
||||
/** 로컬 녹음 파일 경로. 파일이 없거나 앱 녹음이 아니면(파일 전사 원본) null */
|
||||
export function localAudioPath(owner: AudioOwner, id: string): string | null {
|
||||
let candidate: string | null
|
||||
if (owner === 'history') {
|
||||
const row = getDatabase()
|
||||
.select({ audioLocalPath: history.audioLocalPath })
|
||||
.select({ audioLocalPath: history.audioLocalPath, mode: history.mode })
|
||||
.from(history)
|
||||
.where(eq(history.id, id))
|
||||
.get()
|
||||
candidate = row?.audioLocalPath ?? null
|
||||
candidate = row && isSyncableHistoryRecording(row) ? row.audioLocalPath : null
|
||||
} else {
|
||||
candidate = meetingAudioPath(id)
|
||||
}
|
||||
|
|
@ -76,11 +86,11 @@ export function localAudioPath(owner: AudioOwner, id: string): string | null {
|
|||
export function listLocalAudioOwners(owner: AudioOwner): string[] {
|
||||
if (owner === 'history') {
|
||||
return getDatabase()
|
||||
.select({ id: history.id, audioLocalPath: history.audioLocalPath })
|
||||
.select({ id: history.id, audioLocalPath: history.audioLocalPath, mode: history.mode })
|
||||
.from(history)
|
||||
.where(isNotNull(history.audioLocalPath))
|
||||
.all()
|
||||
.filter((r) => isUuid(r.id) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath))
|
||||
.filter((r) => isUuid(r.id) && isSyncableHistoryRecording(r) && r.audioLocalPath !== null && fs.existsSync(r.audioLocalPath))
|
||||
.map((r) => r.id)
|
||||
}
|
||||
return getDatabase()
|
||||
|
|
|
|||
18
apps/desktop/src/main/services/sync/push-gate-policy.ts
Normal file
18
apps/desktop/src/main/services/sync/push-gate-policy.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// src/main/services/sync/push-gate-policy.ts
|
||||
// flush가 이번에 무엇을 push해도 되는지 정하는 규칙(순수 함수).
|
||||
//
|
||||
// push 전 tombstone 읽기에 실패하면 upsert를 보내지 않는다(fail closed). 다른 기기에서 지운 행의
|
||||
// 로컬 대기 upsert가 서버에 행을 다시 만들고, 서버가 updated_at을 새로 찍으면 다음 pull이 그 행을
|
||||
// "보관본 복원"으로 오인해 삭제를 모든 기기에서 영구히 되돌리기 때문이다. 삭제는 되살릴 위험이
|
||||
// 없으므로 그대로 보낸다. 건너뛴 upsert는 outbox에 남아 다음 flush에서 다시 시도된다.
|
||||
|
||||
import type { SyncOp } from './sync-types'
|
||||
|
||||
export interface PushGate {
|
||||
/** push 전 원격 삭제(tombstone)를 끝까지 반영했는지(읽을 필요가 없었으면 true) */
|
||||
tombstonesSynced: boolean
|
||||
}
|
||||
|
||||
export function pushableEntries<T extends { op: SyncOp }>(due: readonly T[], gate: PushGate): T[] {
|
||||
return gate.tombstonesSynced ? [...due] : due.filter((entry) => entry.op !== 'upsert')
|
||||
}
|
||||
|
|
@ -5,7 +5,15 @@
|
|||
import type { LLMActionSelection, ThemeMode } from '@d3ro/core/types'
|
||||
import { configGet, configSet } from '../ConfigService'
|
||||
import { getCustomInstructionService } from '../CustomInstructionService'
|
||||
import { getLogger } from '../LoggerService'
|
||||
import {
|
||||
isActiveInstructionMissing,
|
||||
planActiveInstructionStep,
|
||||
shouldResyncSettingsAfterInstructionPush,
|
||||
type ActiveInstructionStep,
|
||||
} from './active-instruction-push-policy'
|
||||
import { isUuid, type PushContext } from './sync-adapters'
|
||||
import { getSyncState, pendingOps, setSyncState } from './sync-outbox'
|
||||
import {
|
||||
remoteToLocalPatch,
|
||||
resolveActiveInstructionPush,
|
||||
|
|
@ -23,6 +31,10 @@ export const SETTINGS_ROW_ID = 'self'
|
|||
export const SYNCED_CONFIG_KEYS = ['language', 'theme', 'defaultLLMAction', 'activeInstructionId'] as const
|
||||
|
||||
const SETTINGS_COLUMNS = 'user_id,locale,theme_mode,auto_polish_enabled,active_instruction_id,revision'
|
||||
/** 서버에 아직 없어 RPC를 미룬 활성 명령 id(sync_state, 사용자 DB별). '' = 없음 */
|
||||
const DEFERRED_ACTIVE_INSTRUCTION_KEY = 'settings:deferredActiveInstruction'
|
||||
|
||||
const logger = getLogger('SettingsSync')
|
||||
|
||||
let applyingRemote = false
|
||||
|
||||
|
|
@ -49,6 +61,25 @@ function userInstructionIds(): Set<string> {
|
|||
)
|
||||
}
|
||||
|
||||
/** outbox에 upsert가 남아 있어 서버에 없을 수 있는 사용자 명령 id */
|
||||
function unsyncedInstructionIds(): Set<string> {
|
||||
const ids = new Set<string>()
|
||||
for (const [id, op] of pendingOps('custom_instructions')) if (op === 'upsert') ids.add(id)
|
||||
return ids
|
||||
}
|
||||
|
||||
function rememberDeferredActiveInstruction(ctx: PushContext, instructionId: string | null): void {
|
||||
// 해제됐거나 사용자 DB가 바뀌었으면 쓰지 않는다(엔진이 곧 중단한다).
|
||||
if (ctx.isCurrent?.() === false) return
|
||||
const next = instructionId ?? ''
|
||||
if ((getSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY) ?? '') !== next) setSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY, next)
|
||||
}
|
||||
|
||||
/** 방금 서버에 올라간 명령 중 설정 push가 미뤄 둔 활성 명령이 있으면 true(설정을 다시 올려야 한다). */
|
||||
export function settingsNeedResyncAfterInstructionPush(pushedInstructionIds: readonly string[]): boolean {
|
||||
return shouldResyncSettingsAfterInstructionPush(getSyncState(DEFERRED_ACTIVE_INSTRUCTION_KEY), pushedInstructionIds)
|
||||
}
|
||||
|
||||
export async function fetchRemoteSettings(ctx: PushContext): Promise<RemoteRow | null> {
|
||||
const rows = await ctx.remote.selectWhere('user_settings', ctx.userId, [], SETTINGS_COLUMNS)
|
||||
return rows[0] ?? null
|
||||
|
|
@ -97,15 +128,35 @@ export async function pushSettings(ctx: PushContext): Promise<SyncRemoteError |
|
|||
typeof remoteActive === 'string' ? remoteActive : null,
|
||||
userInstructionIds()
|
||||
)
|
||||
if (activePush) {
|
||||
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: activePush.instructionId })
|
||||
}
|
||||
await pushActiveInstruction(ctx, planActiveInstructionStep(activePush, unsyncedInstructionIds()))
|
||||
return null
|
||||
} catch (err) {
|
||||
return toSyncRemoteError(err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 명령 하위 단계. 설정 필드는 이미 저장됐으므로, 서버에 없는 명령 때문에 설정 항목 전체가
|
||||
* 재시도에 묶이지 않게 한다(묶이면 다른 기기의 설정 pull도 막힌다).
|
||||
*/
|
||||
async function pushActiveInstruction(ctx: PushContext, step: ActiveInstructionStep): Promise<void> {
|
||||
if (step.kind === 'defer') {
|
||||
rememberDeferredActiveInstruction(ctx, step.instructionId)
|
||||
logger.info('Active instruction push deferred: instruction is not on the server yet')
|
||||
return
|
||||
}
|
||||
if (step.kind === 'rpc') {
|
||||
try {
|
||||
await ctx.remote.rpc('set_active_custom_instruction', { instruction_id: step.instructionId })
|
||||
} catch (err) {
|
||||
const error = toSyncRemoteError(err)
|
||||
if (!isActiveInstructionMissing(error)) throw error
|
||||
logger.warn(`Active instruction push skipped: ${error.message}`)
|
||||
}
|
||||
}
|
||||
rememberDeferredActiveInstruction(ctx, null)
|
||||
}
|
||||
|
||||
/** 원격 설정을 로컬에 반영한다. 바꾼 것이 있으면 true */
|
||||
export function applyRemoteSettings(row: RemoteRow): boolean {
|
||||
const patch = remoteToLocalPatch(row, readLocalSettings(), userInstructionIds())
|
||||
|
|
|
|||
|
|
@ -1001,13 +1001,14 @@ const knowledgeAdapter: SyncAdapter = {
|
|||
async applyRemote(row, ctx) {
|
||||
const id = row.id
|
||||
if (!isUuid(id)) return false
|
||||
const exists = getDatabase().select({ id: ragDocuments.id }).from(ragDocuments).where(eq(ragDocuments.id, id)).get()
|
||||
if (exists) return false
|
||||
// 이미 있는 문서도 청크를 받아 비교한다 — 다른 기기에서 재색인하면 행의 updated_at만 움직이고
|
||||
// 원문이 바뀐다. 같으면 RAGService가 무시하고, 다르면 청크를 바꿔 다시 임베딩한다.
|
||||
const chunkRows = await ctx.remote.selectChildren('knowledge_chunks', 'document_id', id, 'chunk_index,content', 'chunk_index')
|
||||
// 네트워크를 기다리는 사이 로그아웃·계정 전환이 있었으면 다른 사용자 DB에 쓰지 않는다.
|
||||
assertCurrent(ctx)
|
||||
// 다른 기기가 문서 행을 올리고 청크를 아직(또는 일부만) 올렸을 수 있다 — 다음 pull에서 다시 본다.
|
||||
// 일부만 받아 저장하면 이후 pull은 "이미 있음"으로 건너뛰어 잘린 문서가 영구히 남는다.
|
||||
// 청크 업로드는 행 upsert 뒤에 일어나 행의 updated_at을 다시 움직이지 않으므로, 여기서 건너뛰면
|
||||
// (새 문서든 재색인된 문서든) 완성본을 영영 놓친다. 잘린 청크로 로컬을 덮지도 않는다.
|
||||
const chunks = completeKnowledgeChunks(chunkRows, num(row.chunk_count))
|
||||
if (chunks === null) return 'deferred'
|
||||
return getRAGService().applyRemoteDocument({
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ export interface OutboxEntry {
|
|||
* 로컬 변경을 기록한다. 같은 행의 이전 대기 연산은 최신 연산으로 덮는다
|
||||
* (upsert 뒤 delete면 delete만 남는다). version을 올려 진행 중인 push가
|
||||
* 새 변경을 완료 처리하지 못하게 한다.
|
||||
*
|
||||
* 단, 대기 중인 delete 는 뒤늦은 upsert 로 되돌리지 않는다 — 행 id 는 UUID 라 삭제된 행이 같은 id 로
|
||||
* 되살아나지 않는다. 예전엔 삭제 뒤 끝난 자동 제목 생성의 upsert 가 delete 를 덮어, flush 가 "로컬에 없는
|
||||
* upsert" 를 완료로 처리하며 원격 삭제가 영영 나가지 않았다.
|
||||
*/
|
||||
export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now = Date.now()): void {
|
||||
getDatabase()
|
||||
|
|
@ -34,7 +38,7 @@ export function enqueueChange(entity: SyncEntity, rowId: string, op: SyncOp, now
|
|||
.onConflictDoUpdate({
|
||||
target: [syncOutbox.entity, syncOutbox.rowId],
|
||||
set: {
|
||||
op,
|
||||
op: op === 'upsert' ? sql`CASE WHEN ${syncOutbox.op} = 'delete' THEN 'delete' ELSE 'upsert' END` : op,
|
||||
version: sql`${syncOutbox.version} + 1`,
|
||||
queuedAt: now,
|
||||
attempts: 0,
|
||||
|
|
@ -149,6 +153,10 @@ export function setSyncState(key: string, value: string, now = Date.now()): void
|
|||
.run()
|
||||
}
|
||||
|
||||
export function deleteSyncState(key: string): void {
|
||||
getDatabase().delete(syncState).where(eq(syncState.key, key)).run()
|
||||
}
|
||||
|
||||
function toEntry(row: SyncOutboxRow): OutboxEntry {
|
||||
return {
|
||||
entity: row.entity as SyncEntity,
|
||||
|
|
|
|||
245
apps/desktop/src/main/services/voice-command-policy.ts
Normal file
245
apps/desktop/src/main/services/voice-command-policy.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
// src/main/services/voice-command-policy.ts
|
||||
// 음성 단축키의 순수 정책: 기본 키워드 테이블, 키워드 매칭, 저장된 레거시 기본값 이관.
|
||||
// IO(electron-store, logger)에 의존하지 않으므로 표 형태로 단위 테스트한다.
|
||||
|
||||
import type {
|
||||
VoiceCommandRule,
|
||||
VoiceCommandKeyword,
|
||||
KeywordMatchMode
|
||||
} from '@d3ro/core/types'
|
||||
|
||||
// ============================================================
|
||||
// 기본 키워드 (프리셋 명령어용)
|
||||
// ============================================================
|
||||
|
||||
export interface DefaultKeywordEntry {
|
||||
instructionId: string
|
||||
keywords: ReadonlyArray<VoiceCommandKeyword>
|
||||
priority: number
|
||||
}
|
||||
|
||||
function prefixKeywords(words: ReadonlyArray<string>): VoiceCommandKeyword[] {
|
||||
return words.map((keyword) => ({ keyword, matchMode: 'prefix' as const }))
|
||||
}
|
||||
|
||||
/**
|
||||
* 기본 키워드는 명령형(동사형)만 쓴다. '요약', '번역', '설명', 'explain' 같은
|
||||
* 맨 명사는 "요약 보고서 첨부해서 보내드립니다." 같은 일반 받아쓰기 문장의 첫 단어로
|
||||
* 흔히 나오므로, prefix로 매칭하면 받아쓴 문장이 LLM 변환 결과로 바뀌고 첫 단어가 사라진다.
|
||||
* 긴 키워드를 먼저 둔다 (같은 규칙 안에서 앞의 키워드부터 검사한다).
|
||||
*/
|
||||
export const DEFAULT_VOICE_COMMAND_KEYWORDS: ReadonlyArray<DefaultKeywordEntry> = [
|
||||
{
|
||||
instructionId: 'builtin-translate',
|
||||
keywords: prefixKeywords([
|
||||
'영어로 번역해 주세요',
|
||||
'영어로 번역해주세요',
|
||||
'영어로 번역해 줘',
|
||||
'영어로 번역해줘',
|
||||
'번역해 주세요',
|
||||
'번역해주세요',
|
||||
'번역해 줘',
|
||||
'번역해줘',
|
||||
'translate this'
|
||||
]),
|
||||
priority: 0
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-summarize',
|
||||
keywords: prefixKeywords([
|
||||
'요약해 주세요',
|
||||
'요약해주세요',
|
||||
'요약해 줘',
|
||||
'요약해줘',
|
||||
'summarize this'
|
||||
]),
|
||||
priority: 1
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-formal',
|
||||
keywords: prefixKeywords([
|
||||
'다듬어 주세요',
|
||||
'다듬어주세요',
|
||||
'다듬어 줘',
|
||||
'다듬어줘',
|
||||
'polish this'
|
||||
]),
|
||||
priority: 2
|
||||
},
|
||||
{
|
||||
instructionId: 'builtin-explain-code',
|
||||
keywords: prefixKeywords([
|
||||
'설명해 주세요',
|
||||
'설명해주세요',
|
||||
'설명해 줘',
|
||||
'설명해줘',
|
||||
'explain this'
|
||||
]),
|
||||
priority: 3
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 이전 버전이 첫 실행 때 저장한 기본 키워드. 저장소에 이 목록이 그대로 남아 있으면
|
||||
* (사용자가 손대지 않은 기본값) 새 기본값으로 교체한다.
|
||||
*/
|
||||
const LEGACY_DEFAULT_KEYWORDS: Readonly<Record<string, ReadonlyArray<string>>> = {
|
||||
'builtin-translate': ['번역해줘', '번역', '영어로', 'translate'],
|
||||
'builtin-summarize': ['요약해줘', '요약', 'summarize'],
|
||||
'builtin-formal': ['다듬어줘', '다듬기', 'polish'],
|
||||
'builtin-explain-code': ['설명해줘', '설명', 'explain']
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 키워드 매칭
|
||||
// ============================================================
|
||||
|
||||
export interface KeywordMatchResult {
|
||||
matched: boolean
|
||||
cleanedText: string
|
||||
}
|
||||
|
||||
// 공백·구두점 (따옴표/괄호 포함). 키워드 경계 판정에 쓴다.
|
||||
const WORD_BOUNDARY = /[\s,.!?;:'"()[\]{}\-/…~]/
|
||||
// 키워드를 잘라낸 자리에 남는 구분 기호. 여는/닫는 따옴표·괄호는 내용의 일부이므로 남긴다.
|
||||
const LEADING_SEPARATORS = /^[\s,.!?;:\-/…~–—]+/
|
||||
const TRAILING_SEPARATORS = /[\s,;:\-/…~–—]+$/
|
||||
// 변환할 내용이 실제로 있는지: 글자나 숫자가 하나라도 있어야 한다.
|
||||
const HAS_CONTENT = /[\p{L}\p{N}]/u
|
||||
|
||||
export function isWordBoundary(char: string): boolean {
|
||||
return WORD_BOUNDARY.test(char)
|
||||
}
|
||||
|
||||
function noMatch(trimmed: string): KeywordMatchResult {
|
||||
return { matched: false, cleanedText: trimmed }
|
||||
}
|
||||
|
||||
function withContent(cleaned: string, trimmed: string): KeywordMatchResult {
|
||||
// 명령어만 말하고 대상 내용이 없으면 명령으로 보지 않는다 ('요약해줘.' → 원문 그대로 받아쓰기).
|
||||
if (!HAS_CONTENT.test(cleaned)) return noMatch(trimmed)
|
||||
return { matched: true, cleanedText: cleaned }
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트에서 키워드를 매칭하고, 매칭된 키워드를 제거한 정리된 텍스트를 반환한다.
|
||||
* 키워드 앞뒤의 공백/구두점 경계를 존중하고, 키워드를 뺀 뒤 변환할 내용이 남아야 매칭으로 본다.
|
||||
*/
|
||||
export function matchKeyword(
|
||||
text: string,
|
||||
keyword: string,
|
||||
mode: KeywordMatchMode
|
||||
): KeywordMatchResult {
|
||||
const trimmed = text.trim()
|
||||
const lowerText = trimmed.toLowerCase()
|
||||
const lowerKeyword = keyword.toLowerCase()
|
||||
|
||||
if (lowerKeyword.length === 0 || lowerKeyword.length !== keyword.length) {
|
||||
// 빈 키워드, 또는 소문자 변환으로 길이가 바뀌는 키워드는 오프셋을 신뢰할 수 없다
|
||||
return noMatch(trimmed)
|
||||
}
|
||||
const keywordLength = lowerKeyword.length
|
||||
|
||||
switch (mode) {
|
||||
case 'prefix': {
|
||||
if (!lowerText.startsWith(lowerKeyword)) return noMatch(trimmed)
|
||||
const afterKeyword = trimmed.charAt(keywordLength)
|
||||
if (afterKeyword !== '' && !isWordBoundary(afterKeyword)) return noMatch(trimmed)
|
||||
const cleaned = trimmed.slice(keywordLength).replace(LEADING_SEPARATORS, '')
|
||||
return withContent(cleaned, trimmed)
|
||||
}
|
||||
|
||||
case 'suffix': {
|
||||
if (!lowerText.endsWith(lowerKeyword)) return noMatch(trimmed)
|
||||
const beforeKeyword = trimmed.charAt(trimmed.length - keywordLength - 1)
|
||||
if (beforeKeyword !== '' && !isWordBoundary(beforeKeyword)) return noMatch(trimmed)
|
||||
const cleaned = trimmed
|
||||
.slice(0, trimmed.length - keywordLength)
|
||||
.replace(TRAILING_SEPARATORS, '')
|
||||
return withContent(cleaned, trimmed)
|
||||
}
|
||||
|
||||
case 'contains': {
|
||||
const index = lowerText.indexOf(lowerKeyword)
|
||||
if (index === -1) return noMatch(trimmed)
|
||||
// contains 모드에서는 경계 검사 없이 첫 번째 매칭만 제거
|
||||
const before = trimmed.slice(0, index)
|
||||
const after = trimmed.slice(index + keywordLength)
|
||||
const cleaned = (before + after).replace(/\s{2,}/g, ' ').trim()
|
||||
return withContent(cleaned, trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export interface RuleMatch {
|
||||
rule: VoiceCommandRule
|
||||
keyword: string
|
||||
cleanedText: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 활성 규칙을 priority 오름차순(0이 가장 높음)으로 검사해 첫 매칭을 돌려준다.
|
||||
*/
|
||||
export function findRuleMatch(
|
||||
text: string,
|
||||
rules: ReadonlyArray<VoiceCommandRule>
|
||||
): RuleMatch | null {
|
||||
const trimmed = text.trim()
|
||||
if (trimmed.length === 0) return null
|
||||
|
||||
const sortedRules = rules
|
||||
.filter((r) => r.enabled && r.keywords.length > 0)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
|
||||
for (const rule of sortedRules) {
|
||||
for (const kw of rule.keywords) {
|
||||
const result = matchKeyword(trimmed, kw.keyword, kw.matchMode)
|
||||
if (result.matched) {
|
||||
return { rule, keyword: kw.keyword, cleanedText: result.cleanedText }
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// 기본값 생성 / 레거시 이관
|
||||
// ============================================================
|
||||
|
||||
export function buildDefaultRules(createId: () => string): VoiceCommandRule[] {
|
||||
return DEFAULT_VOICE_COMMAND_KEYWORDS.map((entry) => ({
|
||||
id: createId(),
|
||||
instructionId: entry.instructionId,
|
||||
keywords: entry.keywords.map((kw) => ({ ...kw })),
|
||||
enabled: true,
|
||||
priority: entry.priority
|
||||
}))
|
||||
}
|
||||
|
||||
function isUntouchedLegacyDefault(rule: VoiceCommandRule): boolean {
|
||||
const legacy = LEGACY_DEFAULT_KEYWORDS[rule.instructionId]
|
||||
if (!legacy || rule.keywords.length !== legacy.length) return false
|
||||
return rule.keywords.every(
|
||||
(kw, i) => kw.matchMode === 'prefix' && kw.keyword === legacy[i]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이전 버전이 저장한, 사용자가 손대지 않은 맨 명사 기본 키워드를 새 명령형 기본값으로 바꾼다.
|
||||
* 사용자가 IPC로 바꾼 키워드는 그대로 둔다. migrated는 교체한 규칙 수다.
|
||||
*/
|
||||
export function migrateLegacyDefaultRules(
|
||||
rules: ReadonlyArray<VoiceCommandRule>
|
||||
): { rules: VoiceCommandRule[]; migrated: number } {
|
||||
let migrated = 0
|
||||
const next = rules.map((rule) => {
|
||||
if (!isUntouchedLegacyDefault(rule)) return rule
|
||||
const entry = DEFAULT_VOICE_COMMAND_KEYWORDS.find(
|
||||
(e) => e.instructionId === rule.instructionId
|
||||
)
|
||||
if (!entry) return rule
|
||||
migrated++
|
||||
return { ...rule, keywords: entry.keywords.map((kw) => ({ ...kw })) }
|
||||
})
|
||||
return { rules: next, migrated }
|
||||
}
|
||||
|
|
@ -217,7 +217,17 @@ export function isWithinRollout(
|
|||
return rolloutBucket(deviceId, version) < percentage
|
||||
}
|
||||
|
||||
/** 알 수 없는 JSON을 검증·정규화해 정책으로 만든다. 실패 필드는 기본값 유지. */
|
||||
/**
|
||||
* 알 수 없는 JSON을 검증·정규화해 정책으로 만든다.
|
||||
*
|
||||
* 없는 필드와 형식이 틀린 일반 필드는 기본값을 유지한다. 단 안전 필드는 값이 있는데
|
||||
* 형식이 틀리면 가장 보수적인 값으로 읽는다(fail-closed) — 예전에는 `"killSwitch": "true"`
|
||||
* 나 `"stagingPercentage": "5"` 같은 오타가 킬 스위치 꺼짐·100% 배포로 조용히 바뀌어
|
||||
* 운영자 의도와 정반대로 동작했다.
|
||||
* - killSwitch : boolean 이 아니면 true (업데이트 중단)
|
||||
* - stagingPercentage : 정수가 아니면 0 (아무에게도 배포하지 않음; 필수 업데이트는 무관)
|
||||
* 게시 전 엄격한 검증은 scripts/ci/lib/update-policy-schema.mjs 가 맡는다.
|
||||
*/
|
||||
export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
|
||||
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return DEFAULT_UPDATE_POLICY
|
||||
const record = raw as Record<string, unknown>
|
||||
|
|
@ -244,8 +254,14 @@ export function parseUpdatePolicy(raw: unknown): UpdatePolicy {
|
|||
}
|
||||
if (Number.isSafeInteger(record.stagingPercentage)) {
|
||||
policy.stagingPercentage = Math.min(100, Math.max(0, record.stagingPercentage as number))
|
||||
} else if (record.stagingPercentage !== undefined) {
|
||||
policy.stagingPercentage = 0
|
||||
}
|
||||
if (typeof record.killSwitch === 'boolean') {
|
||||
policy.killSwitch = record.killSwitch
|
||||
} else if (record.killSwitch !== undefined) {
|
||||
policy.killSwitch = true
|
||||
}
|
||||
if (typeof record.killSwitch === 'boolean') policy.killSwitch = record.killSwitch
|
||||
|
||||
if (record.channels && typeof record.channels === 'object' && !Array.isArray(record.channels)) {
|
||||
const channels = record.channels as Record<string, unknown>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
// VoiceMode session-completed → 결과 라우팅(템플릿 필드 등) + History 저장.
|
||||
// bootstrap과 테스트가 같은 함수를 호출한다.
|
||||
|
||||
import { join } from 'path'
|
||||
import { app } from 'electron'
|
||||
import { D3ROError, ErrorCode } from '@d3ro/core/errors'
|
||||
import { getHistoryService } from './services/HistoryService'
|
||||
|
|
@ -19,12 +18,16 @@ export interface PersistableVoiceSession {
|
|||
startedAt: number
|
||||
}
|
||||
|
||||
/**
|
||||
* @param audioPath VoiceMode 가 완료 전에 실제로 저장한 녹음 경로(RecordingStore.save 결과).
|
||||
* 오디오가 없거나 저장에 실패했으면 null — 존재하지 않는 경로를 히스토리에 남기지 않는다.
|
||||
*/
|
||||
export function persistCompletedVoiceSession(
|
||||
session: PersistableVoiceSession,
|
||||
finalText: string,
|
||||
audioPath: string | null = null,
|
||||
): HistoryEntry {
|
||||
const wordCount = finalText.split(/\s+/).filter((w) => w.length > 0).length
|
||||
const audioPath = join(app.getPath('userData'), 'recordings', `${session.id}.wav`)
|
||||
const mode = session.mode === 'hands-free' ? 'dictation' : session.mode
|
||||
|
||||
return getHistoryService().create({
|
||||
|
|
@ -51,11 +54,12 @@ export function persistCompletedVoiceSessionSafe(
|
|||
emitError: (error: D3ROError, session: PersistableVoiceSession) => void,
|
||||
session: PersistableVoiceSession,
|
||||
finalText: string,
|
||||
audioPath: string | null = null,
|
||||
): HistoryEntry | null {
|
||||
// 히스토리 저장이 실패해도 결과는 소비자에게 전달돼야 하므로 먼저 라우팅한다
|
||||
routeCompletedVoiceSession(session, finalText)
|
||||
try {
|
||||
return persistCompletedVoiceSession(session, finalText)
|
||||
return persistCompletedVoiceSession(session, finalText, audioPath)
|
||||
} catch (error) {
|
||||
const d3ro =
|
||||
error instanceof D3ROError
|
||||
|
|
|
|||
23
apps/desktop/src/main/windows/app-menu.ts
Normal file
23
apps/desktop/src/main/windows/app-menu.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// src/main/windows/app-menu.ts
|
||||
// 애플리케이션 메뉴 정책 — 플랫폼별로 어떤 메뉴를 둘지 결정한다.
|
||||
//
|
||||
// Win/Linux: 보더리스 창 + 커스텀 TitleBar 라 메뉴가 없어야 한다(Alt 키로 숨은 메뉴가 활성화되지 않게).
|
||||
// macOS: Cmd+C/V/X/A/Z 와 Cmd+Q 는 애플리케이션 메뉴의 role 항목을 거쳐서만 동작한다. 메뉴를 null 로
|
||||
// 지우면 입력란의 복사·붙여넣기·실행 취소와 종료 단축키가 모두 죽으므로 role 기반 최소 메뉴를 둔다.
|
||||
// role 메뉴의 라벨은 Electron/OS 기본값을 쓴다(앱 코드에 하드코딩 문구 없음).
|
||||
|
||||
import type { Menu, MenuItemConstructorOptions } from 'electron'
|
||||
|
||||
/** 플랫폼에 맞는 애플리케이션 메뉴 템플릿. null 이면 메뉴를 두지 않는다. */
|
||||
export function buildApplicationMenuTemplate(platform: NodeJS.Platform): MenuItemConstructorOptions[] | null {
|
||||
if (platform !== 'darwin') return null
|
||||
return [{ role: 'appMenu' }, { role: 'editMenu' }, { role: 'windowMenu' }]
|
||||
}
|
||||
|
||||
/** Menu 정적 API 중 이 정책이 쓰는 부분 — 테스트에서 가짜로 바꾼다 */
|
||||
export type ApplicationMenuApi = Pick<typeof Menu, 'buildFromTemplate' | 'setApplicationMenu'>
|
||||
|
||||
export function installApplicationMenu(menuApi: ApplicationMenuApi, platform: NodeJS.Platform): void {
|
||||
const template = buildApplicationMenuTemplate(platform)
|
||||
menuApi.setApplicationMenu(template ? menuApi.buildFromTemplate(template) : null)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue