diff --git a/src/bridge/featureBridge.js b/src/bridge/featureBridge.js index a323736..0ddd514 100644 --- a/src/bridge/featureBridge.js +++ b/src/bridge/featureBridge.js @@ -57,8 +57,10 @@ module.exports = { ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(event, force)); // Ask - ipcMain.handle('ask:sendMessage', async (event, userPrompt, conversationHistoryRaw = []) => await askService.sendMessage(userPrompt, conversationHistoryRaw)); - + ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt)); + ipcMain.handle('ask:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt)); + ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton()); + // Listen ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => await listenService.handleSendAudioContent(data, mimeType)); ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => { @@ -71,22 +73,6 @@ module.exports = { ipcMain.handle('start-macos-audio', async () => await listenService.handleStartMacosAudio()); ipcMain.handle('stop-macos-audio', async () => await listenService.handleStopMacosAudio()); ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled)); - - // ModelStateService - ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); - ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys()); - ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key)); - ipcMain.handle('model:remove-api-key', async (e, { provider }) => await modelStateService.handleRemoveApiKey(provider)); - ipcMain.handle('model:get-selected-models', () => modelStateService.getSelectedModels()); - ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId)); - ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type)); - ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); - ipcMain.handle('model:get-current-model-info', (e, { type }) => modelStateService.getCurrentModelInfo(type)); - ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); - - console.log('[FeatureBridge] Initialized with all feature handlers.'); - - ipcMain.handle('listen:changeSession', async (event, listenButtonText) => { console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText); try { @@ -100,6 +86,21 @@ module.exports = { + // ModelStateService + ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key)); + ipcMain.handle('model:get-all-keys', () => modelStateService.getAllApiKeys()); + ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key)); + ipcMain.handle('model:remove-api-key', async (e, { provider }) => await modelStateService.handleRemoveApiKey(provider)); + ipcMain.handle('model:get-selected-models', () => modelStateService.getSelectedModels()); + ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId)); + ipcMain.handle('model:get-available-models', (e, { type }) => modelStateService.getAvailableModels(type)); + ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured()); + ipcMain.handle('model:get-current-model-info', (e, { type }) => modelStateService.getCurrentModelInfo(type)); + ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig()); + + + + console.log('[FeatureBridge] Initialized with all feature handlers.'); }, // Renderer로 상태를 전송 diff --git a/src/features/ask/askService.js b/src/features/ask/askService.js index 537243a..c0d086f 100644 --- a/src/features/ask/askService.js +++ b/src/features/ask/askService.js @@ -6,37 +6,60 @@ const askRepository = require('./repositories'); const { getSystemPrompt } = require('../common/prompts/promptBuilder'); /** - * @class AskService - * @description 사용자의 질문을 처리하고 AI 모델과 통신하여 응답을 스트리밍하는 모든 로직을 캡슐화합니다. + * @class + * @description */ class AskService { - /** - * AskService의 인스턴스를 생성합니다. - */ constructor() { + this.abortController = null; console.log('[AskService] Service instance created.'); } + async toggleAskButton() { + const { windowPool, updateLayout } = require('../../window/windowManager'); + const askWindow = windowPool.get('ask'); + const header = windowPool.get('header'); + try { + if (askWindow.isVisible()) { + askWindow.webContents.send('window-hide-animation'); + } else { + console.log('[AskService] Showing hidden Ask window'); + askWindow.show(); + updateLayout(); + askWindow.webContents.send('window-show-animation'); + } + } catch (error) { + console.error('[AskService] error in toggleAskButton:', error); + throw error; + } + } + + /** - * 대화 기록 배열을 프롬프트에 적합한 단일 문자열로 변환합니다. - * @param {string[]} conversationTexts - 대화 내용 문자열의 배열 - * @returns {string} 프롬프트에 사용될 형식의 대화 기록 + * + * @param {string[]} conversationTexts + * @returns {string} * @private */ _formatConversationForPrompt(conversationTexts) { if (!conversationTexts || conversationTexts.length === 0) { return 'No conversation history available.'; } - // 최근 30개의 대화만 사용 return conversationTexts.slice(-30).join('\n'); } /** - * 사용자의 프롬프트를 받아 AI 모델에 전송하고, 응답을 스트리밍으로 처리합니다. - * @param {string} userPrompt - 사용자가 입력한 질문 또는 메시지 - * @returns {Promise<{success: boolean, response?: string, error?: string}>} 처리 결과 객체 + * + * @param {string} userPrompt + * @returns {Promise<{success: boolean, response?: string, error?: string}>} */ async sendMessage(userPrompt, conversationHistoryRaw=[]) { + if (this.abortController) { + this.abortController.abort('New request received.'); + } + this.abortController = new AbortController(); + const { signal } = this.abortController; + if (!userPrompt || userPrompt.trim().length === 0) { console.warn('[AskService] Cannot process empty message'); return { success: false, error: 'Empty message' }; @@ -47,7 +70,6 @@ class AskService { try { console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); - // --- 사용자 메시지 저장 --- sessionId = await sessionRepository.getOrCreateActive('ask'); await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() }); console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`); @@ -61,7 +83,6 @@ class AskService { const screenshotResult = await captureScreenshot({ quality: 'medium' }); const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null; - // const conversationHistoryRaw = this._getConversationHistory(); const conversationHistory = this._formatConversationForPrompt(conversationHistoryRaw); const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false); @@ -101,14 +122,22 @@ class AskService { return { success: false, error: 'Ask window is not available.' }; } - // --- 스트림 처리 --- - await this._processStream(response.body, askWin, sessionId); + const reader = response.body.getReader(); + signal.addEventListener('abort', () => { + console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`); + reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ }); + }); - // _processStream 내부에서 전체 응답이 완료되면 반환됩니다. - // 하지만 비동기 스트림의 특성상 이 지점에서는 직접 반환 값을 알기 어렵습니다. - // 성공/실패 여부는 스트림 처리 로직 내에서 결정됩니다. + await this._processStream(reader, askWin, sessionId, signal); + + return { success: true }; } catch (error) { + if (error.name === 'AbortError') { + console.log('[AskService] SendMessage operation was successfully aborted.'); + return { success: true, response: 'Cancelled' }; + } + console.error('[AskService] Error processing message:', error); const askWin = windowPool.get('ask'); if (askWin && !askWin.isDestroyed()) { @@ -119,18 +148,17 @@ class AskService { } /** - * AI 모델로부터 받은 응답 스트림을 처리합니다. - * @param {ReadableStream} body - 스트리밍 응답의 body - * @param {BrowserWindow} askWin - 응답을 보낼 대상 창 - * @param {number} sessionId - 현재 세션 ID + * + * @param {ReadableStreamDefaultReader} reader + * @param {BrowserWindow} askWin + * @param {number} sessionId + * @param {AbortSignal} signal * @returns {Promise} * @private */ - async _processStream(body, askWin, sessionId) { - const reader = body.getReader(); + async _processStream(reader, askWin, sessionId, signal) { const decoder = new TextDecoder(); let fullResponse = ''; - let finalResult = { success: false }; // 최종 결과 저장을 위한 변수 try { while (true) { @@ -144,13 +172,9 @@ class AskService { if (line.startsWith('data: ')) { const data = line.substring(6); if (data === '[DONE]') { - askWin.webContents.send('ask-response-stream-end'); - - await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); - console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`); - - // 스트림이 성공적으로 완료되었으므로, 최종 결과를 성공으로 설정합니다. - // 실제 반환은 sendMessage에서 이루어지지만, 로직상의 완료를 의미합니다. + if (askWin && !askWin.isDestroyed()) { + askWin.webContents.send('ask-response-stream-end'); + } return; } try { @@ -158,35 +182,37 @@ class AskService { const token = json.choices[0]?.delta?.content || ''; if (token) { fullResponse += token; - askWin.webContents.send('ask-response-chunk', { token }); + if (askWin && !askWin.isDestroyed()) { + askWin.webContents.send('ask-response-chunk', { token }); + } } } catch (error) { - // JSON 파싱 오류는 무시하고 계속 진행 } } } } } catch (streamError) { - console.error('[AskService] Error while processing stream:', streamError); - askWin.webContents.send('ask-response-stream-error', { error: streamError.message }); - // 스트림 처리 중 에러가 발생했음을 기록 + if (signal.aborted) { + console.log(`[AskService] Stream reading was intentionally cancelled. Reason: ${signal.reason}`); + } else { + console.error('[AskService] Error while processing stream:', streamError); + if (askWin && !askWin.isDestroyed()) { + askWin.webContents.send('ask-response-stream-error', { error: streamError.message }); + } + } } finally { - // 스트림이 정상적으로 [DONE]을 받지 못하고 종료된 경우에도 - // 현재까지의 응답이라도 저장 시도 if (fullResponse) { try { await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); - console.log(`[AskService] DB: Saved partial assistant response to session ${sessionId} after stream interruption.`); + console.log(`[AskService] DB: Saved partial or full assistant response to session ${sessionId} after stream ended.`); } catch(dbError) { - console.error("[AskService] DB: Failed to save assistant response after stream interruption:", dbError); + console.error("[AskService] DB: Failed to save assistant response after stream ended:", dbError); } } } } } -// AskService 클래스의 단일 인스턴스를 생성하여 내보냅니다. -// 이렇게 하면 애플리케이션 전체에서 동일한 서비스 인스턴스를 공유하게 됩니다. const askService = new AskService(); module.exports = askService; \ No newline at end of file diff --git a/src/preload.js b/src/preload.js index abf87f8..9ea0ac2 100644 --- a/src/preload.js +++ b/src/preload.js @@ -131,7 +131,7 @@ contextBridge.exposeInMainWorld('api', { adjustWindowHeight: (height) => ipcRenderer.invoke('adjust-window-height', height), // Message Handling - sendMessage: (text) => ipcRenderer.invoke('ask:sendMessage', text), + sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text), // Listeners onSendQuestionToRenderer: (callback) => ipcRenderer.on('ask:sendQuestionToRenderer', callback), @@ -170,7 +170,7 @@ contextBridge.exposeInMainWorld('api', { // src/ui/listen/summary/SummaryView.js summaryView: { // Message Handling - sendQuestionToMain: (text) => ipcRenderer.invoke('ask:sendQuestionToMain', text), + sendQuestionFromSummary: (text) => ipcRenderer.invoke('ask:sendQuestionFromSummary', text), // Listeners onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback), diff --git a/src/ui/app/MainHeader.js b/src/ui/app/MainHeader.js index 4ce15e5..1ace9ea 100644 --- a/src/ui/app/MainHeader.js +++ b/src/ui/app/MainHeader.js @@ -568,6 +568,17 @@ export class MainHeader extends LitElement { } } + async _handleAskClick() { + if (this.wasJustDragged) return; + + try { + const channel = 'ask:toggleAskButton'; + await this.invoke(channel); + } catch (error) { + console.error('IPC invoke for ask button failed:', error); + } + } + renderShortcut(accelerator) { if (!accelerator) return html``; @@ -636,7 +647,7 @@ export class MainHeader extends LitElement { `} -
this.invoke('toggle-feature', 'ask')}> +
this._handleAskClick()}>
Ask
diff --git a/src/ui/listen/summary/SummaryView.js b/src/ui/listen/summary/SummaryView.js index 4c773cd..6ab80a0 100644 --- a/src/ui/listen/summary/SummaryView.js +++ b/src/ui/listen/summary/SummaryView.js @@ -408,7 +408,7 @@ export class SummaryView extends LitElement { if (window.api) { try { - const result = await window.api.summaryView.sendQuestionToMain(requestText); + const result = await window.api.summaryView.sendQuestionFromSummary(requestText); if (result.success) { console.log('✅ Question sent to AskView successfully'); diff --git a/src/window/windowManager.js b/src/window/windowManager.js index 5be986e..08a8481 100644 --- a/src/window/windowManager.js +++ b/src/window/windowManager.js @@ -887,52 +887,13 @@ function setupIpcHandlers(movementManager) { }); - ipcMain.handle('ask:sendQuestionToMain', (event, question) => { - console.log('📨 Main process: Sending question to AskView', question); - toggleFeature('ask', {ask: { questionText: question }}); - return { success: true }; - }); - - // ipcMain.handle('listen:changeSession', async (event, actionText) => { - // console.log('📨 Main process: Received actionText', actionText); - // const header = windowPool.get('header'); - // const listenWindow = windowPool.get('listen'); - - // try { - // if (listenService && listenService.isSessionActive()) { - // console.log('[WindowManager] Listen session is active, closing it.'); - // // ✨ closeSession도 비동기일 수 있으므로 await 처리 (만약 동기 함수라면 await는 무시됨) - // await listenService.closeSession(); - // listenWindow.webContents.send('session-state-changed', { isActive: false }); - // } else { - // if (listenWindow.isVisible()) { - // listenWindow.webContents.send('window-hide-animation'); - // listenWindow.webContents.send('session-state-changed', { isActive: false }); - // } else { - // listenWindow.show(); - // updateLayout(); - // listenWindow.webContents.send('window-show-animation'); - - // // ✨ 핵심: initializeSession 작업이 끝날 때까지 기다림 - // await listenService.initializeSession(); - - // listenWindow.webContents.send('session-state-changed', { isActive: true }); - // } - // } - - // // ✨ 모든 비동기 작업이 성공적으로 끝난 후 결과 전송 - // header.webContents.send('listen:changeSessionResult', { success: true }); - // return { success: true }; - - // } catch (error) { - // console.error('[WindowManager] Failed to change listen session:', error); - - // // ✨ 작업 실패 시 UI에 실패 결과를 알려 로딩 상태를 해제하도록 함 - // header.webContents.send('listen:changeSessionResult', { success: false }); - // return { success: false, error: error.message }; - // } + // ipcMain.handle('ask:sendQuestionToMain', (event, question) => { + // console.log('📨 Main process: Sending question to AskView', question); + // toggleFeature('ask', {ask: { questionText: question }}); + // return { success: true }; // }); + } @@ -950,33 +911,6 @@ async function toggleFeature(featureName, options = {}) { createFeatureWindows(windowPool.get('header')); } - const header = windowPool.get('header'); - // if (featureName === 'listen') { - // console.log(`[WindowManager] Toggling feature: ${featureName}`); - // const listenWindow = windowPool.get(featureName); - // // const listenService = global.listenService; - // if (listenService && listenService.isSessionActive()) { - // console.log('[WindowManager] Listen session is active, closing it via toggle.'); - // await listenService.closeSession(); - // listenWindow.webContents.send('session-state-changed', { isActive: false }); - // header.webContents.send('session-state-text', 'Done'); - // // return; - // } else { - // if (listenWindow.isVisible()) { - // listenWindow.webContents.send('window-hide-animation'); - // listenWindow.webContents.send('session-state-changed', { isActive: false }); - // header.webContents.send('session-state-text', 'Listen'); - // } else { - // listenWindow.show(); - // updateLayout(); - // listenWindow.webContents.send('window-show-animation'); - // await listenService.initializeSession(); - // listenWindow.webContents.send('session-state-changed', { isActive: true }); - // header.webContents.send('session-state-text', 'Stop'); - // } - // } - // } - if (featureName === 'ask') { let askWindow = windowPool.get('ask');