Merge remote-tracking branch 'origin/main' into pr-82

This commit is contained in:
sanio 2025-07-09 18:25:52 +09:00
commit 338eef7112
25 changed files with 1405 additions and 1035 deletions

View File

@ -42,3 +42,4 @@ jobs:
SLACK_MESSAGE: "😭 Build failed for `${{ github.repository }}` repo on main branch."
SLACK_COLOR: 'danger'
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}

3
.gitmodules vendored Normal file
View File

@ -0,0 +1,3 @@
[submodule "aec"]
path = aec
url = https://github.com/samtiz/aec.git

View File

@ -14,8 +14,6 @@
> This project is a fork of [CheatingDaddy](https://github.com/sohzm/cheating-daddy) with modifications and enhancements. Thanks to [Soham](https://x.com/soham_btw) and all the open-source contributors who made this possible!
> Currently, we're working on a full code refactor and modularization. Once that's completed, we'll jump into addressing the major issues. You can find WIP issues & changelog below this document.
🤖 **Fast, light & open-source**—Glass lives on your desktop, sees what you see, listens in real time, understands your context, and turns every moment into structured knowledge.
💬 **Proactive in meetings**—it surfaces action items, summaries, and answers the instant you need them.
@ -117,15 +115,17 @@ We have a list of [help wanted](https://github.com/pickle-com/glass/issues?q=is%
| Status | Issue | Description |
|--------|--------------------------------|---------------------------------------------------|
| 🚧 WIP | Windows Build | Make Glass buildable & runnable in Windows |
| 🚧 WIP | Local LLM Support | Supporting Local LLM to power AI answers |
| 🚧 WIP | AEC Improvement | Transcription is not working occasionally |
| 🚧 WIP | Firebase Data Storage Issue | Session & ask should be saved in firebase for signup users |
| 🚧 WIP | Liquid Glass | Liquid Glass UI for MacOS 26 |
### Changelog
- Jul 5: Now support Gemini, Intel Mac supported
- Jul 6: Full code refactoring has done.
- Jul 7: Now support Claude, LLM/STT model selection
- Jul 8: Now support Windows(beta), Improved AEC by Rust(to seperate mic/system audio), shortcut editing(beta)
## About Pickle

1
aec Submodule

@ -0,0 +1 @@
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "pickle-glass",
"version": "0.2.1",
"version": "0.2.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pickle-glass",
"version": "0.2.1",
"version": "0.2.3",
"hasInstallScript": true,
"license": "GPL-3.0",
"dependencies": {

View File

@ -1,7 +1,9 @@
{
"name": "pickle-glass",
"productName": "Glass",
"version": "0.2.2",
"version": "0.2.3",
"description": "Cl*ely for Free",
"main": "src/index.js",
"scripts": {
@ -39,7 +41,6 @@
"better-sqlite3": "^9.4.3",
"cors": "^2.8.5",
"dotenv": "^17.0.0",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",

View File

@ -3,11 +3,12 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class MainHeader extends LitElement {
static properties = {
isSessionActive: { type: Boolean, state: true },
shortcuts: { type: Object, state: true },
};
static styles = css`
:host {
display: block;
display: flex;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
transition: transform 0.2s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.2s ease-out;
@ -99,7 +100,7 @@ export class MainHeader extends LitElement {
}
.header {
width: 100%;
width: max-content;
height: 47px;
padding: 2px 10px 2px 13px;
background: transparent;
@ -212,16 +213,6 @@ export class MainHeader extends LitElement {
}
.action-button,
.settings-button {
background: transparent;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.action-text {
padding-bottom: 1px;
justify-content: center;
@ -275,7 +266,14 @@ export class MainHeader extends LitElement {
.settings-button {
padding: 5px;
border-radius: 50%;
background: transparent;
transition: background 0.15s ease;
color: white;
border: none;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
}
.settings-button:hover {
@ -286,6 +284,7 @@ export class MainHeader extends LitElement {
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
}
.settings-icon svg {
@ -346,6 +345,7 @@ export class MainHeader extends LitElement {
constructor() {
super();
this.shortcuts = {};
this.dragState = null;
this.wasJustDragged = false;
this.isVisible = true;
@ -501,6 +501,11 @@ export class MainHeader extends LitElement {
this.isSessionActive = isActive;
};
ipcRenderer.on('session-state-changed', this._sessionStateListener);
this._shortcutListener = (event, keybinds) => {
console.log('[MainHeader] Received updated shortcuts:', keybinds);
this.shortcuts = keybinds;
};
ipcRenderer.on('shortcuts-updated', this._shortcutListener);
}
}
@ -518,6 +523,9 @@ export class MainHeader extends LitElement {
if (this._sessionStateListener) {
ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
}
if (this._shortcutListener) {
ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener);
}
}
}
@ -567,6 +575,29 @@ export class MainHeader extends LitElement {
}
renderShortcut(accelerator) {
if (!accelerator) return html``;
const keyMap = {
'Cmd': '⌘', 'Command': '⌘',
'Ctrl': '⌃', 'Control': '⌃',
'Alt': '⌥', 'Option': '⌥',
'Shift': '⇧',
'Enter': '↵',
'Backspace': '⌫',
'Delete': '⌦',
'Tab': '⇥',
'Escape': '⎋',
'Up': '↑', 'Down': '↓', 'Left': '←', 'Right': '→',
'\\': html`<svg viewBox="0 0 6 12" fill="none" xmlns="http://www.w3.org/2000/svg" style="width:6px; height:12px;"><path d="M1.5 1.3L5.1 10.6" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/></svg>`,
};
const keys = accelerator.split('+');
return html`${keys.map(key => html`
<div class="icon-box">${keyMap[key] || key}</div>
`)}`;
}
render() {
return html`
<div class="header" @mousedown=${this.handleMouseDown}>
@ -599,14 +630,8 @@ export class MainHeader extends LitElement {
<div class="action-text">
<div class="action-text-content">Ask</div>
</div>
<div class="icon-container ask-icons">
<div class="icon-box"></div>
<div class="icon-box">
<svg viewBox="0 0 15 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.41797 8.16406C2.41797 8.00935 2.47943 7.86098 2.58882 7.75158C2.69822 7.64219 2.84659 7.58073 3.0013 7.58073H10.0013C10.4654 7.58073 10.9106 7.39636 11.2387 7.06817C11.5669 6.73998 11.7513 6.29486 11.7513 5.83073V3.4974C11.7513 3.34269 11.8128 3.19431 11.9222 3.08492C12.0316 2.97552 12.1799 2.91406 12.3346 2.91406C12.4893 2.91406 12.6377 2.97552 12.7471 3.08492C12.8565 3.19431 12.918 3.34269 12.918 3.4974V5.83073C12.918 6.60428 12.6107 7.34614 12.0637 7.89312C11.5167 8.44011 10.7748 8.7474 10.0013 8.7474H3.0013C2.84659 8.7474 2.69822 8.68594 2.58882 8.57654C2.47943 8.46715 2.41797 8.31877 2.41797 8.16406Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M2.58876 8.57973C2.4794 8.47034 2.41797 8.32199 2.41797 8.16731C2.41797 8.01263 2.4794 7.86429 2.58876 7.75489L4.92209 5.42156C5.03211 5.3153 5.17946 5.25651 5.33241 5.25783C5.48536 5.25916 5.63167 5.32051 5.73982 5.42867C5.84798 5.53682 5.90932 5.68313 5.91065 5.83608C5.91198 5.98903 5.85319 6.13638 5.74693 6.24639L3.82601 8.16731L5.74693 10.0882C5.80264 10.142 5.84708 10.2064 5.87765 10.2776C5.90823 10.3487 5.92432 10.4253 5.92499 10.5027C5.92566 10.5802 5.9109 10.657 5.88157 10.7287C5.85224 10.8004 5.80893 10.8655 5.75416 10.9203C5.69939 10.9751 5.63426 11.0184 5.56257 11.0477C5.49088 11.077 5.41406 11.0918 5.33661 11.0911C5.25916 11.0905 5.18261 11.0744 5.11144 11.0438C5.04027 11.0132 4.9759 10.9688 4.92209 10.9131L2.58876 8.57973Z" fill="white"/>
</svg>
</div>
<div class="icon-container">
${this.renderShortcut(this.shortcuts.nextStep)}
</div>
</div>
@ -614,13 +639,8 @@ export class MainHeader extends LitElement {
<div class="action-text">
<div class="action-text-content">Show/Hide</div>
</div>
<div class="icon-container showhide-icons">
<div class="icon-box"></div>
<div class="icon-box">
<svg viewBox="0 0 6 12" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1.50391 1.32812L5.16391 10.673" stroke="white" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div class="icon-container">
${this.renderShortcut(this.shortcuts.toggleVisibility)}
</div>
</div>

View File

@ -2,6 +2,7 @@ import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
import { SettingsView } from '../features/settings/SettingsView.js';
import { AssistantView } from '../features/listen/AssistantView.js';
import { AskView } from '../features/ask/AskView.js';
import { ShortcutSettingsView } from '../features/settings/ShortCutSettingsView.js';
import '../features/listen/renderer/renderer.js';
@ -268,6 +269,8 @@ export class PickleGlassApp extends LitElement {
.onProfileChange=${profile => (this.selectedProfile = profile)}
.onLanguageChange=${lang => (this.selectedLanguage = lang)}
></settings-view>`;
case 'shortcut-settings':
return html`<shortcut-settings-view></shortcut-settings-view>`;
case 'history':
return html`<history-view></history-view>`;
case 'help':

20
src/assets/aec.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,5 @@
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { GoogleGenAI } = require('@google/genai');
const { GoogleGenerativeAI } = require("@google/generative-ai")
const { GoogleGenAI } = require("@google/genai")
/**
* Creates a Gemini STT session
@ -9,322 +9,294 @@ const { GoogleGenAI } = require('@google/genai');
* @param {object} [opts.callbacks] - Event callbacks
* @returns {Promise<object>} STT session
*/
async function createSTT({ apiKey, language = 'en-US', callbacks = {}, ...config }) {
const liveClient = new GoogleGenAI({ vertexai: false, apiKey });
async function createSTT({ apiKey, language = "en-US", callbacks = {}, ...config }) {
const liveClient = new GoogleGenAI({ vertexai: false, apiKey })
// Language code BCP-47 conversion
const lang = language.includes('-') ? language : `${language}-US`;
const lang = language.includes("-") ? language : `${language}-US`
const session = await liveClient.live.connect({
model: 'gemini-live-2.5-flash-preview',
callbacks,
callbacks: {
...callbacks,
onMessage: (msg) => {
if (!msg || typeof msg !== 'object') return;
msg.provider = 'gemini';
callbacks.onmessage?.(msg);
}
},
config: {
inputAudioTranscription: {},
speechConfig: { languageCode: lang },
},
});
})
return {
sendRealtimeInput: async payload => session.sendRealtimeInput(payload),
sendRealtimeInput: async (payload) => session.sendRealtimeInput(payload),
close: async () => session.close(),
};
}
}
/**
* Creates a Gemini LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Gemini API key
* @param {string} [opts.model='gemini-2.5-flash'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=8192] - Max tokens
* @returns {object} LLM instance
* Creates a Gemini LLM instance with proper text response handling
*/
function createLLM({ apiKey, model = 'gemini-2.5-flash', temperature = 0.7, maxTokens = 8192, ...config }) {
const client = new GoogleGenerativeAI(apiKey);
function createLLM({ apiKey, model = "gemini-2.5-flash", temperature = 0.7, maxTokens = 8192, ...config }) {
const client = new GoogleGenerativeAI(apiKey)
return {
generateContent: async (parts) => {
const geminiModel = client.getGenerativeModel({ model: model });
const geminiModel = client.getGenerativeModel({
model: model,
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
// Ensure we get text responses, not JSON
responseMimeType: "text/plain",
},
})
let systemPrompt = '';
let userContent = [];
const systemPrompt = ""
const userContent = []
for (const part of parts) {
if (typeof part === 'string') {
if (systemPrompt === '' && part.includes('You are')) {
systemPrompt = part;
} else {
userContent.push(part);
}
if (typeof part === "string") {
// Don't automatically assume strings starting with "You are" are system prompts
// Check if it's explicitly marked as a system instruction
userContent.push(part)
} else if (part.inlineData) {
// Convert base64 image data to Gemini format
userContent.push({
inlineData: {
mimeType: part.inlineData.mimeType,
data: part.inlineData.data
}
});
data: part.inlineData.data,
},
})
}
}
// Prepare content array
const content = [];
// Add system instruction if present
if (systemPrompt) {
// For Gemini, we'll prepend system prompt to user content
content.push(systemPrompt + '\n\n' + userContent[0]);
content.push(...userContent.slice(1));
} else {
content.push(...userContent);
}
try {
const result = await geminiModel.generateContent(content);
const response = await result.response;
const result = await geminiModel.generateContent(userContent)
const response = await result.response
// Return plain text, not wrapped in JSON structure
return {
response: {
text: () => response.text()
}
};
text: () => response.text(),
},
}
} catch (error) {
console.error('Gemini API error:', error);
throw error;
console.error("Gemini API error:", error)
throw error
}
},
// For compatibility with chat-style interfaces
chat: async (messages) => {
// Extract system instruction if present
let systemInstruction = '';
const history = [];
let lastMessage;
// Filter out any system prompts that might be causing JSON responses
let systemInstruction = ""
const history = []
let lastMessage
messages.forEach((msg, index) => {
if (msg.role === 'system') {
systemInstruction = msg.content;
return;
if (msg.role === "system") {
// Clean system instruction - avoid JSON formatting requests
systemInstruction = msg.content
.replace(/respond in json/gi, "")
.replace(/format.*json/gi, "")
.replace(/return.*json/gi, "")
// Add explicit instruction for natural text
if (!systemInstruction.includes("respond naturally")) {
systemInstruction += "\n\nRespond naturally in plain text, not in JSON or structured format."
}
return
}
// Gemini's history format
const role = msg.role === 'user' ? 'user' : 'model';
const role = msg.role === "user" ? "user" : "model"
if (index === messages.length - 1) {
lastMessage = msg;
lastMessage = msg
} else {
history.push({ role, parts: [{ text: msg.content }] });
history.push({ role, parts: [{ text: msg.content }] })
}
});
})
const geminiModel = client.getGenerativeModel({
model: model,
systemInstruction: systemInstruction
});
const chat = geminiModel.startChat({
history: history,
systemInstruction:
systemInstruction ||
"Respond naturally in plain text format. Do not use JSON or structured responses unless specifically requested.",
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens,
}
});
// Force plain text responses
responseMimeType: "text/plain",
},
})
// Get the last user message content
let content = lastMessage.content;
const chat = geminiModel.startChat({
history: history,
})
// Handle multimodal content for the last message
let content = lastMessage.content
// Handle multimodal content
if (Array.isArray(content)) {
const geminiContent = [];
const geminiContent = []
for (const part of content) {
if (typeof part === 'string') {
geminiContent.push(part);
} else if (part.type === 'text') {
geminiContent.push(part.text);
} else if (part.type === 'image_url' && part.image_url) {
// Convert base64 image to Gemini format
const base64Data = part.image_url.url.split(',')[1];
if (typeof part === "string") {
geminiContent.push(part)
} else if (part.type === "text") {
geminiContent.push(part.text)
} else if (part.type === "image_url" && part.image_url) {
const base64Data = part.image_url.url.split(",")[1]
geminiContent.push({
inlineData: {
mimeType: 'image/png',
data: base64Data
}
});
mimeType: "image/png",
data: base64Data,
},
})
}
}
content = geminiContent;
content = geminiContent
}
const result = await chat.sendMessage(content);
const response = await result.response;
const result = await chat.sendMessage(content)
const response = await result.response
// Return plain text content
return {
content: response.text(),
raw: result
};
}
};
raw: result,
}
},
}
}
/**
* Creates a Gemini streaming LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Gemini API key
* @param {string} [opts.model='gemini-2.5-flash'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=8192] - Max tokens
* @returns {object} Streaming LLM instance
* Creates a Gemini streaming LLM instance with text response fix
*/
function createStreamingLLM({ apiKey, model = 'gemini-2.5-flash', temperature = 0.7, maxTokens = 8192, ...config }) {
const client = new GoogleGenerativeAI(apiKey);
function createStreamingLLM({ apiKey, model = "gemini-2.5-flash", temperature = 0.7, maxTokens = 8192, ...config }) {
const client = new GoogleGenerativeAI(apiKey)
return {
streamChat: async (messages) => {
console.log('[Gemini Provider] Starting streaming request');
console.log("[Gemini Provider] Starting streaming request")
// Extract system instruction if present
let systemInstruction = '';
const nonSystemMessages = [];
let systemInstruction = ""
const nonSystemMessages = []
for (const msg of messages) {
if (msg.role === 'system') {
systemInstruction = msg.content;
if (msg.role === "system") {
// Clean and modify system instruction
systemInstruction = msg.content
.replace(/respond in json/gi, "")
.replace(/format.*json/gi, "")
.replace(/return.*json/gi, "")
if (!systemInstruction.includes("respond naturally")) {
systemInstruction += "\n\nRespond naturally in plain text, not in JSON or structured format."
}
} else {
nonSystemMessages.push(msg);
nonSystemMessages.push(msg)
}
}
const geminiModel = client.getGenerativeModel({
model: model,
systemInstruction: systemInstruction || undefined
});
const chat = geminiModel.startChat({
history: [],
systemInstruction:
systemInstruction ||
"Respond naturally in plain text format. Do not use JSON or structured responses unless specifically requested.",
generationConfig: {
temperature,
maxOutputTokens: maxTokens || 8192,
}
});
// Force plain text responses
responseMimeType: "text/plain",
},
})
// Create a ReadableStream to handle Gemini's streaming
const stream = new ReadableStream({
async start(controller) {
try {
console.log('[Gemini Provider] Processing messages:', nonSystemMessages.length, 'messages (excluding system)');
const lastMessage = nonSystemMessages[nonSystemMessages.length - 1]
let geminiContent = []
// Get the last user message
const lastMessage = nonSystemMessages[nonSystemMessages.length - 1];
let lastUserMessage = lastMessage.content;
// Handle case where content might be an array (multimodal)
if (Array.isArray(lastUserMessage)) {
// Extract text content from array
const textParts = lastUserMessage.filter(part =>
typeof part === 'string' || (part && part.type === 'text')
);
lastUserMessage = textParts.map(part =>
typeof part === 'string' ? part : part.text
).join(' ');
}
console.log('[Gemini Provider] Sending message to Gemini:',
typeof lastUserMessage === 'string' ? lastUserMessage.substring(0, 100) + '...' : 'multimodal content');
// Prepare the message content for Gemini
let geminiContent = [];
// Handle multimodal content properly
if (Array.isArray(lastMessage.content)) {
for (const part of lastMessage.content) {
if (typeof part === 'string') {
geminiContent.push(part);
} else if (part.type === 'text') {
geminiContent.push(part.text);
} else if (part.type === 'image_url' && part.image_url) {
// Convert base64 image to Gemini format
const base64Data = part.image_url.url.split(',')[1];
if (typeof part === "string") {
geminiContent.push(part)
} else if (part.type === "text") {
geminiContent.push(part.text)
} else if (part.type === "image_url" && part.image_url) {
const base64Data = part.image_url.url.split(",")[1]
geminiContent.push({
inlineData: {
mimeType: 'image/png',
data: base64Data
}
});
mimeType: "image/png",
data: base64Data,
},
})
}
}
} else {
geminiContent = [lastUserMessage];
geminiContent = [lastMessage.content]
}
console.log('[Gemini Provider] Prepared Gemini content:',
geminiContent.length, 'parts');
// Stream the response
let chunkCount = 0;
let totalContent = '';
const contentParts = geminiContent.map(part => {
if (typeof part === 'string') {
return { text: part };
const contentParts = geminiContent.map((part) => {
if (typeof part === "string") {
return { text: part }
} else if (part.inlineData) {
return { inlineData: part.inlineData };
return { inlineData: part.inlineData }
}
return part;
});
return part
})
const result = await geminiModel.generateContentStream({
contents: [{
role: 'user',
parts: contentParts
}],
generationConfig: {
temperature,
maxOutputTokens: maxTokens || 8192,
}
});
contents: [
{
role: "user",
parts: contentParts,
},
],
})
for await (const chunk of result.stream) {
chunkCount++;
const chunkText = chunk.text() || '';
totalContent += chunkText;
const chunkText = chunk.text() || ""
// Format as SSE data
// Format as SSE data - this should now be plain text
const data = JSON.stringify({
choices: [{
delta: {
content: chunkText
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
choices: [
{
delta: {
content: chunkText,
},
},
],
})
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`))
}
console.log(`[Gemini Provider] Streamed ${chunkCount} chunks, total length: ${totalContent.length} chars`);
// Send the final done message
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
controller.close();
console.log('[Gemini Provider] Streaming completed successfully');
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
} catch (error) {
console.error('[Gemini Provider] Streaming error:', error);
controller.error(error);
console.error("[Gemini Provider] Streaming error:", error)
controller.error(error)
}
}
});
},
})
// Create a Response object with the stream
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
};
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
},
}
}
module.exports = {
createSTT,
createLLM,
createStreamingLLM
};
createStreamingLLM,
}

