change askservice to class
This commit is contained in:
parent
27f6f0e68e
commit
b5b6f40995
@ -1,145 +1,203 @@
|
||||
const { ipcMain, BrowserWindow } = require('electron');
|
||||
const { createStreamingLLM } = require('../common/ai/factory');
|
||||
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../window/windowManager');
|
||||
const authService = require('../common/services/authService');
|
||||
const { getCurrentModelInfo, windowPool, captureScreenshot } = require('../../window/windowManager');
|
||||
const sessionRepository = require('../common/repositories/session');
|
||||
const askRepository = require('./repositories');
|
||||
const { getSystemPrompt } = require('../common/prompts/promptBuilder');
|
||||
|
||||
function formatConversationForPrompt(conversationTexts) {
|
||||
if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.';
|
||||
return conversationTexts.slice(-30).join('\n');
|
||||
}
|
||||
|
||||
// Access conversation history via the global listenService instance created in index.js
|
||||
function getConversationHistory() {
|
||||
const listenService = global.listenService;
|
||||
return listenService ? listenService.getConversationHistory() : [];
|
||||
}
|
||||
|
||||
async function sendMessage(userPrompt) {
|
||||
if (!userPrompt || userPrompt.trim().length === 0) {
|
||||
console.warn('[AskService] Cannot process empty message');
|
||||
return { success: false, error: 'Empty message' };
|
||||
/**
|
||||
* @class AskService
|
||||
* @description 사용자의 질문을 처리하고 AI 모델과 통신하여 응답을 스트리밍하는 모든 로직을 캡슐화합니다.
|
||||
*/
|
||||
class AskService {
|
||||
/**
|
||||
* AskService의 인스턴스를 생성합니다.
|
||||
*/
|
||||
constructor() {
|
||||
console.log('[AskService] Service instance created.');
|
||||
}
|
||||
|
||||
let sessionId;
|
||||
|
||||
try {
|
||||
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
|
||||
|
||||
// --- Save user's message immediately ---
|
||||
// This ensures the user message is always timestamped before the assistant's response.
|
||||
sessionId = await sessionRepository.getOrCreateActive('ask');
|
||||
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
|
||||
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
|
||||
// --- End of user message saving ---
|
||||
|
||||
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
|
||||
if (!modelInfo || !modelInfo.apiKey) {
|
||||
throw new Error('AI model or API key not configured.');
|
||||
}
|
||||
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
|
||||
|
||||
const screenshotResult = await captureScreenshot({ quality: 'medium' });
|
||||
const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
|
||||
|
||||
const conversationHistoryRaw = getConversationHistory();
|
||||
const conversationHistory = formatConversationForPrompt(conversationHistoryRaw);
|
||||
|
||||
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
|
||||
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (screenshotBase64) {
|
||||
messages[1].content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
|
||||
});
|
||||
}
|
||||
|
||||
const streamingLLM = createStreamingLLM(modelInfo.provider, {
|
||||
apiKey: modelInfo.apiKey,
|
||||
model: modelInfo.model,
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
usePortkey: modelInfo.provider === 'openai-glass',
|
||||
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
|
||||
/**
|
||||
* IPC 리스너를 등록하여 렌더러 프로세스로부터의 요청을 처리합니다.
|
||||
* Electron 애플리케이션의 메인 프로세스에서 한 번만 호출되어야 합니다.
|
||||
*/
|
||||
initialize() {
|
||||
ipcMain.handle('ask:sendMessage', async (event, userPrompt, conversationHistoryRaw=[]) => {
|
||||
return this.sendMessage(userPrompt, conversationHistoryRaw);
|
||||
});
|
||||
console.log('[AskService] Initialized and ready.');
|
||||
}
|
||||
|
||||
const response = await streamingLLM.streamChat(messages);
|
||||
/**
|
||||
* 대화 기록 배열을 프롬프트에 적합한 단일 문자열로 변환합니다.
|
||||
* @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');
|
||||
}
|
||||
|
||||
// --- Stream Processing ---
|
||||
const reader = response.body.getReader();
|
||||
/**
|
||||
* 사용자의 프롬프트를 받아 AI 모델에 전송하고, 응답을 스트리밍으로 처리합니다.
|
||||
* @param {string} userPrompt - 사용자가 입력한 질문 또는 메시지
|
||||
* @returns {Promise<{success: boolean, response?: string, error?: string}>} 처리 결과 객체
|
||||
*/
|
||||
async sendMessage(userPrompt, conversationHistoryRaw=[]) {
|
||||
if (!userPrompt || userPrompt.trim().length === 0) {
|
||||
console.warn('[AskService] Cannot process empty message');
|
||||
return { success: false, error: 'Empty message' };
|
||||
}
|
||||
|
||||
let sessionId;
|
||||
|
||||
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}`);
|
||||
|
||||
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
|
||||
if (!modelInfo || !modelInfo.apiKey) {
|
||||
throw new Error('AI model or API key not configured.');
|
||||
}
|
||||
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
|
||||
|
||||
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);
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
if (screenshotBase64) {
|
||||
messages[1].content.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
|
||||
});
|
||||
}
|
||||
|
||||
const streamingLLM = createStreamingLLM(modelInfo.provider, {
|
||||
apiKey: modelInfo.apiKey,
|
||||
model: modelInfo.model,
|
||||
temperature: 0.7,
|
||||
maxTokens: 2048,
|
||||
usePortkey: modelInfo.provider === 'openai-glass',
|
||||
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
|
||||
});
|
||||
|
||||
const response = await streamingLLM.streamChat(messages);
|
||||
const askWin = windowPool.get('ask');
|
||||
|
||||
if (!askWin || askWin.isDestroyed()) {
|
||||
console.error("[AskService] Ask window is not available to send stream to.");
|
||||
response.body.getReader().cancel();
|
||||
return { success: false, error: 'Ask window is not available.' };
|
||||
}
|
||||
|
||||
// --- 스트림 처리 ---
|
||||
await this._processStream(response.body, askWin, sessionId);
|
||||
|
||||
// _processStream 내부에서 전체 응답이 완료되면 반환됩니다.
|
||||
// 하지만 비동기 스트림의 특성상 이 지점에서는 직접 반환 값을 알기 어렵습니다.
|
||||
// 성공/실패 여부는 스트림 처리 로직 내에서 결정됩니다.
|
||||
|
||||
} catch (error) {
|
||||
console.error('[AskService] Error processing message:', error);
|
||||
const askWin = windowPool.get('ask');
|
||||
if (askWin && !askWin.isDestroyed()) {
|
||||
askWin.webContents.send('ask-response-stream-error', { error: error.message });
|
||||
}
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 모델로부터 받은 응답 스트림을 처리합니다.
|
||||
* @param {ReadableStream} body - 스트리밍 응답의 body
|
||||
* @param {BrowserWindow} askWin - 응답을 보낼 대상 창
|
||||
* @param {number} sessionId - 현재 세션 ID
|
||||
* @returns {Promise<void>}
|
||||
* @private
|
||||
*/
|
||||
async _processStream(body, askWin, sessionId) {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullResponse = '';
|
||||
let finalResult = { success: false }; // 최종 결과 저장을 위한 변수
|
||||
|
||||
const askWin = windowPool.get('ask');
|
||||
if (!askWin || askWin.isDestroyed()) {
|
||||
console.error("[AskService] Ask window is not available to send stream to.");
|
||||
reader.cancel();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.substring(6);
|
||||
if (data === '[DONE]') {
|
||||
askWin.webContents.send('ask-response-stream-end');
|
||||
|
||||
// Save assistant's message to DB
|
||||
try {
|
||||
// sessionId is already available from when we saved the user prompt
|
||||
for (const line of lines) {
|
||||
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}`);
|
||||
} catch(dbError) {
|
||||
console.error("[AskService] DB: Failed to save assistant response:", dbError);
|
||||
|
||||
// 스트림이 성공적으로 완료되었으므로, 최종 결과를 성공으로 설정합니다.
|
||||
// 실제 반환은 sendMessage에서 이루어지지만, 로직상의 완료를 의미합니다.
|
||||
return;
|
||||
}
|
||||
|
||||
return { success: true, response: fullResponse };
|
||||
}
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const token = json.choices[0]?.delta?.content || '';
|
||||
if (token) {
|
||||
fullResponse += token;
|
||||
askWin.webContents.send('ask-response-chunk', { token });
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const token = json.choices[0]?.delta?.content || '';
|
||||
if (token) {
|
||||
fullResponse += token;
|
||||
askWin.webContents.send('ask-response-chunk', { token });
|
||||
}
|
||||
} catch (error) {
|
||||
// JSON 파싱 오류는 무시하고 계속 진행
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore parsing errors for now
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (streamError) {
|
||||
console.error('[AskService] Error while processing stream:', streamError);
|
||||
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.`);
|
||||
} catch(dbError) {
|
||||
console.error("[AskService] DB: Failed to save assistant response after stream interruption:", dbError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AskService] Error processing message:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
ipcMain.handle('ask:sendMessage', async (event, userPrompt) => {
|
||||
return sendMessage(userPrompt);
|
||||
});
|
||||
console.log('[AskService] Initialized and ready.');
|
||||
}
|
||||
// AskService 클래스의 단일 인스턴스를 생성하여 내보냅니다.
|
||||
// 이렇게 하면 애플리케이션 전체에서 동일한 서비스 인스턴스를 공유하게 됩니다.
|
||||
const askService = new AskService();
|
||||
|
||||
module.exports = {
|
||||
initialize,
|
||||
};
|
||||
module.exports = askService;
|
@ -1,4 +1,4 @@
|
||||
const { BrowserWindow, app } = require('electron');
|
||||
const { ipcMain, BrowserWindow } = require('electron');
|
||||
const SttService = require('./stt/sttService');
|
||||
const SummaryService = require('./summary/summaryService');
|
||||
const authService = require('../common/services/authService');
|
||||
@ -11,8 +11,9 @@ class ListenService {
|
||||
this.summaryService = new SummaryService();
|
||||
this.currentSessionId = null;
|
||||
this.isInitializingSession = false;
|
||||
|
||||
|
||||
this.setupServiceCallbacks();
|
||||
console.log('[ListenService] Service instance created.');
|
||||
}
|
||||
|
||||
setupServiceCallbacks() {
|
||||
@ -45,6 +46,11 @@ class ListenService {
|
||||
});
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.setupIpcHandlers();
|
||||
console.log('[ListenService] Initialized and ready.');
|
||||
}
|
||||
|
||||
async handleTranscriptionComplete(speaker, text) {
|
||||
console.log(`[ListenService] Transcription complete: ${speaker} - ${text}`);
|
||||
|
||||
@ -183,6 +189,8 @@ class ListenService {
|
||||
// Close STT sessions
|
||||
await this.sttService.closeSessions();
|
||||
|
||||
await this.stopMacOSAudioCapture();
|
||||
|
||||
// End database session
|
||||
if (this.currentSessionId) {
|
||||
await sessionRepository.end(this.currentSessionId);
|
||||
@ -215,8 +223,6 @@ class ListenService {
|
||||
}
|
||||
|
||||
setupIpcHandlers() {
|
||||
const { ipcMain } = require('electron');
|
||||
|
||||
ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
|
||||
try {
|
||||
await this.sendAudioContent(data, mimeType);
|
||||
@ -282,4 +288,5 @@ class ListenService {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ListenService;
|
||||
const listenService = new ListenService();
|
||||
module.exports = listenService;
|
17
src/index.js
17
src/index.js
@ -13,7 +13,7 @@ if (require('electron-squirrel-startup')) {
|
||||
|
||||
const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
|
||||
const { createWindows } = require('./window/windowManager.js');
|
||||
const ListenService = require('./features/listen/listenService');
|
||||
const listenService = require('./features/listen/listenService');
|
||||
const { initializeFirebase } = require('./features/common/services/firebaseClient');
|
||||
const databaseInitializer = require('./features/common/services/databaseInitializer');
|
||||
const authService = require('./features/common/services/authService');
|
||||
@ -34,10 +34,6 @@ const eventBridge = new EventEmitter();
|
||||
let WEB_PORT = 3000;
|
||||
let isShuttingDown = false; // Flag to prevent infinite shutdown loop
|
||||
|
||||
const listenService = new ListenService();
|
||||
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
|
||||
global.listenService = listenService;
|
||||
|
||||
//////// after_modelStateService ////////
|
||||
const modelStateService = new ModelStateService(authService);
|
||||
global.modelStateService = modelStateService;
|
||||
@ -203,7 +199,7 @@ app.whenReady().then(async () => {
|
||||
await modelStateService.initialize();
|
||||
//////// after_modelStateService ////////
|
||||
|
||||
listenService.setupIpcHandlers();
|
||||
listenService.initialize();
|
||||
askService.initialize();
|
||||
settingsService.initialize();
|
||||
featureBridge.initialize(); // 추가: featureBridge 초기화
|
||||
@ -250,13 +246,6 @@ app.whenReady().then(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
listenService.stopMacOSAudioCapture();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('before-quit', async (event) => {
|
||||
// Prevent infinite loop by checking if shutdown is already in progress
|
||||
if (isShuttingDown) {
|
||||
@ -274,7 +263,7 @@ app.on('before-quit', async (event) => {
|
||||
|
||||
try {
|
||||
// 1. Stop audio capture first (immediate)
|
||||
listenService.stopMacOSAudioCapture();
|
||||
await listenService.closeSession();
|
||||
console.log('[Shutdown] Audio capture stopped');
|
||||
|
||||
// 2. End all active sessions (database operations) - with error handling
|
||||
|
@ -6,6 +6,7 @@ const fs = require('node:fs');
|
||||
const os = require('os');
|
||||
const util = require('util');
|
||||
const execFile = util.promisify(require('child_process').execFile);
|
||||
const listenService = require('../features/listen/listenService');
|
||||
|
||||
// Try to load sharp, but don't fail if it's not available
|
||||
let sharp;
|
||||
@ -94,7 +95,7 @@ async function toggleFeature(featureName, options = {}) {
|
||||
if (featureName === 'listen') {
|
||||
console.log(`[WindowManager] Toggling feature: ${featureName}`);
|
||||
const listenWindow = windowPool.get(featureName);
|
||||
const listenService = global.listenService;
|
||||
// const listenService = global.listenService;
|
||||
if (listenService && listenService.isSessionActive()) {
|
||||
console.log('[WindowManager] Listen session is active, closing it via toggle.');
|
||||
await listenService.closeSession();
|
||||
|
Loading…
x
Reference in New Issue
Block a user