Merge branch 'main' of https://github.com/pickle-com/glass
This commit is contained in:
commit
fbe5c22aa4
@ -6,15 +6,15 @@ const whisperService = require('../features/common/services/whisperService');
|
||||
const ollamaService = require('../features/common/services/ollamaService');
|
||||
const modelStateService = require('../features/common/services/modelStateService');
|
||||
const shortcutsService = require('../features/shortcuts/shortcutsService');
|
||||
const presetRepository = require('../features/common/repositories/preset');
|
||||
|
||||
const askService = require('../features/ask/askService');
|
||||
const listenService = require('../features/listen/listenService');
|
||||
const permissionService = require('../features/common/services/permissionService');
|
||||
|
||||
module.exports = {
|
||||
// Renderer로부터의 요청을 수신
|
||||
// Renderer로부터의 요청을 수신하고 서비스로 전달
|
||||
initialize() {
|
||||
|
||||
// Settings Service
|
||||
ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
|
||||
ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
|
||||
@ -33,14 +33,12 @@ module.exports = {
|
||||
ipcMain.handle('get-default-shortcuts', async () => await shortcutsService.handleRestoreDefaults());
|
||||
ipcMain.handle('save-shortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
|
||||
|
||||
|
||||
// Permissions
|
||||
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
|
||||
ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
|
||||
ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section));
|
||||
ipcMain.handle('mark-permissions-completed', async () => await permissionService.markPermissionsAsCompleted());
|
||||
ipcMain.handle('check-permissions-completed', async () => await permissionService.checkPermissionsCompleted());
|
||||
|
||||
|
||||
// User/Auth
|
||||
ipcMain.handle('get-current-user', () => authService.getCurrentUser());
|
||||
@ -51,7 +49,7 @@ module.exports = {
|
||||
ipcMain.handle('quit-application', () => app.quit());
|
||||
|
||||
// Whisper
|
||||
ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(event, modelId));
|
||||
ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(modelId));
|
||||
ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
|
||||
|
||||
// General
|
||||
@ -60,17 +58,17 @@ module.exports = {
|
||||
|
||||
// Ollama
|
||||
ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus());
|
||||
ipcMain.handle('ollama:install', async (event) => await ollamaService.handleInstall(event));
|
||||
ipcMain.handle('ollama:start-service', async (event) => await ollamaService.handleStartService(event));
|
||||
ipcMain.handle('ollama:install', async () => await ollamaService.handleInstall());
|
||||
ipcMain.handle('ollama:start-service', async () => await ollamaService.handleStartService());
|
||||
ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady());
|
||||
ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels());
|
||||
ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions());
|
||||
ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(event, modelName));
|
||||
ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(modelName));
|
||||
ipcMain.handle('ollama:is-model-installed', async (event, modelName) => await ollamaService.handleIsModelInstalled(modelName));
|
||||
ipcMain.handle('ollama:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName));
|
||||
ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp());
|
||||
ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus());
|
||||
ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(event, force));
|
||||
ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(force));
|
||||
|
||||
// Ask
|
||||
ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt));
|
||||
@ -101,9 +99,7 @@ module.exports = {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
|
||||
// ModelStateService
|
||||
// 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));
|
||||
@ -114,8 +110,6 @@ module.exports = {
|
||||
ipcMain.handle('model:are-providers-configured', () => modelStateService.areProvidersConfigured());
|
||||
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
|
||||
|
||||
|
||||
|
||||
console.log('[FeatureBridge] Initialized with all feature handlers.');
|
||||
},
|
||||
|
||||
|
@ -12,6 +12,7 @@ module.exports = {
|
||||
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
|
||||
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
|
||||
ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
|
||||
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
|
||||
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
|
||||
ipcMain.on('close-shortcut-editor', () => windowManager.closeWindow('shortcut-settings'));
|
||||
|
||||
|
@ -281,35 +281,78 @@ class AskService {
|
||||
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
|
||||
});
|
||||
|
||||
const response = await streamingLLM.streamChat(messages);
|
||||
const askWin = getWindowPool()?.get('ask');
|
||||
try {
|
||||
const response = await streamingLLM.streamChat(messages);
|
||||
const askWin = getWindowPool()?.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.' };
|
||||
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.' };
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
signal.addEventListener('abort', () => {
|
||||
console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`);
|
||||
reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ });
|
||||
});
|
||||
|
||||
await this._processStream(reader, askWin, sessionId, signal);
|
||||
return { success: true };
|
||||
|
||||
} catch (multimodalError) {
|
||||
// 멀티모달 요청이 실패했고 스크린샷이 포함되어 있다면 텍스트만으로 재시도
|
||||
if (screenshotBase64 && this._isMultimodalError(multimodalError)) {
|
||||
console.log(`[AskService] Multimodal request failed, retrying with text-only: ${multimodalError.message}`);
|
||||
|
||||
// 텍스트만으로 메시지 재구성
|
||||
const textOnlyMessages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{
|
||||
role: 'user',
|
||||
content: `User Request: ${userPrompt.trim()}`
|
||||
}
|
||||
];
|
||||
|
||||
const fallbackResponse = await streamingLLM.streamChat(textOnlyMessages);
|
||||
const askWin = getWindowPool()?.get('ask');
|
||||
|
||||
if (!askWin || askWin.isDestroyed()) {
|
||||
console.error("[AskService] Ask window is not available for fallback response.");
|
||||
fallbackResponse.body.getReader().cancel();
|
||||
return { success: false, error: 'Ask window is not available.' };
|
||||
}
|
||||
|
||||
const fallbackReader = fallbackResponse.body.getReader();
|
||||
signal.addEventListener('abort', () => {
|
||||
console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`);
|
||||
fallbackReader.cancel(signal.reason).catch(() => {});
|
||||
});
|
||||
|
||||
await this._processStream(fallbackReader, askWin, sessionId, signal);
|
||||
return { success: true };
|
||||
} else {
|
||||
// 다른 종류의 에러이거나 스크린샷이 없었다면 그대로 throw
|
||||
throw multimodalError;
|
||||
}
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
signal.addEventListener('abort', () => {
|
||||
console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`);
|
||||
reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ });
|
||||
});
|
||||
|
||||
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 during message processing:', error);
|
||||
this.state = {
|
||||
...this.state,
|
||||
isLoading: false,
|
||||
isStreaming: false,
|
||||
showTextInput: true,
|
||||
};
|
||||
this._broadcastState();
|
||||
|
||||
const askWin = getWindowPool()?.get('ask');
|
||||
if (askWin && !askWin.isDestroyed()) {
|
||||
const streamError = error.message || 'Unknown error occurred';
|
||||
askWin.webContents.send('ask-response-stream-error', { error: streamError });
|
||||
}
|
||||
|
||||
console.error('[AskService] Error processing message:', error);
|
||||
this.state.isLoading = false;
|
||||
this.state.error = error.message;
|
||||
this._broadcastState();
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@ -381,6 +424,24 @@ class AskService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 멀티모달 관련 에러인지 판단
|
||||
* @private
|
||||
*/
|
||||
_isMultimodalError(error) {
|
||||
const errorMessage = error.message?.toLowerCase() || '';
|
||||
return (
|
||||
errorMessage.includes('vision') ||
|
||||
errorMessage.includes('image') ||
|
||||
errorMessage.includes('multimodal') ||
|
||||
errorMessage.includes('unsupported') ||
|
||||
errorMessage.includes('image_url') ||
|
||||
errorMessage.includes('400') || // Bad Request often for unsupported features
|
||||
errorMessage.includes('invalid') ||
|
||||
errorMessage.includes('not supported')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const askService = new AskService();
|
||||
|
@ -68,7 +68,8 @@ const PROVIDERS = {
|
||||
handler: () => {
|
||||
// This needs to remain a function due to its conditional logic for renderer/main process
|
||||
if (typeof window === 'undefined') {
|
||||
return require("./providers/whisper");
|
||||
const { WhisperProvider } = require("./providers/whisper");
|
||||
return new WhisperProvider();
|
||||
}
|
||||
// Return a dummy object for the renderer process
|
||||
return {
|
||||
|
@ -1,6 +1,79 @@
|
||||
const http = require('http');
|
||||
const fetch = require('node-fetch');
|
||||
|
||||
// Request Queue System for Ollama API (only for non-streaming requests)
|
||||
class RequestQueue {
|
||||
constructor() {
|
||||
this.queue = [];
|
||||
this.processing = false;
|
||||
this.streamingActive = false;
|
||||
}
|
||||
|
||||
async addStreamingRequest(requestFn) {
|
||||
// Streaming requests have priority - wait for current processing to finish
|
||||
while (this.processing) {
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
this.streamingActive = true;
|
||||
console.log('[Ollama Queue] Starting streaming request (priority)');
|
||||
|
||||
try {
|
||||
const result = await requestFn();
|
||||
return result;
|
||||
} finally {
|
||||
this.streamingActive = false;
|
||||
console.log('[Ollama Queue] Streaming request completed');
|
||||
}
|
||||
}
|
||||
|
||||
async add(requestFn) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.queue.push({ requestFn, resolve, reject });
|
||||
this.process();
|
||||
});
|
||||
}
|
||||
|
||||
async process() {
|
||||
if (this.processing || this.queue.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Wait if streaming is active
|
||||
if (this.streamingActive) {
|
||||
setTimeout(() => this.process(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
this.processing = true;
|
||||
|
||||
while (this.queue.length > 0) {
|
||||
// Check if streaming started while processing queue
|
||||
if (this.streamingActive) {
|
||||
this.processing = false;
|
||||
setTimeout(() => this.process(), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
const { requestFn, resolve, reject } = this.queue.shift();
|
||||
|
||||
try {
|
||||
console.log(`[Ollama Queue] Processing queued request (${this.queue.length} remaining)`);
|
||||
const result = await requestFn();
|
||||
resolve(result);
|
||||
} catch (error) {
|
||||
console.error('[Ollama Queue] Request failed:', error);
|
||||
reject(error);
|
||||
}
|
||||
}
|
||||
|
||||
this.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Global request queue instance
|
||||
const requestQueue = new RequestQueue();
|
||||
|
||||
class OllamaProvider {
|
||||
static async validateApiKey() {
|
||||
try {
|
||||
@ -79,71 +152,77 @@ function createLLM({
|
||||
}
|
||||
messages.push({ role: 'user', content: userContent.join('\n') });
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature,
|
||||
num_predict: maxTokens,
|
||||
}
|
||||
})
|
||||
});
|
||||
// Use request queue to prevent concurrent API calls
|
||||
return await requestQueue.add(async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature,
|
||||
num_predict: maxTokens,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: () => result.message.content
|
||||
},
|
||||
raw: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ollama LLM error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: () => result.message.content
|
||||
},
|
||||
raw: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ollama LLM error:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
chat: async (messages) => {
|
||||
const ollamaMessages = convertMessagesToOllamaFormat(messages);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: ollamaMessages,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature,
|
||||
num_predict: maxTokens,
|
||||
}
|
||||
})
|
||||
});
|
||||
// Use request queue to prevent concurrent API calls
|
||||
return await requestQueue.add(async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: ollamaMessages,
|
||||
stream: false,
|
||||
options: {
|
||||
temperature,
|
||||
num_predict: maxTokens,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
content: result.message.content,
|
||||
raw: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ollama chat error:', error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
return {
|
||||
content: result.message.content,
|
||||
raw: result
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Ollama chat error:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -165,89 +244,92 @@ function createStreamingLLM({
|
||||
const ollamaMessages = convertMessagesToOllamaFormat(messages);
|
||||
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
temperature,
|
||||
num_predict: maxTokens,
|
||||
}
|
||||
})
|
||||
});
|
||||
// Streaming requests have priority over queued requests
|
||||
return await requestQueue.addStreamingRequest(async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
options: {
|
||||
temperature,
|
||||
num_predict: maxTokens,
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
console.log('[Ollama Provider] Got streaming response');
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
response.body.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
|
||||
if (data.message?.content) {
|
||||
const sseData = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
content: data.message.content
|
||||
}
|
||||
}]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Ollama Provider] Failed to parse chunk:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.body.on('end', () => {
|
||||
controller.close();
|
||||
console.log('[Ollama Provider] Streaming completed');
|
||||
});
|
||||
|
||||
response.body.on('error', (error) => {
|
||||
console.error('[Ollama Provider] Streaming error:', error);
|
||||
controller.error(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Ollama Provider] Streaming setup error:', error);
|
||||
controller.error(error);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: stream
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Ollama Provider] Request error:', error);
|
||||
throw error;
|
||||
}
|
||||
console.log('[Ollama Provider] Got streaming response');
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
response.body.on('data', (chunk) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(line);
|
||||
|
||||
if (data.message?.content) {
|
||||
const sseData = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
content: data.message.content
|
||||
}
|
||||
}]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
|
||||
}
|
||||
|
||||
if (data.done) {
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[Ollama Provider] Failed to parse chunk:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
response.body.on('end', () => {
|
||||
controller.close();
|
||||
console.log('[Ollama Provider] Streaming completed');
|
||||
});
|
||||
|
||||
response.body.on('error', (error) => {
|
||||
console.error('[Ollama Provider] Streaming error:', error);
|
||||
controller.error(error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Ollama Provider] Streaming setup error:', error);
|
||||
controller.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
body: stream
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Ollama Provider] Request error:', error);
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
|
@ -184,9 +184,10 @@ class WhisperProvider {
|
||||
|
||||
async initialize() {
|
||||
if (!this.whisperService) {
|
||||
const { WhisperService } = require('../../services/whisperService');
|
||||
this.whisperService = new WhisperService();
|
||||
await this.whisperService.initialize();
|
||||
this.whisperService = require('../../services/whisperService');
|
||||
if (!this.whisperService.isInitialized) {
|
||||
await this.whisperService.initialize();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1,20 +1,42 @@
|
||||
const sqliteClient = require('../../services/sqliteClient');
|
||||
const encryptionService = require('../../services/encryptionService');
|
||||
|
||||
function getByProvider(uid, provider) {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? AND provider = ?');
|
||||
return stmt.get(uid, provider) || null;
|
||||
const result = stmt.get(uid, provider) || null;
|
||||
|
||||
if (result && result.api_key) {
|
||||
// Decrypt API key if it exists
|
||||
result.api_key = encryptionService.decrypt(result.api_key);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function getAllByUid(uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
const stmt = db.prepare('SELECT * FROM provider_settings WHERE uid = ? ORDER BY provider');
|
||||
return stmt.all(uid);
|
||||
const results = stmt.all(uid);
|
||||
|
||||
// Decrypt API keys for all results
|
||||
return results.map(result => {
|
||||
if (result.api_key) {
|
||||
result.api_key = encryptionService.decrypt(result.api_key);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
function upsert(uid, provider, settings) {
|
||||
const db = sqliteClient.getDb();
|
||||
|
||||
// Encrypt API key if it exists
|
||||
const encryptedSettings = { ...settings };
|
||||
if (encryptedSettings.api_key) {
|
||||
encryptedSettings.api_key = encryptionService.encrypt(encryptedSettings.api_key);
|
||||
}
|
||||
|
||||
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO provider_settings (uid, provider, api_key, selected_llm_model, selected_stt_model, created_at, updated_at)
|
||||
@ -29,11 +51,11 @@ function upsert(uid, provider, settings) {
|
||||
const result = stmt.run(
|
||||
uid,
|
||||
provider,
|
||||
settings.api_key || null,
|
||||
settings.selected_llm_model || null,
|
||||
settings.selected_stt_model || null,
|
||||
settings.created_at || Date.now(),
|
||||
settings.updated_at
|
||||
encryptedSettings.api_key || null,
|
||||
encryptedSettings.selected_llm_model || null,
|
||||
encryptedSettings.selected_stt_model || null,
|
||||
encryptedSettings.created_at || Date.now(),
|
||||
encryptedSettings.updated_at
|
||||
);
|
||||
|
||||
return { changes: result.changes };
|
||||
|
@ -1,6 +1,7 @@
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { EventEmitter } = require('events');
|
||||
const { BrowserWindow } = require('electron');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const https = require('https');
|
||||
@ -17,6 +18,19 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
this.installationProgress = new Map();
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getPlatform() {
|
||||
return process.platform;
|
||||
}
|
||||
@ -65,7 +79,7 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
|
||||
setInstallProgress(modelName, progress) {
|
||||
this.installationProgress.set(modelName, progress);
|
||||
this.emit('install-progress', { model: modelName, progress });
|
||||
// 각 서비스에서 직접 브로드캐스트하도록 변경
|
||||
}
|
||||
|
||||
clearInstallProgress(modelName) {
|
||||
@ -152,7 +166,8 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
const {
|
||||
onProgress = null,
|
||||
headers = { 'User-Agent': 'Glass-App' },
|
||||
timeout = 300000 // 5 minutes default
|
||||
timeout = 300000, // 5 minutes default
|
||||
modelId = null // 모델 ID를 위한 추가 옵션
|
||||
} = options;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
@ -190,9 +205,15 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
response.on('data', (chunk) => {
|
||||
downloadedSize += chunk.length;
|
||||
|
||||
if (onProgress && totalSize > 0) {
|
||||
if (totalSize > 0) {
|
||||
const progress = Math.round((downloadedSize / totalSize) * 100);
|
||||
onProgress(progress, downloadedSize, totalSize);
|
||||
|
||||
// 이벤트 기반 진행률 보고는 각 서비스에서 직접 처리
|
||||
|
||||
// 기존 콜백 지원 (호환성 유지)
|
||||
if (onProgress) {
|
||||
onProgress(progress, downloadedSize, totalSize);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -200,7 +221,7 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
|
||||
file.on('finish', () => {
|
||||
file.close(() => {
|
||||
this.emit('download-complete', { url, destination, size: downloadedSize });
|
||||
// download-complete 이벤트는 각 서비스에서 직접 처리
|
||||
resolve({ success: true, size: downloadedSize });
|
||||
});
|
||||
});
|
||||
@ -216,7 +237,7 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
request.on('error', (err) => {
|
||||
file.close();
|
||||
fs.unlink(destination, () => {});
|
||||
this.emit('download-error', { url, error: err });
|
||||
this.emit('download-error', { url, error: err, modelId });
|
||||
reject(err);
|
||||
});
|
||||
|
||||
@ -230,11 +251,20 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
}
|
||||
|
||||
async downloadWithRetry(url, destination, options = {}) {
|
||||
const { maxRetries = 3, retryDelay = 1000, expectedChecksum = null, ...downloadOptions } = options;
|
||||
const {
|
||||
maxRetries = 3,
|
||||
retryDelay = 1000,
|
||||
expectedChecksum = null,
|
||||
modelId = null, // 모델 ID를 위한 추가 옵션
|
||||
...downloadOptions
|
||||
} = options;
|
||||
|
||||
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const result = await this.downloadFile(url, destination, downloadOptions);
|
||||
const result = await this.downloadFile(url, destination, {
|
||||
...downloadOptions,
|
||||
modelId
|
||||
});
|
||||
|
||||
if (expectedChecksum) {
|
||||
const isValid = await this.verifyChecksum(destination, expectedChecksum);
|
||||
@ -248,6 +278,7 @@ class LocalAIServiceBase extends EventEmitter {
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (attempt === maxRetries) {
|
||||
// download-error 이벤트는 각 서비스에서 직접 처리
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
@ -1,6 +1,7 @@
|
||||
const Store = require('electron-store');
|
||||
const fetch = require('node-fetch');
|
||||
const { ipcMain, webContents } = require('electron');
|
||||
const { EventEmitter } = require('events');
|
||||
const { BrowserWindow } = require('electron');
|
||||
const { PROVIDERS, getProviderClass } = require('../ai/factory');
|
||||
const encryptionService = require('./encryptionService');
|
||||
const providerSettingsRepository = require('../repositories/providerSettings');
|
||||
@ -9,8 +10,9 @@ const userModelSelectionsRepository = require('../repositories/userModelSelectio
|
||||
// Import authService directly (singleton)
|
||||
const authService = require('./authService');
|
||||
|
||||
class ModelStateService {
|
||||
class ModelStateService extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this.authService = authService;
|
||||
this.store = new Store({ name: 'pickle-glass-model-state' });
|
||||
this.state = {};
|
||||
@ -21,6 +23,19 @@ class ModelStateService {
|
||||
userModelSelectionsRepository.setAuthService(authService);
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
console.log('[ModelStateService] Initializing...');
|
||||
await this._loadStateForCurrentUser();
|
||||
@ -143,17 +158,8 @@ class ModelStateService {
|
||||
|
||||
for (const setting of providerSettings) {
|
||||
if (setting.api_key) {
|
||||
// API keys are stored encrypted in database, decrypt them
|
||||
if (setting.provider !== 'ollama' && setting.provider !== 'whisper') {
|
||||
try {
|
||||
apiKeys[setting.provider] = encryptionService.decrypt(setting.api_key);
|
||||
} catch (error) {
|
||||
console.error(`[ModelStateService] Failed to decrypt API key for ${setting.provider}, resetting`);
|
||||
apiKeys[setting.provider] = null;
|
||||
}
|
||||
} else {
|
||||
apiKeys[setting.provider] = setting.api_key;
|
||||
}
|
||||
// API keys are already decrypted by the repository layer
|
||||
apiKeys[setting.provider] = setting.api_key;
|
||||
}
|
||||
}
|
||||
|
||||
@ -171,6 +177,9 @@ class ModelStateService {
|
||||
|
||||
console.log(`[ModelStateService] State loaded from database for user: ${userId}`);
|
||||
|
||||
// Auto-select available models after loading state
|
||||
this._autoSelectAvailableModels();
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ModelStateService] Failed to load state from database:', error);
|
||||
// Fall back to default state
|
||||
@ -217,12 +226,9 @@ class ModelStateService {
|
||||
// Save provider settings (API keys)
|
||||
for (const [provider, apiKey] of Object.entries(this.state.apiKeys)) {
|
||||
if (apiKey) {
|
||||
const encryptedKey = (provider !== 'ollama' && provider !== 'whisper')
|
||||
? encryptionService.encrypt(apiKey)
|
||||
: apiKey;
|
||||
|
||||
// API keys will be encrypted by the repository layer
|
||||
await providerSettingsRepository.upsert(provider, {
|
||||
api_key: encryptedKey
|
||||
api_key: apiKey
|
||||
});
|
||||
} else {
|
||||
// Remove empty API keys
|
||||
@ -262,7 +268,7 @@ class ModelStateService {
|
||||
};
|
||||
|
||||
for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
|
||||
if (key && provider !== 'ollama' && provider !== 'whisper') {
|
||||
if (key) {
|
||||
try {
|
||||
stateToSave.apiKeys[provider] = encryptionService.encrypt(key);
|
||||
} catch (error) {
|
||||
@ -331,22 +337,19 @@ class ModelStateService {
|
||||
}
|
||||
|
||||
async setApiKey(provider, key) {
|
||||
if (provider in this.state.apiKeys) {
|
||||
this.state.apiKeys[provider] = key;
|
||||
|
||||
const supportedTypes = [];
|
||||
if (PROVIDERS[provider]?.llmModels.length > 0 || provider === 'ollama') {
|
||||
supportedTypes.push('llm');
|
||||
}
|
||||
if (PROVIDERS[provider]?.sttModels.length > 0 || provider === 'whisper') {
|
||||
supportedTypes.push('stt');
|
||||
}
|
||||
|
||||
this._autoSelectAvailableModels(supportedTypes);
|
||||
await this._saveState();
|
||||
return true;
|
||||
console.log(`[ModelStateService] setApiKey: ${provider}`);
|
||||
if (!provider) {
|
||||
throw new Error('Provider is required');
|
||||
}
|
||||
return false;
|
||||
|
||||
// API keys will be encrypted by the repository layer
|
||||
this.state.apiKeys[provider] = key;
|
||||
await this._saveState();
|
||||
|
||||
this._autoSelectAvailableModels([]);
|
||||
|
||||
this._broadcastToAllWindows('model-state:updated', this.state);
|
||||
this._broadcastToAllWindows('settings-updated');
|
||||
}
|
||||
|
||||
getApiKey(provider) {
|
||||
@ -358,19 +361,14 @@ class ModelStateService {
|
||||
return displayKeys;
|
||||
}
|
||||
|
||||
removeApiKey(provider) {
|
||||
console.log(`[ModelStateService] Removing API key for provider: ${provider}`);
|
||||
if (provider in this.state.apiKeys) {
|
||||
async removeApiKey(provider) {
|
||||
if (this.state.apiKeys[provider]) {
|
||||
this.state.apiKeys[provider] = null;
|
||||
const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm);
|
||||
if (llmProvider === provider) this.state.selectedModels.llm = null;
|
||||
|
||||
const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt);
|
||||
if (sttProvider === provider) this.state.selectedModels.stt = null;
|
||||
|
||||
this._autoSelectAvailableModels();
|
||||
this._saveState();
|
||||
this._logCurrentSelection();
|
||||
await providerSettingsRepository.remove(provider);
|
||||
await this._saveState();
|
||||
this._autoSelectAvailableModels([]);
|
||||
this._broadcastToAllWindows('model-state:updated', this.state);
|
||||
this._broadcastToAllWindows('settings-updated');
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
@ -456,11 +454,36 @@ class ModelStateService {
|
||||
const available = [];
|
||||
const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
|
||||
|
||||
Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
|
||||
if (key && PROVIDERS[providerId]?.[modelList]) {
|
||||
for (const [providerId, key] of Object.entries(this.state.apiKeys)) {
|
||||
if (!key) continue;
|
||||
|
||||
// Ollama의 경우 데이터베이스에서 설치된 모델을 가져오기
|
||||
if (providerId === 'ollama' && type === 'llm') {
|
||||
try {
|
||||
const ollamaModelRepository = require('../repositories/ollamaModel');
|
||||
const installedModels = ollamaModelRepository.getInstalledModels();
|
||||
const ollamaModels = installedModels.map(model => ({
|
||||
id: model.name,
|
||||
name: model.name
|
||||
}));
|
||||
available.push(...ollamaModels);
|
||||
} catch (error) {
|
||||
console.warn('[ModelStateService] Failed to get Ollama models from DB:', error.message);
|
||||
}
|
||||
}
|
||||
// Whisper의 경우 정적 모델 목록 사용 (설치 상태는 별도 확인)
|
||||
else if (providerId === 'whisper' && type === 'stt') {
|
||||
// Whisper 모델은 factory.js의 정적 목록 사용
|
||||
if (PROVIDERS[providerId]?.[modelList]) {
|
||||
available.push(...PROVIDERS[providerId][modelList]);
|
||||
}
|
||||
}
|
||||
// 다른 provider들은 기존 로직 사용
|
||||
else if (PROVIDERS[providerId]?.[modelList]) {
|
||||
available.push(...PROVIDERS[providerId][modelList]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return [...new Map(available.map(item => [item.id, item])).values()];
|
||||
}
|
||||
|
||||
@ -469,20 +492,31 @@ class ModelStateService {
|
||||
}
|
||||
|
||||
setSelectedModel(type, modelId) {
|
||||
const provider = this.getProviderForModel(type, modelId);
|
||||
if (provider && this.state.apiKeys[provider]) {
|
||||
const previousModel = this.state.selectedModels[type];
|
||||
this.state.selectedModels[type] = modelId;
|
||||
this._saveState();
|
||||
|
||||
// Auto warm-up for Ollama LLM models when changed
|
||||
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
|
||||
this._autoWarmUpOllamaModel(modelId, previousModel);
|
||||
}
|
||||
|
||||
return true;
|
||||
const availableModels = this.getAvailableModels(type);
|
||||
const isAvailable = availableModels.some(model => model.id === modelId);
|
||||
|
||||
if (!isAvailable) {
|
||||
console.warn(`[ModelStateService] Model ${modelId} is not available for type ${type}`);
|
||||
return false;
|
||||
}
|
||||
return false;
|
||||
|
||||
const previousModelId = this.state.selectedModels[type];
|
||||
this.state.selectedModels[type] = modelId;
|
||||
this._saveState();
|
||||
|
||||
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (was: ${previousModelId})`);
|
||||
|
||||
// Auto warm-up for Ollama models
|
||||
if (type === 'llm' && modelId && modelId !== previousModelId) {
|
||||
const provider = this.getProviderForModel('llm', modelId);
|
||||
if (provider === 'ollama') {
|
||||
this._autoWarmUpOllamaModel(modelId, previousModelId);
|
||||
}
|
||||
}
|
||||
|
||||
this._broadcastToAllWindows('model-state:updated', this.state);
|
||||
this._broadcastToAllWindows('settings-updated');
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -493,7 +527,7 @@ class ModelStateService {
|
||||
*/
|
||||
async _autoWarmUpOllamaModel(newModelId, previousModelId) {
|
||||
try {
|
||||
console.log(`[ModelStateService] 🔥 LLM model changed: ${previousModelId || 'None'} → ${newModelId}, triggering warm-up`);
|
||||
console.log(`[ModelStateService] LLM model changed: ${previousModelId || 'None'} → ${newModelId}, triggering warm-up`);
|
||||
|
||||
// Get Ollama service if available
|
||||
const ollamaService = require('./ollamaService');
|
||||
@ -509,12 +543,12 @@ class ModelStateService {
|
||||
const success = await ollamaService.warmUpModel(newModelId);
|
||||
|
||||
if (success) {
|
||||
console.log(`[ModelStateService] ✅ Successfully warmed up model: ${newModelId}`);
|
||||
console.log(`[ModelStateService] Successfully warmed up model: ${newModelId}`);
|
||||
} else {
|
||||
console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`);
|
||||
console.log(`[ModelStateService] Failed to warm up model: ${newModelId}`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(`[ModelStateService] 🚫 Error during auto warm-up for ${newModelId}:`, error.message);
|
||||
console.log(`[ModelStateService] Error during auto warm-up for ${newModelId}:`, error.message);
|
||||
}
|
||||
}, 500); // 500ms delay
|
||||
|
||||
@ -544,13 +578,11 @@ class ModelStateService {
|
||||
|
||||
async handleRemoveApiKey(provider) {
|
||||
console.log(`[ModelStateService] handleRemoveApiKey: ${provider}`);
|
||||
const success = this.removeApiKey(provider);
|
||||
const success = await this.removeApiKey(provider);
|
||||
if (success) {
|
||||
const selectedModels = this.getSelectedModels();
|
||||
if (!selectedModels.llm || !selectedModels.stt) {
|
||||
webContents.getAllWebContents().forEach(wc => {
|
||||
wc.send('force-show-apikey-header');
|
||||
});
|
||||
this._broadcastToAllWindows('force-show-apikey-header');
|
||||
}
|
||||
}
|
||||
return success;
|
||||
|
@ -3,7 +3,7 @@ const { promisify } = require('util');
|
||||
const fetch = require('node-fetch');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { app } = require('electron');
|
||||
const { app, BrowserWindow } = require('electron');
|
||||
const LocalAIServiceBase = require('./localAIServiceBase');
|
||||
const { spawnAsync } = require('../utils/spawnHelper');
|
||||
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
|
||||
@ -27,8 +27,8 @@ class OllamaService extends LocalAIServiceBase {
|
||||
};
|
||||
|
||||
// Configuration
|
||||
this.requestTimeout = 8000; // 8s for health checks
|
||||
this.warmupTimeout = 15000; // 15s for model warmup
|
||||
this.requestTimeout = 0; // Delete timeout
|
||||
this.warmupTimeout = 120000; // 120s for model warmup
|
||||
this.healthCheckInterval = 60000; // 1min between health checks
|
||||
this.circuitBreakerThreshold = 3;
|
||||
this.circuitBreakerCooldown = 30000; // 30s
|
||||
@ -40,6 +40,19 @@ class OllamaService extends LocalAIServiceBase {
|
||||
this._startHealthMonitoring();
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async getStatus() {
|
||||
try {
|
||||
const installed = await this.isInstalled();
|
||||
@ -87,14 +100,17 @@ class OllamaService extends LocalAIServiceBase {
|
||||
const controller = new AbortController();
|
||||
const timeout = options.timeout || this.requestTimeout;
|
||||
|
||||
// Set up timeout mechanism
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
this.activeRequests.delete(requestId);
|
||||
this._recordFailure();
|
||||
}, timeout);
|
||||
|
||||
this.requestTimeouts.set(requestId, timeoutId);
|
||||
// Set up timeout mechanism only if timeout > 0
|
||||
let timeoutId = null;
|
||||
if (timeout > 0) {
|
||||
timeoutId = setTimeout(() => {
|
||||
controller.abort();
|
||||
this.activeRequests.delete(requestId);
|
||||
this._recordFailure();
|
||||
}, timeout);
|
||||
|
||||
this.requestTimeouts.set(requestId, timeoutId);
|
||||
}
|
||||
|
||||
const requestPromise = this._executeRequest(url, {
|
||||
...options,
|
||||
@ -115,8 +131,10 @@ class OllamaService extends LocalAIServiceBase {
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
this.requestTimeouts.delete(requestId);
|
||||
if (timeoutId !== null) {
|
||||
clearTimeout(timeoutId);
|
||||
this.requestTimeouts.delete(requestId);
|
||||
}
|
||||
this.activeRequests.delete(operationType === 'health' ? 'health' : requestId);
|
||||
}
|
||||
}
|
||||
@ -377,7 +395,7 @@ class OllamaService extends LocalAIServiceBase {
|
||||
|
||||
if (progress !== null) {
|
||||
this.setInstallProgress(modelName, progress);
|
||||
this.emit('pull-progress', {
|
||||
this._broadcastToAllWindows('ollama:pull-progress', {
|
||||
model: modelName,
|
||||
progress,
|
||||
status: data.status || 'downloading'
|
||||
@ -388,7 +406,7 @@ class OllamaService extends LocalAIServiceBase {
|
||||
// Handle completion
|
||||
if (data.status === 'success') {
|
||||
console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
|
||||
this.emit('pull-complete', { model: modelName });
|
||||
this._broadcastToAllWindows('ollama:pull-complete', { model: modelName });
|
||||
this.clearInstallProgress(modelName);
|
||||
resolve();
|
||||
return;
|
||||
@ -406,7 +424,7 @@ class OllamaService extends LocalAIServiceBase {
|
||||
const data = JSON.parse(buffer);
|
||||
if (data.status === 'success') {
|
||||
console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
|
||||
this.emit('pull-complete', { model: modelName });
|
||||
this._broadcastToAllWindows('ollama:pull-complete', { model: modelName });
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('[OllamaService] Failed to parse final buffer:', buffer);
|
||||
@ -639,8 +657,48 @@ class OllamaService extends LocalAIServiceBase {
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
|
||||
return false;
|
||||
// Check if it's a 404 error (model not found/installed)
|
||||
if (error.message.includes('HTTP 404') || error.message.includes('Not Found')) {
|
||||
console.log(`[OllamaService] Model ${modelName} not found (404), attempting to install...`);
|
||||
|
||||
try {
|
||||
// Try to install the model
|
||||
await this.pullModel(modelName);
|
||||
console.log(`[OllamaService] Successfully installed model ${modelName}, retrying warm-up...`);
|
||||
|
||||
// Update database to reflect installation
|
||||
await ollamaModelRepository.updateInstallStatus(modelName, true, false);
|
||||
|
||||
// Retry warm-up after installation
|
||||
const retryResponse = await this._makeRequest(`${this.baseUrl}/api/chat`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: modelName,
|
||||
messages: [
|
||||
{ role: 'user', content: 'Hi' }
|
||||
],
|
||||
stream: false,
|
||||
options: {
|
||||
num_predict: 1,
|
||||
temperature: 0
|
||||
}
|
||||
}),
|
||||
timeout: this.warmupTimeout
|
||||
}, `warmup_retry_${modelName}`);
|
||||
|
||||
console.log(`[OllamaService] Successfully warmed up model ${modelName} after installation`);
|
||||
return true;
|
||||
|
||||
} catch (installError) {
|
||||
console.error(`[OllamaService] Failed to auto-install model ${modelName}:`, installError.message);
|
||||
await ollamaModelRepository.updateInstallStatus(modelName, false, false);
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -671,14 +729,8 @@ class OllamaService extends LocalAIServiceBase {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if model is installed
|
||||
const isInstalled = await this.isModelInstalled(llmModelId);
|
||||
if (!isInstalled) {
|
||||
console.log(`[OllamaService] Model ${llmModelId} not installed, skipping warm-up`);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId}`);
|
||||
// 설치 여부 체크 제거 - _performWarmUp에서 자동으로 설치 처리
|
||||
console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId} (will auto-install if needed)`);
|
||||
return await this.warmUpModel(llmModelId);
|
||||
|
||||
} catch (error) {
|
||||
@ -844,10 +896,10 @@ class OllamaService extends LocalAIServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
async handleInstall(event) {
|
||||
async handleInstall() {
|
||||
try {
|
||||
const onProgress = (data) => {
|
||||
event.sender.send('ollama:install-progress', data);
|
||||
this._broadcastToAllWindows('ollama:install-progress', data);
|
||||
};
|
||||
|
||||
await this.autoInstall(onProgress);
|
||||
@ -857,26 +909,26 @@ class OllamaService extends LocalAIServiceBase {
|
||||
await this.startService();
|
||||
onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 });
|
||||
}
|
||||
event.sender.send('ollama:install-complete', { success: true });
|
||||
this._broadcastToAllWindows('ollama:install-complete', { success: true });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[OllamaService] Failed to install:', error);
|
||||
event.sender.send('ollama:install-complete', { success: false, error: error.message });
|
||||
this._broadcastToAllWindows('ollama:install-complete', { success: false, error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async handleStartService(event) {
|
||||
async handleStartService() {
|
||||
try {
|
||||
if (!await this.isServiceRunning()) {
|
||||
console.log('[OllamaService] Starting Ollama service...');
|
||||
await this.startService();
|
||||
}
|
||||
event.sender.send('ollama:install-complete', { success: true });
|
||||
this.emit('install-complete', { success: true });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[OllamaService] Failed to start service:', error);
|
||||
event.sender.send('ollama:install-complete', { success: false, error: error.message });
|
||||
this.emit('install-complete', { success: false, error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@ -914,29 +966,12 @@ class OllamaService extends LocalAIServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
async handlePullModel(event, modelName) {
|
||||
async handlePullModel(modelName) {
|
||||
try {
|
||||
console.log(`[OllamaService] Starting model pull: ${modelName}`);
|
||||
|
||||
await ollamaModelRepository.updateInstallStatus(modelName, false, true);
|
||||
|
||||
const progressHandler = (data) => {
|
||||
if (data.model === modelName) {
|
||||
event.sender.send('ollama:pull-progress', data);
|
||||
}
|
||||
};
|
||||
|
||||
const completeHandler = (data) => {
|
||||
if (data.model === modelName) {
|
||||
console.log(`[OllamaService] Model ${modelName} pull completed`);
|
||||
this.removeListener('pull-progress', progressHandler);
|
||||
this.removeListener('pull-complete', completeHandler);
|
||||
}
|
||||
};
|
||||
|
||||
this.on('pull-progress', progressHandler);
|
||||
this.on('pull-complete', completeHandler);
|
||||
|
||||
await this.pullModel(modelName);
|
||||
|
||||
await ollamaModelRepository.updateInstallStatus(modelName, true, false);
|
||||
@ -946,6 +981,7 @@ class OllamaService extends LocalAIServiceBase {
|
||||
} catch (error) {
|
||||
console.error('[OllamaService] Failed to pull model:', error);
|
||||
await ollamaModelRepository.updateInstallStatus(modelName, false, false);
|
||||
this._broadcastToAllWindows('ollama:pull-error', { model: modelName, error: error.message });
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
@ -990,7 +1026,7 @@ class OllamaService extends LocalAIServiceBase {
|
||||
}
|
||||
}
|
||||
|
||||
async handleShutdown(event, force = false) {
|
||||
async handleShutdown(force = false) {
|
||||
try {
|
||||
console.log(`[OllamaService] Manual shutdown requested (force: ${force})`);
|
||||
const success = await this.shutdown(force);
|
||||
|
@ -2,6 +2,7 @@ const { spawn } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const { BrowserWindow } = require('electron');
|
||||
const LocalAIServiceBase = require('./localAIServiceBase');
|
||||
const { spawnAsync } = require('../utils/spawnHelper');
|
||||
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
|
||||
@ -39,6 +40,19 @@ class WhisperService extends LocalAIServiceBase {
|
||||
};
|
||||
}
|
||||
|
||||
// 모든 윈도우에 이벤트 브로드캐스트
|
||||
_broadcastToAllWindows(eventName, data = null) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (win && !win.isDestroyed()) {
|
||||
if (data !== null) {
|
||||
win.webContents.send(eventName, data);
|
||||
} else {
|
||||
win.webContents.send(eventName);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
@ -157,19 +171,21 @@ class WhisperService extends LocalAIServiceBase {
|
||||
const modelPath = await this.getModelPath(modelId);
|
||||
const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
|
||||
|
||||
this.emit('downloadProgress', { modelId, progress: 0 });
|
||||
this._broadcastToAllWindows('whisper:download-progress', { modelId, progress: 0 });
|
||||
|
||||
await this.downloadWithRetry(modelInfo.url, modelPath, {
|
||||
expectedChecksum: checksumInfo?.sha256,
|
||||
modelId, // modelId를 전달하여 LocalAIServiceBase에서 이벤트 발생 시 사용
|
||||
onProgress: (progress) => {
|
||||
this.emit('downloadProgress', { modelId, progress });
|
||||
this._broadcastToAllWindows('whisper:download-progress', { modelId, progress });
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
|
||||
this._broadcastToAllWindows('whisper:download-complete', { modelId });
|
||||
}
|
||||
|
||||
async handleDownloadModel(event, modelId) {
|
||||
async handleDownloadModel(modelId) {
|
||||
try {
|
||||
console.log(`[WhisperService] Handling download for model: ${modelId}`);
|
||||
|
||||
@ -177,19 +193,7 @@ class WhisperService extends LocalAIServiceBase {
|
||||
await this.initialize();
|
||||
}
|
||||
|
||||
const progressHandler = (data) => {
|
||||
if (data.modelId === modelId && event && event.sender) {
|
||||
event.sender.send('whisper:download-progress', data);
|
||||
}
|
||||
};
|
||||
|
||||
this.on('downloadProgress', progressHandler);
|
||||
|
||||
try {
|
||||
await this.ensureModelAvailable(modelId);
|
||||
} finally {
|
||||
this.removeListener('downloadProgress', progressHandler);
|
||||
}
|
||||
await this.ensureModelAvailable(modelId);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
|
@ -41,11 +41,58 @@ class ListenService {
|
||||
}
|
||||
|
||||
sendToRenderer(channel, data) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
const { windowPool } = require('../../window/windowManager');
|
||||
const listenWindow = windowPool?.get('listen');
|
||||
|
||||
if (listenWindow && !listenWindow.isDestroyed()) {
|
||||
listenWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.setupIpcHandlers();
|
||||
console.log('[ListenService] Initialized and ready.');
|
||||
}
|
||||
|
||||
async handleListenRequest(listenButtonText) {
|
||||
const { windowPool, updateLayout } = require('../../window/windowManager');
|
||||
const listenWindow = windowPool.get('listen');
|
||||
const header = windowPool.get('header');
|
||||
|
||||
try {
|
||||
switch (listenButtonText) {
|
||||
case 'Listen':
|
||||
console.log('[ListenService] changeSession to "Listen"');
|
||||
listenWindow.show();
|
||||
updateLayout();
|
||||
listenWindow.webContents.send('window-show-animation');
|
||||
await this.initializeSession();
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: true });
|
||||
break;
|
||||
|
||||
case 'Stop':
|
||||
console.log('[ListenService] changeSession to "Stop"');
|
||||
await this.closeSession();
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: false });
|
||||
break;
|
||||
|
||||
case 'Done':
|
||||
console.log('[ListenService] changeSession to "Done"');
|
||||
listenWindow.webContents.send('window-hide-animation');
|
||||
listenWindow.webContents.send('session-state-changed', { isActive: false });
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`[ListenService] unknown listenButtonText: ${listenButtonText}`);
|
||||
}
|
||||
});
|
||||
|
||||
header.webContents.send('listen:changeSessionResult', { success: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('[ListenService] error in handleListenRequest:', error);
|
||||
header.webContents.send('listen:changeSessionResult', { success: false });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
|
@ -35,11 +35,24 @@ class SttService {
|
||||
}
|
||||
|
||||
sendToRenderer(channel, data) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
});
|
||||
// Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
|
||||
const { windowPool } = require('../../../window/windowManager');
|
||||
const listenWindow = windowPool?.get('listen');
|
||||
|
||||
if (listenWindow && !listenWindow.isDestroyed()) {
|
||||
listenWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
async handleSendSystemAudioContent(data, mimeType) {
|
||||
try {
|
||||
await this.sendSystemAudioContent(data, mimeType);
|
||||
this.sendToRenderer('system-audio-data', { data });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending system audio:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async handleSendSystemAudioContent(data, mimeType) {
|
||||
|
@ -28,11 +28,12 @@ class SummaryService {
|
||||
}
|
||||
|
||||
sendToRenderer(channel, data) {
|
||||
BrowserWindow.getAllWindows().forEach(win => {
|
||||
if (!win.isDestroyed()) {
|
||||
win.webContents.send(channel, data);
|
||||
}
|
||||
});
|
||||
const { windowPool } = require('../../../window/windowManager');
|
||||
const listenWindow = windowPool?.get('listen');
|
||||
|
||||
if (listenWindow && !listenWindow.isDestroyed()) {
|
||||
listenWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
addConversationTurn(speaker, text) {
|
||||
@ -304,25 +305,20 @@ Keep all points concise and build upon previous analysis if provided.`,
|
||||
*/
|
||||
async triggerAnalysisIfNeeded() {
|
||||
if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
|
||||
console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`);
|
||||
console.log(`Triggering analysis - ${this.conversationHistory.length} conversation texts accumulated`);
|
||||
|
||||
this.makeOutlineAndRequests(this.conversationHistory)
|
||||
.then(data => {
|
||||
if (data) {
|
||||
console.log('📤 Sending structured data to renderer');
|
||||
this.sendToRenderer('summary-update', data);
|
||||
|
||||
// Notify callback
|
||||
if (this.onAnalysisComplete) {
|
||||
this.onAnalysisComplete(data);
|
||||
}
|
||||
} else {
|
||||
console.log('❌ No analysis data returned from non-blocking call');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('❌ Error in non-blocking analysis:', error);
|
||||
});
|
||||
const data = await this.makeOutlineAndRequests(this.conversationHistory);
|
||||
if (data) {
|
||||
console.log('Sending structured data to renderer');
|
||||
this.sendToRenderer('summary-update', data);
|
||||
|
||||
// Notify callback
|
||||
if (this.onAnalysisComplete) {
|
||||
this.onAnalysisComplete(data);
|
||||
}
|
||||
} else {
|
||||
console.log('No analysis data returned');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -27,13 +27,16 @@ const NOTIFICATION_CONFIG = {
|
||||
// New facade functions for model state management
|
||||
async function getModelSettings() {
|
||||
try {
|
||||
const [config, storedKeys, availableLlm, availableStt, selectedModels] = await Promise.all([
|
||||
const [config, storedKeys, selectedModels] = await Promise.all([
|
||||
modelStateService.getProviderConfig(),
|
||||
modelStateService.getAllApiKeys(),
|
||||
modelStateService.getAvailableModels('llm'),
|
||||
modelStateService.getAvailableModels('stt'),
|
||||
modelStateService.getSelectedModels(),
|
||||
]);
|
||||
|
||||
// 동기 함수들은 별도로 호출
|
||||
const availableLlm = modelStateService.getAvailableModels('llm');
|
||||
const availableStt = modelStateService.getAvailableModels('stt');
|
||||
|
||||
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
|
||||
} catch (error) {
|
||||
console.error('[SettingsService] Error getting model settings:', error);
|
||||
|
@ -532,6 +532,7 @@ async function handleFirebaseAuthCallback(params) {
|
||||
};
|
||||
|
||||
// 1. Sync user data to local DB
|
||||
userRepository.setAuthService(authService);
|
||||
userRepository.findOrCreate(firebaseUser);
|
||||
console.log('[Auth] User data synced with local DB.');
|
||||
|
||||
|
@ -136,6 +136,9 @@ contextBridge.exposeInMainWorld('api', {
|
||||
// Listeners
|
||||
onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
|
||||
removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback),
|
||||
|
||||
onAskStreamError: (callback) => ipcRenderer.on('ask-response-stream-error', callback),
|
||||
removeOnAskStreamError: (callback) => ipcRenderer.removeListener('ask-response-stream-error', callback),
|
||||
|
||||
// Listeners
|
||||
onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
|
||||
|
File diff suppressed because it is too large
Load Diff
@ -1,18 +1,20 @@
|
||||
import './MainHeader.js';
|
||||
import './ApiKeyHeader.js';
|
||||
import './PermissionHeader.js';
|
||||
import './WelcomeHeader.js';
|
||||
|
||||
class HeaderTransitionManager {
|
||||
constructor() {
|
||||
this.headerContainer = document.getElementById('header-container');
|
||||
this.currentHeaderType = null; // 'apikey' | 'main' | 'permission'
|
||||
this.currentHeaderType = null; // 'welcome' | 'apikey' | 'main' | 'permission'
|
||||
this.welcomeHeader = null;
|
||||
this.apiKeyHeader = null;
|
||||
this.mainHeader = null;
|
||||
this.permissionHeader = null;
|
||||
|
||||
/**
|
||||
* only one header window is allowed
|
||||
* @param {'apikey'|'main'|'permission'} type
|
||||
* @param {'welcome'|'apikey'|'main'|'permission'} type
|
||||
*/
|
||||
this.ensureHeader = (type) => {
|
||||
console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
|
||||
@ -23,14 +25,25 @@ class HeaderTransitionManager {
|
||||
|
||||
this.headerContainer.innerHTML = '';
|
||||
|
||||
this.welcomeHeader = null;
|
||||
this.apiKeyHeader = null;
|
||||
this.mainHeader = null;
|
||||
this.permissionHeader = null;
|
||||
|
||||
// Create new header element
|
||||
if (type === 'apikey') {
|
||||
if (type === 'welcome') {
|
||||
this.welcomeHeader = document.createElement('welcome-header');
|
||||
this.welcomeHeader.loginCallback = () => this.handleLoginOption();
|
||||
this.welcomeHeader.apiKeyCallback = () => this.handleApiKeyOption();
|
||||
this.headerContainer.appendChild(this.welcomeHeader);
|
||||
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
|
||||
} else if (type === 'apikey') {
|
||||
this.apiKeyHeader = document.createElement('apikey-header');
|
||||
this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
|
||||
this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
|
||||
this.apiKeyHeader.addEventListener('request-resize', e => {
|
||||
this._resizeForApiKey(e.detail.height);
|
||||
});
|
||||
this.headerContainer.appendChild(this.apiKeyHeader);
|
||||
console.log('[HeaderController] ensureHeader: Header of type:', type, 'created.');
|
||||
} else if (type === 'permission') {
|
||||
@ -49,6 +62,10 @@ class HeaderTransitionManager {
|
||||
|
||||
console.log('[HeaderController] Manager initialized');
|
||||
|
||||
// WelcomeHeader 콜백 메서드들
|
||||
this.handleLoginOption = this.handleLoginOption.bind(this);
|
||||
this.handleApiKeyOption = this.handleApiKeyOption.bind(this);
|
||||
|
||||
this._bootstrap();
|
||||
|
||||
if (window.api) {
|
||||
@ -66,8 +83,14 @@ class HeaderTransitionManager {
|
||||
});
|
||||
window.api.headerController.onForceShowApiKeyHeader(async () => {
|
||||
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
const isConfigured = await window.api.apiKeyHeader.areProvidersConfigured();
|
||||
if (!isConfigured) {
|
||||
await this._resizeForWelcome();
|
||||
this.ensureHeader('welcome');
|
||||
} else {
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -88,7 +111,7 @@ class HeaderTransitionManager {
|
||||
this.handleStateUpdate(userState);
|
||||
} else {
|
||||
// Fallback for non-electron environment (testing/web)
|
||||
this.ensureHeader('apikey');
|
||||
this.ensureHeader('welcome');
|
||||
}
|
||||
}
|
||||
|
||||
@ -110,10 +133,38 @@ class HeaderTransitionManager {
|
||||
this.transitionToMainHeader();
|
||||
}
|
||||
} else {
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
// 프로바이더가 설정되지 않았으면 WelcomeHeader 먼저 표시
|
||||
await this._resizeForWelcome();
|
||||
this.ensureHeader('welcome');
|
||||
}
|
||||
}
|
||||
|
||||
// WelcomeHeader 콜백 메서드들
|
||||
async handleLoginOption() {
|
||||
console.log('[HeaderController] Login option selected');
|
||||
if (window.api) {
|
||||
await window.api.common.startFirebaseAuth();
|
||||
}
|
||||
}
|
||||
|
||||
async handleApiKeyOption() {
|
||||
console.log('[HeaderController] API key option selected');
|
||||
await this._resizeForApiKey(400);
|
||||
this.ensureHeader('apikey');
|
||||
// ApiKeyHeader에 뒤로가기 콜백 설정
|
||||
if (this.apiKeyHeader) {
|
||||
this.apiKeyHeader.backCallback = () => this.transitionToWelcomeHeader();
|
||||
}
|
||||
}
|
||||
|
||||
async transitionToWelcomeHeader() {
|
||||
if (this.currentHeaderType === 'welcome') {
|
||||
return this._resizeForWelcome();
|
||||
}
|
||||
|
||||
await this._resizeForWelcome();
|
||||
this.ensureHeader('welcome');
|
||||
}
|
||||
//////// after_modelStateService ////////
|
||||
|
||||
async transitionToPermissionHeader() {
|
||||
@ -161,15 +212,13 @@ class HeaderTransitionManager {
|
||||
async _resizeForMain() {
|
||||
if (!window.api) return;
|
||||
console.log('[HeaderController] _resizeForMain: Resizing window to 353x47');
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 })
|
||||
.catch(() => {});
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 353, height: 47 }).catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForApiKey() {
|
||||
async _resizeForApiKey(height = 370) {
|
||||
if (!window.api) return;
|
||||
console.log('[HeaderController] _resizeForApiKey: Resizing window to 350x300');
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 350, height: 300 })
|
||||
.catch(() => {});
|
||||
console.log(`[HeaderController] _resizeForApiKey: Resizing window to 456x${height}`);
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 456, height: height }).catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForPermissionHeader() {
|
||||
@ -178,6 +227,13 @@ class HeaderTransitionManager {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForWelcome() {
|
||||
if (!window.api) return;
|
||||
console.log('[HeaderController] _resizeForWelcome: Resizing window to 456x370');
|
||||
return window.api.headerController.resizeHeaderWindow({ width: 456, height: 364 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async checkPermissions() {
|
||||
if (!window.api) {
|
||||
return { success: true };
|
||||
|
234
src/ui/app/WelcomeHeader.js
Normal file
234
src/ui/app/WelcomeHeader.js
Normal file
@ -0,0 +1,234 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class WelcomeHeader extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
height: auto;
|
||||
padding: 24px 16px;
|
||||
background: rgba(0, 0, 0, 0.64);
|
||||
box-shadow: 0px 0px 0px 1.5px rgba(255, 255, 255, 0.64) inset;
|
||||
border-radius: 16px;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 32px;
|
||||
display: inline-flex;
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
.close-button {
|
||||
-webkit-app-region: no-drag;
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.15s ease;
|
||||
z-index: 10;
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
.header-section {
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
display: flex;
|
||||
}
|
||||
.title {
|
||||
color: white;
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.subtitle {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.option-card {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
display: inline-flex;
|
||||
}
|
||||
.divider {
|
||||
width: 1px;
|
||||
align-self: stretch;
|
||||
position: relative;
|
||||
background: #bebebe;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.option-content {
|
||||
flex: 1 1 0;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
display: inline-flex;
|
||||
min-width: 0;
|
||||
}
|
||||
.option-title {
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.option-description {
|
||||
color: #dcdcdc;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
line-height: 18px;
|
||||
letter-spacing: 0.12px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.action-button {
|
||||
-webkit-app-region: no-drag;
|
||||
padding: 8px 10px;
|
||||
background: rgba(132.6, 132.6, 132.6, 0.8);
|
||||
box-shadow: 0px 2px 2px rgba(0, 0, 0, 0.16);
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.5);
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
.action-button:hover {
|
||||
background: rgba(150, 150, 150, 0.9);
|
||||
}
|
||||
.button-text {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.button-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.arrow-icon {
|
||||
border: solid white;
|
||||
border-width: 0 1.2px 1.2px 0;
|
||||
display: inline-block;
|
||||
padding: 3px;
|
||||
transform: rotate(-45deg);
|
||||
-webkit-transform: rotate(-45deg);
|
||||
}
|
||||
.footer {
|
||||
align-self: stretch;
|
||||
text-align: center;
|
||||
color: #dcdcdc;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
line-height: 19.2px;
|
||||
}
|
||||
.footer-link {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
loginCallback: { type: Function },
|
||||
apiKeyCallback: { type: Function },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.loginCallback = () => {};
|
||||
this.apiKeyCallback = () => {};
|
||||
this.handleClose = this.handleClose.bind(this);
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
super.updated(changedProperties);
|
||||
this.dispatchEvent(new CustomEvent('content-changed', { bubbles: true, composed: true }));
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="container">
|
||||
<button class="close-button" @click=${this.handleClose}>×</button>
|
||||
<div class="header-section">
|
||||
<div class="title">Welcome to Glass</div>
|
||||
<div class="subtitle">Choose how to connect your AI model</div>
|
||||
</div>
|
||||
<div class="option-card">
|
||||
<div class="divider"></div>
|
||||
<div class="option-content">
|
||||
<div class="option-title">Quick start with default API key</div>
|
||||
<div class="option-description">
|
||||
100% free with Pickle's OpenAI key<br/>No personal data collected<br/>Sign up with Google in seconds
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-button" @click=${this.loginCallback}>
|
||||
<div class="button-text">Open Browser to Log in</div>
|
||||
<div class="button-icon"><div class="arrow-icon"></div></div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="option-card">
|
||||
<div class="divider"></div>
|
||||
<div class="option-content">
|
||||
<div class="option-title">Use Personal API keys</div>
|
||||
<div class="option-description">
|
||||
Costs may apply based on your API usage<br/>No personal data collected<br/>Use your own API keys (OpenAI, Gemini, etc.)
|
||||
</div>
|
||||
</div>
|
||||
<button class="action-button" @click=${this.apiKeyCallback}>
|
||||
<div class="button-text">Enter Your API Key</div>
|
||||
<div class="button-icon"><div class="arrow-icon"></div></div>
|
||||
</button>
|
||||
</div>
|
||||
<div class="footer">
|
||||
Glass does not collect your personal data —
|
||||
<span class="footer-link" @click=${this.openPrivacyPolicy}>See details</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
openPrivacyPolicy() {
|
||||
if (window.require) {
|
||||
window.require('electron').shell.openExternal('https://pickleglass.com/privacy');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('welcome-header', WelcomeHeader);
|
@ -1,5 +1,5 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js';
|
||||
// import { getOllamaProgressTracker } from '../../features/common/services/localProgressTracker.js'; // 제거됨
|
||||
|
||||
export class SettingsView extends LitElement {
|
||||
static styles = css`
|
||||
@ -531,7 +531,6 @@ export class SettingsView extends LitElement {
|
||||
this.ollamaStatus = { installed: false, running: false };
|
||||
this.ollamaModels = [];
|
||||
this.installingModels = {}; // { modelName: progress }
|
||||
this.progressTracker = getOllamaProgressTracker();
|
||||
// Whisper related
|
||||
this.whisperModels = [];
|
||||
this.whisperProgressTracker = null; // Will be initialized when needed
|
||||
@ -775,31 +774,42 @@ export class SettingsView extends LitElement {
|
||||
}
|
||||
|
||||
async installOllamaModel(modelName) {
|
||||
// Mark as installing
|
||||
this.installingModels = { ...this.installingModels, [modelName]: 0 };
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
// Use the clean progress tracker - no manual event management needed
|
||||
const success = await this.progressTracker.installModel(modelName, (progress) => {
|
||||
this.installingModels = { ...this.installingModels, [modelName]: progress };
|
||||
this.requestUpdate();
|
||||
});
|
||||
|
||||
if (success) {
|
||||
// Refresh status after installation
|
||||
await this.refreshOllamaStatus();
|
||||
await this.refreshModelData();
|
||||
// Auto-select the model after installation
|
||||
await this.selectModel('llm', modelName);
|
||||
} else {
|
||||
alert(`Installation of ${modelName} was cancelled`);
|
||||
// Ollama 모델 다운로드 시작
|
||||
this.installingModels = { ...this.installingModels, [modelName]: 0 };
|
||||
this.requestUpdate();
|
||||
|
||||
// 진행률 이벤트 리스너 설정
|
||||
const progressHandler = (event, data) => {
|
||||
if (data.modelId === modelName) {
|
||||
this.installingModels = { ...this.installingModels, [modelName]: data.progress };
|
||||
this.requestUpdate();
|
||||
}
|
||||
};
|
||||
|
||||
// 진행률 이벤트 리스너 등록
|
||||
window.api.settingsView.onOllamaPullProgress(progressHandler);
|
||||
|
||||
try {
|
||||
const result = await window.api.settingsView.pullOllamaModel(modelName);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[SettingsView] Model ${modelName} installed successfully`);
|
||||
delete this.installingModels[modelName];
|
||||
this.requestUpdate();
|
||||
|
||||
// 상태 새로고침
|
||||
await this.refreshOllamaStatus();
|
||||
await this.refreshModelData();
|
||||
} else {
|
||||
throw new Error(result.error || 'Installation failed');
|
||||
}
|
||||
} finally {
|
||||
// 진행률 이벤트 리스너 제거
|
||||
window.api.settingsView.removeOnOllamaPullProgress(progressHandler);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[SettingsView] Error installing model ${modelName}:`, error);
|
||||
alert(`Error installing ${modelName}: ${error.message}`);
|
||||
} finally {
|
||||
// Automatic cleanup - no manual event listener management
|
||||
delete this.installingModels[modelName];
|
||||
this.requestUpdate();
|
||||
}
|
||||
@ -891,7 +901,7 @@ export class SettingsView extends LitElement {
|
||||
const installingModels = Object.keys(this.installingModels);
|
||||
if (installingModels.length > 0) {
|
||||
installingModels.forEach(modelName => {
|
||||
this.progressTracker.cancelInstallation(modelName);
|
||||
window.api.settingsView.cancelOllamaInstallation(modelName);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user