View File

@ -48,11 +48,11 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
turn_detection: {
type: 'server_vad',
threshold: 0.5,
prefix_padding_ms: 50,
silence_duration_ms: 25,
prefix_padding_ms: 200,
silence_duration_ms: 100,
},
input_audio_noise_reduction: {
type: 'near_field'
type: 'far_field'
}
}
};
@ -72,6 +72,7 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
close: () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'session.close' }));
ws.onmessage = ws.onerror = () => {}; // 핸들러 제거
ws.close(1000, 'Client initiated close.');
}
}
@ -79,10 +80,17 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (callbacks && callbacks.onmessage) {
callbacks.onmessage(message);
}
// ── 종료·하트비트 패킷 필터링 ──────────────────────────────
if (!event.data || event.data === 'null' || event.data === '[DONE]') return;
let msg;
try { msg = JSON.parse(event.data); }
catch { return; } // JSON 파싱 실패 무시
if (!msg || typeof msg !== 'object') return;
msg.provider = 'openai'; // ← 항상 명시
callbacks.onmessage?.(msg);
};
ws.onerror = (error) => {

View File

@ -6,7 +6,8 @@ const LATEST_SCHEMA = {
{ name: 'email', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'api_key', type: 'TEXT' },
{ name: 'provider', type: 'TEXT DEFAULT \'openai\'' }
{ name: 'provider', type: 'TEXT DEFAULT \'openai\'' },
{ name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' }
]
},
sessions: {

View File

@ -37,56 +37,64 @@ class AuthService {
this.currentUserMode = 'local'; // 'local' or 'firebase'
this.currentUser = null;
this.isInitialized = false;
this.initializationPromise = null;
}
initialize() {
if (this.isInitialized) return;
if (this.isInitialized) return this.initializationPromise;
const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => {
const previousUser = this.currentUser;
this.initializationPromise = new Promise((resolve) => {
const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => {
const previousUser = this.currentUser;
if (user) {
// User signed IN
console.log(`[AuthService] Firebase user signed in:`, user.uid);
this.currentUser = user;
this.currentUserId = user.uid;
this.currentUserMode = 'firebase';
if (user) {
// User signed IN
console.log(`[AuthService] Firebase user signed in:`, user.uid);
this.currentUser = user;
this.currentUserId = user.uid;
this.currentUserMode = 'firebase';
// Start background task to fetch and save virtual key
(async () => {
try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
// Start background task to fetch and save virtual key
(async () => {
try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(virtualKey);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(virtualKey);
}
console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
} catch (error) {
console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
}
console.log(`[AuthService] BG: Virtual key for ${user.email} has been processed.`);
})();
} catch (error) {
console.error('[AuthService] BG: Failed to fetch or save virtual key:', error);
}
})();
} else {
// User signed OUT
console.log(`[AuthService] No Firebase user.`);
if (previousUser) {
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(null);
} else {
// User signed OUT
console.log(`[AuthService] No Firebase user.`);
if (previousUser) {
console.log(`[AuthService] Clearing API key for logged-out user: ${previousUser.uid}`);
if (global.modelStateService) {
global.modelStateService.setFirebaseVirtualKey(null);
}
}
this.currentUser = null;
this.currentUserId = 'default_user';
this.currentUserMode = 'local';
}
this.currentUser = null;
this.currentUserId = 'default_user';
this.currentUserMode = 'local';
}
this.broadcastUserState();
this.broadcastUserState();
if (!this.isInitialized) {
this.isInitialized = true;
console.log('[AuthService] Initialized and resolved initialization promise.');
resolve();
}
});
});
this.isInitialized = true;
console.log('[AuthService] Initialized and attached to Firebase Auth state.');
return this.initializationPromise;
}
async signInWithCustomToken(token) {

View File

@ -67,6 +67,11 @@ class ModelStateService {
};
this.state = this.store.get(`users.${userId}`, defaultState);
console.log(`[ModelStateService] State loaded for user: ${userId}`);
for (const p of Object.keys(PROVIDERS)) {
if (!(p in this.state.apiKeys)) {
this.state.apiKeys[p] = null;
}
}
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();

View File

@ -11,7 +11,13 @@ const authService = require('../common/services/authService');
const systemSettingsRepository = require('../common/repositories/systemSettings');
const userRepository = require('../common/repositories/user');
const fetch = require('node-fetch');
const Store = require('electron-store');
const shortCutStore = new Store({
name: 'user-preferences',
defaults: {
customKeybinds: {}
}
});
/* ────────────────[ GLASS BYPASS ]─────────────── */
let liquidGlass;
@ -51,6 +57,7 @@ let settingsHideTimer = null;
let selectedCaptureSourceId = null;
// let shortcutEditorWindow = null;
let layoutManager = null;
function updateLayout() {
if (layoutManager) {
@ -60,16 +67,16 @@ function updateLayout() {
let movementManager = null;
let storedProvider = 'openai';
const featureWindows = ['listen','ask','settings'];
// const featureWindows = ['listen','ask','settings','shortcut-settings'];
function isAllowed(name) {
if (name === 'header') return true;
return featureWindows.includes(name) && currentHeaderState === 'main';
}
function createFeatureWindows(header) {
if (windowPool.has('listen')) return;
function createFeatureWindows(header, namesToCreate) {
// if (windowPool.has('listen')) return;
const commonChildOptions = {
parent: header,
@ -84,106 +91,207 @@ function createFeatureWindows(header) {
webPreferences: { nodeIntegration: true, contextIsolation: false },
};
// listen
const listen = new BrowserWindow({
...commonChildOptions, width:400,minWidth:400,maxWidth:400,
maxHeight:700,
});
listen.setContentProtection(isContentProtectionOn);
listen.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') {
listen.setWindowButtonVisibility(false);
}
const listenLoadOptions = { query: { view: 'listen' } };
if (!shouldUseLiquidGlass) {
listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions);
}
else {
listenLoadOptions.query.glass = 'true';
listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions);
listen.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(listen.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
const createFeatureWindow = (name) => {
if (windowPool.has(name)) return;
switch (name) {
case 'listen': {
const listen = new BrowserWindow({
...commonChildOptions, width:400,minWidth:400,maxWidth:400,
maxHeight:700,
});
listen.setContentProtection(isContentProtectionOn);
listen.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') {
listen.setWindowButtonVisibility(false);
}
const listenLoadOptions = { query: { view: 'listen' } };
if (!shouldUseLiquidGlass) {
listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions);
}
else {
listenLoadOptions.query.glass = 'true';
listen.loadFile(path.join(__dirname, '../app/content.html'), listenLoadOptions);
listen.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(listen.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
windowPool.set('listen', listen);
break;
}
});
}
// ask
case 'ask': {
const ask = new BrowserWindow({ ...commonChildOptions, width:600 });
ask.setContentProtection(isContentProtectionOn);
ask.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') {
ask.setWindowButtonVisibility(false);
}
const askLoadOptions = { query: { view: 'ask' } };
if (!shouldUseLiquidGlass) {
ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions);
}
else {
askLoadOptions.query.glass = 'true';
ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions);
ask.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(ask.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
windowPool.set('listen', listen);
ask.on('blur',()=>ask.webContents.send('window-blur'));
// ask
const ask = new BrowserWindow({ ...commonChildOptions, width:600 });
ask.setContentProtection(isContentProtectionOn);
ask.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') {
ask.setWindowButtonVisibility(false);
}
const askLoadOptions = { query: { view: 'ask' } };
if (!shouldUseLiquidGlass) {
ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions);
}
else {
askLoadOptions.query.glass = 'true';
ask.loadFile(path.join(__dirname, '../app/content.html'), askLoadOptions);
ask.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(ask.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
// Open DevTools in development
if (!app.isPackaged) {
ask.webContents.openDevTools({ mode: 'detach' });
}
windowPool.set('ask', ask);
break;
}
});
}
ask.on('blur',()=>ask.webContents.send('window-blur'));
// Open DevTools in development
if (!app.isPackaged) {
ask.webContents.openDevTools({ mode: 'detach' });
}
windowPool.set('ask', ask);
// settings
const settings = new BrowserWindow({ ...commonChildOptions, width:240, maxHeight:400, parent:undefined });
settings.setContentProtection(isContentProtectionOn);
settings.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') {
settings.setWindowButtonVisibility(false);
}
const settingsLoadOptions = { query: { view: 'settings' } };
if (!shouldUseLiquidGlass) {
settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions)
.catch(console.error);
}
else {
settingsLoadOptions.query.glass = 'true';
settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions)
.catch(console.error);
settings.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(settings.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
// settings
case 'settings': {
const settings = new BrowserWindow({ ...commonChildOptions, width:240, maxHeight:400, parent:undefined });
settings.setContentProtection(isContentProtectionOn);
settings.setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true});
if (process.platform === 'darwin') {
settings.setWindowButtonVisibility(false);
}
const settingsLoadOptions = { query: { view: 'settings' } };
if (!shouldUseLiquidGlass) {
settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions)
.catch(console.error);
}
else {
settingsLoadOptions.query.glass = 'true';
settings.loadFile(path.join(__dirname,'../app/content.html'), settingsLoadOptions)
.catch(console.error);
settings.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(settings.getNativeWindowHandle(), {
cornerRadius: 12,
tintColor: '#FF00001A', // Red tint
opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
// liquidGlass.unstable_setScrim(viewId, 1);
// liquidGlass.unstable_setSubdued(viewId, 1);
}
});
}
windowPool.set('settings', settings);
break;
}
});
case 'shortcut-settings': {
const shortcutEditor = new BrowserWindow({
...commonChildOptions,
width: 420,
height: 720,
modal: false,
parent: undefined,
alwaysOnTop: true,
titleBarOverlay: false,
});
if (process.platform === 'darwin') {
shortcutEditor.setAlwaysOnTop(true, 'screen-saver');
} else {
shortcutEditor.setAlwaysOnTop(true);
}
/* ──────────[ ① 다른 창 클릭 차단 ]────────── */
const disableClicks = () => {
for (const [name, win] of windowPool) {
if (win !== shortcutEditor && !win.isDestroyed()) {
win.setIgnoreMouseEvents(true, { forward: true });
}
}
};
const restoreClicks = () => {
for (const [, win] of windowPool) {
if (!win.isDestroyed()) win.setIgnoreMouseEvents(false);
}
};
const header = windowPool.get('header');
if (header && !header.isDestroyed()) {
const { x, y, width } = header.getBounds();
shortcutEditor.setBounds({ x, y, width });
}
shortcutEditor.once('ready-to-show', () => {
disableClicks();
shortcutEditor.show();
});
const loadOptions = { query: { view: 'shortcut-settings' } };
if (!shouldUseLiquidGlass) {
shortcutEditor.loadFile(path.join(__dirname, '../app/content.html'), loadOptions);
} else {
loadOptions.query.glass = 'true';
shortcutEditor.loadFile(path.join(__dirname, '../app/content.html'), loadOptions);
shortcutEditor.webContents.once('did-finish-load', () => {
const viewId = liquidGlass.addView(shortcutEditor.getNativeWindowHandle(), {
cornerRadius: 12, tintColor: '#FF00001A', opaque: false,
});
if (viewId !== -1) {
liquidGlass.unstable_setVariant(viewId, 2);
}
});
}
shortcutEditor.on('closed', () => {
restoreClicks();
windowPool.delete('shortcut-settings');
console.log('[Shortcuts] Re-enabled after editing.');
loadAndRegisterShortcuts(movementManager);
});
shortcutEditor.webContents.once('dom-ready', async () => {
const savedKeybinds = shortCutStore.get('customKeybinds', {});
const defaultKeybinds = getDefaultKeybinds();
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
shortcutEditor.webContents.send('load-shortcuts', keybinds);
});
if (!app.isPackaged) {
shortcutEditor.webContents.openDevTools({ mode: 'detach' });
}
windowPool.set('shortcut-settings', shortcutEditor);
break;
}
}
};
if (Array.isArray(namesToCreate)) {
namesToCreate.forEach(name => createFeatureWindow(name));
} else if (typeof namesToCreate === 'string') {
createFeatureWindow(namesToCreate);
} else {
createFeatureWindow('listen');
createFeatureWindow('ask');
createFeatureWindow('settings');
}
windowPool.set('settings', settings);
}
function destroyFeatureWindows() {
@ -199,6 +307,7 @@ function destroyFeatureWindows() {
}
function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
@ -354,7 +463,7 @@ function createWindows() {
setupIpcHandlers(movementManager);
if (currentHeaderState === 'main') {
createFeatureWindows(header);
createFeatureWindows(header, ['listen', 'ask', 'settings', 'shortcut-settings']);
}
header.setContentProtection(isContentProtectionOn);
@ -385,10 +494,6 @@ function createWindows() {
header.on('resize', updateLayout);
// header.webContents.once('dom-ready', () => {
// loadAndRegisterShortcuts();
// });
ipcMain.handle('toggle-all-windows-visibility', () => toggleAllWindowsVisibility(movementManager));
ipcMain.handle('toggle-feature', async (event, featureName) => {
@ -584,37 +689,32 @@ function createWindows() {
}
});
// setupIpcHandlers();
return windowPool;
}
function loadAndRegisterShortcuts(movementManager) {
if (windowPool.has('shortcut-settings')) {
console.log('[Shortcuts] Editing in progress, skipping registration.');
return;
}
const defaultKeybinds = getDefaultKeybinds();
const header = windowPool.get('header');
const savedKeybinds = shortCutStore.get('customKeybinds', {});
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
const sendToRenderer = (channel, ...args) => {
windowPool.forEach(win => {
try {
if (win && !win.isDestroyed()) {
if (win && !win.isDestroyed()) {
try {
win.webContents.send(channel, ...args);
} catch (e) {
// 창이 이미 닫혔을 수 있으므로 오류를 무시합니다.
}
} catch (e) {}
}
});
};
if (!header) {
return updateGlobalShortcuts(defaultKeybinds, undefined, sendToRenderer, movementManager);
}
header.webContents
.executeJavaScript(`(() => localStorage.getItem('customKeybinds'))()`)
.then(saved => (saved ? JSON.parse(saved) : {}))
.then(savedKeybinds => {
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
updateGlobalShortcuts(keybinds, header, sendToRenderer, movementManager);
})
.catch(() => updateGlobalShortcuts(defaultKeybinds, header, sendToRenderer, movementManager));
updateGlobalShortcuts(keybinds, windowPool.get('header'), sendToRenderer, movementManager);
}
@ -768,6 +868,7 @@ function setupIpcHandlers(movementManager) {
} else { // 'apikey' | 'permission'
destroyFeatureWindows();
}
loadAndRegisterShortcuts(movementManager);
for (const [name, win] of windowPool) {
if (!isAllowed(name) && !win.isDestroyed()) {
@ -777,36 +878,69 @@ function setupIpcHandlers(movementManager) {
win.show();
}
}
const header = windowPool.get('header');
if (header && !header.isDestroyed()) {
header.webContents
.executeJavaScript(`(() => localStorage.getItem('customKeybinds'))()`)
.then(saved => {
const defaultKeybinds = getDefaultKeybinds();
const savedKeybinds = saved ? JSON.parse(saved) : {};
const keybinds = { ...defaultKeybinds, ...savedKeybinds };
const sendToRenderer = (channel, ...args) => {
windowPool.forEach(win => {
try {
if (win && !win.isDestroyed()) {
win.webContents.send(channel, ...args);
}
} catch (e) {}
});
};
updateGlobalShortcuts(keybinds, header, sendToRenderer, movementManager);
})
.catch(console.error);
}
});
ipcMain.on('update-keybinds', (event, newKeybinds) => {
updateGlobalShortcuts(newKeybinds);
});
ipcMain.handle('get-current-shortcuts', () => {
const defaultKeybinds = getDefaultKeybinds();
const savedKeybinds = shortCutStore.get('customKeybinds', {});
return { ...defaultKeybinds, ...savedKeybinds };
});
ipcMain.handle('open-shortcut-editor', () => {
const header = windowPool.get('header');
if (!header) return;
// 편집기 열기 전 모든 단축키 비활성화
globalShortcut.unregisterAll();
console.log('[Shortcuts] Disabled for editing.');
createFeatureWindows(header, 'shortcut-settings');
});
ipcMain.handle('get-default-shortcuts', () => {
shortCutStore.set('customKeybinds', {});
return getDefaultKeybinds();
});
ipcMain.handle('save-shortcuts', async (event, newKeybinds) => {
try {
const defaultKeybinds = getDefaultKeybinds();
const customKeybinds = {};
for (const key in newKeybinds) {
if (newKeybinds[key] && newKeybinds[key] !== defaultKeybinds[key]) {
customKeybinds[key] = newKeybinds[key];
}
}
shortCutStore.set('customKeybinds', customKeybinds);
console.log('[Shortcuts] Custom keybinds saved to store:', customKeybinds);
const editor = windowPool.get('shortcut-settings');
if (editor && !editor.isDestroyed()) {
editor.close();
} else {
loadAndRegisterShortcuts(movementManager);
}
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
loadAndRegisterShortcuts(movementManager);
return { success: false, error: error.message };
}
});
ipcMain.on('close-shortcut-editor', () => {
const editor = windowPool.get('shortcut-settings');
if (editor && !editor.isDestroyed()) {
editor.close();
}
});
ipcMain.handle('open-login-page', () => {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
const personalizeUrl = `${webUrl}/personalize?desktop=true`;
@ -971,15 +1105,6 @@ function setupIpcHandlers(movementManager) {
console.log('[WindowManager] Received request to log out.');
await authService.signOut();
//////// before_modelStateService ////////
// await setApiKey(null);
// windowPool.forEach(win => {
// if (win && !win.isDestroyed()) {
// win.webContents.send('api-key-removed');
// }
// });
//////// before_modelStateService ////////
});
ipcMain.handle('check-system-permissions', async () => {
@ -1114,101 +1239,6 @@ function setupIpcHandlers(movementManager) {
}
//////// before_modelStateService ////////
// async function setApiKey(apiKey, provider = 'openai') {
// console.log('[WindowManager] Persisting API key and provider to DB');
// try {
// await userRepository.saveApiKey(apiKey, authService.getCurrentUserId(), provider);
// console.log('[WindowManager] API key and provider saved to SQLite');
// // Notify authService that the key status may have changed
// await authService.updateApiKeyStatus();
// } catch (err) {
// console.error('[WindowManager] Failed to save API key to SQLite:', err);
// }
// windowPool.forEach(win => {
// if (win && !win.isDestroyed()) {
// const js = apiKey ? `
// localStorage.setItem('openai_api_key', ${JSON.stringify(apiKey)});
// localStorage.setItem('ai_provider', ${JSON.stringify(provider)});
// ` : `
// localStorage.removeItem('openai_api_key');
// localStorage.removeItem('ai_provider');
// `;
// win.webContents.executeJavaScript(js).catch(() => {});
// }
// });
// }
// async function getStoredApiKey() {
// const userId = authService.getCurrentUserId();
// if (!userId) return null;
// const user = await userRepository.getById(userId);
// return user?.api_key || null;
// }
// async function getStoredProvider() {
// const userId = authService.getCurrentUserId();
// if (!userId) return 'openai';
// const user = await userRepository.getById(userId);
// return user?.provider || 'openai';
// }
// function setupApiKeyIPC() {
// const { ipcMain } = require('electron');
// // Both handlers now do the same thing: fetch the key from the source of truth.
// ipcMain.handle('get-stored-api-key', getStoredApiKey);
// ipcMain.handle('api-key-validated', async (event, data) => {
// console.log('[WindowManager] API key validation completed, saving...');
// // Support both old format (string) and new format (object)
// const apiKey = typeof data === 'string' ? data : data.apiKey;
// const provider = typeof data === 'string' ? 'openai' : (data.provider || 'openai');
// await setApiKey(apiKey, provider);
// windowPool.forEach((win, name) => {
// if (win && !win.isDestroyed()) {
// win.webContents.send('api-key-validated', { apiKey, provider });
// }
// });
// return { success: true };
// });
// ipcMain.handle('remove-api-key', async () => {
// console.log('[WindowManager] API key removal requested');
// await setApiKey(null);
// windowPool.forEach((win, name) => {
// if (win && !win.isDestroyed()) {
// win.webContents.send('api-key-removed');
// }
// });
// const settingsWindow = windowPool.get('settings');
// if (settingsWindow && settingsWindow.isVisible()) {
// settingsWindow.hide();
// console.log('[WindowManager] Settings window hidden after clearing API key.');
// }
// return { success: true };
// });
// ipcMain.handle('get-ai-provider', getStoredProvider);
// console.log('[WindowManager] API key related IPC handlers registered (SQLite-backed)');
// }
//////// before_modelStateService ////////
//////// after_modelStateService ////////
async function getStoredApiKey() {
@ -1227,15 +1257,15 @@ async function getStoredProvider() {
}
/**
* 렌더러에서 요청한 타입('llm' 또는 'stt') 대한 모델 정보를 반환합니다.
* @param {IpcMainInvokeEvent} event - 일렉트론 IPC 이벤트 객체
* @param {{type: 'llm' | 'stt'}} { type } - 요청할 모델 타입
*
* @param {IpcMainInvokeEvent} event
* @param {{type: 'llm' | 'stt'}}
*/
async function getCurrentModelInfo(event, { type }) {
if (global.modelStateService && (type === 'llm' || type === 'stt')) {
return global.modelStateService.getCurrentModelInfo(type);
}
return null; // 서비스가 없거나 유효하지 않은 타입일 경우 null 반환
return null;
}
function setupApiKeyIPC() {
@ -1279,33 +1309,26 @@ function getDefaultKeybinds() {
}
function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementManager) {
// console.log('Updating global shortcuts with:', keybinds);
// Unregister all existing shortcuts
globalShortcut.unregisterAll();
let toggleVisibilityDebounceTimer = null;
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl';
if (keybinds.toggleVisibility) {
try {
globalShortcut.register(keybinds.toggleVisibility, () => toggleAllWindowsVisibility(movementManager));
console.log(`Registered toggleVisibility: ${keybinds.toggleVisibility}`);
} catch (error) {
console.error(`Failed to register toggleVisibility (${keybinds.toggleVisibility}):`, error);
}
if (sendToRenderer) {
sendToRenderer('shortcuts-updated', keybinds);
console.log('[Shortcuts] Broadcasted updated shortcuts to all windows.');
}
// ✨ 하드코딩된 단축키 등록을 위해 변수 유지
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl';
const header = windowPool.get('header');
const state = header?.currentHeaderState || currentHeaderState;
// ✨ 기능 1: 사용자가 설정할 수 없는 '모니터 이동' 단축키 (기존 로직 유지)
const displays = screen.getAllDisplays();
if (displays.length > 1) {
displays.forEach((display, index) => {
const key = `${modifier}+Shift+${index + 1}`;
try {
globalShortcut.register(key, () => {
movementManager.moveToDisplay(display.id);
});
globalShortcut.register(key, () => movementManager.moveToDisplay(display.id));
console.log(`Registered display switch shortcut: ${key} -> Display ${index + 1}`);
} catch (error) {
console.error(`Failed to register display switch ${key}:`, error);
@ -1313,171 +1336,122 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, movementMan
});
}
if (currentHeaderState === 'apikey') {
// API 키 입력 상태에서는 필수 단축키(toggleVisibility) 외에는 아무것도 등록하지 않음
if (state === 'apikey') {
if (keybinds.toggleVisibility) {
try {
globalShortcut.register(keybinds.toggleVisibility, () => toggleAllWindowsVisibility(movementManager));
} catch (error) {
console.error(`Failed to register toggleVisibility (${keybinds.toggleVisibility}):`, error);
}
}
console.log('ApiKeyHeader is active, skipping conditional shortcuts');
return;
}
const directions = [
{ key: `${modifier}+Left`, direction: 'left' },
{ key: `${modifier}+Right`, direction: 'right' },
{ key: `${modifier}+Up`, direction: 'up' },
{ key: `${modifier}+Down`, direction: 'down' },
];
directions.forEach(({ key, direction }) => {
try {
globalShortcut.register(key, () => {
const header = windowPool.get('header');
if (header && header.isVisible()) {
movementManager.moveStep(direction);
}
});
// console.log(`Registered global shortcut: ${key} -> ${direction}`);
} catch (error) {
console.error(`Failed to register ${key}:`, error);
}
});
// ✨ 기능 2: 사용자가 설정할 수 없는 '화면 가장자리 이동' 단축키 (기존 로직 유지)
const edgeDirections = [
{ key: `${modifier}+Shift+Left`, direction: 'left' },
{ key: `${modifier}+Shift+Right`, direction: 'right' },
{ key: `${modifier}+Shift+Up`, direction: 'up' },
{ key: `${modifier}+Shift+Down`, direction: 'down' },
// { key: `${modifier}+Shift+Up`, direction: 'up' },
// { key: `${modifier}+Shift+Down`, direction: 'down' },
];
edgeDirections.forEach(({ key, direction }) => {
try {
globalShortcut.register(key, () => {
const header = windowPool.get('header');
if (header && header.isVisible()) {
movementManager.moveToEdge(direction);
}
if (header && header.isVisible()) movementManager.moveToEdge(direction);
});
console.log(`Registered global shortcut: ${key} -> edge ${direction}`);
} catch (error) {
console.error(`Failed to register ${key}:`, error);
console.error(`Failed to register edge move for ${key}:`, error);
}
});
if (keybinds.toggleClickThrough) {
// ✨ 기능 3: 사용자가 설정 가능한 모든 단축키를 동적으로 등록 (새로운 방식 적용)
for (const action in keybinds) {
const accelerator = keybinds[action];
if (!accelerator) continue;
try {
globalShortcut.register(keybinds.toggleClickThrough, () => {
mouseEventsIgnored = !mouseEventsIgnored;
if (mouseEventsIgnored) {
mainWindow.setIgnoreMouseEvents(true, { forward: true });
console.log('Mouse events ignored');
} else {
mainWindow.setIgnoreMouseEvents(false);
console.log('Mouse events enabled');
}
mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored);
});
// console.log(`Registered toggleClickThrough: ${keybinds.toggleClickThrough}`);
} catch (error) {
console.error(`Failed to register toggleClickThrough (${keybinds.toggleClickThrough}):`, error);
}
}
if (keybinds.nextStep) {
try {
globalShortcut.register(keybinds.nextStep, () => {
console.log('⌘/Ctrl+Enter Ask shortcut triggered');
const askWindow = windowPool.get('ask');
if (!askWindow || askWindow.isDestroyed()) {
console.error('Ask window not found or destroyed');
return;
}
if (askWindow.isVisible()) {
askWindow.webContents.send('ask-global-send');
} else {
try {
askWindow.show();
const header = windowPool.get('header');
if (header) {
const currentHeaderPosition = header.getBounds();
let callback;
switch(action) {
case 'toggleVisibility':
callback = () => toggleAllWindowsVisibility(movementManager);
break;
case 'nextStep':
callback = () => {
const askWindow = windowPool.get('ask');
if (!askWindow || askWindow.isDestroyed()) return;
if (askWindow.isVisible()) {
askWindow.webContents.send('ask-global-send');
} else {
askWindow.show();
updateLayout();
header.setPosition(currentHeaderPosition.x, currentHeaderPosition.y, false);
askWindow.webContents.send('window-show-animation');
}
};
break;
case 'scrollUp':
callback = () => {
// 'ask' 창을 명시적으로 가져옵니다.
const askWindow = windowPool.get('ask');
// 'ask' 창이 존재하고, 파괴되지 않았으며, 보이는 경우에만 이벤트를 전송합니다.
if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
askWindow.webContents.send('scroll-response-up');
}
};
break;
case 'scrollDown':
callback = () => {
// 'ask' 창을 명시적으로 가져옵니다.
const askWindow = windowPool.get('ask');
// 'ask' 창이 존재하고, 파괴되지 않았으며, 보이는 경우에만 이벤트를 전송합니다.
if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
askWindow.webContents.send('scroll-response-down');
}
};
break;
case 'moveUp':
callback = () => { if (header && header.isVisible()) movementManager.moveStep('up'); };
break;
case 'moveDown':
callback = () => { if (header && header.isVisible()) movementManager.moveStep('down'); };
break;
case 'moveLeft':
callback = () => { if (header && header.isVisible()) movementManager.moveStep('left'); };
break;
case 'moveRight':
callback = () => { if (header && header.isVisible()) movementManager.moveStep('right'); };
break;
case 'toggleClickThrough':
callback = () => {
mouseEventsIgnored = !mouseEventsIgnored;
if(mainWindow && !mainWindow.isDestroyed()){
mainWindow.setIgnoreMouseEvents(mouseEventsIgnored, { forward: true });
mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored);
}
};
break;
case 'manualScreenshot':
callback = () => {
if(mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.executeJavaScript('window.captureManualScreenshot && window.captureManualScreenshot();');
}
};
break;
case 'previousResponse':
callback = () => sendToRenderer('navigate-previous-response');
break;
case 'nextResponse':
callback = () => sendToRenderer('navigate-next-response');
break;
}
askWindow.webContents.send('window-show-animation');
} catch (e) {
console.error('Error showing Ask window:', e);
}
}
});
// console.log(`Registered Ask shortcut (nextStep): ${keybinds.nextStep}`);
} catch (error) {
console.error(`Failed to register Ask shortcut (${keybinds.nextStep}):`, error);
}
}
if (keybinds.manualScreenshot) {
try {
globalShortcut.register(keybinds.manualScreenshot, () => {
console.log('Manual screenshot shortcut triggered');
mainWindow.webContents.executeJavaScript(`
if (window.captureManualScreenshot) {
window.captureManualScreenshot();
} else {
console.log('Manual screenshot function not available');
}
`);
});
// console.log(`Registered manualScreenshot: ${keybinds.manualScreenshot}`);
} catch (error) {
console.error(`Failed to register manualScreenshot (${keybinds.manualScreenshot}):`, error);
}
}
if (keybinds.previousResponse) {
try {
globalShortcut.register(keybinds.previousResponse, () => {
console.log('Previous response shortcut triggered');
sendToRenderer('navigate-previous-response');
});
// console.log(`Registered previousResponse: ${keybinds.previousResponse}`);
} catch (error) {
console.error(`Failed to register previousResponse (${keybinds.previousResponse}):`, error);
}
}
if (keybinds.nextResponse) {
try {
globalShortcut.register(keybinds.nextResponse, () => {
console.log('Next response shortcut triggered');
sendToRenderer('navigate-next-response');
});
// console.log(`Registered nextResponse: ${keybinds.nextResponse}`);
} catch (error) {
console.error(`Failed to register nextResponse (${keybinds.nextResponse}):`, error);
}
}
if (keybinds.scrollUp) {
try {
globalShortcut.register(keybinds.scrollUp, () => {
console.log('Scroll up shortcut triggered');
sendToRenderer('scroll-response-up');
});
// console.log(`Registered scrollUp: ${keybinds.scrollUp}`);
} catch (error) {
console.error(`Failed to register scrollUp (${keybinds.scrollUp}):`, error);
}
}
if (keybinds.scrollDown) {
try {
globalShortcut.register(keybinds.scrollDown, () => {
console.log('Scroll down shortcut triggered');
sendToRenderer('scroll-response-down');
});
// console.log(`Registered scrollDown: ${keybinds.scrollDown}`);
} catch (error) {
console.error(`Failed to register scrollDown (${keybinds.scrollDown}):`, error);
if (callback) {
globalShortcut.register(accelerator, callback);
}
} catch(e) {
console.error(`Failed to register shortcut for "${action}" (${accelerator}):`, e.message);
}
}
}

View File

@ -658,6 +658,8 @@ export class AskView extends LitElement {
this.handleDocumentClick = this.handleDocumentClick.bind(this);
this.handleWindowBlur = this.handleWindowBlur.bind(this);
this.handleScroll = this.handleScroll.bind(this);
this.loadLibraries();
// --- Resize helpers ---
@ -863,6 +865,9 @@ export class AskView extends LitElement {
ipcRenderer.on('ask-response-chunk', this.handleStreamChunk);
ipcRenderer.on('ask-response-stream-end', this.handleStreamEnd);
ipcRenderer.on('scroll-response-up', () => this.handleScroll('up'));
ipcRenderer.on('scroll-response-down', () => this.handleScroll('down'));
console.log('✅ AskView: IPC 이벤트 리스너 등록 완료');
}
}
@ -901,10 +906,25 @@ export class AskView extends LitElement {
ipcRenderer.removeListener('ask-response-chunk', this.handleStreamChunk);
ipcRenderer.removeListener('ask-response-stream-end', this.handleStreamEnd);
ipcRenderer.removeListener('scroll-response-up', () => this.handleScroll('up'));
ipcRenderer.removeListener('scroll-response-down', () => this.handleScroll('down'));
console.log('✅ AskView: IPC 이벤트 리스너 제거 완료');
}
}
handleScroll(direction) {
const scrollableElement = this.shadowRoot.querySelector('#responseContainer');
if (scrollableElement) {
const scrollAmount = 100; // 한 번에 스크롤할 양 (px)
if (direction === 'up') {
scrollableElement.scrollTop -= scrollAmount;
} else {
scrollableElement.scrollTop += scrollAmount;
}
}
}
// --- 스트리밍 처리 핸들러 ---
handleStreamChunk(event, { token }) {
if (!this.isStreaming) {

View File

@ -1,4 +1,4 @@
const { BrowserWindow } = require('electron');
const { BrowserWindow, app } = require('electron');
const SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService');
const authService = require('../../common/services/authService');
@ -117,8 +117,27 @@ class ListenService {
throw new Error('Failed to initialize database session');
}
// Initialize STT sessions
await this.sttService.initializeSttSessions(language);
/* ---------- STT Initialization Retry Logic ---------- */
const MAX_RETRY = 10;
const RETRY_DELAY_MS = 300; // 0.3 seconds
let sttReady = false;
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
await this.sttService.initializeSttSessions(language);
sttReady = true;
break; // Exit on success
} catch (err) {
console.warn(
`[ListenService] STT init attempt ${attempt} failed: ${err.message}`
);
if (attempt < MAX_RETRY) {
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
}
}
}
if (!sttReady) throw new Error('STT init failed after retries');
/* ------------------------------------------- */
console.log('✅ Listen service initialized successfully.');
@ -213,9 +232,9 @@ class ListenService {
try {
await this.sendAudioContent(data, mimeType);
return { success: true };
} catch (error) {
console.error('Error sending user audio:', error);
return { success: false, error: error.message };
} catch (e) {
console.error('Error sending user audio:', e);
return { success: false, error: e.message };
}
});
@ -237,9 +256,13 @@ class ListenService {
if (process.platform !== 'darwin') {
return { success: false, error: 'macOS audio capture only available on macOS' };
}
if (this.sttService.isMacOSAudioRunning?.()) {
return { success: false, error: 'already_running' };
}
try {
const success = await this.startMacOSAudioCapture();
return { success };
return { success, error: null };
} catch (error) {
console.error('Error starting macOS audio capture:', error);
return { success: false, error: error.message };

View File

@ -1,5 +1,30 @@
const { ipcRenderer } = require('electron');
const createAecModule = require('../../../assets/aec.js');
let aecModPromise = null; // 한 번만 로드
let aecMod = null;
let aecPtr = 0; // Rust Aec* 1개만 재사용
/** WASM 모듈 가져오고 1회 초기화 */
async function getAec () {
if (aecModPromise) return aecModPromise; // 캐시
aecModPromise = createAecModule().then((M) => {
aecMod = M;
// C 심볼 → JS 래퍼 바인딩 (딱 1번)
M.newPtr = M.cwrap('AecNew', 'number',
['number','number','number','number']);
M.cancel = M.cwrap('AecCancelEcho', null,
['number','number','number','number','number']);
M.destroy = M.cwrap('AecDestroy', null, ['number']);
return M;
});
return aecModPromise;
}
// 바로 로드-실패 로그를 보기 위해
getAec().catch(console.error);
// ---------------------------
// Constants & Globals
// ---------------------------
@ -80,128 +105,49 @@ function arrayBufferToBase64(buffer) {
return btoa(binary);
}
// ---------------------------
// Complete SimpleAEC implementation (exact from renderer.js)
// ---------------------------
class SimpleAEC {
constructor() {
this.adaptiveFilter = new Float32Array(1024);
this.mu = 0.2;
this.echoDelay = 100;
this.sampleRate = 24000;
this.delaySamples = Math.floor((this.echoDelay / 1000) * this.sampleRate);
this.echoGain = 0.5;
this.noiseFloor = 0.01;
// 🔧 Adaptive-gain parameters (User-tuned, very aggressive)
this.targetErr = 0.002;
this.adaptRate = 0.1;
console.log('🎯 AEC initialized (hyper-aggressive)');
}
process(micData, systemData) {
if (!systemData || systemData.length === 0) {
return micData;
}
for (let i = 0; i < systemData.length; i++) {
if (systemData[i] > 0.98) systemData[i] = 0.98;
else if (systemData[i] < -0.98) systemData[i] = -0.98;
systemData[i] = Math.tanh(systemData[i] * 4);
}
let sum2 = 0;
for (let i = 0; i < systemData.length; i++) sum2 += systemData[i] * systemData[i];
const rms = Math.sqrt(sum2 / systemData.length);
const targetRms = 0.08; // 🔧 기준 RMS (기존 0.1)
const scale = targetRms / (rms + 1e-6); // 1e-6: 0-division 방지
const output = new Float32Array(micData.length);
const optimalDelay = this.findOptimalDelay(micData, systemData);
for (let i = 0; i < micData.length; i++) {
let echoEstimate = 0;
for (let d = -500; d <= 500; d += 100) {
const delayIndex = i - optimalDelay - d;
if (delayIndex >= 0 && delayIndex < systemData.length) {
const weight = Math.exp(-Math.abs(d) / 1000);
echoEstimate += systemData[delayIndex] * scale * this.echoGain * weight;
}
}
output[i] = micData[i] - echoEstimate * 0.9;
if (Math.abs(output[i]) < this.noiseFloor) {
output[i] *= 0.5;
}
if (this.isSimilarToSystem(output[i], systemData, i, optimalDelay)) {
output[i] *= 0.25;
}
output[i] = Math.max(-1, Math.min(1, output[i]));
}
let errSum = 0;
for (let i = 0; i < output.length; i++) errSum += output[i] * output[i];
const errRms = Math.sqrt(errSum / output.length);
const err = errRms - this.targetErr;
this.echoGain += this.adaptRate * err; // 비례 제어
this.echoGain = Math.max(0, Math.min(1, this.echoGain));
return output;
}
findOptimalDelay(micData, systemData) {
let maxCorr = 0;
let optimalDelay = this.delaySamples;
for (let delay = 0; delay < 5000 && delay < systemData.length; delay += 200) {
let corr = 0;
let count = 0;
for (let i = 0; i < Math.min(500, micData.length); i++) {
if (i + delay < systemData.length) {
corr += micData[i] * systemData[i + delay];
count++;
}
}
if (count > 0) {
corr = Math.abs(corr / count);
if (corr > maxCorr) {
maxCorr = corr;
optimalDelay = delay;
}
}
}
return optimalDelay;
}
isSimilarToSystem(sample, systemData, index, delay) {
const windowSize = 50;
let similarity = 0;
for (let i = -windowSize; i <= windowSize; i++) {
const sysIndex = index - delay + i;
if (sysIndex >= 0 && sysIndex < systemData.length) {
similarity += Math.abs(sample - systemData[sysIndex]);
}
}
return similarity / (2 * windowSize + 1) < 0.15;
}
/* ───────────────────────── JS ↔︎ WASM 헬퍼 ───────────────────────── */
function int16PtrFromFloat32(mod, f32) {
const len = f32.length;
const bytes = len * 2;
const ptr = mod._malloc(bytes);
// HEAP16이 없으면 HEAPU8.buffer로 직접 래핑
const heapBuf = (mod.HEAP16 ? mod.HEAP16.buffer : mod.HEAPU8.buffer);
const i16 = new Int16Array(heapBuf, ptr, len);
for (let i = 0; i < len; ++i) {
const s = Math.max(-1, Math.min(1, f32[i]));
i16[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return { ptr, view: i16 };
}
function float32FromInt16View(i16) {
const out = new Float32Array(i16.length);
for (let i = 0; i < i16.length; ++i) out[i] = i16[i] / 32768;
return out;
}
/* 필요하다면 종료 시 */
function disposeAec () {
getAec().then(mod => { if (aecPtr) mod.destroy(aecPtr); });
}
function runAecSync (micF32, sysF32) {
if (!aecMod || !aecPtr || !aecMod.HEAPU8) return micF32; // 아직 모듈 안 뜸 → 패스
const len = micF32.length;
const mic = int16PtrFromFloat32(aecMod, micF32);
const echo = int16PtrFromFloat32(aecMod, sysF32);
const out = aecMod._malloc(len * 2);
aecMod.cancel(aecPtr, mic.ptr, echo.ptr, out, len);
const heapBuf = (aecMod.HEAP16 ? aecMod.HEAP16.buffer : aecMod.HEAPU8.buffer);
const outF32 = float32FromInt16View(new Int16Array(heapBuf, out, len));
aecMod._free(mic.ptr); aecMod._free(echo.ptr); aecMod._free(out);
return outF32;
}
let aecProcessor = new SimpleAEC();
// System audio data handler
ipcRenderer.on('system-audio-data', (event, { data }) => {
@ -214,8 +160,6 @@ ipcRenderer.on('system-audio-data', (event, { data }) => {
if (systemAudioBuffer.length > MAX_SYSTEM_BUFFER_SIZE) {
systemAudioBuffer = systemAudioBuffer.slice(-MAX_SYSTEM_BUFFER_SIZE);
}
console.log('📥 Received system audio for AEC reference');
});
// ---------------------------
@ -305,39 +249,47 @@ setInterval(() => {
// ---------------------------
// Audio processing functions (exact from renderer.js)
// ---------------------------
function setupMicProcessing(micStream) {
async function setupMicProcessing(micStream) {
/* ── WASM 먼저 로드 ───────────────────────── */
const mod = await getAec();
if (!aecPtr) aecPtr = mod.newPtr(160, 1600, 24000, 1);
const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
await micAudioContext.resume();
const micSource = micAudioContext.createMediaStreamSource(micStream);
const micProcessor = micAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
let audioBuffer = [];
const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
micProcessor.onaudioprocess = async e => {
micProcessor.onaudioprocess = (e) => {
const inputData = e.inputBuffer.getChannelData(0);
audioBuffer.push(...inputData);
console.log('🎤 micProcessor.onaudioprocess');
// samplesPerChunk(=2400) 만큼 모이면 전송
while (audioBuffer.length >= samplesPerChunk) {
let chunk = audioBuffer.splice(0, samplesPerChunk);
let processedChunk = new Float32Array(chunk);
let processedChunk = new Float32Array(chunk); // 기본값
// Check for system audio and apply AEC only if voice is active
if (aecProcessor && systemAudioBuffer.length > 0) {
const latestSystemAudio = systemAudioBuffer[systemAudioBuffer.length - 1];
const systemFloat32 = base64ToFloat32Array(latestSystemAudio.data);
// ───────────────── WASM AEC ─────────────────
if (systemAudioBuffer.length > 0) {
const latest = systemAudioBuffer[systemAudioBuffer.length - 1];
const sysF32 = base64ToFloat32Array(latest.data);
// Apply AEC only when system audio has active speech
if (isVoiceActive(systemFloat32)) {
processedChunk = aecProcessor.process(new Float32Array(chunk), systemFloat32);
console.log('🔊 Applied AEC because system audio is active');
}
// **음성 구간일 때만 런**
processedChunk = runAecSync(new Float32Array(chunk), sysF32);
console.log('🔊 Applied WASM-AEC (speex)');
} else {
console.log('🔊 No system audio for AEC reference');
}
const pcmData16 = convertFloat32ToInt16(processedChunk);
const base64Data = arrayBufferToBase64(pcmData16.buffer);
const pcm16 = convertFloat32ToInt16(processedChunk);
const b64 = arrayBufferToBase64(pcm16.buffer);
await ipcRenderer.invoke('send-audio-content', {
data: base64Data,
ipcRenderer.invoke('send-audio-content', {
data: b64,
mimeType: 'audio/pcm;rate=24000',
});
}
@ -520,7 +472,19 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
// Start macOS audio capture
const audioResult = await ipcRenderer.invoke('start-macos-audio');
if (!audioResult.success) {
throw new Error('Failed to start macOS audio capture: ' + audioResult.error);
console.warn('[listenCapture] macOS audio start failed:', audioResult.error);
// 이미 실행 중 → stop 후 재시도
if (audioResult.error === 'already_running') {
await ipcRenderer.invoke('stop-macos-audio');
await new Promise(r => setTimeout(r, 500));
const retry = await ipcRenderer.invoke('start-macos-audio');
if (!retry.success) {
throw new Error('Retry failed: ' + retry.error);
}
} else {
throw new Error('Failed to start macOS audio capture: ' + audioResult.error);
}
}
// Initialize screen capture in main process
@ -543,7 +507,7 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
});
console.log('macOS microphone capture started');
const { context, processor } = setupMicProcessing(micMediaStream);
const { context, processor } = await setupMicProcessing(micMediaStream);
audioContext = context;
audioProcessor = processor;
} catch (micErr) {
@ -616,7 +580,7 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
video: false,
});
console.log('Windows microphone capture started');
const { context, processor } = setupMicProcessing(micMediaStream);
const { context, processor } = await setupMicProcessing(micMediaStream);
audioContext = context;
audioProcessor = processor;
} catch (micErr) {
@ -719,6 +683,9 @@ function stopCapture() {
// Exports & global registration
// ---------------------------
module.exports = {
getAec, // 새로 만든 초기화 함수
runAecSync, // sync 버전
disposeAec, // 필요시 Rust 객체 파괴
startCapture,
stopCapture,
captureManualScreenshot,

View File

@ -12,11 +12,6 @@ class SttService {
this.myCurrentUtterance = '';
this.theirCurrentUtterance = '';
this.myLastPartialText = '';
this.theirLastPartialText = '';
this.myInactivityTimer = null;
this.theirInactivityTimer = null;
// Turn-completion debouncing
this.myCompletionBuffer = '';
this.theirCompletionBuffer = '';
@ -38,33 +33,6 @@ class SttService {
this.onStatusUpdate = onStatusUpdate;
}
// async getApiKey() {
// const storedKey = await getStoredApiKey();
// if (storedKey) {
// console.log('[SttService] Using stored API key');
// return storedKey;
// }
// const envKey = process.env.OPENAI_API_KEY;
// if (envKey) {
// console.log('[SttService] Using environment API key');
// return envKey;
// }
// console.error('[SttService] No API key found in storage or environment');
// return null;
// }
// async getAiProvider() {
// try {
// const { ipcRenderer } = require('electron');
// const provider = await ipcRenderer.invoke('get-ai-provider');
// return provider || 'openai';
// } catch (error) {
// return getStoredProvider ? getStoredProvider() : 'openai';
// }
// }
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
@ -74,9 +42,8 @@ class SttService {
}
flushMyCompletion() {
if (!this.modelInfo || !this.myCompletionBuffer.trim()) return;
const finalText = this.myCompletionBuffer.trim();
const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return;
// Notify completion callback
if (this.onTranscriptionComplete) {
@ -102,9 +69,8 @@ class SttService {
}
flushTheirCompletion() {
if (!this.modelInfo || !this.theirCompletionBuffer.trim()) return;
const finalText = this.theirCompletionBuffer.trim();
const finalText = (this.theirCompletionBuffer + this.theirCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return;
// Notify completion callback
if (this.onTranscriptionComplete) {
@ -130,27 +96,23 @@ class SttService {
}
debounceMyCompletion(text) {
// 상대방이 말하고 있던 경우, 화자가 변경되었으므로 즉시 상대방의 말풍선을 완성합니다.
if (this.theirCompletionTimer) {
clearTimeout(this.theirCompletionTimer);
this.flushTheirCompletion();
if (this.modelInfo?.provider === 'gemini') {
this.myCompletionBuffer += text;
} else {
this.myCompletionBuffer += (this.myCompletionBuffer ? ' ' : '') + text;
}
this.myCompletionBuffer += (this.myCompletionBuffer ? ' ' : '') + text;
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = setTimeout(() => this.flushMyCompletion(), COMPLETION_DEBOUNCE_MS);
}
debounceTheirCompletion(text) {
// 내가 말하고 있던 경우, 화자가 변경되었으므로 즉시 내 말풍선을 완성합니다.
if (this.myCompletionTimer) {
clearTimeout(this.myCompletionTimer);
this.flushMyCompletion();
if (this.modelInfo?.provider === 'gemini') {
this.theirCompletionBuffer += text;
} else {
this.theirCompletionBuffer += (this.theirCompletionBuffer ? ' ' : '') + text;
}
this.theirCompletionBuffer += (this.theirCompletionBuffer ? ' ' : '') + text;
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = setTimeout(() => this.flushTheirCompletion(), COMPLETION_DEBOUNCE_MS);
}
@ -158,12 +120,6 @@ class SttService {
async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
// const API_KEY = await this.getApiKey();
// if (!API_KEY) {
// throw new Error('No API key available');
// }
// const provider = await this.getAiProvider();
const modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.');
@ -171,10 +127,6 @@ class SttService {
this.modelInfo = modelInfo;
console.log(`[SttService] Initializing STT for ${modelInfo.provider} using model ${modelInfo.model}`);
// const isGemini = modelInfo.provider === 'gemini';
// console.log(`[SttService] Initializing STT for provider: ${modelInfo.provider}`);
const handleMyMessage = message => {
if (!this.modelInfo) {
console.log('[SttService] Ignoring message - session already closed');
@ -182,13 +134,35 @@ class SttService {
}
if (this.modelInfo.provider === 'gemini') {
const text = message.serverContent?.inputTranscription?.text || '';
if (text && text.trim()) {
const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
if (finalUtteranceText && finalUtteranceText !== '.') {
this.debounceMyCompletion(finalUtteranceText);
}
if (!message.serverContent?.modelTurn) {
console.log('[Gemini STT - Me]', JSON.stringify(message, null, 2));
}
if (message.serverContent?.turnComplete) {
if (this.myCompletionTimer) {
clearTimeout(this.myCompletionTimer);
this.flushMyCompletion();
}
return;
}
const transcription = message.serverContent?.inputTranscription;
if (!transcription || !transcription.text) return;
const textChunk = transcription.text;
if (!textChunk.trim() || textChunk.trim() === '<noise>') {
return; // 1. Ignore whitespace-only chunks or noise
}
this.debounceMyCompletion(textChunk);
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: this.myCompletionBuffer,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
} else {
const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -222,19 +196,43 @@ class SttService {
};
const handleTheirMessage = message => {
if (!message || typeof message !== 'object') return;
if (!this.modelInfo) {
console.log('[SttService] Ignoring message - session already closed');
return;
}
if (this.modelInfo.provider === 'gemini') {
const text = message.serverContent?.inputTranscription?.text || '';
if (text && text.trim()) {
const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim();
if (finalUtteranceText && finalUtteranceText !== '.') {
this.debounceTheirCompletion(finalUtteranceText);
}
if (!message.serverContent?.modelTurn) {
console.log('[Gemini STT - Them]', JSON.stringify(message, null, 2));
}
if (message.serverContent?.turnComplete) {
if (this.theirCompletionTimer) {
clearTimeout(this.theirCompletionTimer);
this.flushTheirCompletion();
}
return;
}
const transcription = message.serverContent?.inputTranscription;
if (!transcription || !transcription.text) return;
const textChunk = transcription.text;
if (!textChunk.trim() || textChunk.trim() === '<noise>') {
return; // 1. Ignore whitespace-only chunks or noise
}
this.debounceTheirCompletion(textChunk);
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: this.theirCompletionBuffer,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
} else {
const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -494,14 +492,6 @@ class SttService {
this.stopMacOSAudioCapture();
// Clear timers
if (this.myInactivityTimer) {
clearTimeout(this.myInactivityTimer);
this.myInactivityTimer = null;
}
if (this.theirInactivityTimer) {
clearTimeout(this.theirInactivityTimer);
this.theirInactivityTimer = null;
}
if (this.myCompletionTimer) {
clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null;
@ -527,8 +517,6 @@ class SttService {
// Reset state
this.myCurrentUtterance = '';
this.theirCurrentUtterance = '';
this.myLastPartialText = '';
this.theirLastPartialText = '';
this.myCompletionBuffer = '';
this.theirCompletionBuffer = '';
this.modelInfo = null;

View File

@ -437,22 +437,10 @@ export class SettingsView extends LitElement {
}
`;
//////// before_modelStateService ////////
// static properties = {
// firebaseUser: { type: Object, state: true },
// apiKey: { type: String, state: true },
// isLoading: { type: Boolean, state: true },
// isContentProtectionOn: { type: Boolean, state: true },
// settings: { type: Object, state: true },
// presets: { type: Array, state: true },
// selectedPreset: { type: Object, state: true },
// showPresets: { type: Boolean, state: true },
// saving: { type: Boolean, state: true },
// };
//////// before_modelStateService ////////
//////// after_modelStateService ////////
static properties = {
shortcuts: { type: Object, state: true },
firebaseUser: { type: Object, state: true },
isLoading: { type: Boolean, state: true },
isContentProtectionOn: { type: Boolean, state: true },
@ -468,25 +456,15 @@ export class SettingsView extends LitElement {
presets: { type: Array, state: true },
selectedPreset: { type: Object, state: true },
showPresets: { type: Boolean, state: true },
autoUpdateEnabled: { type: Boolean, state: true },
autoUpdateLoading: { type: Boolean, state: true },
};
//////// after_modelStateService ////////
constructor() {
super();
//////// before_modelStateService ////////
// this.firebaseUser = null;
// this.apiKey = null;
// this.isLoading = false;
// this.isContentProtectionOn = true;
// this.settings = null;
// this.presets = [];
// this.selectedPreset = null;
// this.showPresets = false;
// this.saving = false;
// this.loadInitialData();
//////// before_modelStateService ////////
//////// after_modelStateService ////////
this.shortcuts = {};
this.firebaseUser = null;
this.apiKeys = { openai: '', gemini: '', anthropic: '' };
this.providerConfig = {};
@ -503,51 +481,47 @@ export class SettingsView extends LitElement {
this.selectedPreset = null;
this.showPresets = false;
this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this)
this.autoUpdateEnabled = true;
this.autoUpdateLoading = true;
this.loadInitialData();
//////// after_modelStateService ////////
}
async loadAutoUpdateSetting() {
if (!window.require) return;
const { ipcRenderer } = window.require('electron');
this.autoUpdateLoading = true;
try {
const enabled = await ipcRenderer.invoke('settings:get-auto-update');
this.autoUpdateEnabled = enabled;
console.log('Auto-update setting loaded:', enabled);
} catch (e) {
console.error('Error loading auto-update setting:', e);
this.autoUpdateEnabled = true; // fallback
}
this.autoUpdateLoading = false;
this.requestUpdate();
}
//////// before_modelStateService ////////
// async loadInitialData() {
// if (!window.require) return;
// try {
// this.isLoading = true;
// const { ipcRenderer } = window.require('electron');
// // Load all data in parallel
// const [settings, presets, apiKey, contentProtection, userState] = await Promise.all([
// ipcRenderer.invoke('settings:getSettings'),
// ipcRenderer.invoke('settings:getPresets'),
// ipcRenderer.invoke('get-stored-api-key'),
// ipcRenderer.invoke('get-content-protection-status'),
// ipcRenderer.invoke('get-current-user')
// ]);
// this.settings = settings;
// this.presets = presets || [];
// this.apiKey = apiKey;
// this.isContentProtectionOn = contentProtection;
// // Set first user preset as selected
// if (this.presets.length > 0) {
// const firstUserPreset = this.presets.find(p => p.is_default === 0);
// if (firstUserPreset) {
// this.selectedPreset = firstUserPreset;
// }
// }
// if (userState && userState.isLoggedIn) {
// this.firebaseUser = userState.user;
// }
// } catch (error) {
// console.error('Error loading initial data:', error);
// } finally {
// this.isLoading = false;
// }
// }
//////// before_modelStateService ////////
async handleToggleAutoUpdate() {
if (!window.require || this.autoUpdateLoading) return;
const { ipcRenderer } = window.require('electron');
this.autoUpdateLoading = true;
this.requestUpdate();
try {
const newValue = !this.autoUpdateEnabled;
const result = await ipcRenderer.invoke('settings:set-auto-update', newValue);
if (result && result.success) {
this.autoUpdateEnabled = newValue;
} else {
console.error('Failed to update auto-update setting');
}
} catch (e) {
console.error('Error toggling auto-update:', e);
}
this.autoUpdateLoading = false;
this.requestUpdate();
}
//////// after_modelStateService ////////
async loadInitialData() {
@ -555,7 +529,7 @@ export class SettingsView extends LitElement {
this.isLoading = true;
const { ipcRenderer } = window.require('electron');
try {
const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection] = await Promise.all([
const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection, shortcuts] = await Promise.all([
ipcRenderer.invoke('get-current-user'),
ipcRenderer.invoke('model:get-provider-config'), // Provider 설정 로드
ipcRenderer.invoke('model:get-all-keys'),
@ -563,7 +537,8 @@ export class SettingsView extends LitElement {
ipcRenderer.invoke('model:get-available-models', { type: 'stt' }),
ipcRenderer.invoke('model:get-selected-models'),
ipcRenderer.invoke('settings:getPresets'),
ipcRenderer.invoke('get-content-protection-status')
ipcRenderer.invoke('get-content-protection-status'),
ipcRenderer.invoke('get-current-shortcuts')
]);
if (userState && userState.isLoggedIn) this.firebaseUser = userState;
@ -575,6 +550,7 @@ export class SettingsView extends LitElement {
this.selectedStt = selectedModels.stt;
this.presets = presets || [];
this.isContentProtectionOn = contentProtection;
this.shortcuts = shortcuts || {};
if (this.presets.length > 0) {
const firstUserPreset = this.presets.find(p => p.is_default === 0);
if (firstUserPreset) this.selectedPreset = firstUserPreset;
@ -668,12 +644,20 @@ export class SettingsView extends LitElement {
}
//////// after_modelStateService ////////
openShortcutEditor() {
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('open-shortcut-editor');
}
}
connectedCallback() {
super.connectedCallback();
this.setupEventListeners();
this.setupIpcListeners();
this.setupWindowResize();
this.loadAutoUpdateSetting();
}
disconnectedCallback() {
@ -705,6 +689,7 @@ export class SettingsView extends LitElement {
} else {
this.firebaseUser = null;
}
this.loadAutoUpdateSetting();
this.requestUpdate();
};
@ -732,10 +717,15 @@ export class SettingsView extends LitElement {
console.error('[SettingsView] Failed to refresh presets:', error);
}
};
this._shortcutListener = (event, keybinds) => {
console.log('[SettingsView] Received updated shortcuts:', keybinds);
this.shortcuts = keybinds;
};
ipcRenderer.on('user-state-changed', this._userStateListener);
ipcRenderer.on('settings-updated', this._settingsUpdatedListener);
ipcRenderer.on('presets-updated', this._presetsUpdatedListener);
ipcRenderer.on('shortcuts-updated', this._shortcutListener);
}
cleanupIpcListeners() {
@ -752,6 +742,9 @@ export class SettingsView extends LitElement {
if (this._presetsUpdatedListener) {
ipcRenderer.removeListener('presets-updated', this._presetsUpdatedListener);
}
if (this._shortcutListener) {
ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener);
}
}
setupWindowResize() {
@ -797,14 +790,41 @@ export class SettingsView extends LitElement {
}
}
// getMainShortcuts() {
// return [
// { name: 'Show / Hide', key: '\\' },
// { name: 'Ask Anything', key: '↵' },
// { name: 'Scroll AI Response', key: '↕' }
// ];
// }
getMainShortcuts() {
return [
{ name: 'Show / Hide', key: '\\' },
{ name: 'Ask Anything', key: '↵' },
{ name: 'Scroll AI Response', key: '↕' }
{ name: 'Show / Hide', accelerator: this.shortcuts.toggleVisibility },
{ name: 'Ask Anything', accelerator: this.shortcuts.nextStep },
{ name: 'Scroll Up Response', accelerator: this.shortcuts.scrollUp },
{ name: 'Scroll Down Response', accelerator: this.shortcuts.scrollDown },
];
}
renderShortcutKeys(accelerator) {
if (!accelerator) return html`N/A`;
const keyMap = {
'Cmd': '⌘', 'Command': '⌘', 'Ctrl': '⌃', 'Alt': '⌥', 'Shift': '⇧', 'Enter': '↵',
'Up': '↑', 'Down': '↓', 'Left': '←', 'Right': '→'
};
// scrollDown/scrollUp의 특수 처리
if (accelerator.includes('↕')) {
const keys = accelerator.replace('↕','').split('+');
keys.push('↕');
return html`${keys.map(key => html`<span class="shortcut-key">${keyMap[key] || key}</span>`)}`;
}
const keys = accelerator.split('+');
return html`${keys.map(key => html`<span class="shortcut-key">${keyMap[key] || key}</span>`)}`;
}
togglePresets() {
this.showPresets = !this.showPresets;
}
@ -1132,13 +1152,19 @@ export class SettingsView extends LitElement {
${apiKeyManagementHTML}
${modelSelectionHTML}
<div class="buttons-section" style="border-top: 1px solid rgba(255, 255, 255, 0.1); padding-top: 6px; margin-top: 6px;">
<button class="settings-button full-width" @click=${this.openShortcutEditor}>
Edit Shortcuts
</button>
</div>
<div class="shortcuts-section">
${this.getMainShortcuts().map(shortcut => html`
<div class="shortcut-item">
<span class="shortcut-name">${shortcut.name}</span>
<div class="shortcut-keys">
<span class="cmd-key"></span>
<span class="shortcut-key">${shortcut.key}</span>
${this.renderShortcutKeys(shortcut.accelerator)}
</div>
</div>
`)}
@ -1177,6 +1203,9 @@ export class SettingsView extends LitElement {
<button class="settings-button full-width" @click=${this.handlePersonalize}>
<span>Personalize / Meeting Notes</span>
</button>
<button class="settings-button full-width" @click=${this.handleToggleAutoUpdate} ?disabled=${this.autoUpdateLoading}>
<span>Automatic Updates: ${this.autoUpdateEnabled ? 'On' : 'Off'}</span>
</button>
<div class="move-buttons">
<button class="settings-button half-width" @click=${this.handleMoveLeft}>

View File

@ -0,0 +1,235 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
const commonSystemShortcuts = new Set([
'Cmd+Q', 'Cmd+W', 'Cmd+A', 'Cmd+S', 'Cmd+Z', 'Cmd+X', 'Cmd+C', 'Cmd+V', 'Cmd+P', 'Cmd+F', 'Cmd+G', 'Cmd+H', 'Cmd+M', 'Cmd+N', 'Cmd+O', 'Cmd+T',
'Ctrl+Q', 'Ctrl+W', 'Ctrl+A', 'Ctrl+S', 'Ctrl+Z', 'Ctrl+X', 'Ctrl+C', 'Ctrl+V', 'Ctrl+P', 'Ctrl+F', 'Ctrl+G', 'Ctrl+H', 'Ctrl+M', 'Ctrl+N', 'Ctrl+O', 'Ctrl+T'
]);
const displayNameMap = {
nextStep: 'Ask Anything',
moveUp: 'Move Up Window',
moveDown: 'Move Down Window',
scrollUp: 'Scroll Up Response',
scrollDown: 'Scroll Down Response',
};
export class ShortcutSettingsView extends LitElement {
static styles = css`
* { font-family:'Helvetica Neue',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;
cursor:default; user-select:none; box-sizing:border-box; }
:host { display:flex; width:100%; height:100%; color:white; }
.container { display:flex; flex-direction:column; height:100%;
background:rgba(20,20,20,.9); border-radius:12px;
outline:.5px rgba(255,255,255,.2) solid; outline-offset:-1px;
position:relative; overflow:hidden; padding:12px; }
.close-button{position:absolute;top:10px;right:10px;inline-size:14px;block-size:14px;
background:rgba(255,255,255,.1);border:none;border-radius:3px;
color:rgba(255,255,255,.7);display:grid;place-items:center;
font-size:14px;line-height:0;cursor:pointer;transition:.15s;z-index:10;}
.close-button:hover{background:rgba(255,255,255,.2);color:rgba(255,255,255,.9);}
.title{font-size:14px;font-weight:500;margin:0 0 8px;padding-bottom:8px;
border-bottom:1px solid rgba(255,255,255,.1);text-align:center;}
.scroll-area{flex:1 1 auto;overflow-y:auto;margin:0 -4px;padding:4px;}
.shortcut-entry{display:flex;align-items:center;width:100%;gap:8px;
margin-bottom:8px;font-size:12px;padding:4px;}
.shortcut-name{flex:1 1 auto;color:rgba(255,255,255,.9);font-weight:300;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.action-btn{background:none;border:none;color:rgba(0,122,255,.8);
font-size:11px;padding:0 4px;cursor:pointer;transition:.15s;}
.action-btn:hover{color:#0a84ff;text-decoration:underline;}
.shortcut-input{inline-size:120px;background:rgba(0,0,0,.2);
border:1px solid rgba(255,255,255,.2);border-radius:4px;
padding:4px 6px;font:11px 'SF Mono','Menlo',monospace;
color:white;text-align:right;cursor:text;margin-left:auto;}
.shortcut-input:focus,.shortcut-input.capturing{
outline:none;border-color:rgba(0,122,255,.6);
box-shadow:0 0 0 1px rgba(0,122,255,.3);}
.feedback{font-size:10px;margin-top:2px;min-height:12px;}
.feedback.error{color:#ef4444;}
.feedback.success{color:#22c55e;}
.actions{display:flex;gap:4px;padding-top:8px;border-top:1px solid rgba(255,255,255,.1);}
.settings-button{flex:1;background:rgba(255,255,255,.1);
border:1px solid rgba(255,255,255,.2);border-radius:4px;
color:white;padding:5px 10px;font-size:11px;cursor:pointer;transition:.15s;}
.settings-button:hover{background:rgba(255,255,255,.15);}
.settings-button.primary{background:rgba(0,122,255,.25);border-color:rgba(0,122,255,.6);}
.settings-button.primary:hover{background:rgba(0,122,255,.35);}
.settings-button.danger{background:rgba(255,59,48,.1);border-color:rgba(255,59,48,.3);
color:rgba(255,59,48,.9);}
.settings-button.danger:hover{background:rgba(255,59,48,.15);}
`;
static properties = {
shortcuts: { type: Object, state: true },
isLoading: { type: Boolean, state: true },
capturingKey: { type: String, state: true },
feedback: { type:Object, state:true }
};
constructor() {
super();
this.shortcuts = {};
this.feedback = {};
this.isLoading = true;
this.capturingKey = null;
this.ipcRenderer = window.require ? window.require('electron').ipcRenderer : null;
}
connectedCallback() {
super.connectedCallback();
if (!this.ipcRenderer) return;
this.loadShortcutsHandler = (event, keybinds) => {
this.shortcuts = keybinds;
this.isLoading = false;
};
this.ipcRenderer.on('load-shortcuts', this.loadShortcutsHandler);
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.ipcRenderer && this.loadShortcutsHandler) {
this.ipcRenderer.removeListener('load-shortcuts', this.loadShortcutsHandler);
}
}
handleKeydown(e, shortcutKey){
e.preventDefault(); e.stopPropagation();
const result = this._parseAccelerator(e);
if(!result) return; // modifier키만 누른 상태
const {accel, error} = result;
if(error){
this.feedback = {...this.feedback, [shortcutKey]:{type:'error',msg:error}};
return;
}
// 성공
this.shortcuts = {...this.shortcuts, [shortcutKey]:accel};
this.feedback = {...this.feedback, [shortcutKey]:{type:'success',msg:'Shortcut set'}};
this.stopCapture();
}
_parseAccelerator(e){
/* returns {accel?, error?} */
const parts=[]; if(e.metaKey) parts.push('Cmd');
if(e.ctrlKey) parts.push('Ctrl');
if(e.altKey) parts.push('Alt');
if(e.shiftKey) parts.push('Shift');
const isModifier=['Meta','Control','Alt','Shift'].includes(e.key);
if(isModifier) return null;
const map={ArrowUp:'Up',ArrowDown:'Down',ArrowLeft:'Left',ArrowRight:'Right',' ':'Space'};
parts.push(e.key.length===1? e.key.toUpperCase() : (map[e.key]||e.key));
const accel=parts.join('+');
/* ---- validation ---- */
if(parts.length===1) return {error:'Invalid shortcut: needs a modifier'};
if(parts.length>4) return {error:'Invalid shortcut: max 4 keys'};
if(commonSystemShortcuts.has(accel)) return {error:'Invalid shortcut: system reserved'};
return {accel};
}
startCapture(key){ this.capturingKey = key; this.feedback = {...this.feedback, [key]:undefined}; }
disableShortcut(key){
this.shortcuts = {...this.shortcuts, [key]:''}; // 공백 => 작동 X
this.feedback = {...this.feedback, [key]:{type:'success',msg:'Shortcut disabled'}};
}
stopCapture() {
this.capturingKey = null;
}
async handleSave() {
if (!this.ipcRenderer) return;
const result = await this.ipcRenderer.invoke('save-shortcuts', this.shortcuts);
if (!result.success) {
alert('Failed to save shortcuts: ' + result.error);
}
}
handleClose() {
if (!this.ipcRenderer) return;
this.ipcRenderer.send('close-shortcut-editor');
}
async handleResetToDefault() {
if (!this.ipcRenderer) return;
const confirmation = confirm("Are you sure you want to reset all shortcuts to their default values?");
if (!confirmation) return;
try {
const defaultShortcuts = await this.ipcRenderer.invoke('get-default-shortcuts');
this.shortcuts = defaultShortcuts;
} catch (error) {
alert('Failed to load default settings.');
}
}
formatShortcutName(name) {
if (displayNameMap[name]) {
return displayNameMap[name];
}
const result = name.replace(/([A-Z])/g, " $1");
return result.charAt(0).toUpperCase() + result.slice(1);
}
render(){
if(this.isLoading){
return html`<div class="container"><div class="loading-state">Loading Shortcuts...</div></div>`;
}
return html`
<div class="container">
<button class="close-button" @click=${this.handleClose} title="Close">&times;</button>
<h1 class="title">Edit Shortcuts</h1>
<div class="scroll-area">
${Object.keys(this.shortcuts).map(key=>html`
<div>
<div class="shortcut-entry">
<span class="shortcut-name">${this.formatShortcutName(key)}</span>
<!-- Edit & Disable 버튼 -->
<button class="action-btn" @click=${()=>this.startCapture(key)}>Edit</button>
<button class="action-btn" @click=${()=>this.disableShortcut(key)}>Disable</button>
<input readonly
class="shortcut-input ${this.capturingKey===key?'capturing':''}"
.value=${this.shortcuts[key]||''}
placeholder=${this.capturingKey===key?'Press new shortcut…':'Click to edit'}
@click=${()=>this.startCapture(key)}
@keydown=${e=>this.handleKeydown(e,key)}
@blur=${()=>this.stopCapture()}
/>
</div>
${this.feedback[key] ? html`
<div class="feedback ${this.feedback[key].type}">
${this.feedback[key].msg}
</div>` : html`<div class="feedback"></div>`
}
</div>
`)}
</div>
<div class="actions">
<button class="settings-button" @click=${this.handleClose}>Cancel</button>
<button class="settings-button danger" @click=${this.handleResetToDefault}>Reset to Default</button>
<button class="settings-button primary" @click=${this.handleSave}>Save</button>
</div>
</div>
`;
}
}
customElements.define('shortcut-settings-view', ShortcutSettingsView);

View File

@ -18,4 +18,6 @@ module.exports = {
createPreset: (...args) => getRepository().createPreset(...args),
updatePreset: (...args) => getRepository().updatePreset(...args),
deletePreset: (...args) => getRepository().deletePreset(...args),
getAutoUpdate: (...args) => getRepository().getAutoUpdate(...args),
setAutoUpdate: (...args) => getRepository().setAutoUpdate(...args),
};

View File

@ -90,10 +90,57 @@ function deletePreset(id, uid) {
}
}
function getAutoUpdate(uid) {
const db = sqliteClient.getDb();
const targetUid = uid;
try {
const row = db.prepare('SELECT auto_update_enabled FROM users WHERE uid = ?').get(targetUid);
if (row) {
console.log('SQLite: Auto update setting found:', row.auto_update_enabled);
return row.auto_update_enabled !== 0;
} else {
// User doesn't exist, create them with default settings
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare(
'INSERT OR REPLACE INTO users (uid, display_name, email, created_at, auto_update_enabled) VALUES (?, ?, ?, ?, ?)');
stmt.run(targetUid, 'User', 'user@example.com', now, 1);
return true; // default to enabled
}
} catch (error) {
console.error('SQLite: Error getting auto_update_enabled setting:', error);
return true; // fallback to enabled
}
}
function setAutoUpdate(uid, isEnabled) {
const db = sqliteClient.getDb();
const targetUid = uid || sqliteClient.defaultUserId;
try {
const result = db.prepare('UPDATE users SET auto_update_enabled = ? WHERE uid = ?').run(isEnabled ? 1 : 0, targetUid);
// If no rows were updated, the user might not exist, so create them
if (result.changes === 0) {
const now = Math.floor(Date.now() / 1000);
const stmt = db.prepare('INSERT OR REPLACE INTO users (uid, display_name, email, created_at, auto_update_enabled) VALUES (?, ?, ?, ?, ?)');
stmt.run(targetUid, 'User', 'user@example.com', now, isEnabled ? 1 : 0);
}
return { success: true };
} catch (error) {
console.error('SQLite: Error setting auto-update:', error);
throw error;
}
}
module.exports = {
getPresets,
getPresetTemplates,
createPreset,
updatePreset,
deletePreset
deletePreset,
getAutoUpdate,
setAutoUpdate
};

View File

@ -383,6 +383,29 @@ async function updateContentProtection(enabled) {
}
}
async function getAutoUpdateSetting() {
try {
const uid = authService.getCurrentUserId();
// This can be awaited if the repository returns a promise.
// Assuming it's synchronous for now based on original structure.
return settingsRepository.getAutoUpdate(uid);
} catch (error) {
console.error('[SettingsService] Error getting auto update setting:', error);
return true; // Fallback to enabled
}
}
async function setAutoUpdateSetting(isEnabled) {
try {
const uid = authService.getCurrentUserId();
await settingsRepository.setAutoUpdate(uid, isEnabled);
return { success: true };
} catch (error) {
console.error('[SettingsService] Error setting auto update setting:', error);
return { success: false, error: error.message };
}
}
function initialize() {
// cleanup
windowNotificationManager.cleanup();
@ -429,6 +452,15 @@ function initialize() {
return await updateContentProtection(enabled);
});
ipcMain.handle('settings:get-auto-update', async () => {
return await getAutoUpdateSetting();
});
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => {
console.log('[SettingsService] Setting auto update setting:', isEnabled);
return await setAutoUpdateSetting(isEnabled);
});
console.log('[SettingsService] Initialized and ready.');
}
@ -459,4 +491,5 @@ module.exports = {
saveApiKey,
removeApiKey,
updateContentProtection,
getAutoUpdateSetting,
};

View File

@ -26,6 +26,7 @@ const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./common/repositories/session');
const ModelStateService = require('./common/services/modelStateService');
const sqliteClient = require('./common/services/sqliteClient');
const eventBridge = new EventEmitter();
let WEB_PORT = 3000;
@ -189,10 +190,12 @@ app.whenReady().then(async () => {
// Clean up zombie sessions from previous runs first
sessionRepository.endAllActiveSessions();
authService.initialize();
await authService.initialize();
//////// after_modelStateService ////////
modelStateService.initialize();
//////// after_modelStateService ////////
listenService.setupIpcHandlers();
askService.initialize();
settingsService.initialize();
@ -213,6 +216,7 @@ app.whenReady().then(async () => {
);
}
// initAutoUpdater should be called after auth is initialized
initAutoUpdater();
// Process any pending deep link after everything is initialized
@ -649,8 +653,13 @@ async function startWebStack() {
}
// Auto-update initialization
function initAutoUpdater() {
async function initAutoUpdater() {
try {
const autoUpdateEnabled = await settingsService.getAutoUpdateSetting();
if (!autoUpdateEnabled) {
console.log('[AutoUpdater] Skipped because auto-updates are disabled in settings');
return;
}
// Skip auto-updater in development mode
if (!app.isPackaged) {
console.log('[AutoUpdater] Skipped in development (app is not packaged)');