Merge branch 'main' into feature/selectable-text

This commit is contained in:
Sarang19 2025-07-09 00:00:41 +05:30 committed by GitHub
commit a1acce1a3f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
85 changed files with 28942 additions and 9298 deletions

45
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: Build & Verify
on:
push:
branches: [ "main" ] # Runs on every push to main branch
jobs:
build:
# Currently runs on macOS only, can add windows-latest later
runs-on: macos-latest
steps:
- name: 🚚 Checkout code
uses: actions/checkout@v4
- name: ⚙️ Setup Node.js environment
uses: actions/setup-node@v4
with:
node-version: '20.x' # Node.js version compatible with project
cache: 'npm' # npm dependency caching for speed improvement
- name: 📦 Install root dependencies
run: npm install
- name: 🌐 Install and build web (Renderer) part
# Move to pickleglass_web directory and run commands
working-directory: ./pickleglass_web
run: |
npm install
npm run build
- name: 🖥️ Build Electron app
# Run Electron build script from root directory
run: npm run build
- name: 🚨 Send failure notification to Slack
if: failure()
uses: rtCamp/action-slack-notify@v2
env:
SLACK_CHANNEL: general
SLACK_TITLE: "🚨 Build Failed"
SLACK_MESSAGE: "😭 Build failed for `${{ github.repository }}` repo on main branch."
SLACK_COLOR: 'danger'
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}

1
.gitignore vendored
View File

@ -102,7 +102,6 @@ pickleglass_web/venv/
node_modules/
npm-debug.log
yarn-error.log
package-lock.json
# Database
data/*.db

3
.gitmodules vendored Normal file
View File

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

1
.npmrc
View File

@ -1,3 +1,2 @@
better-sqlite3:ignore-scripts=true
electron-deeplink:ignore-scripts=true
sharp:ignore-scripts=true

95
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,95 @@
# Contributing to Glass
Thank you for considering contributing to **Glass by Pickle**! Contributions make the open-source community vibrant, innovative, and collaborative. We appreciate every contribution you make—big or small.
## 📌 Contribution Guidelines
### 👥 Avoid Work Duplication
Before creating an issue or submitting a pull request (PR), please check existing [Issues](https://github.com/pickle-com/glass/issues) and [Pull Requests](https://github.com/pickle-com/glass/pulls) to prevent duplicate efforts.
### ✅ Start with Approved Issues
- **Feature Requests**: Please wait for approval from core maintainers before starting work. Issues needing approval are marked with the `🚨 needs approval` label.
- **Bugs & Improvements**: You may begin immediately without explicit approval.
### 📝 Clearly Document Your Work
Provide enough context and detail to allow easy understanding. Issues and PRs should clearly communicate the problem or feature and stand alone without external references.
### 💡 Summarize Pull Requests
Include a brief summary at the top of your PR, describing the intent and scope of your changes.
### 🔗 Link Related Issues
Use GitHub keywords (`Closes #123`, `Fixes #456`) to auto-link and close issues upon PR merge.
### 🧪 Include Testing Information
Clearly state how your changes were tested.
> Example:
> "Tested locally on macOS 14, confirmed all features working as expected."
### 🧠 Future-Proof Your Descriptions
Document trade-offs, edge cases, and temporary workarounds clearly to help future maintainers understand your decisions.
---
## 🔖 Issue Priorities
| Issue Type | Priority |
|----------------------------------------------------|---------------------|
| Minor enhancements & non-core feature requests | 🟢 Low Priority |
| UX improvements & minor bugs | 🟡 Medium Priority |
| Core functionalities & essential features | 🟠 High Priority |
| Critical bugs & breaking issues | 🔴 Urgent |
|
# Developing
### Prerequisites
Ensure the following are installed:
- [Node.js v20.x.x](https://nodejs.org/en/download)
- [Python](https://www.python.org/downloads/)
- (Windows users) [Build Tools for Visual Studio](https://visualstudio.microsoft.com/downloads/)
Ensure you're using Node.js version 20.x.x to avoid build errors with native dependencies.
```bash
# Check your Node.js version
node --version
# If you need to install Node.js 20.x.x, we recommend using nvm:
# curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
# nvm install 20
# nvm use 20
```
## Setup and Build
```bash
npm run setup
```
Please ensure that you can make a full production build before pushing code.
## Linting
```bash
npm run lint
```
If you get errors, be sure to fix them before committing.
## Making a Pull Request
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) when creating your PR. (This option isn't available if you're [contributing from a fork belonging to an organization](https://github.com/orgs/community/discussions/5634))
- If your PR refers to or fixes an issue, add `refs #XXX` or `fixes #XXX` to the PR description. Replace `XXX` with the respective issue number. See more about [linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Lastly, make sure to keep your branches updated (e.g., click the `Update branch` button on the GitHub PR page).

View File

@ -62,11 +62,14 @@ npm run setup
<img width="100%" alt="booking-screen" src="./public/assets/01.gif">
### Use your own OpenAI API key, or sign up to use ours (free)
### Use your own API key, or sign up to use ours (free)
<img width="100%" alt="booking-screen" src="./public/assets/02.gif">
You can visit [here](https://platform.openai.com/api-keys) to get your OpenAI API Key.
**Currently Supporting:**
- OpenAI API: Get OpenAI API Key [here](https://platform.openai.com/api-keys)
- Gemini API: Get Gemini API Key [here](https://aistudio.google.com/apikey)
- Local LLM (WIP)
### Liquid Glass Design (coming soon)
@ -88,20 +91,40 @@ You can visit [here](https://platform.openai.com/api-keys) to get your OpenAI AP
`Ctrl/Cmd + Arrows` : move main window position
## Repo Activity
![Alt](https://repobeats.axiom.co/api/embed/a23e342faafa84fa8797fa57762885d82fac1180.svg "Repobeats analytics image")
## Contributing
We love contributions! Feel free to open issues for bugs or feature requests.
We love contributions! Feel free to open issues for bugs or feature requests. For detailed guide, please see our [contributing guide](/CONTRIBUTING.md).
> Currently, we're working on a full code refactor and modularization. Once that's completed, we'll jump into addressing the major issues.
## 🛠 Current Issues & Improvements
### Contributors
<a href="https://github.com/pickle-com/glass/graphs/contributors">
<img src="https://contrib.rocks/image?repo=pickle-com/glass" />
</a>
### Help Wanted Issues
We have a list of [help wanted](https://github.com/pickle-com/glass/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22%F0%9F%99%8B%E2%80%8D%E2%99%82%EF%B8%8Fhelp%20wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
### 🛠 Current Issues & Improvements
| Status | Issue | Description |
|--------|--------------------------------|---------------------------------------------------|
| 🚧 WIP | AEC Improvement | Transcription is not working occasionally |
| 🚧 WIP | Code Refactoring | Refactoring the entire codebase for better maintainability. |
| 🚧 WIP | Local LLM Support | Supporting Local LLM to power AI answers |
| 🚧 WIP | Firebase Data Storage Issue | Session & ask should be saved in firebase for signup users |
| 🚧 WIP | Login Issue | Currently breaking when switching between local and sign-in mode |
| 🚧 WIP | Liquid Glass | Liquid Glass UI for MacOS 26 |
| 🚧 WIP | Permission Issue | Mic & system audio & display capture permission sometimes not working|
### 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)

1
aec Submodule

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

View File

@ -13,6 +13,12 @@ publish:
repo: glass
releaseType: draft
# Protocols configuration for deep linking
protocols:
name: PickleGlass Protocol
schemes:
- pickleglass
# List of files to be included in the app package
files:
- src/**/*
@ -29,6 +35,28 @@ extraResources:
asarUnpack:
- "src/assets/SystemAudioDump"
# Windows configuration
win:
icon: src/assets/logo.ico
target:
- target: nsis
arch: x64
- target: portable
arch: x64
requestedExecutionLevel: asInvoker
# Disable code signing to avoid symbolic link issues on Windows
signAndEditExecutable: false
# NSIS installer configuration for Windows
nsis:
oneClick: false
perMachine: false
allowToChangeInstallationDirectory: true
deleteAppDataOnUninstall: true
createDesktopShortcut: always
createStartMenuShortcut: true
shortcutName: Glass
# macOS specific configuration
mac:
# The application category type

12105
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,7 +1,9 @@
{
"name": "pickle-glass",
"productName": "Glass",
"version": "0.1.2",
"version": "0.2.2",
"description": "Cl*ely for Free",
"main": "src/index.js",
"scripts": {
@ -9,11 +11,14 @@
"start": "npm run build:renderer && electron-forge start",
"package": "npm run build:renderer && electron-forge package",
"make": "npm run build:renderer && electron-forge make",
"build": "npm run build:renderer && electron-builder --config electron-builder.yml --publish never",
"publish": "npm run build:renderer && electron-builder --config electron-builder.yml --publish always",
"build": "npm run build:all && electron-builder --config electron-builder.yml --publish never",
"build:win": "npm run build:all && electron-builder --win --x64 --publish never",
"publish": "npm run build:all && electron-builder --config electron-builder.yml --publish always",
"lint": "eslint --ext .ts,.tsx,.js .",
"postinstall": "electron-builder install-app-deps",
"build:renderer": "node build.js",
"build:web": "cd pickleglass_web && npm run build && cd ..",
"build:all": "npm run build:renderer && npm run build:web",
"watch:renderer": "node build.js --watch"
},
"keywords": [
@ -29,13 +34,13 @@
},
"license": "GPL-3.0",
"dependencies": {
"@anthropic-ai/sdk": "^0.56.0",
"@google/genai": "^1.8.0",
"@google/generative-ai": "^0.24.1",
"axios": "^1.10.0",
"better-sqlite3": "^9.4.3",
"cors": "^2.8.5",
"dotenv": "^17.0.0",
"electron-deeplink": "^1.0.10",
"electron-squirrel-startup": "^1.0.1",
"electron-store": "^8.2.0",
"electron-updater": "^6.6.2",
@ -47,7 +52,6 @@
"openai": "^4.70.0",
"react-hot-toast": "^2.5.2",
"sharp": "^0.34.2",
"sqlite3": "^5.1.7",
"validator": "^13.11.0",
"wait-on": "^8.0.3",
"ws": "^8.18.0"
@ -69,7 +73,6 @@
"esbuild": "^0.25.5"
},
"optionalDependencies": {
"@img/sharp-darwin-x64": "^0.34.2",
"@img/sharp-libvips-darwin-x64": "^1.1.0"
"electron-liquid-glass": "^1.0.1"
}
}

View File

@ -1,33 +0,0 @@
const path = require('path');
const databaseInitializer = require('../../src/common/services/databaseInitializer');
const Database = require('better-sqlite3');
const dbPath = databaseInitializer.getDatabasePath();
const db = new Database(dbPath);
db.pragma('journal_mode = WAL');
// The schema is now managed by the main Electron process on startup.
// This file can assume the schema is correct and up-to-date.
const defaultPresets = [
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],
['meetings', 'Meetings', 'You are a meeting assistant. Your goal is to help the user capture key information during meetings and follow up effectively.\n\nYou help capture meeting notes, track action items, identify key decisions, and summarize important points discussed during meetings.', 1],
['sales', 'Sales', 'You are a real-time AI sales assistant, and your goal is to help the user close deals during sales interactions.\n\nYou provide real-time sales support, suggest responses to objections, help identify customer needs, and recommend strategies to advance deals.', 1],
['recruiting', 'Recruiting', 'You are a recruiting assistant. Your goal is to help the user interview candidates and evaluate talent effectively.\n\nYou help evaluate candidates, suggest interview questions, analyze responses, and provide insights about candidate fit for positions.', 1],
['customer-support', 'Customer Support', 'You are a customer support assistant. Your goal is to help resolve customer issues efficiently and thoroughly.\n\nYou help diagnose customer problems, suggest solutions, provide step-by-step troubleshooting guidance, and ensure customer satisfaction.', 1],
];
const stmt = db.prepare(`
INSERT OR IGNORE INTO prompt_presets (id, uid, title, prompt, is_default, created_at)
VALUES (@id, 'default_user', @title, @prompt, @is_default, strftime('%s','now'));
`);
db.transaction(() => defaultPresets.forEach(([id, title, prompt, is_default]) => stmt.run({ id, title, prompt, is_default })))();
const defaultUserStmt = db.prepare(`
INSERT OR IGNORE INTO users (uid, display_name, email, created_at)
VALUES ('default_user', 'Default User', 'contact@pickle.com', strftime('%s','now'));
`);
defaultUserStmt.run();
module.exports = db;

View File

@ -1,9 +1,9 @@
const express = require('express');
const cors = require('cors');
const db = require('./db');
// const db = require('./db'); // No longer needed
const { identifyUser } = require('./middleware/auth');
function createApp() {
function createApp(eventBridge) {
const app = express();
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
@ -20,6 +20,11 @@ function createApp() {
res.json({ message: "pickleglass API is running" });
});
app.use((req, res, next) => {
req.bridge = eventBridge;
next();
});
app.use('/api', identifyUser);
app.use('/api/auth', require('./routes/auth'));

View File

@ -0,0 +1,35 @@
const crypto = require('crypto');
function ipcRequest(req, channel, payload) {
return new Promise((resolve, reject) => {
// 즉시 브리지 상태 확인 - 문제있으면 바로 실패
if (!req.bridge || typeof req.bridge.emit !== 'function') {
reject(new Error('IPC bridge is not available'));
return;
}
const responseChannel = `${channel}-${crypto.randomUUID()}`;
req.bridge.once(responseChannel, (response) => {
if (!response) {
reject(new Error(`No response received from ${channel}`));
return;
}
if (response.success) {
resolve(response.data);
} else {
reject(new Error(response.error || `IPC request to ${channel} failed`));
}
});
try {
req.bridge.emit('web-data-request', channel, responseChannel, payload);
} catch (error) {
req.bridge.removeAllListeners(responseChannel);
reject(new Error(`Failed to emit IPC request: ${error.message}`));
}
});
}
module.exports = { ipcRequest };

View File

@ -1,13 +0,0 @@
const jwt = require('jsonwebtoken');
const SECRET = process.env.JWT_SECRET_KEY || 'change-me';
const EXPIRE = 60 * 24; // minutes
exports.sign = (sub, extra = {}) => jwt.sign({ sub, ...extra }, SECRET, { algorithm: 'HS256', expiresIn: `${EXPIRE}m` });
exports.verify = token => {
try {
return jwt.verify(token, SECRET).sub;
} catch {
return null;
}
};

View File

@ -1,5 +1,3 @@
const { verify } = require('../jwt');
function identifyUser(req, res, next) {
const userId = req.get('X-User-ID');

View File

@ -1,9 +1,10 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
const { ipcRequest } = require('../ipcBridge');
router.get('/status', (req, res) => {
const user = db.prepare('SELECT uid, display_name FROM users WHERE uid = ?').get('default_user');
router.get('/status', async (req, res) => {
try {
const user = await ipcRequest(req, 'get-user-profile');
if (!user) {
return res.status(500).json({ error: 'Default user not initialized' });
}
@ -14,6 +15,10 @@ router.get('/status', (req, res) => {
name: user.display_name
}
});
} catch (error) {
console.error('Failed to get auth status via IPC:', error);
res.status(500).json({ error: 'Failed to retrieve auth status' });
}
});
module.exports = router;

View File

@ -1,121 +1,54 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
const crypto = require('crypto');
const validator = require('validator');
const { ipcRequest } = require('../ipcBridge');
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
try {
const sessions = db.prepare(
"SELECT id, uid, title, session_type, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC"
).all(req.uid);
const sessions = await ipcRequest(req, 'get-sessions');
res.json(sessions);
} catch (error) {
console.error('Failed to get sessions:', error);
console.error('Failed to get sessions via IPC:', error);
res.status(500).json({ error: 'Failed to retrieve sessions' });
}
});
router.post('/', (req, res) => {
const { title } = req.body;
const sessionId = crypto.randomUUID();
const now = Math.floor(Date.now() / 1000);
router.post('/', async (req, res) => {
try {
db.prepare(
`INSERT INTO sessions (id, uid, title, started_at, updated_at)
VALUES (?, ?, ?, ?, ?)`
).run(sessionId, req.uid, title || 'New Conversation', now, now);
res.status(201).json({ id: sessionId, message: 'Session created successfully' });
const result = await ipcRequest(req, 'create-session', req.body);
res.status(201).json({ ...result, message: 'Session created successfully' });
} catch (error) {
console.error('Failed to create session:', error);
console.error('Failed to create session via IPC:', error);
res.status(500).json({ error: 'Failed to create session' });
}
});
router.get('/:session_id', (req, res) => {
const { session_id } = req.params;
router.get('/:session_id', async (req, res) => {
try {
const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(session_id);
if (!session) {
const details = await ipcRequest(req, 'get-session-details', req.params.session_id);
if (!details) {
return res.status(404).json({ error: 'Session not found' });
}
const transcripts = db.prepare("SELECT * FROM transcripts WHERE session_id = ? ORDER BY start_at ASC").all(session_id);
const ai_messages = db.prepare("SELECT * FROM ai_messages WHERE session_id = ? ORDER BY sent_at ASC").all(session_id);
const summary = db.prepare("SELECT * FROM summaries WHERE session_id = ?").get(session_id);
res.json({
session,
transcripts,
ai_messages,
summary: summary || null
});
res.json(details);
} catch (error) {
console.error(`Failed to get session ${session_id}:`, error);
console.error(`Failed to get session details via IPC for ${req.params.session_id}:`, error);
res.status(500).json({ error: 'Failed to retrieve session details' });
}
});
router.delete('/:session_id', (req, res) => {
const { session_id } = req.params;
const session = db.prepare("SELECT id FROM sessions WHERE id = ?").get(session_id);
if (!session) {
return res.status(404).json({ error: 'Session not found' });
}
router.delete('/:session_id', async (req, res) => {
try {
db.transaction(() => {
db.prepare("DELETE FROM transcripts WHERE session_id = ?").run(session_id);
db.prepare("DELETE FROM ai_messages WHERE session_id = ?").run(session_id);
db.prepare("DELETE FROM summaries WHERE session_id = ?").run(session_id);
db.prepare("DELETE FROM sessions WHERE id = ?").run(session_id);
})();
await ipcRequest(req, 'delete-session', req.params.session_id);
res.status(200).json({ message: 'Session deleted successfully' });
} catch (error) {
console.error(`Failed to delete session ${session_id}:`, error);
console.error(`Failed to delete session via IPC for ${req.params.session_id}:`, error);
res.status(500).json({ error: 'Failed to delete session' });
}
});
// The search functionality will be more complex to move to IPC.
// For now, we can disable it or leave it as is, knowing it's a future task.
router.get('/search', (req, res) => {
const { q } = req.query;
if (!q || !validator.isLength(q, { min: 3 })) {
return res.status(400).json({ error: 'Query parameter "q" is required' });
}
// Sanitize and validate input
const sanitizedQuery = validator.escape(q.trim()); // Escapes HTML and special chars
if (sanitizedQuery.length === 0 || sanitizedQuery.length > 255) {
return res.status(400).json({ error: 'Query parameter "q" must be between 3 and 255 characters' });
}
try {
const searchQuery = `%${sanitizedQuery}%`;
const sessionIds = db.prepare(`
SELECT DISTINCT session_id FROM (
SELECT session_id FROM transcripts WHERE text LIKE ?
UNION
SELECT session_id FROM ai_messages WHERE content LIKE ?
UNION
SELECT session_id FROM summaries WHERE text LIKE ? OR tldr LIKE ?
)
`).all(searchQuery, searchQuery, searchQuery, searchQuery).map(row => row.session_id);
if (sessionIds.length === 0) {
return res.json([]);
}
const placeholders = sessionIds.map(() => '?').join(',');
const sessions = db.prepare(
`SELECT id, uid, title, started_at, ended_at, sync_state, updated_at FROM sessions WHERE id IN (${placeholders}) ORDER BY started_at DESC`
).all(sessionIds);
res.json(sessions);
} catch (error) {
console.error('Search failed:', error);
res.status(500).json({ error: 'Failed to perform search' });
}
res.status(501).json({ error: 'Search not implemented for IPC bridge yet.' });
});
module.exports = router;

View File

@ -1,85 +1,43 @@
const express = require('express');
const crypto = require('crypto');
const db = require('../db');
const router = express.Router();
const { ipcRequest } = require('../ipcBridge');
router.get('/', (req, res) => {
router.get('/', async (req, res) => {
try {
const presets = db.prepare(
`SELECT * FROM prompt_presets
WHERE uid = ? OR is_default = 1
ORDER BY is_default DESC, title ASC`
).all(req.uid);
const presets = await ipcRequest(req, 'get-presets');
res.json(presets);
} catch (error) {
console.error('Failed to get presets:', error);
console.error('Failed to get presets via IPC:', error);
res.status(500).json({ error: 'Failed to retrieve presets' });
}
});
router.post('/', (req, res) => {
const { title, prompt } = req.body;
if (!title || !prompt) {
return res.status(400).json({ error: 'Title and prompt are required' });
}
const presetId = crypto.randomUUID();
const now = Math.floor(Date.now() / 1000);
router.post('/', async (req, res) => {
try {
db.prepare(
`INSERT INTO prompt_presets (id, uid, title, prompt, is_default, created_at, sync_state)
VALUES (?, ?, ?, ?, 0, ?, 'dirty')`
).run(presetId, req.uid, title, prompt, now);
res.status(201).json({ id: presetId, message: 'Preset created successfully' });
const result = await ipcRequest(req, 'create-preset', req.body);
res.status(201).json({ ...result, message: 'Preset created successfully' });
} catch (error) {
console.error('Failed to create preset:', error);
console.error('Failed to create preset via IPC:', error);
res.status(500).json({ error: 'Failed to create preset' });
}
});
router.put('/:id', (req, res) => {
const { id } = req.params;
const { title, prompt } = req.body;
if (!title || !prompt) {
return res.status(400).json({ error: 'Title and prompt are required' });
}
router.put('/:id', async (req, res) => {
try {
const result = db.prepare(
`UPDATE prompt_presets
SET title = ?, prompt = ?, sync_state = 'dirty'
WHERE id = ? AND uid = ? AND is_default = 0`
).run(title, prompt, id, req.uid);
if (result.changes === 0) {
return res.status(404).json({ error: "Preset not found or you don't have permission to edit it." });
}
await ipcRequest(req, 'update-preset', { id: req.params.id, data: req.body });
res.json({ message: 'Preset updated successfully' });
} catch (error) {
console.error('Failed to update preset:', error);
console.error('Failed to update preset via IPC:', error);
res.status(500).json({ error: 'Failed to update preset' });
}
});
router.delete('/:id', (req, res) => {
const { id } = req.params;
router.delete('/:id', async (req, res) => {
try {
const result = db.prepare(
`DELETE FROM prompt_presets
WHERE id = ? AND uid = ? AND is_default = 0`
).run(id, req.uid);
if (result.changes === 0) {
return res.status(404).json({ error: "Preset not found or you don't have permission to delete it." });
}
await ipcRequest(req, 'delete-preset', req.params.id);
res.json({ message: 'Preset deleted successfully' });
} catch (error) {
console.error('Failed to delete preset:', error);
console.error('Failed to delete preset via IPC:', error);
res.status(500).json({ error: 'Failed to delete preset' });
}
});

View File

@ -1,144 +1,87 @@
const express = require('express');
const db = require('../db');
const router = express.Router();
const { ipcRequest } = require('../ipcBridge');
router.put('/profile', (req, res) => {
const { displayName } = req.body;
if (!displayName) return res.status(400).json({ error: 'displayName is required' });
router.put('/profile', async (req, res) => {
try {
db.prepare("UPDATE users SET display_name = ? WHERE uid = ?").run(displayName, req.uid);
await ipcRequest(req, 'update-user-profile', req.body);
res.json({ message: 'Profile updated successfully' });
} catch (error) {
console.error('Failed to update profile:', error);
console.error('Failed to update profile via IPC:', error);
res.status(500).json({ error: 'Failed to update profile' });
}
});
router.get('/profile', (req, res) => {
router.get('/profile', async (req, res) => {
try {
const user = db.prepare('SELECT uid, display_name, email FROM users WHERE uid = ?').get(req.uid);
const user = await ipcRequest(req, 'get-user-profile');
if (!user) return res.status(404).json({ error: 'User not found' });
res.json(user);
} catch (error) {
console.error('Failed to get profile:', error);
console.error('Failed to get profile via IPC:', error);
res.status(500).json({ error: 'Failed to get profile' });
}
});
router.post('/find-or-create', (req, res) => {
const { uid, displayName, email } = req.body;
if (!uid || !displayName || !email) {
return res.status(400).json({ error: 'uid, displayName, and email are required' });
router.post('/find-or-create', async (req, res) => {
try {
console.log('[API] find-or-create request received:', req.body);
if (!req.body || !req.body.uid) {
return res.status(400).json({ error: 'User data with uid is required' });
}
try {
const now = Math.floor(Date.now() / 1000);
db.prepare(
`INSERT INTO users (uid, display_name, email, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(uid) DO NOTHING`
).run(uid, displayName, email, now);
const user = db.prepare('SELECT * FROM users WHERE uid = ?').get(uid);
const user = await ipcRequest(req, 'find-or-create-user', req.body);
console.log('[API] find-or-create response:', user);
res.status(200).json(user);
} catch (error) {
console.error('Failed to find or create user:', error);
res.status(500).json({ error: 'Failed to find or create user' });
console.error('Failed to find or create user via IPC:', error);
console.error('Request body:', req.body);
res.status(500).json({
error: 'Failed to find or create user',
details: error.message
});
}
});
router.post('/api-key', (req, res) => {
const { apiKey } = req.body;
if (typeof apiKey !== 'string') {
return res.status(400).json({ error: 'API key must be a string' });
}
router.post('/api-key', async (req, res) => {
try {
db.prepare("UPDATE users SET api_key = ? WHERE uid = ?").run(apiKey, req.uid);
await ipcRequest(req, 'save-api-key', req.body.apiKey);
res.json({ message: 'API key saved successfully' });
} catch (error) {
console.error('Failed to save API key:', error);
console.error('Failed to save API key via IPC:', error);
res.status(500).json({ error: 'Failed to save API key' });
}
});
router.get('/api-key-status', (req, res) => {
router.get('/api-key-status', async (req, res) => {
try {
const row = db.prepare('SELECT api_key FROM users WHERE uid = ?').get(req.uid);
if (!row) {
return res.status(404).json({ error: 'User not found' });
}
res.json({ hasApiKey: !!row.api_key && row.api_key.length > 0 });
const status = await ipcRequest(req, 'check-api-key-status');
res.json(status);
} catch (error) {
console.error('Failed to get API key status:', error);
console.error('Failed to get API key status via IPC:', error);
res.status(500).json({ error: 'Failed to get API key status' });
}
});
router.delete('/profile', (req, res) => {
router.delete('/profile', async (req, res) => {
try {
const user = db.prepare('SELECT uid FROM users WHERE uid = ?').get(req.uid);
if (!user) {
return res.status(404).json({ error: 'User not found' });
}
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(user.uid);
const sessionIds = userSessions.map(s => s.id);
db.transaction(() => {
if (sessionIds.length > 0) {
const placeholders = sessionIds.map(() => '?').join(',');
db.prepare(`DELETE FROM transcripts WHERE session_id IN (${placeholders})`).run(...sessionIds);
db.prepare(`DELETE FROM ai_messages WHERE session_id IN (${placeholders})`).run(...sessionIds);
db.prepare(`DELETE FROM summaries WHERE session_id IN (${placeholders})`).run(...sessionIds);
db.prepare(`DELETE FROM sessions WHERE uid = ?`).run(user.uid);
}
db.prepare('DELETE FROM prompt_presets WHERE uid = ?').run(user.uid);
db.prepare('DELETE FROM users WHERE uid = ?').run(user.uid);
})();
await ipcRequest(req, 'delete-account');
res.status(200).json({ message: 'User account and all data deleted successfully.' });
} catch (error) {
console.error('Failed to delete user account:', error);
console.error('Failed to delete user account via IPC:', error);
res.status(500).json({ error: 'Failed to delete user account' });
}
});
async function getUserBatchData(req, res) {
const { include = 'profile,presets,sessions' } = req.query;
router.get('/batch', async (req, res) => {
try {
const includes = include.split(',').map(item => item.trim());
const result = {};
if (includes.includes('profile')) {
const user = db.prepare('SELECT uid, display_name, email FROM users WHERE uid = ?').get(req.uid);
result.profile = user || null;
}
if (includes.includes('presets')) {
const presets = db.prepare('SELECT * FROM prompt_presets WHERE uid = ? OR is_default = 1').all(req.uid);
result.presets = presets || [];
}
if (includes.includes('sessions')) {
const recent_sessions = db.prepare(
"SELECT id, title, started_at, updated_at FROM sessions WHERE uid = ? ORDER BY updated_at DESC LIMIT 10"
).all(req.uid);
result.sessions = recent_sessions || [];
}
const result = await ipcRequest(req, 'get-batch-data', req.query.include);
res.json(result);
} catch(error) {
console.error('Failed to get batch data:', error);
console.error('Failed to get batch data via IPC:', error);
res.status(500).json({ error: 'Failed to get batch data' });
}
}
router.get('/batch', getUserBatchData);
});
module.exports = router;

6976
pickleglass_web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -87,7 +87,11 @@ export interface SessionDetails {
const isFirebaseMode = (): boolean => {
return firebaseAuth.currentUser !== null;
// The web frontend can no longer directly access Firebase state,
// so we assume communication always goes through the backend API.
// In the future, we can create an endpoint like /api/auth/status
// in the backend to retrieve the authentication state.
return false;
};
const timestampToUnix = (timestamp: Timestamp): number => {
@ -185,41 +189,13 @@ const loadRuntimeConfig = async (): Promise<string | null> => {
return null;
};
const getApiUrlFromElectron = (): string | null => {
if (typeof window !== 'undefined') {
try {
const { ipcRenderer } = window.require?.('electron') || {};
if (ipcRenderer) {
try {
const apiUrl = ipcRenderer.sendSync('get-api-url-sync');
if (apiUrl) {
console.log('✅ API URL from Electron IPC:', apiUrl);
return apiUrl;
}
} catch (error) {
console.log('⚠️ Electron IPC failed:', error);
}
}
} catch (error) {
console.log(' Not in Electron environment');
}
}
return null;
};
let apiUrlInitialized = false;
let initializationPromise: Promise<void> | null = null;
const initializeApiUrl = async () => {
if (apiUrlInitialized) return;
const electronUrl = getApiUrlFromElectron();
if (electronUrl) {
API_ORIGIN = electronUrl;
apiUrlInitialized = true;
return;
}
// Electron IPC 관련 코드를 모두 제거하고 runtime-config.json 또는 fallback에만 의존합니다.
const runtimeUrl = await loadRuntimeConfig();
if (runtimeUrl) {
API_ORIGIN = runtimeUrl;

View File

@ -36,21 +36,12 @@ export const useAuth = () => {
setUser(profile);
setUserInfo(profile);
if (window.ipcRenderer) {
window.ipcRenderer.send('set-current-user', profile.uid);
}
} else {
console.log('🏠 Local mode activated');
setMode('local');
setUser(defaultLocalUser);
setUserInfo(defaultLocalUser);
if (window.ipcRenderer) {
window.ipcRenderer.send('set-current-user', defaultLocalUser.uid);
}
}
setIsLoading(false);
});

0
preload.js Normal file
View File

View File

@ -1,12 +1,17 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js"
export class ApiKeyHeader extends LitElement {
//////// after_modelStateService ////////
static properties = {
apiKey: { type: String },
llmApiKey: { type: String },
sttApiKey: { type: String },
llmProvider: { type: String },
sttProvider: { type: String },
isLoading: { type: Boolean },
errorMessage: { type: String },
selectedProvider: { type: String },
};
providers: { type: Object, state: true },
}
//////// after_modelStateService ////////
static styles = css`
:host {
@ -45,7 +50,7 @@ export class ApiKeyHeader extends LitElement {
}
.container {
width: 285px;
width: 350px;
min-height: 260px;
padding: 18px 20px;
background: rgba(0, 0, 0, 0.3);
@ -153,28 +158,22 @@ export class ApiKeyHeader extends LitElement {
outline: none;
}
.provider-select {
.providers-container { display: flex; gap: 12px; width: 100%; }
.provider-column { flex: 1; display: flex; flex-direction: column; align-items: center; }
.provider-label { color: rgba(255, 255, 255, 0.7); font-size: 11px; font-weight: 500; margin-bottom: 6px; }
.api-input, .provider-select {
width: 100%;
height: 34px;
text-align: center;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.2);
padding: 0 10px;
color: white;
font-size: 12px;
font-weight: 400;
margin-bottom: 6px;
text-align: center;
cursor: pointer;
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2714%27%20height%3D%278%27%20viewBox%3D%270%200%2014%208%27%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%3E%3Cpath%20d%3D%27M1%201l6%206%206-6%27%20stroke%3D%27%23ffffff%27%20stroke-width%3D%271.5%27%20fill%3D%27none%27%20fill-rule%3D%27evenodd%27/%3E%3C/svg%3E');
background-repeat: no-repeat;
background-position: right 10px center;
background-size: 12px;
padding-right: 30px;
}
.provider-select option { background: #1a1a1a; color: white; }
.provider-select:hover {
background-color: rgba(255, 255, 255, 0.15);
@ -187,11 +186,6 @@ export class ApiKeyHeader extends LitElement {
border-color: rgba(255, 255, 255, 0.4);
}
.provider-select option {
background: #1a1a1a;
color: white;
padding: 5px;
}
.action-button {
width: 100%;
@ -240,54 +234,104 @@ export class ApiKeyHeader extends LitElement {
margin: 10px 0;
}
.provider-label {
color: rgba(255, 255, 255, 0.7);
font-size: 11px;
font-weight: 400;
margin-bottom: 4px;
width: 100%;
text-align: left;
/* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) .container,
:host-context(body.has-glass) .api-input,
:host-context(body.has-glass) .provider-select,
:host-context(body.has-glass) .action-button,
:host-context(body.has-glass) .close-button {
background: transparent !important;
border: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
`;
:host-context(body.has-glass) .container::after,
:host-context(body.has-glass) .action-button::after {
display: none !important;
}
:host-context(body.has-glass) .action-button:hover,
:host-context(body.has-glass) .provider-select:hover,
:host-context(body.has-glass) .close-button:hover {
background: transparent !important;
}
`
constructor() {
super();
this.dragState = null;
this.wasJustDragged = false;
this.apiKey = '';
this.isLoading = false;
this.errorMessage = '';
this.validatedApiKey = null;
this.selectedProvider = 'openai';
super()
this.dragState = null
this.wasJustDragged = false
this.isLoading = false
this.errorMessage = ""
//////// after_modelStateService ////////
this.llmApiKey = "";
this.sttApiKey = "";
this.llmProvider = "openai";
this.sttProvider = "openai";
this.providers = { llm: [], stt: [] }; // 초기화
this.loadProviderConfig();
//////// after_modelStateService ////////
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
this.handleSubmit = this.handleSubmit.bind(this);
this.handleInput = this.handleInput.bind(this);
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this);
this.handleProviderChange = this.handleProviderChange.bind(this);
this.handleMouseMove = this.handleMouseMove.bind(this)
this.handleMouseUp = this.handleMouseUp.bind(this)
this.handleKeyPress = this.handleKeyPress.bind(this)
this.handleSubmit = this.handleSubmit.bind(this)
this.handleInput = this.handleInput.bind(this)
this.handleAnimationEnd = this.handleAnimationEnd.bind(this)
this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this)
this.handleProviderChange = this.handleProviderChange.bind(this)
}
reset() {
this.apiKey = '';
this.isLoading = false;
this.errorMessage = '';
this.validatedApiKey = null;
this.selectedProvider = 'openai';
this.apiKey = ""
this.isLoading = false
this.errorMessage = ""
this.validatedApiKey = null
this.selectedProvider = "openai"
this.requestUpdate()
}
async loadProviderConfig() {
if (!window.require) return;
const { ipcRenderer } = window.require('electron');
const config = await ipcRenderer.invoke('model:get-provider-config');
const llmProviders = [];
const sttProviders = [];
for (const id in config) {
// 'openai-glass' 같은 가상 Provider는 UI에 표시하지 않음
if (id.includes('-glass')) continue;
if (config[id].llmModels.length > 0) {
llmProviders.push({ id, name: config[id].name });
}
if (config[id].sttModels.length > 0) {
sttProviders.push({ id, name: config[id].name });
}
}
this.providers = { llm: llmProviders, stt: sttProviders };
// 기본 선택 값 설정
if (llmProviders.length > 0) this.llmProvider = llmProviders[0].id;
if (sttProviders.length > 0) this.sttProvider = sttProviders[0].id;
this.requestUpdate();
}
async handleMouseDown(e) {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'BUTTON' || e.target.tagName === 'SELECT') {
return;
if (e.target.tagName === "INPUT" || e.target.tagName === "BUTTON" || e.target.tagName === "SELECT") {
return
}
e.preventDefault();
e.preventDefault()
const { ipcRenderer } = window.require('electron');
const initialPosition = await ipcRenderer.invoke('get-header-position');
const { ipcRenderer } = window.require("electron")
const initialPosition = await ipcRenderer.invoke("get-header-position")
this.dragState = {
initialMouseX: e.screenX,
@ -295,304 +339,213 @@ export class ApiKeyHeader extends LitElement {
initialWindowX: initialPosition.x,
initialWindowY: initialPosition.y,
moved: false,
};
}
window.addEventListener('mousemove', this.handleMouseMove);
window.addEventListener('mouseup', this.handleMouseUp, { once: true });
window.addEventListener("mousemove", this.handleMouseMove)
window.addEventListener("mouseup", this.handleMouseUp, { once: true })
}
handleMouseMove(e) {
if (!this.dragState) return;
if (!this.dragState) return
const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX);
const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY);
const deltaX = Math.abs(e.screenX - this.dragState.initialMouseX)
const deltaY = Math.abs(e.screenY - this.dragState.initialMouseY)
if (deltaX > 3 || deltaY > 3) {
this.dragState.moved = true;
this.dragState.moved = true
}
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX);
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY);
const newWindowX = this.dragState.initialWindowX + (e.screenX - this.dragState.initialMouseX)
const newWindowY = this.dragState.initialWindowY + (e.screenY - this.dragState.initialMouseY)
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('move-header-to', newWindowX, newWindowY);
const { ipcRenderer } = window.require("electron")
ipcRenderer.invoke("move-header-to", newWindowX, newWindowY)
}
handleMouseUp(e) {
if (!this.dragState) return;
if (!this.dragState) return
const wasDragged = this.dragState.moved;
const wasDragged = this.dragState.moved
window.removeEventListener('mousemove', this.handleMouseMove);
this.dragState = null;
window.removeEventListener("mousemove", this.handleMouseMove)
this.dragState = null
if (wasDragged) {
this.wasJustDragged = true;
this.wasJustDragged = true
setTimeout(() => {
this.wasJustDragged = false;
}, 200);
this.wasJustDragged = false
}, 200)
}
}
handleInput(e) {
this.apiKey = e.target.value;
this.errorMessage = '';
console.log('Input changed:', this.apiKey?.length || 0, 'chars');
this.apiKey = e.target.value
this.errorMessage = ""
console.log("Input changed:", this.apiKey?.length || 0, "chars")
this.requestUpdate();
this.requestUpdate()
this.updateComplete.then(() => {
const inputField = this.shadowRoot?.querySelector('.apikey-input');
const inputField = this.shadowRoot?.querySelector(".apikey-input")
if (inputField && this.isInputFocused) {
inputField.focus();
inputField.focus()
}
});
})
}
handleProviderChange(e) {
this.selectedProvider = e.target.value;
this.errorMessage = '';
console.log('Provider changed to:', this.selectedProvider);
this.requestUpdate();
this.selectedProvider = e.target.value
this.errorMessage = ""
console.log("Provider changed to:", this.selectedProvider)
this.requestUpdate()
}
handlePaste(e) {
e.preventDefault();
this.errorMessage = '';
const clipboardText = (e.clipboardData || window.clipboardData).getData('text');
console.log('Paste event detected:', clipboardText?.substring(0, 10) + '...');
e.preventDefault()
this.errorMessage = ""
const clipboardText = (e.clipboardData || window.clipboardData).getData("text")
console.log("Paste event detected:", clipboardText?.substring(0, 10) + "...")
if (clipboardText) {
this.apiKey = clipboardText.trim();
this.apiKey = clipboardText.trim()
const inputElement = e.target;
inputElement.value = this.apiKey;
const inputElement = e.target
inputElement.value = this.apiKey
}
this.requestUpdate();
this.requestUpdate()
this.updateComplete.then(() => {
const inputField = this.shadowRoot?.querySelector('.apikey-input');
const inputField = this.shadowRoot?.querySelector(".apikey-input")
if (inputField) {
inputField.focus();
inputField.setSelectionRange(inputField.value.length, inputField.value.length);
inputField.focus()
inputField.setSelectionRange(inputField.value.length, inputField.value.length)
}
});
})
}
handleKeyPress(e) {
if (e.key === 'Enter') {
e.preventDefault();
this.handleSubmit();
if (e.key === "Enter") {
e.preventDefault()
this.handleSubmit()
}
}
//////// after_modelStateService ////////
async handleSubmit() {
if (this.wasJustDragged || this.isLoading || !this.apiKey.trim()) {
console.log('Submit blocked:', {
wasJustDragged: this.wasJustDragged,
isLoading: this.isLoading,
hasApiKey: !!this.apiKey.trim(),
});
console.log('[ApiKeyHeader] handleSubmit: Submitting API keys...');
if (this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim()) {
this.errorMessage = "Please enter keys for both LLM and STT.";
return;
}
console.log('Starting API key validation...');
this.isLoading = true;
this.errorMessage = '';
this.errorMessage = "";
this.requestUpdate();
const apiKey = this.apiKey.trim();
let isValid = false;
try {
const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider);
const { ipcRenderer } = window.require('electron');
if (isValid) {
console.log('API key valid - starting slide out animation');
console.log('[ApiKeyHeader] handleSubmit: Validating LLM key...');
const llmValidation = ipcRenderer.invoke('model:validate-key', { provider: this.llmProvider, key: this.llmApiKey.trim() });
const sttValidation = ipcRenderer.invoke('model:validate-key', { provider: this.sttProvider, key: this.sttApiKey.trim() });
const [llmResult, sttResult] = await Promise.all([llmValidation, sttValidation]);
if (llmResult.success && sttResult.success) {
console.log('[ApiKeyHeader] handleSubmit: Both LLM and STT keys are valid.');
this.startSlideOutAnimation();
this.validatedApiKey = this.apiKey.trim();
this.validatedProvider = this.selectedProvider;
} else {
this.errorMessage = 'Invalid API key - please check and try again';
console.log('API key validation failed');
console.log('[ApiKeyHeader] handleSubmit: Validation failed.');
let errorParts = [];
if (!llmResult.success) errorParts.push(`LLM Key: ${llmResult.error || 'Invalid'}`);
if (!sttResult.success) errorParts.push(`STT Key: ${sttResult.error || 'Invalid'}`);
this.errorMessage = errorParts.join(' | ');
}
} catch (error) {
console.error('API key validation error:', error);
this.errorMessage = 'Validation error - please try again';
} finally {
this.isLoading = false;
this.requestUpdate();
}
}
//////// after_modelStateService ////////
async validateApiKey(apiKey, provider = 'openai') {
if (!apiKey || apiKey.length < 15) return false;
if (provider === 'openai') {
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
try {
console.log('Validating OpenAI API key...');
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
});
if (response.ok) {
const data = await response.json();
const hasGPTModels = data.data && data.data.some(m => m.id.startsWith('gpt-'));
if (hasGPTModels) {
console.log('OpenAI API key validation successful');
return true;
} else {
console.log('API key valid but no GPT models available');
return false;
}
} else {
const errorData = await response.json().catch(() => ({}));
console.log('API key validation failed:', response.status, errorData.error?.message || 'Unknown error');
return false;
}
} catch (error) {
console.error('API key validation network error:', error);
return apiKey.length >= 20; // Fallback for network issues
}
} else if (provider === 'gemini') {
// Gemini API keys typically start with 'AIza'
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
try {
console.log('Validating Gemini API key...');
// Test the API key with a simple models list request
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
if (response.ok) {
const data = await response.json();
if (data.models && data.models.length > 0) {
console.log('Gemini API key validation successful');
return true;
}
}
console.log('Gemini API key validation failed');
return false;
} catch (error) {
console.error('Gemini API key validation network error:', error);
return apiKey.length >= 20; // Fallback
}
}
return false;
}
startSlideOutAnimation() {
this.classList.add('sliding-out');
console.log('[ApiKeyHeader] startSlideOutAnimation: Starting slide out animation.');
this.classList.add("sliding-out")
}
handleUsePicklesKey(e) {
e.preventDefault();
if (this.wasJustDragged) return;
e.preventDefault()
if (this.wasJustDragged) return
console.log('Requesting Firebase authentication from main process...');
console.log("Requesting Firebase authentication from main process...")
if (window.require) {
window.require('electron').ipcRenderer.invoke('start-firebase-auth');
window.require("electron").ipcRenderer.invoke("start-firebase-auth")
}
}
handleClose() {
console.log('Close button clicked');
console.log("Close button clicked")
if (window.require) {
window.require('electron').ipcRenderer.invoke('quit-application');
window.require("electron").ipcRenderer.invoke("quit-application")
}
}
//////// after_modelStateService ////////
handleAnimationEnd(e) {
if (e.target !== this) return;
if (this.classList.contains('sliding-out')) {
this.classList.remove('sliding-out');
this.classList.add('hidden');
if (this.validatedApiKey) {
if (window.require) {
window.require('electron').ipcRenderer.invoke('api-key-validated', {
apiKey: this.validatedApiKey,
provider: this.validatedProvider || 'openai'
if (e.target !== this || !this.classList.contains('sliding-out')) return;
this.classList.remove("sliding-out");
this.classList.add("hidden");
window.require('electron').ipcRenderer.invoke('get-current-user').then(userState => {
console.log('[ApiKeyHeader] handleAnimationEnd: User state updated:', userState);
this.stateUpdateCallback?.(userState);
});
}
this.validatedApiKey = null;
this.validatedProvider = null;
}
}
}
//////// after_modelStateService ////////
connectedCallback() {
super.connectedCallback();
this.addEventListener('animationend', this.handleAnimationEnd);
super.connectedCallback()
this.addEventListener("animationend", this.handleAnimationEnd)
}
disconnectedCallback() {
super.disconnectedCallback();
this.removeEventListener('animationend', this.handleAnimationEnd);
super.disconnectedCallback()
this.removeEventListener("animationend", this.handleAnimationEnd)
}
render() {
const isButtonDisabled = this.isLoading || !this.apiKey || !this.apiKey.trim();
console.log('Rendering with provider:', this.selectedProvider);
const isButtonDisabled = this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim();
return html`
<div class="container" @mousedown=${this.handleMouseDown}>
<button class="close-button" @click=${this.handleClose} title="Close application">
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
</svg>
</button>
<h1 class="title">Choose how to power your AI</h1>
<h1 class="title">Enter Your API Keys</h1>
<div class="form-content">
<div class="error-message">${this.errorMessage}</div>
<div class="provider-label">Select AI Provider:</div>
<select
class="provider-select"
.value=${this.selectedProvider || 'openai'}
@change=${this.handleProviderChange}
?disabled=${this.isLoading}
tabindex="0"
>
<option value="openai" ?selected=${this.selectedProvider === 'openai'}>OpenAI</option>
<option value="gemini" ?selected=${this.selectedProvider === 'gemini'}>Google Gemini</option>
<div class="providers-container">
<div class="provider-column">
<div class="provider-label"></div>
<select class="provider-select" .value=${this.llmProvider} @change=${e => this.llmProvider = e.target.value} ?disabled=${this.isLoading}>
${this.providers.llm.map(p => html`<option value=${p.id}>${p.name}</option>`)}
</select>
<input
type="password"
class="api-input"
placeholder=${this.selectedProvider === 'openai' ? "Enter your OpenAI API key" : "Enter your Gemini API key"}
.value=${this.apiKey || ''}
@input=${this.handleInput}
@keypress=${this.handleKeyPress}
@paste=${this.handlePaste}
@focus=${() => (this.errorMessage = '')}
?disabled=${this.isLoading}
autocomplete="off"
spellcheck="false"
tabindex="0"
/>
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled} tabindex="0">
${this.isLoading ? 'Validating...' : 'Confirm'}
</button>
<div class="or-text">or</div>
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's API Key</button>
<input type="password" class="api-input" placeholder="LLM Provider API Key" .value=${this.llmApiKey} @input=${e => this.llmApiKey = e.target.value} ?disabled=${this.isLoading}>
</div>
<div class="provider-column">
<div class="provider-label"></div>
<select class="provider-select" .value=${this.sttProvider} @change=${e => this.sttProvider = e.target.value} ?disabled=${this.isLoading}>
${this.providers.stt.map(p => html`<option value=${p.id}>${p.name}</option>`)}
</select>
<input type="password" class="api-input" placeholder="STT Provider API Key" .value=${this.sttApiKey} @input=${e => this.sttApiKey = e.target.value} ?disabled=${this.isLoading}>
</div>
</div>
<div class="error-message">${this.errorMessage}</div>
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled}>
${this.isLoading ? "Validating..." : "Confirm"}
</button>
<div class="or-text">or</div>
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's Key (Login)</button>
</div>
`;
}
}
customElements.define('apikey-header', ApiKeyHeader);
customElements.define("apikey-header", ApiKeyHeader)

View File

@ -1,57 +1,45 @@
import { initializeApp } from 'firebase/app';
import { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithCredential, signInWithCustomToken, signOut } from 'firebase/auth';
import './AppHeader.js';
import './MainHeader.js';
import './ApiKeyHeader.js';
import './PermissionSetup.js';
const firebaseConfig = {
apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g',
authDomain: 'pickle-3651a.firebaseapp.com',
projectId: 'pickle-3651a',
storageBucket: 'pickle-3651a.firebasestorage.app',
messagingSenderId: '904706892885',
appId: '1:904706892885:web:0e42b3dda796674ead20dc',
measurementId: 'G-SQ0WM6S28T',
};
const firebaseApp = initializeApp(firebaseConfig);
const auth = getAuth(firebaseApp);
import './PermissionHeader.js';
class HeaderTransitionManager {
constructor() {
this.headerContainer = document.getElementById('header-container');
this.currentHeaderType = null; // 'apikey' | 'app' | 'permission'
this.currentHeaderType = null; // 'apikey' | 'main' | 'permission'
this.apiKeyHeader = null;
this.appHeader = null;
this.permissionSetup = null;
this.mainHeader = null;
this.permissionHeader = null;
/**
* only one header window is allowed
* @param {'apikey'|'app'|'permission'} type
* @param {'apikey'|'main'|'permission'} type
*/
this.ensureHeader = (type) => {
if (this.currentHeaderType === type) return;
console.log('[HeaderController] ensureHeader: Ensuring header of type:', type);
if (this.currentHeaderType === type) {
console.log('[HeaderController] ensureHeader: Header of type:', type, 'already exists.');
return;
}
this.headerContainer.innerHTML = '';
this.apiKeyHeader = null;
this.appHeader = null;
this.permissionSetup = null;
this.mainHeader = null;
this.permissionHeader = null;
// Create new header element
if (type === 'apikey') {
this.apiKeyHeader = document.createElement('apikey-header');
this.apiKeyHeader.stateUpdateCallback = (userState) => this.handleStateUpdate(userState);
this.headerContainer.appendChild(this.apiKeyHeader);
} else if (type === 'permission') {
this.permissionSetup = document.createElement('permission-setup');
this.permissionSetup.continueCallback = () => this.transitionToAppHeader();
this.headerContainer.appendChild(this.permissionSetup);
this.permissionHeader = document.createElement('permission-setup');
this.permissionHeader.continueCallback = () => this.transitionToMainHeader();
this.headerContainer.appendChild(this.permissionHeader);
} else {
this.appHeader = document.createElement('app-header');
this.headerContainer.appendChild(this.appHeader);
this.appHeader.startSlideInAnimation?.();
this.mainHeader = document.createElement('main-header');
this.headerContainer.appendChild(this.mainHeader);
this.mainHeader.startSlideInAnimation?.();
}
this.currentHeaderType = type;
@ -60,150 +48,29 @@ class HeaderTransitionManager {
console.log('[HeaderController] Manager initialized');
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer
.invoke('get-current-api-key')
.then(storedKey => {
this.hasApiKey = !!storedKey;
})
.catch(() => {});
}
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('login-successful', async (event, payload) => {
const { customToken, token, error } = payload || {};
try {
if (customToken) {
console.log('[HeaderController] Received custom token, signing in with custom token...');
await signInWithCustomToken(auth, customToken);
return;
}
if (token) {
console.log('[HeaderController] Received ID token, attempting Google credential sign-in...');
const credential = GoogleAuthProvider.credential(token);
await signInWithCredential(auth, credential);
return;
}
if (error) {
console.warn('[HeaderController] Login payload indicates verification failure. Showing permission setup.');
// Show permission setup after login error
this.transitionToPermissionSetup();
}
} catch (error) {
console.error('[HeaderController] Sign-in failed', error);
// Show permission setup after sign-in failure
this.transitionToPermissionSetup();
}
});
ipcRenderer.on('request-firebase-logout', async () => {
console.log('[HeaderController] Received request to sign out.');
try {
this.hasApiKey = false;
await signOut(auth);
} catch (error) {
console.error('[HeaderController] Sign out failed', error);
}
});
ipcRenderer.on('api-key-validated', () => {
this.hasApiKey = true;
// Wait for animation to complete before transitioning
setTimeout(() => {
this.transitionToPermissionSetup();
}, 350); // Give time for slide-out animation to complete
});
ipcRenderer.on('api-key-removed', () => {
this.hasApiKey = false;
this.transitionToApiKeyHeader();
});
ipcRenderer.on('api-key-updated', () => {
this.hasApiKey = true;
if (!auth.currentUser) {
this.transitionToPermissionSetup();
}
});
ipcRenderer.on('firebase-auth-success', async (event, firebaseUser) => {
console.log('[HeaderController] Received firebase-auth-success:', firebaseUser.uid);
try {
if (firebaseUser.idToken) {
const credential = GoogleAuthProvider.credential(firebaseUser.idToken);
await signInWithCredential(auth, credential);
console.log('[HeaderController] Firebase sign-in successful via ID token');
} else {
console.warn('[HeaderController] No ID token received from deeplink, showing permission setup');
// Show permission setup after Firebase auth
this.transitionToPermissionSetup();
}
} catch (error) {
console.error('[HeaderController] Firebase auth failed:', error);
this.transitionToPermissionSetup();
}
});
}
this._bootstrap();
onAuthStateChanged(auth, async user => {
console.log('[HeaderController] Auth state changed. User:', user ? user.email : 'null');
if (window.require) {
const { ipcRenderer } = window.require('electron');
let userDataWithToken = null;
if (user) {
try {
const idToken = await user.getIdToken();
userDataWithToken = {
uid: user.uid,
email: user.email,
name: user.displayName,
photoURL: user.photoURL,
idToken: idToken,
};
} catch (error) {
console.error('[HeaderController] Failed to get ID token:', error);
userDataWithToken = {
uid: user.uid,
email: user.email,
name: user.displayName,
photoURL: user.photoURL,
idToken: null,
};
}
}
ipcRenderer.on('user-state-changed', (event, userState) => {
console.log('[HeaderController] Received user state change:', userState);
this.handleStateUpdate(userState);
});
ipcRenderer.invoke('firebase-auth-state-changed', userDataWithToken).catch(console.error);
}
if (!this.isInitialized) {
this.isInitialized = true;
return; // Skip on initial load - bootstrap handles it
}
// Only handle state changes after initial load
if (user) {
console.log('[HeaderController] User logged in, updating hasApiKey and checking permissions...');
this.hasApiKey = true; // User login should provide API key
// Delay permission check to ensure smooth login flow
setTimeout(() => this.transitionToPermissionSetup(), 500);
} else if (this.hasApiKey) {
console.log('[HeaderController] No Firebase user but API key exists, checking if permission setup is needed...');
setTimeout(() => this.transitionToPermissionSetup(), 500);
} else {
console.log('[HeaderController] No auth & no API key — showing ApiKeyHeader');
this.transitionToApiKeyHeader();
ipcRenderer.on('auth-failed', (event, { message }) => {
console.error('[HeaderController] Received auth failure from main process:', message);
if (this.apiKeyHeader) {
this.apiKeyHeader.errorMessage = 'Authentication failed. Please try again.';
this.apiKeyHeader.isLoading = false;
}
});
ipcRenderer.on('force-show-apikey-header', async () => {
console.log('[HeaderController] Received broadcast to show apikey header. Switching now.');
await this._resizeForApiKey();
this.ensureHeader('apikey');
});
}
}
notifyHeaderState(stateOverride) {
@ -214,44 +81,44 @@ class HeaderTransitionManager {
}
async _bootstrap() {
let storedKey = null;
// The initial state will be sent by the main process via 'user-state-changed'
// We just need to request it.
if (window.require) {
try {
storedKey = await window
.require('electron')
.ipcRenderer.invoke('get-current-api-key');
} catch (_) {}
}
this.hasApiKey = !!storedKey;
const user = await new Promise(resolve => {
const unsubscribe = onAuthStateChanged(auth, u => {
unsubscribe();
resolve(u);
});
});
// check flow order: API key -> Permissions -> App
if (!user && !this.hasApiKey) {
// No auth and no API key -> show API key input
await this._resizeForApiKey();
this.ensureHeader('apikey');
const userState = await window.require('electron').ipcRenderer.invoke('get-current-user');
console.log('[HeaderController] Bootstrapping with initial user state:', userState);
this.handleStateUpdate(userState);
} else {
// Has API key or user -> check permissions first
// Fallback for non-electron environment (testing/web)
this.ensureHeader('apikey');
}
}
//////// after_modelStateService ////////
async handleStateUpdate(userState) {
const { ipcRenderer } = window.require('electron');
const isConfigured = await ipcRenderer.invoke('model:are-providers-configured');
if (isConfigured) {
const { isLoggedIn } = userState;
if (isLoggedIn) {
const permissionResult = await this.checkPermissions();
if (permissionResult.success) {
// All permissions granted -> go to app
await this._resizeForApp();
this.ensureHeader('app');
this.transitionToMainHeader();
} else {
// Permissions needed -> show permission setup
await this._resizeForPermissionSetup();
this.ensureHeader('permission');
}
this.transitionToPermissionHeader();
}
} else {
this.transitionToMainHeader();
}
} else {
await this._resizeForApiKey();
this.ensureHeader('apikey');
}
}
//////// after_modelStateService ////////
async transitionToPermissionSetup() {
async transitionToPermissionHeader() {
// Prevent duplicate transitions
if (this.currentHeaderType === 'permission') {
console.log('[HeaderController] Already showing permission setup, skipping transition');
@ -270,7 +137,7 @@ class HeaderTransitionManager {
const permissionResult = await this.checkPermissions();
if (permissionResult.success) {
// Skip permission setup if already granted
this.transitionToAppHeader();
this.transitionToMainHeader();
return;
}
@ -281,41 +148,24 @@ class HeaderTransitionManager {
}
}
await this._resizeForPermissionSetup();
await this._resizeForPermissionHeader();
this.ensureHeader('permission');
}
async transitionToAppHeader(animate = true) {
if (this.currentHeaderType === 'app') {
return this._resizeForApp();
async transitionToMainHeader(animate = true) {
if (this.currentHeaderType === 'main') {
return this._resizeForMain();
}
const canAnimate =
animate &&
(this.apiKeyHeader || this.permissionSetup) &&
this.currentHeaderType !== 'app';
if (canAnimate && this.apiKeyHeader?.startSlideOutAnimation) {
const old = this.apiKeyHeader;
const onEnd = () => {
clearTimeout(fallback);
this._resizeForApp().then(() => this.ensureHeader('app'));
};
old.addEventListener('animationend', onEnd, { once: true });
old.startSlideOutAnimation();
const fallback = setTimeout(onEnd, 450);
} else {
this.ensureHeader('app');
this._resizeForApp();
}
await this._resizeForMain();
this.ensureHeader('main');
}
_resizeForApp() {
_resizeForMain() {
if (!window.require) return;
return window
.require('electron')
.ipcRenderer.invoke('resize-header-window', { width: 353, height: 60 })
.ipcRenderer.invoke('resize-header-window', { width: 353, height: 47 })
.catch(() => {});
}
@ -323,11 +173,11 @@ class HeaderTransitionManager {
if (!window.require) return;
return window
.require('electron')
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 300 })
.ipcRenderer.invoke('resize-header-window', { width: 350, height: 300 })
.catch(() => {});
}
async _resizeForPermissionSetup() {
async _resizeForPermissionHeader() {
if (!window.require) return;
return window
.require('electron')
@ -335,16 +185,6 @@ class HeaderTransitionManager {
.catch(() => {});
}
async transitionToApiKeyHeader() {
await this._resizeForApiKey();
if (this.currentHeaderType !== 'apikey') {
this.ensureHeader('apikey');
}
if (this.apiKeyHeader) this.apiKeyHeader.reset();
}
async checkPermissions() {
if (!window.require) {
return { success: true };
@ -353,7 +193,6 @@ class HeaderTransitionManager {
const { ipcRenderer } = window.require('electron');
try {
// Check permission status
const permissions = await ipcRenderer.invoke('check-system-permissions');
console.log('[HeaderController] Current permissions:', permissions);
@ -361,7 +200,6 @@ class HeaderTransitionManager {
return { success: true };
}
// If permissions are not set up, return false
let errorMessage = '';
if (!permissions.microphone && !permissions.screen) {
errorMessage = 'Microphone and screen recording access required';

View File

@ -1,13 +1,14 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
export class AppHeader extends LitElement {
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 AppHeader extends LitElement {
}
.header {
width: 100%;
width: max-content;
height: 47px;
padding: 2px 10px 2px 13px;
background: transparent;
@ -212,16 +213,6 @@ export class AppHeader 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 AppHeader 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,16 +284,68 @@ export class AppHeader extends LitElement {
display: flex;
align-items: center;
justify-content: center;
padding: 3px;
}
.settings-icon svg {
width: 16px;
height: 16px;
}
/* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) .header,
:host-context(body.has-glass) .listen-button,
:host-context(body.has-glass) .header-actions,
:host-context(body.has-glass) .settings-button {
background: transparent !important;
filter: none !important;
box-shadow: none !important;
backdrop-filter: none !important;
}
:host-context(body.has-glass) .icon-box {
background: transparent !important;
border: none !important;
}
:host-context(body.has-glass) .header::before,
:host-context(body.has-glass) .header::after,
:host-context(body.has-glass) .listen-button::before,
:host-context(body.has-glass) .listen-button::after {
display: none !important;
}
:host-context(body.has-glass) .header-actions:hover,
:host-context(body.has-glass) .settings-button:hover,
:host-context(body.has-glass) .listen-button:hover::before {
background: transparent !important;
}
:host-context(body.has-glass) * {
animation: none !important;
transition: none !important;
transform: none !important;
filter: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
}
:host-context(body.has-glass) .header,
:host-context(body.has-glass) .listen-button,
:host-context(body.has-glass) .header-actions,
:host-context(body.has-glass) .settings-button,
:host-context(body.has-glass) .icon-box {
border-radius: 0 !important;
}
:host-context(body.has-glass) {
animation: none !important;
transition: none !important;
transform: none !important;
will-change: auto !important;
}
`;
constructor() {
super();
this.shortcuts = {};
this.dragState = null;
this.wasJustDragged = false;
this.isVisible = true;
@ -304,19 +354,6 @@ export class AppHeader extends LitElement {
this.settingsHideTimer = null;
this.isSessionActive = false;
this.animationEndTimer = null;
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('toggle-header-visibility', () => {
this.toggleVisibility();
});
ipcRenderer.on('cancel-hide-settings', () => {
this.cancelHideWindow('settings');
});
}
this.handleMouseMove = this.handleMouseMove.bind(this);
this.handleMouseUp = this.handleMouseUp.bind(this);
this.handleAnimationEnd = this.handleAnimationEnd.bind(this);
@ -375,7 +412,7 @@ export class AppHeader extends LitElement {
toggleVisibility() {
if (this.isAnimating) {
console.log('[AppHeader] Animation already in progress, ignoring toggle');
console.log('[MainHeader] Animation already in progress, ignoring toggle');
return;
}
@ -445,7 +482,7 @@ export class AppHeader extends LitElement {
} else if (this.classList.contains('sliding-in')) {
this.classList.remove('sliding-in');
this.hasSlidIn = true;
console.log('[AppHeader] Slide-in animation completed');
console.log('[MainHeader] Slide-in animation completed');
}
}
@ -464,6 +501,11 @@ export class AppHeader 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);
}
}
@ -478,11 +520,12 @@ export class AppHeader extends LitElement {
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeAllListeners('toggle-header-visibility');
ipcRenderer.removeAllListeners('cancel-hide-settings');
if (this._sessionStateListener) {
ipcRenderer.removeListener('session-state-changed', this._sessionStateListener);
}
if (this._shortcutListener) {
ipcRenderer.removeListener('shortcuts-updated', this._shortcutListener);
}
}
}
@ -499,7 +542,7 @@ export class AppHeader extends LitElement {
if (this.wasJustDragged) return;
if (window.require) {
const { ipcRenderer } = window.require('electron');
console.log(`[AppHeader] showWindow('${name}') called at ${Date.now()}`);
console.log(`[MainHeader] showWindow('${name}') called at ${Date.now()}`);
ipcRenderer.send('cancel-hide-window', name);
@ -523,7 +566,7 @@ export class AppHeader extends LitElement {
hideWindow(name) {
if (this.wasJustDragged) return;
if (window.require) {
console.log(`[AppHeader] hideWindow('${name}') called at ${Date.now()}`);
console.log(`[MainHeader] hideWindow('${name}') called at ${Date.now()}`);
window.require('electron').ipcRenderer.send('hide-window', name);
}
}
@ -532,6 +575,29 @@ export class AppHeader 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}>
@ -564,14 +630,8 @@ export class AppHeader 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>
@ -579,13 +639,8 @@ export class AppHeader 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>
@ -605,4 +660,4 @@ export class AppHeader extends LitElement {
}
}
customElements.define('app-header', AppHeader);
customElements.define('main-header', MainHeader);

View File

@ -1,6 +1,6 @@
import { LitElement, html, css } from '../assets/lit-core-2.7.4.min.js';
export class PermissionSetup extends LitElement {
export class PermissionHeader extends LitElement {
static styles = css`
:host {
display: block;
@ -237,6 +237,30 @@ export class PermissionSetup extends LitElement {
background: rgba(255, 255, 255, 0.2);
cursor: not-allowed;
}
/* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) .container,
:host-context(body.has-glass) .action-button,
:host-context(body.has-glass) .continue-button,
:host-context(body.has-glass) .close-button {
background: transparent !important;
border: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
:host-context(body.has-glass) .container::after,
:host-context(body.has-glass) .action-button::after,
:host-context(body.has-glass) .continue-button::after {
display: none !important;
}
:host-context(body.has-glass) .action-button:hover,
:host-context(body.has-glass) .continue-button:hover,
:host-context(body.has-glass) .close-button:hover {
background: transparent !important;
}
`;
static properties = {
@ -337,7 +361,7 @@ export class PermissionSetup extends LitElement {
try {
const permissions = await ipcRenderer.invoke('check-system-permissions');
console.log('[PermissionSetup] Permission check result:', permissions);
console.log('[PermissionHeader] Permission check result:', permissions);
const prevMic = this.microphoneGranted;
const prevScreen = this.screenGranted;
@ -347,7 +371,7 @@ export class PermissionSetup extends LitElement {
// if permissions changed == UI update
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
console.log('[PermissionSetup] Permission status changed, updating UI');
console.log('[PermissionHeader] Permission status changed, updating UI');
this.requestUpdate();
}
@ -355,11 +379,11 @@ export class PermissionSetup extends LitElement {
if (this.microphoneGranted === 'granted' &&
this.screenGranted === 'granted' &&
this.continueCallback) {
console.log('[PermissionSetup] All permissions granted, proceeding automatically');
console.log('[PermissionHeader] All permissions granted, proceeding automatically');
setTimeout(() => this.handleContinue(), 500);
}
} catch (error) {
console.error('[PermissionSetup] Error checking permissions:', error);
console.error('[PermissionHeader] Error checking permissions:', error);
} finally {
this.isChecking = false;
}
@ -368,12 +392,12 @@ export class PermissionSetup extends LitElement {
async handleMicrophoneClick() {
if (!window.require || this.microphoneGranted === 'granted' || this.wasJustDragged) return;
console.log('[PermissionSetup] Requesting microphone permission...');
console.log('[PermissionHeader] Requesting microphone permission...');
const { ipcRenderer } = window.require('electron');
try {
const result = await ipcRenderer.invoke('check-system-permissions');
console.log('[PermissionSetup] Microphone permission result:', result);
console.log('[PermissionHeader] Microphone permission result:', result);
if (result.microphone === 'granted') {
this.microphoneGranted = 'granted';
@ -394,19 +418,19 @@ export class PermissionSetup extends LitElement {
// Check permissions again after a delay
// setTimeout(() => this.checkPermissions(), 1000);
} catch (error) {
console.error('[PermissionSetup] Error requesting microphone permission:', error);
console.error('[PermissionHeader] Error requesting microphone permission:', error);
}
}
async handleScreenClick() {
if (!window.require || this.screenGranted === 'granted' || this.wasJustDragged) return;
console.log('[PermissionSetup] Checking screen recording permission...');
console.log('[PermissionHeader] Checking screen recording permission...');
const { ipcRenderer } = window.require('electron');
try {
const permissions = await ipcRenderer.invoke('check-system-permissions');
console.log('[PermissionSetup] Screen permission check result:', permissions);
console.log('[PermissionHeader] Screen permission check result:', permissions);
if (permissions.screen === 'granted') {
this.screenGranted = 'granted';
@ -414,7 +438,7 @@ export class PermissionSetup extends LitElement {
return;
}
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
console.log('[PermissionSetup] Opening screen recording preferences...');
console.log('[PermissionHeader] Opening screen recording preferences...');
await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
}
@ -422,7 +446,7 @@ export class PermissionSetup extends LitElement {
// (This may not execute if app restarts after permission grant)
// setTimeout(() => this.checkPermissions(), 2000);
} catch (error) {
console.error('[PermissionSetup] Error opening screen recording preferences:', error);
console.error('[PermissionHeader] Error opening screen recording preferences:', error);
}
}
@ -436,9 +460,9 @@ export class PermissionSetup extends LitElement {
const { ipcRenderer } = window.require('electron');
try {
await ipcRenderer.invoke('mark-permissions-completed');
console.log('[PermissionSetup] Marked permissions as completed');
console.log('[PermissionHeader] Marked permissions as completed');
} catch (error) {
console.error('[PermissionSetup] Error marking permissions as completed:', error);
console.error('[PermissionHeader] Error marking permissions as completed:', error);
}
}
@ -530,4 +554,4 @@ export class PermissionSetup extends LitElement {
}
}
customElements.define('permission-setup', PermissionSetup);
customElements.define('permission-setup', PermissionHeader);

View File

@ -1,16 +1,17 @@
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
import { CustomizeView } from '../features/customize/CustomizeView.js';
import { SettingsView } from '../features/settings/SettingsView.js';
import { AssistantView } from '../features/listen/AssistantView.js';
import { OnboardingView } from '../features/onboarding/OnboardingView.js';
import { AskView } from '../features/ask/AskView.js';
import { ShortcutSettingsView } from '../features/settings/ShortCutSettingsView.js';
import '../features/listen/renderer.js';
import '../features/listen/renderer/renderer.js';
export class PickleGlassApp extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
height: 100%;
color: var(--text-color);
background: transparent;
border-radius: 7px;
@ -19,11 +20,13 @@ export class PickleGlassApp extends LitElement {
assistant-view {
display: block;
width: 100%;
height: 100%;
}
ask-view, customize-view, history-view, help-view, onboarding-view, setup-view {
ask-view, settings-view, history-view, help-view, setup-view {
display: block;
width: 100%;
height: 100%;
}
`;
@ -83,10 +86,6 @@ export class PickleGlassApp extends LitElement {
ipcRenderer.on('click-through-toggled', (_, isEnabled) => {
this._isClickThrough = isEnabled;
});
ipcRenderer.on('show-view', (_, view) => {
this.currentView = view;
this.isMainViewVisible = true;
});
ipcRenderer.on('start-listening-session', () => {
console.log('Received start-listening-session command, calling handleListenClick.');
this.handleListenClick();
@ -100,7 +99,6 @@ export class PickleGlassApp extends LitElement {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeAllListeners('update-status');
ipcRenderer.removeAllListeners('click-through-toggled');
ipcRenderer.removeAllListeners('show-view');
ipcRenderer.removeAllListeners('start-listening-session');
}
}
@ -110,10 +108,7 @@ export class PickleGlassApp extends LitElement {
this.requestWindowResize();
}
if (changedProperties.has('currentView') && window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.send('view-changed', this.currentView);
if (changedProperties.has('currentView')) {
const viewContainer = this.shadowRoot?.querySelector('.view-container');
if (viewContainer) {
viewContainer.classList.add('entering');
@ -188,8 +183,8 @@ export class PickleGlassApp extends LitElement {
this.isMainViewVisible = !this.isMainViewVisible;
}
handleCustomizeClick() {
this.currentView = 'customize';
handleSettingsClick() {
this.currentView = 'settings';
this.isMainViewVisible = true;
}
@ -255,10 +250,6 @@ export class PickleGlassApp extends LitElement {
this.currentResponseIndex = e.detail.index;
}
handleOnboardingComplete() {
this.currentView = 'main';
}
render() {
switch (this.currentView) {
case 'listen':
@ -271,19 +262,19 @@ export class PickleGlassApp extends LitElement {
></assistant-view>`;
case 'ask':
return html`<ask-view></ask-view>`;
case 'customize':
return html`<customize-view
case 'settings':
return html`<settings-view
.selectedProfile=${this.selectedProfile}
.selectedLanguage=${this.selectedLanguage}
.onProfileChange=${profile => (this.selectedProfile = profile)}
.onLanguageChange=${lang => (this.selectedLanguage = lang)}
></customize-view>`;
></settings-view>`;
case 'shortcut-settings':
return html`<shortcut-settings-view></shortcut-settings-view>`;
case 'history':
return html`<history-view></history-view>`;
case 'help':
return html`<help-view></help-view>`;
case 'onboarding':
return html`<onboarding-view></onboarding-view>`;
case 'setup':
return html`<setup-view></setup-view>`;
default:

View File

@ -100,13 +100,13 @@
}
.window-sliding-down {
animation: slideDownFromHeader 0.25s cubic-bezier(0.23, 1, 0.32, 1) forwards;
animation: slideDownFromHeader 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
will-change: transform, opacity;
transform-style: preserve-3d;
}
.window-sliding-up {
animation: slideUpToHeader 0.18s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
animation: slideUpToHeader 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
will-change: transform, opacity;
transform-style: preserve-3d;
}
@ -156,14 +156,14 @@
}
.settings-window-show {
animation: settingsPopFromButton 0.22s cubic-bezier(0.23, 1, 0.32, 1) forwards;
animation: settingsPopFromButton 0.12s cubic-bezier(0.23, 1, 0.32, 1) forwards;
transform-origin: 85% 0%;
will-change: transform, opacity;
transform-style: preserve-3d;
}
.settings-window-hide {
animation: settingsCollapseToButton 0.18s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
animation: settingsCollapseToButton 0.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
transform-origin: 85% 0%;
will-change: transform, opacity;
transform-style: preserve-3d;
@ -250,18 +250,7 @@
if (animationTimeout) clearTimeout(animationTimeout);
animationTimeout = setTimeout(() => {
app.classList.remove('window-sliding-down');
}, 250);
});
ipcRenderer.on('settings-window-show-animation', () => {
console.log('Starting settings window show animation');
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
app.classList.add('settings-window-show');
if (animationTimeout) clearTimeout(animationTimeout);
animationTimeout = setTimeout(() => {
app.classList.remove('settings-window-show');
}, 220);
}, 120);
});
ipcRenderer.on('window-hide-animation', () => {
@ -273,7 +262,7 @@
animationTimeout = setTimeout(() => {
app.classList.remove('window-sliding-up');
app.classList.add('window-hidden');
}, 180);
}, 100);
});
ipcRenderer.on('settings-window-hide-animation', () => {
@ -285,7 +274,7 @@
animationTimeout = setTimeout(() => {
app.classList.remove('settings-window-hide');
app.classList.add('window-hidden');
}, 180);
}, 100);
});
ipcRenderer.on('listen-window-move-to-center', () => {
@ -312,5 +301,11 @@
}
});
</script>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body>
</html>

View File

@ -15,10 +15,14 @@
</head>
<body>
<div id="header-container" tabindex="0" style="outline: none;">
<!-- <apikey-header id="apikey-header" style="display: none;"></apikey-header>
<app-header id="app-header" style="display: none;"></app-header> -->
</div>
<script type="module" src="../../public/build/header.js"></script>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body>
</html>

20
src/assets/aec.js Normal file

File diff suppressed because one or more lines are too long

121
src/common/ai/factory.js Normal file
View File

@ -0,0 +1,121 @@
// factory.js
/**
* @typedef {object} ModelOption
* @property {string} id
* @property {string} name
*/
/**
* @typedef {object} Provider
* @property {string} name
* @property {() => any} handler
* @property {ModelOption[]} llmModels
* @property {ModelOption[]} sttModels
*/
/**
* @type {Object.<string, Provider>}
*/
const PROVIDERS = {
'openai': {
name: 'OpenAI',
handler: () => require("./providers/openai"),
llmModels: [
{ id: 'gpt-4.1', name: 'GPT-4.1' },
],
sttModels: [
{ id: 'gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' }
],
},
'openai-glass': {
name: 'OpenAI (Glass)',
handler: () => require("./providers/openai"),
llmModels: [
{ id: 'gpt-4.1-glass', name: 'GPT-4.1 (glass)' },
],
sttModels: [
{ id: 'gpt-4o-mini-transcribe-glass', name: 'GPT-4o Mini Transcribe (glass)' }
],
},
'gemini': {
name: 'Gemini',
handler: () => require("./providers/gemini"),
llmModels: [
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
],
sttModels: [
{ id: 'gemini-live-2.5-flash-preview', name: 'Gemini Live 2.5 Flash' }
],
},
'anthropic': {
name: 'Anthropic',
handler: () => require("./providers/anthropic"),
llmModels: [
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
],
sttModels: [],
},
};
function sanitizeModelId(model) {
return (typeof model === 'string') ? model.replace(/-glass$/, '') : model;
}
function createSTT(provider, opts) {
if (provider === 'openai-glass') provider = 'openai';
const handler = PROVIDERS[provider]?.handler();
if (!handler?.createSTT) {
throw new Error(`STT not supported for provider: ${provider}`);
}
if (opts && opts.model) {
opts = { ...opts, model: sanitizeModelId(opts.model) };
}
return handler.createSTT(opts);
}
function createLLM(provider, opts) {
if (provider === 'openai-glass') provider = 'openai';
const handler = PROVIDERS[provider]?.handler();
if (!handler?.createLLM) {
throw new Error(`LLM not supported for provider: ${provider}`);
}
if (opts && opts.model) {
opts = { ...opts, model: sanitizeModelId(opts.model) };
}
return handler.createLLM(opts);
}
function createStreamingLLM(provider, opts) {
if (provider === 'openai-glass') provider = 'openai';
const handler = PROVIDERS[provider]?.handler();
if (!handler?.createStreamingLLM) {
throw new Error(`Streaming LLM not supported for provider: ${provider}`);
}
if (opts && opts.model) {
opts = { ...opts, model: sanitizeModelId(opts.model) };
}
return handler.createStreamingLLM(opts);
}
function getAvailableProviders() {
const stt = [];
const llm = [];
for (const [id, provider] of Object.entries(PROVIDERS)) {
if (provider.sttModels.length > 0) stt.push(id);
if (provider.llmModels.length > 0) llm.push(id);
}
return { stt: [...new Set(stt)], llm: [...new Set(llm)] };
}
module.exports = {
PROVIDERS,
createSTT,
createLLM,
createStreamingLLM,
getAvailableProviders,
};

View File

@ -0,0 +1,292 @@
const Anthropic = require("@anthropic-ai/sdk")
/**
* Creates an Anthropic STT session
* Note: Anthropic doesn't have native real-time STT, so this is a placeholder
* You might want to use a different STT service or implement a workaround
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Anthropic API key
* @param {string} [opts.language='en'] - Language code
* @param {object} [opts.callbacks] - Event callbacks
* @returns {Promise<object>} STT session placeholder
*/
async function createSTT({ apiKey, language = "en", callbacks = {}, ...config }) {
console.warn("[Anthropic] STT not natively supported. Consider using OpenAI or Gemini for STT.")
// Return a mock STT session that doesn't actually do anything
// You might want to fallback to another provider for STT
return {
sendRealtimeInput: async (audioData) => {
console.warn("[Anthropic] STT sendRealtimeInput called but not implemented")
},
close: async () => {
console.log("[Anthropic] STT session closed")
},
}
}
/**
* Creates an Anthropic LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Anthropic API key
* @param {string} [opts.model='claude-3-5-sonnet-20241022'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=4096] - Max tokens
* @returns {object} LLM instance
*/
function createLLM({ apiKey, model = "claude-3-5-sonnet-20241022", temperature = 0.7, maxTokens = 4096, ...config }) {
const client = new Anthropic({ apiKey })
return {
generateContent: async (parts) => {
const messages = []
let systemPrompt = ""
const userContent = []
for (const part of parts) {
if (typeof part === "string") {
if (systemPrompt === "" && part.includes("You are")) {
systemPrompt = part
} else {
userContent.push({ type: "text", text: part })
}
} else if (part.inlineData) {
userContent.push({
type: "image",
source: {
type: "base64",
media_type: part.inlineData.mimeType,
data: part.inlineData.data,
},
})
}
}
if (userContent.length > 0) {
messages.push({ role: "user", content: userContent })
}
try {
const response = await client.messages.create({
model: model,
max_tokens: maxTokens,
temperature: temperature,
system: systemPrompt || undefined,
messages: messages,
})
return {
response: {
text: () => response.content[0].text,
},
raw: response,
}
} catch (error) {
console.error("Anthropic API error:", error)
throw error
}
},
// For compatibility with chat-style interfaces
chat: async (messages) => {
let systemPrompt = ""
const anthropicMessages = []
for (const msg of messages) {
if (msg.role === "system") {
systemPrompt = msg.content
} else {
// Handle multimodal content
let content
if (Array.isArray(msg.content)) {
content = []
for (const part of msg.content) {
if (typeof part === "string") {
content.push({ type: "text", text: part })
} else if (part.type === "text") {
content.push({ type: "text", text: part.text })
} else if (part.type === "image_url" && part.image_url) {
// Convert base64 image to Anthropic format
const imageUrl = part.image_url.url
const [mimeInfo, base64Data] = imageUrl.split(",")
// Extract the actual MIME type from the data URL
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/jpeg"
content.push({
type: "image",
source: {
type: "base64",
media_type: mimeType,
data: base64Data,
},
})
}
}
} else {
content = [{ type: "text", text: msg.content }]
}
anthropicMessages.push({
role: msg.role === "user" ? "user" : "assistant",
content: content,
})
}
}
const response = await client.messages.create({
model: model,
max_tokens: maxTokens,
temperature: temperature,
system: systemPrompt || undefined,
messages: anthropicMessages,
})
return {
content: response.content[0].text,
raw: response,
}
},
}
}
/**
* Creates an Anthropic streaming LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Anthropic API key
* @param {string} [opts.model='claude-3-5-sonnet-20241022'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=4096] - Max tokens
* @returns {object} Streaming LLM instance
*/
function createStreamingLLM({
apiKey,
model = "claude-3-5-sonnet-20241022",
temperature = 0.7,
maxTokens = 4096,
...config
}) {
const client = new Anthropic({ apiKey })
return {
streamChat: async (messages) => {
console.log("[Anthropic Provider] Starting streaming request")
let systemPrompt = ""
const anthropicMessages = []
for (const msg of messages) {
if (msg.role === "system") {
systemPrompt = msg.content
} else {
// Handle multimodal content
let content
if (Array.isArray(msg.content)) {
content = []
for (const part of msg.content) {
if (typeof part === "string") {
content.push({ type: "text", text: part })
} else if (part.type === "text") {
content.push({ type: "text", text: part.text })
} else if (part.type === "image_url" && part.image_url) {
// Convert base64 image to Anthropic format
const imageUrl = part.image_url.url
const [mimeInfo, base64Data] = imageUrl.split(",")
// Extract the actual MIME type from the data URL
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/jpeg"
console.log(`[Anthropic] Processing image with MIME type: ${mimeType}`)
content.push({
type: "image",
source: {
type: "base64",
media_type: mimeType,
data: base64Data,
},
})
}
}
} else {
content = [{ type: "text", text: msg.content }]
}
anthropicMessages.push({
role: msg.role === "user" ? "user" : "assistant",
content: content,
})
}
}
// Create a ReadableStream to handle Anthropic's streaming
const stream = new ReadableStream({
async start(controller) {
try {
console.log("[Anthropic Provider] Processing messages:", anthropicMessages.length, "messages")
let chunkCount = 0
let totalContent = ""
// Stream the response
const stream = await client.messages.create({
model: model,
max_tokens: maxTokens,
temperature: temperature,
system: systemPrompt || undefined,
messages: anthropicMessages,
stream: true,
})
for await (const chunk of stream) {
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
chunkCount++
const chunkText = chunk.delta.text || ""
totalContent += chunkText
// Format as SSE data
const data = JSON.stringify({
choices: [
{
delta: {
content: chunkText,
},
},
],
})
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`))
}
}
console.log(
`[Anthropic 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("[Anthropic Provider] Streaming completed successfully")
} catch (error) {
console.error("[Anthropic 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",
},
})
},
}
}
module.exports = {
createSTT,
createLLM,
createStreamingLLM,
}

View File

@ -0,0 +1,302 @@
const { GoogleGenerativeAI } = require("@google/generative-ai")
const { GoogleGenAI } = require("@google/genai")
/**
* Creates a Gemini STT session
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Gemini API key
* @param {string} [opts.language='en-US'] - Language code
* @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 })
// Language code BCP-47 conversion
const lang = language.includes("-") ? language : `${language}-US`
const session = await liveClient.live.connect({
model: 'gemini-live-2.5-flash-preview',
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),
close: async () => session.close(),
}
}
/**
* 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)
return {
generateContent: async (parts) => {
const geminiModel = client.getGenerativeModel({
model: model,
generationConfig: {
temperature,
maxOutputTokens: maxTokens,
// Ensure we get text responses, not JSON
responseMimeType: "text/plain",
},
})
const systemPrompt = ""
const userContent = []
for (const part of parts) {
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) {
userContent.push({
inlineData: {
mimeType: part.inlineData.mimeType,
data: part.inlineData.data,
},
})
}
}
try {
const result = await geminiModel.generateContent(userContent)
const response = await result.response
// Return plain text, not wrapped in JSON structure
return {
response: {
text: () => response.text(),
},
}
} catch (error) {
console.error("Gemini API error:", error)
throw error
}
},
chat: async (messages) => {
// 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") {
// 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
}
const role = msg.role === "user" ? "user" : "model"
if (index === messages.length - 1) {
lastMessage = msg
} else {
history.push({ role, parts: [{ text: msg.content }] })
}
})
const geminiModel = client.getGenerativeModel({
model: model,
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",
},
})
const chat = geminiModel.startChat({
history: history,
})
let content = lastMessage.content
// Handle multimodal content
if (Array.isArray(content)) {
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) {
const base64Data = part.image_url.url.split(",")[1]
geminiContent.push({
inlineData: {
mimeType: "image/png",
data: base64Data,
},
})
}
}
content = geminiContent
}
const result = await chat.sendMessage(content)
const response = await result.response
// Return plain text content
return {
content: response.text(),
raw: result,
}
},
}
}
/**
* 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)
return {
streamChat: async (messages) => {
console.log("[Gemini Provider] Starting streaming request")
let systemInstruction = ""
const nonSystemMessages = []
for (const msg of messages) {
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)
}
}
const geminiModel = client.getGenerativeModel({
model: model,
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",
},
})
const stream = new ReadableStream({
async start(controller) {
try {
const lastMessage = nonSystemMessages[nonSystemMessages.length - 1]
let geminiContent = []
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) {
const base64Data = part.image_url.url.split(",")[1]
geminiContent.push({
inlineData: {
mimeType: "image/png",
data: base64Data,
},
})
}
}
} else {
geminiContent = [lastMessage.content]
}
const contentParts = geminiContent.map((part) => {
if (typeof part === "string") {
return { text: part }
} else if (part.inlineData) {
return { inlineData: part.inlineData }
}
return part
})
const result = await geminiModel.generateContentStream({
contents: [
{
role: "user",
parts: contentParts,
},
],
})
for await (const chunk of result.stream) {
const chunkText = chunk.text() || ""
// 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`))
}
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
controller.close()
} catch (error) {
console.error("[Gemini Provider] Streaming error:", error)
controller.error(error)
}
},
})
return new Response(stream, {
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
},
})
},
}
}
module.exports = {
createSTT,
createLLM,
createStreamingLLM,
}

View File

@ -0,0 +1,263 @@
const OpenAI = require('openai');
const WebSocket = require('ws');
/**
* Creates an OpenAI STT session
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - OpenAI API key
* @param {string} [opts.language='en'] - Language code
* @param {object} [opts.callbacks] - Event callbacks
* @param {boolean} [opts.usePortkey=false] - Whether to use Portkey
* @param {string} [opts.portkeyVirtualKey] - Portkey virtual key
* @returns {Promise<object>} STT session
*/
async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey = false, portkeyVirtualKey, ...config }) {
const keyType = usePortkey ? 'vKey' : 'apiKey';
const key = usePortkey ? (portkeyVirtualKey || apiKey) : apiKey;
const wsUrl = keyType === 'apiKey'
? 'wss://api.openai.com/v1/realtime?intent=transcription'
: 'wss://api.portkey.ai/v1/realtime?intent=transcription';
const headers = keyType === 'apiKey'
? {
'Authorization': `Bearer ${key}`,
'OpenAI-Beta': 'realtime=v1',
}
: {
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
'x-portkey-virtual-key': key,
'OpenAI-Beta': 'realtime=v1',
};
const ws = new WebSocket(wsUrl, { headers });
return new Promise((resolve, reject) => {
ws.onopen = () => {
console.log("WebSocket session opened.");
const sessionConfig = {
type: 'transcription_session.update',
session: {
input_audio_format: 'pcm16',
input_audio_transcription: {
model: 'gpt-4o-mini-transcribe',
prompt: config.prompt || '',
language: language || 'en'
},
turn_detection: {
type: 'server_vad',
threshold: 0.5,
prefix_padding_ms: 200,
silence_duration_ms: 100,
},
input_audio_noise_reduction: {
type: 'far_field'
}
}
};
ws.send(JSON.stringify(sessionConfig));
resolve({
sendRealtimeInput: (audioData) => {
if (ws.readyState === WebSocket.OPEN) {
const message = {
type: 'input_audio_buffer.append',
audio: audioData
};
ws.send(JSON.stringify(message));
}
},
close: () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'session.close' }));
ws.onmessage = ws.onerror = () => {}; // 핸들러 제거
ws.close(1000, 'Client initiated close.');
}
}
});
};
ws.onmessage = (event) => {
// ── 종료·하트비트 패킷 필터링 ──────────────────────────────
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) => {
console.error('WebSocket error:', error.message);
if (callbacks && callbacks.onerror) {
callbacks.onerror(error);
}
reject(error);
};
ws.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
if (callbacks && callbacks.onclose) {
callbacks.onclose(event);
}
};
});
}
/**
* Creates an OpenAI LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - OpenAI API key
* @param {string} [opts.model='gpt-4.1'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=2048] - Max tokens
* @param {boolean} [opts.usePortkey=false] - Whether to use Portkey
* @param {string} [opts.portkeyVirtualKey] - Portkey virtual key
* @returns {object} LLM instance
*/
function createLLM({ apiKey, model = 'gpt-4.1', temperature = 0.7, maxTokens = 2048, usePortkey = false, portkeyVirtualKey, ...config }) {
const client = new OpenAI({ apiKey });
const callApi = async (messages) => {
if (!usePortkey) {
const response = await client.chat.completions.create({
model: model,
messages: messages,
temperature: temperature,
max_tokens: maxTokens
});
return {
content: response.choices[0].message.content.trim(),
raw: response
};
} else {
const fetchUrl = 'https://api.portkey.ai/v1/chat/completions';
const response = await fetch(fetchUrl, {
method: 'POST',
headers: {
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model,
messages,
temperature,
max_tokens: maxTokens,
}),
});
if (!response.ok) {
throw new Error(`Portkey API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
content: result.choices[0].message.content.trim(),
raw: result
};
}
};
return {
generateContent: async (parts) => {
const messages = [];
let systemPrompt = '';
let userContent = [];
for (const part of parts) {
if (typeof part === 'string') {
if (systemPrompt === '' && part.includes('You are')) {
systemPrompt = part;
} else {
userContent.push({ type: 'text', text: part });
}
} else if (part.inlineData) {
userContent.push({
type: 'image_url',
image_url: { url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` }
});
}
}
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
if (userContent.length > 0) messages.push({ role: 'user', content: userContent });
const result = await callApi(messages);
return {
response: {
text: () => result.content
},
raw: result.raw
};
},
// For compatibility with chat-style interfaces
chat: async (messages) => {
return await callApi(messages);
}
};
}
/**
* Creates an OpenAI streaming LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - OpenAI API key
* @param {string} [opts.model='gpt-4.1'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=2048] - Max tokens
* @param {boolean} [opts.usePortkey=false] - Whether to use Portkey
* @param {string} [opts.portkeyVirtualKey] - Portkey virtual key
* @returns {object} Streaming LLM instance
*/
function createStreamingLLM({ apiKey, model = 'gpt-4.1', temperature = 0.7, maxTokens = 2048, usePortkey = false, portkeyVirtualKey, ...config }) {
return {
streamChat: async (messages) => {
const fetchUrl = usePortkey
? 'https://api.portkey.ai/v1/chat/completions'
: 'https://api.openai.com/v1/chat/completions';
const headers = usePortkey
? {
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
'Content-Type': 'application/json',
}
: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
};
const response = await fetch(fetchUrl, {
method: 'POST',
headers,
body: JSON.stringify({
model: model,
messages,
temperature,
max_tokens: maxTokens,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
}
return response;
}
};
}
module.exports = {
createSTT,
createLLM,
createStreamingLLM
};

View File

@ -0,0 +1,19 @@
const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService');
function getRepository() {
// const user = authService.getCurrentUser();
// if (user.isLoggedIn) {
// return firebaseRepository;
// }
return sqliteRepository;
}
module.exports = {
getPresets: (...args) => getRepository().getPresets(...args),
getPresetTemplates: (...args) => getRepository().getPresetTemplates(...args),
create: (...args) => getRepository().create(...args),
update: (...args) => getRepository().update(...args),
delete: (...args) => getRepository().delete(...args),
};

View File

@ -0,0 +1,85 @@
const sqliteClient = require('../../services/sqliteClient');
function getPresets(uid) {
const db = sqliteClient.getDb();
const query = `
SELECT * FROM prompt_presets
WHERE uid = ? OR is_default = 1
ORDER BY is_default DESC, title ASC
`;
try {
return db.prepare(query).all(uid);
} catch (err) {
console.error('SQLite: Failed to get presets:', err);
throw err;
}
}
function getPresetTemplates() {
const db = sqliteClient.getDb();
const query = `
SELECT * FROM prompt_presets
WHERE is_default = 1
ORDER BY title ASC
`;
try {
return db.prepare(query).all();
} catch (err) {
console.error('SQLite: Failed to get preset templates:', err);
throw err;
}
}
function create({ uid, title, prompt }) {
const db = sqliteClient.getDb();
const presetId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO prompt_presets (id, uid, title, prompt, is_default, created_at, sync_state) VALUES (?, ?, ?, ?, 0, ?, 'dirty')`;
try {
db.prepare(query).run(presetId, uid, title, prompt, now);
return { id: presetId };
} catch (err) {
throw err;
}
}
function update(id, { title, prompt }, uid) {
const db = sqliteClient.getDb();
const query = `UPDATE prompt_presets SET title = ?, prompt = ?, sync_state = 'dirty' WHERE id = ? AND uid = ? AND is_default = 0`;
try {
const result = db.prepare(query).run(title, prompt, id, uid);
if (result.changes === 0) {
throw new Error("Preset not found or permission denied.");
}
return { changes: result.changes };
} catch (err) {
throw err;
}
}
function del(id, uid) {
const db = sqliteClient.getDb();
const query = `DELETE FROM prompt_presets WHERE id = ? AND uid = ? AND is_default = 0`;
try {
const result = db.prepare(query).run(id, uid);
if (result.changes === 0) {
throw new Error("Preset not found or permission denied.");
}
return { changes: result.changes };
} catch (err) {
throw err;
}
}
module.exports = {
getPresets,
getPresetTemplates,
create,
update,
delete: del
};

View File

@ -0,0 +1,26 @@
const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository'); // Future implementation
const authService = require('../../../common/services/authService');
function getRepository() {
// In the future, we can check the user's login status from authService
// const user = authService.getCurrentUser();
// if (user.isLoggedIn) {
// return firebaseRepository;
// }
return sqliteRepository;
}
// Directly export functions for ease of use, decided by the strategy
module.exports = {
getById: (...args) => getRepository().getById(...args),
create: (...args) => getRepository().create(...args),
getAllByUserId: (...args) => getRepository().getAllByUserId(...args),
updateTitle: (...args) => getRepository().updateTitle(...args),
deleteWithRelatedData: (...args) => getRepository().deleteWithRelatedData(...args),
end: (...args) => getRepository().end(...args),
updateType: (...args) => getRepository().updateType(...args),
touch: (...args) => getRepository().touch(...args),
getOrCreateActive: (...args) => getRepository().getOrCreateActive(...args),
endAllActiveSessions: (...args) => getRepository().endAllActiveSessions(...args),
};

View File

@ -0,0 +1,137 @@
const sqliteClient = require('../../services/sqliteClient');
function getById(id) {
const db = sqliteClient.getDb();
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
}
function create(uid, type = 'ask') {
const db = sqliteClient.getDb();
const sessionId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO sessions (id, uid, title, session_type, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`;
try {
db.prepare(query).run(sessionId, uid, `Session @ ${new Date().toLocaleTimeString()}`, type, now, now);
console.log(`SQLite: Created session ${sessionId} for user ${uid} (type: ${type})`);
return sessionId;
} catch (err) {
console.error('SQLite: Failed to create session:', err);
throw err;
}
}
function getAllByUserId(uid) {
const db = sqliteClient.getDb();
const query = "SELECT id, uid, title, session_type, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC";
return db.prepare(query).all(uid);
}
function updateTitle(id, title) {
const db = sqliteClient.getDb();
const result = db.prepare('UPDATE sessions SET title = ? WHERE id = ?').run(title, id);
return { changes: result.changes };
}
function deleteWithRelatedData(id) {
const db = sqliteClient.getDb();
const transaction = db.transaction(() => {
db.prepare("DELETE FROM transcripts WHERE session_id = ?").run(id);
db.prepare("DELETE FROM ai_messages WHERE session_id = ?").run(id);
db.prepare("DELETE FROM summaries WHERE session_id = ?").run(id);
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
});
try {
transaction();
return { success: true };
} catch (err) {
throw err;
}
}
function end(id) {
const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000);
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE id = ?`;
const result = db.prepare(query).run(now, now, id);
return { changes: result.changes };
}
function updateType(id, type) {
const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000);
const query = 'UPDATE sessions SET session_type = ?, updated_at = ? WHERE id = ?';
const result = db.prepare(query).run(type, now, id);
return { changes: result.changes };
}
function touch(id) {
const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000);
const query = 'UPDATE sessions SET updated_at = ? WHERE id = ?';
const result = db.prepare(query).run(now, id);
return { changes: result.changes };
}
function getOrCreateActive(uid, requestedType = 'ask') {
const db = sqliteClient.getDb();
// 1. Look for ANY active session for the user (ended_at IS NULL).
// Prefer 'listen' sessions over 'ask' sessions to ensure continuity.
const findQuery = `
SELECT id, session_type FROM sessions
WHERE uid = ? AND ended_at IS NULL
ORDER BY CASE session_type WHEN 'listen' THEN 1 WHEN 'ask' THEN 2 ELSE 3 END
LIMIT 1
`;
const activeSession = db.prepare(findQuery).get(uid);
if (activeSession) {
// An active session exists.
console.log(`[Repo] Found active session ${activeSession.id} of type ${activeSession.session_type}`);
// 2. Promotion Logic: If it's an 'ask' session and we need 'listen', promote it.
if (activeSession.session_type === 'ask' && requestedType === 'listen') {
updateType(activeSession.id, 'listen');
console.log(`[Repo] Promoted session ${activeSession.id} to 'listen' type.`);
}
// 3. Touch the session and return its ID.
touch(activeSession.id);
return activeSession.id;
} else {
// 4. No active session found, create a new one.
console.log(`[Repo] No active session for user ${uid}. Creating new '${requestedType}' session.`);
return create(uid, requestedType);
}
}
function endAllActiveSessions() {
const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000);
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE ended_at IS NULL`;
try {
const result = db.prepare(query).run(now, now);
console.log(`[Repo] Ended ${result.changes} active session(s).`);
return { changes: result.changes };
} catch (err) {
console.error('SQLite: Failed to end all active sessions:', err);
throw err;
}
}
module.exports = {
getById,
create,
getAllByUserId,
updateTitle,
deleteWithRelatedData,
end,
updateType,
touch,
getOrCreateActive,
endAllActiveSessions,
};

View File

@ -0,0 +1,11 @@
const sqliteRepository = require('./sqlite.repository');
// This repository is not user-specific, so we always return sqlite.
function getRepository() {
return sqliteRepository;
}
module.exports = {
markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args),
checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...args),
};

View File

@ -0,0 +1,14 @@
const sqliteClient = require('../../services/sqliteClient');
async function markPermissionsAsCompleted() {
return sqliteClient.markPermissionsAsCompleted();
}
async function checkPermissionsCompleted() {
return sqliteClient.checkPermissionsCompleted();
}
module.exports = {
markPermissionsAsCompleted,
checkPermissionsCompleted,
};

View File

@ -0,0 +1,19 @@
const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService');
function getRepository() {
// const user = authService.getCurrentUser();
// if (user.isLoggedIn) {
// return firebaseRepository;
// }
return sqliteRepository;
}
module.exports = {
findOrCreate: (...args) => getRepository().findOrCreate(...args),
getById: (...args) => getRepository().getById(...args),
saveApiKey: (...args) => getRepository().saveApiKey(...args),
update: (...args) => getRepository().update(...args),
deleteById: (...args) => getRepository().deleteById(...args),
};

View File

@ -0,0 +1,92 @@
const sqliteClient = require('../../services/sqliteClient');
function findOrCreate(user) {
const db = sqliteClient.getDb();
if (!user || !user.uid) {
throw new Error('User object and uid are required');
}
const { uid, displayName, email } = user;
const now = Math.floor(Date.now() / 1000);
// Validate inputs
const safeDisplayName = displayName || 'User';
const safeEmail = email || 'no-email@example.com';
const query = `
INSERT INTO users (uid, display_name, email, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(uid) DO UPDATE SET
display_name=excluded.display_name,
email=excluded.email
`;
try {
console.log('[SQLite] Creating/updating user:', { uid, displayName: safeDisplayName, email: safeEmail });
db.prepare(query).run(uid, safeDisplayName, safeEmail, now);
const result = getById(uid);
console.log('[SQLite] User operation successful:', result);
return result;
} catch (err) {
console.error('SQLite: Failed to find or create user:', err);
console.error('SQLite: User data:', { uid, displayName: safeDisplayName, email: safeEmail });
throw new Error(`Failed to create user in database: ${err.message}`);
}
}
function getById(uid) {
const db = sqliteClient.getDb();
return db.prepare('SELECT * FROM users WHERE uid = ?').get(uid);
}
function saveApiKey(apiKey, uid, provider = 'openai') {
const db = sqliteClient.getDb();
try {
const result = db.prepare('UPDATE users SET api_key = ?, provider = ? WHERE uid = ?').run(apiKey, provider, uid);
console.log(`SQLite: API key saved for user ${uid} with provider ${provider}.`);
return { changes: result.changes };
} catch (err) {
console.error('SQLite: Failed to save API key:', err);
throw err;
}
}
function update({ uid, displayName }) {
const db = sqliteClient.getDb();
const result = db.prepare('UPDATE users SET display_name = ? WHERE uid = ?').run(displayName, uid);
return { changes: result.changes };
}
function deleteById(uid) {
const db = sqliteClient.getDb();
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid);
const sessionIds = userSessions.map(s => s.id);
const transaction = db.transaction(() => {
if (sessionIds.length > 0) {
const placeholders = sessionIds.map(() => '?').join(',');
db.prepare(`DELETE FROM transcripts WHERE session_id IN (${placeholders})`).run(...sessionIds);
db.prepare(`DELETE FROM ai_messages WHERE session_id IN (${placeholders})`).run(...sessionIds);
db.prepare(`DELETE FROM summaries WHERE session_id IN (${placeholders})`).run(...sessionIds);
db.prepare(`DELETE FROM sessions WHERE uid = ?`).run(uid);
}
db.prepare('DELETE FROM prompt_presets WHERE uid = ? AND is_default = 0').run(uid);
db.prepare('DELETE FROM users WHERE uid = ?').run(uid);
});
try {
transaction();
return { success: true };
} catch (err) {
throw err;
}
}
module.exports = {
findOrCreate,
getById,
saveApiKey,
update,
deleteById
};

View File

@ -1,377 +0,0 @@
const { createOpenAiGenerativeClient, getOpenAiGenerativeModel } = require('./openAiClient.js');
const { createGeminiClient, getGeminiGenerativeModel, createGeminiChat } = require('./googleGeminiClient.js');
/**
* Creates an AI client based on the provider
* @param {string} apiKey - The API key
* @param {string} provider - The provider ('openai' or 'gemini')
* @returns {object} The AI client
*/
function createAIClient(apiKey, provider = 'openai') {
switch (provider) {
case 'openai':
return createOpenAiGenerativeClient(apiKey);
case 'gemini':
return createGeminiClient(apiKey);
default:
throw new Error(`Unsupported AI provider: ${provider}`);
}
}
/**
* Gets a generative model based on the provider
* @param {object} client - The AI client
* @param {string} provider - The provider ('openai' or 'gemini')
* @param {string} model - The model name (optional)
* @returns {object} The model object
*/
function getGenerativeModel(client, provider = 'openai', model) {
switch (provider) {
case 'openai':
return getOpenAiGenerativeModel(client, model || 'gpt-4.1');
case 'gemini':
return getGeminiGenerativeModel(client, model || 'gemini-2.5-flash');
default:
throw new Error(`Unsupported AI provider: ${provider}`);
}
}
/**
* Makes a chat completion request based on the provider
* @param {object} params - Request parameters
* @returns {Promise<object>} The completion response
*/
async function makeChatCompletion({ apiKey, provider = 'openai', messages, temperature = 0.7, maxTokens = 1024, model, stream = false }) {
if (provider === 'openai') {
const fetchUrl = 'https://api.openai.com/v1/chat/completions';
const response = await fetch(fetchUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model || 'gpt-4.1',
messages,
temperature,
max_tokens: maxTokens,
stream,
}),
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
}
if (stream) {
return response;
}
const result = await response.json();
return {
content: result.choices[0].message.content.trim(),
raw: result
};
} else if (provider === 'gemini') {
const client = createGeminiClient(apiKey);
const genModel = getGeminiGenerativeModel(client, model || 'gemini-2.5-flash');
// Convert OpenAI format messages to Gemini format
const parts = [];
for (const message of messages) {
if (message.role === 'system') {
parts.push(message.content);
} else if (message.role === 'user') {
if (typeof message.content === 'string') {
parts.push(message.content);
} else if (Array.isArray(message.content)) {
// Handle multimodal content
for (const part of message.content) {
if (part.type === 'text') {
parts.push(part.text);
} else if (part.type === 'image_url' && part.image_url?.url) {
// Extract base64 data from data URL
const base64Match = part.image_url.url.match(/^data:(.+);base64,(.+)$/);
if (base64Match) {
parts.push({
inlineData: {
mimeType: base64Match[1],
data: base64Match[2]
}
});
}
}
}
}
}
}
const result = await genModel.generateContent(parts);
return {
content: result.response.text(),
raw: result
};
} else {
throw new Error(`Unsupported AI provider: ${provider}`);
}
}
/**
* Makes a chat completion request with Portkey support
* @param {object} params - Request parameters including Portkey options
* @returns {Promise<object>} The completion response
*/
async function makeChatCompletionWithPortkey({
apiKey,
provider = 'openai',
messages,
temperature = 0.7,
maxTokens = 1024,
model,
usePortkey = false,
portkeyVirtualKey = null
}) {
if (!usePortkey) {
return makeChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
}
// Portkey is only supported for OpenAI currently
if (provider !== 'openai') {
console.warn('Portkey is only supported for OpenAI provider, falling back to direct API');
return makeChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
}
const fetchUrl = 'https://api.portkey.ai/v1/chat/completions';
const response = await fetch(fetchUrl, {
method: 'POST',
headers: {
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model || 'gpt-4.1',
messages,
temperature,
max_tokens: maxTokens,
}),
});
if (!response.ok) {
throw new Error(`Portkey API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
content: result.choices[0].message.content.trim(),
raw: result
};
}
/**
* Makes a streaming chat completion request
* @param {object} params - Request parameters
* @returns {Promise<Response>} The streaming response
*/
async function makeStreamingChatCompletion({ apiKey, provider = 'openai', messages, temperature = 0.7, maxTokens = 1024, model }) {
if (provider === 'openai') {
const fetchUrl = 'https://api.openai.com/v1/chat/completions';
const response = await fetch(fetchUrl, {
method: 'POST',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model || 'gpt-4.1',
messages,
temperature,
max_tokens: maxTokens,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
}
return response;
} else if (provider === 'gemini') {
console.log('[AIProviderService] Starting Gemini streaming request');
// Gemini streaming requires a different approach
// We'll create a ReadableStream that mimics OpenAI's SSE format
const geminiClient = createGeminiClient(apiKey);
// Extract system instruction if present
let systemInstruction = '';
const nonSystemMessages = [];
for (const msg of messages) {
if (msg.role === 'system') {
systemInstruction = msg.content;
} else {
nonSystemMessages.push(msg);
}
}
const chat = createGeminiChat(geminiClient, model || 'gemini-2.0-flash-exp', {
temperature,
maxOutputTokens: maxTokens || 8192,
systemInstruction: systemInstruction || undefined
});
// Create a ReadableStream to handle Gemini's streaming
const stream = new ReadableStream({
async start(controller) {
try {
console.log('[AIProviderService] Processing messages for Gemini:', nonSystemMessages.length, 'messages (excluding system)');
// 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('[AIProviderService] 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];
geminiContent.push({
inlineData: {
mimeType: 'image/png',
data: base64Data
}
});
}
}
} else {
geminiContent = [lastUserMessage];
}
console.log('[AIProviderService] Prepared Gemini content:',
geminiContent.length, 'parts');
// Stream the response
let chunkCount = 0;
let totalContent = '';
for await (const chunk of chat.sendMessageStream(geminiContent)) {
chunkCount++;
const chunkText = chunk.text || '';
totalContent += chunkText;
// Format as SSE data
const data = JSON.stringify({
choices: [{
delta: {
content: chunkText
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
}
console.log(`[AIProviderService] 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('[AIProviderService] Gemini streaming completed successfully');
} catch (error) {
console.error('[AIProviderService] Gemini 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'
}
});
} else {
throw new Error(`Unsupported AI provider: ${provider}`);
}
}
/**
* Makes a streaming chat completion request with Portkey support
* @param {object} params - Request parameters
* @returns {Promise<Response>} The streaming response
*/
async function makeStreamingChatCompletionWithPortkey({
apiKey,
provider = 'openai',
messages,
temperature = 0.7,
maxTokens = 1024,
model,
usePortkey = false,
portkeyVirtualKey = null
}) {
if (!usePortkey) {
return makeStreamingChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
}
// Portkey is only supported for OpenAI currently
if (provider !== 'openai') {
console.warn('Portkey is only supported for OpenAI provider, falling back to direct API');
return makeStreamingChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
}
const fetchUrl = 'https://api.portkey.ai/v1/chat/completions';
const response = await fetch(fetchUrl, {
method: 'POST',
headers: {
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: model || 'gpt-4.1',
messages,
temperature,
max_tokens: maxTokens,
stream: true,
}),
});
if (!response.ok) {
throw new Error(`Portkey API error: ${response.status} ${response.statusText}`);
}
return response;
}
module.exports = {
createAIClient,
getGenerativeModel,
makeChatCompletion,
makeChatCompletionWithPortkey,
makeStreamingChatCompletion,
makeStreamingChatCompletionWithPortkey
};

View File

@ -1,239 +0,0 @@
const axios = require('axios');
const config = require('../config/config');
class APIClient {
constructor() {
this.baseURL = config.get('apiUrl');
this.client = axios.create({
baseURL: this.baseURL,
timeout: config.get('apiTimeout'),
headers: {
'Content-Type': 'application/json'
}
});
this.client.interceptors.response.use(
(response) => response,
(error) => {
console.error('API request failed:', error.message);
if (error.response) {
console.error('response status:', error.response.status);
console.error('response data:', error.response.data);
}
return Promise.reject(error);
}
);
}
async initialize() {
try {
const response = await this.client.get('/api/auth/status');
console.log('[APIClient] checked default user status:', response.data);
return true;
} catch (error) {
console.error('[APIClient] failed to initialize:', error);
return false;
}
}
async checkConnection() {
try {
const response = await this.client.get('/');
return response.status === 200;
} catch (error) {
return false;
}
}
async saveApiKey(apiKey) {
try {
const response = await this.client.post('/api/user/api-key', { apiKey });
return response.data;
} catch (error) {
console.error('failed to save api key:', error);
throw error;
}
}
async checkApiKey() {
try {
const response = await this.client.get('/api/user/api-key');
return response.data;
} catch (error) {
console.error('failed to check api key:', error);
return { hasApiKey: false };
}
}
async getUserBatchData(includes = ['profile', 'context', 'presets']) {
try {
const includeParam = includes.join(',');
const response = await this.client.get(`/api/user/batch?include=${includeParam}`);
return response.data;
} catch (error) {
console.error('failed to get user batch data:', error);
return null;
}
}
async getUserContext() {
try {
const response = await this.client.get('/api/user/context');
return response.data.context;
} catch (error) {
console.error('fail to get user context:', error);
return null;
}
}
async getUserProfile() {
try {
const response = await this.client.get('/api/user/profile');
return response.data;
} catch (error) {
console.error('failed to get user profile:', error);
return null;
}
}
async getUserPresets() {
try {
const response = await this.client.get('/api/user/presets');
return response.data;
} catch (error) {
console.error('failed to get user presets:', error);
return [];
}
}
async updateUserContext(context) {
try {
const response = await this.client.post('/api/user/context', context);
return response.data;
} catch (error) {
console.error('failed to update user context:', error);
throw error;
}
}
async addActivity(activity) {
try {
const response = await this.client.post('/api/user/activities', activity);
return response.data;
} catch (error) {
console.error('failed to add activity:', error);
throw error;
}
}
async getPresetTemplates() {
try {
const response = await this.client.get('/api/preset-templates');
return response.data;
} catch (error) {
console.error('failed to get preset templates:', error);
return [];
}
}
async updateUserProfile(profile) {
try {
const response = await this.client.post('/api/user/profile', profile);
return response.data;
} catch (error) {
console.error('failed to update user profile:', error);
throw error;
}
}
async searchUsers(name = '') {
try {
const response = await this.client.get('/api/users/search', {
params: { name }
});
return response.data;
} catch (error) {
console.error('failed to search users:', error);
return [];
}
}
async getUserProfileById(userId) {
try {
const response = await this.client.get(`/api/users/${userId}/profile`);
return response.data;
} catch (error) {
console.error('failed to get user profile by id:', error);
return null;
}
}
async saveConversationSession(sessionId, conversationHistory) {
try {
const payload = {
sessionId,
conversationHistory
};
const response = await this.client.post('/api/conversations', payload);
return response.data;
} catch (error) {
console.error('failed to save conversation session:', error);
throw error;
}
}
async getConversationSession(sessionId) {
try {
const response = await this.client.get(`/api/conversations/${sessionId}`);
return response.data;
} catch (error) {
console.error('failed to get conversation session:', error);
return null;
}
}
async getAllConversationSessions() {
try {
const response = await this.client.get('/api/conversations');
return response.data;
} catch (error) {
console.error('failed to get all conversation sessions:', error);
return [];
}
}
async deleteConversationSession(sessionId) {
try {
const response = await this.client.delete(`/api/conversations/${sessionId}`);
return response.data;
} catch (error) {
console.error('failed to delete conversation session:', error);
throw error;
}
}
async getSyncStatus() {
try {
const response = await this.client.get('/api/sync/status');
return response.data;
} catch (error) {
console.error('failed to get sync status:', error);
return null;
}
}
async getFullUserData() {
try {
const response = await this.client.get('/api/user/full');
return response.data;
} catch (error) {
console.error('failed to get full user data:', error);
return null;
}
}
}
const apiClient = new APIClient();
module.exports = apiClient;
module.exports = apiClient;

View File

@ -0,0 +1,160 @@
const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
const { BrowserWindow } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient');
const userRepository = require('../repositories/user');
const fetch = require('node-fetch');
async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) {
throw new Error('Firebase ID token is required for virtual key request');
}
const resp = await fetch('https://serverless-api-sf3o.vercel.app/api/virtual_key', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${idToken}`,
},
body: JSON.stringify({ email: email.trim().toLowerCase() }),
redirect: 'follow',
});
const json = await resp.json().catch(() => ({}));
if (!resp.ok) {
console.error('[VK] API request failed:', json.message || 'Unknown error');
throw new Error(json.message || `HTTP ${resp.status}: Virtual key request failed`);
}
const vKey = json?.data?.virtualKey || json?.data?.virtual_key || json?.data?.newVKey?.slug;
if (!vKey) throw new Error('virtual key missing in response');
return vKey;
}
class AuthService {
constructor() {
this.currentUserId = 'default_user';
this.currentUserMode = 'local'; // 'local' or 'firebase'
this.currentUser = null;
this.isInitialized = false;
}
initialize() {
if (this.isInitialized) return;
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';
// 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);
}
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);
}
}
this.currentUser = null;
this.currentUserId = 'default_user';
this.currentUserMode = 'local';
}
this.broadcastUserState();
});
this.isInitialized = true;
console.log('[AuthService] Initialized and attached to Firebase Auth state.');
}
async signInWithCustomToken(token) {
const auth = getFirebaseAuth();
try {
const userCredential = await signInWithCustomToken(auth, token);
console.log(`[AuthService] Successfully signed in with custom token for user:`, userCredential.user.uid);
// onAuthStateChanged will handle the state update and broadcast
} catch (error) {
console.error('[AuthService] Error signing in with custom token:', error);
throw error; // Re-throw to be handled by the caller
}
}
async signOut() {
const auth = getFirebaseAuth();
try {
await signOut(auth);
console.log('[AuthService] User sign-out initiated successfully.');
// onAuthStateChanged will handle the state update and broadcast,
// which will also re-evaluate the API key status.
} catch (error) {
console.error('[AuthService] Error signing out:', error);
}
}
broadcastUserState() {
const userState = this.getCurrentUser();
console.log('[AuthService] Broadcasting user state change:', userState);
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed() && win.webContents && !win.webContents.isDestroyed()) {
win.webContents.send('user-state-changed', userState);
}
});
}
getCurrentUserId() {
return this.currentUserId;
}
getCurrentUser() {
const isLoggedIn = !!(this.currentUserMode === 'firebase' && this.currentUser);
if (isLoggedIn) {
return {
uid: this.currentUser.uid,
email: this.currentUser.email,
displayName: this.currentUser.displayName,
mode: 'firebase',
isLoggedIn: true,
//////// before_modelStateService ////////
// hasApiKey: this.hasApiKey // Always true for firebase users, but good practice
//////// before_modelStateService ////////
};
}
return {
uid: this.currentUserId, // returns 'default_user'
email: 'contact@pickle.com',
displayName: 'Default User',
mode: 'local',
isLoggedIn: false,
//////// before_modelStateService ////////
// hasApiKey: this.hasApiKey
//////// before_modelStateService ////////
};
}
}
const authService = new AuthService();
module.exports = authService;

View File

@ -1,158 +0,0 @@
const config = require('../config/config');
class DataService {
constructor() {
this.cache = new Map();
this.cacheTimeout = config.get('cacheTimeout');
this.enableCaching = config.get('enableCaching');
this.sqliteClient = null;
this.currentUserId = 'default_user';
this.isInitialized = false;
if (config.get('enableSQLiteStorage')) {
try {
this.sqliteClient = require('./sqliteClient');
console.log('[DataService] SQLite storage enabled.');
} catch (error) {
console.error('[DataService] Failed to load SQLite client:', error);
}
}
}
async initialize() {
if (this.isInitialized || !this.sqliteClient) {
return;
}
try {
await this.sqliteClient.connect();
this.isInitialized = true;
console.log('[DataService] Initialized successfully');
} catch (error) {
console.error('[DataService] Failed to initialize:', error);
throw error;
}
}
setCurrentUser(uid) {
if (this.currentUserId !== uid) {
console.log(`[DataService] Current user switched to: ${uid}`);
this.currentUserId = uid;
this.clearCache();
}
}
getCacheKey(operation, params = '') {
return `${this.currentUserId}:${operation}:${params}`;
}
getFromCache(key) {
if (!this.enableCaching) return null;
const cached = this.cache.get(key);
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
return cached.data;
}
return null;
}
setCache(key, data) {
if (!this.enableCaching) return;
this.cache.set(key, { data, timestamp: Date.now() });
}
clearCache() {
this.cache.clear();
}
async findOrCreateUser(firebaseUser) {
if (!this.sqliteClient) {
console.log('[DataService] SQLite client not available, skipping user creation');
return firebaseUser;
}
try {
await this.initialize();
const existingUser = await this.sqliteClient.getUser(firebaseUser.uid);
if (!existingUser) {
console.log(`[DataService] Creating new user in local DB: ${firebaseUser.uid}`);
await this.sqliteClient.findOrCreateUser({
uid: firebaseUser.uid,
display_name: firebaseUser.displayName || firebaseUser.display_name,
email: firebaseUser.email
});
}
this.clearCache();
return firebaseUser;
} catch (error) {
console.error('[DataService] Failed to sync Firebase user to local DB:', error);
return firebaseUser;
}
}
async saveApiKey(apiKey) {
if (!this.sqliteClient) {
throw new Error("SQLite client not available.");
}
try {
await this.initialize();
const result = await this.sqliteClient.saveApiKey(apiKey, this.currentUserId);
this.clearCache();
return result;
} catch (error) {
console.error('[DataService] Failed to save API key to SQLite:', error);
throw error;
}
}
async checkApiKey() {
if (!this.sqliteClient) return { hasApiKey: false };
try {
await this.initialize();
const user = await this.sqliteClient.getUser(this.currentUserId);
return { hasApiKey: !!user?.api_key && user.api_key.length > 0 };
} catch (error) {
console.error('[DataService] Failed to check API key from SQLite:', error);
return { hasApiKey: false };
}
}
async getUserPresets() {
const cacheKey = this.getCacheKey('presets');
const cached = this.getFromCache(cacheKey);
if (cached) return cached;
if (!this.sqliteClient) return [];
try {
await this.initialize();
const presets = await this.sqliteClient.getPresets(this.currentUserId);
this.setCache(cacheKey, presets);
return presets;
} catch (error) {
console.error('[DataService] Failed to get presets from SQLite:', error);
return [];
}
}
async getPresetTemplates() {
const cacheKey = this.getCacheKey('preset_templates');
const cached = this.getFromCache(cacheKey);
if (cached) return cached;
if (!this.sqliteClient) return [];
try {
await this.initialize();
const templates = await this.sqliteClient.getPresetTemplates();
this.setCache(cacheKey, templates);
return templates;
} catch (error) {
console.error('[DataService] Failed to get preset templates from SQLite:', error);
return [];
}
}
}
const dataService = new DataService();
module.exports = dataService;

View File

@ -0,0 +1,103 @@
const { initializeApp } = require('firebase/app');
const { initializeAuth } = require('firebase/auth');
const Store = require('electron-store');
/**
* Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*,
* not instances. It then calls `new PersistenceClass()` internally.
*
* The helper below returns such a class, pre-configured with an `electron-store` instance that
* will be shared across all constructed objects. This mirrors the pattern used by Firebase's own
* `browserLocalPersistence` implementation as well as community solutions for NodeJS.
*/
function createElectronStorePersistence(storeName = 'firebase-auth-session') {
// Create a single `electron-store` behind the scenes all Persistence instances will use it.
const sharedStore = new Store({ name: storeName });
return class ElectronStorePersistence {
constructor() {
this.store = sharedStore;
this.type = 'LOCAL';
}
/**
* Firebase calls this to check whether the persistence is usable in the current context.
*/
_isAvailable() {
return Promise.resolve(true);
}
async _set(key, value) {
this.store.set(key, value);
}
async _get(key) {
return this.store.get(key) ?? null;
}
async _remove(key) {
this.store.delete(key);
}
/**
* These are used by Firebase to react to external storage events (e.g. multi-tab).
* Electron apps are single-renderer per process, so we can safely provide no-op
* implementations.
*/
_addListener(_key, _listener) {
// no-op
}
_removeListener(_key, _listener) {
// no-op
}
};
}
const firebaseConfig = {
apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g',
authDomain: 'pickle-3651a.firebaseapp.com',
projectId: 'pickle-3651a',
storageBucket: 'pickle-3651a.firebasestorage.app',
messagingSenderId: '904706892885',
appId: '1:904706892885:web:0e42b3dda796674ead20dc',
measurementId: 'G-SQ0WM6S28T',
};
let firebaseApp = null;
let firebaseAuth = null;
function initializeFirebase() {
if (firebaseApp) {
console.log('[FirebaseClient] Firebase already initialized.');
return;
}
try {
firebaseApp = initializeApp(firebaseConfig);
// Build a *class* persistence provider and hand it to Firebase.
const ElectronStorePersistence = createElectronStorePersistence('firebase-auth-session');
firebaseAuth = initializeAuth(firebaseApp, {
// `initializeAuth` accepts a single class or an array we pass an array for future
// extensibility and to match Firebase examples.
persistence: [ElectronStorePersistence],
});
console.log('[FirebaseClient] Firebase initialized successfully with class-based electron-store persistence.');
} catch (error) {
console.error('[FirebaseClient] Firebase initialization failed:', error);
}
}
function getFirebaseAuth() {
if (!firebaseAuth) {
throw new Error("Firebase Auth has not been initialized. Call initializeFirebase() first.");
}
return firebaseAuth;
}
module.exports = {
initializeFirebase,
getFirebaseAuth,
};

View File

@ -1,171 +0,0 @@
const { GoogleGenerativeAI } = require('@google/generative-ai');
const { GoogleGenAI } = require('@google/genai');
/**
* Creates and returns a Google Gemini client instance for generative AI.
* @param {string} apiKey - The API key for authentication.
* @returns {GoogleGenerativeAI} The initialized Gemini client.
*/
function createGeminiClient(apiKey) {
return new GoogleGenerativeAI(apiKey);
}
/**
* Gets a Gemini model for text/image generation.
* @param {GoogleGenerativeAI} client - The Gemini client instance.
* @param {string} [model='gemini-2.5-flash'] - The name for the text/vision model.
* @returns {object} Model object with generateContent method
*/
function getGeminiGenerativeModel(client, model = 'gemini-2.5-flash') {
const genAI = client;
const geminiModel = genAI.getGenerativeModel({ model: model });
return {
generateContent: async (parts) => {
let systemPrompt = '';
let userContent = [];
for (const part of parts) {
if (typeof part === 'string') {
if (systemPrompt === '' && part.includes('You are')) {
systemPrompt = part;
} else {
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
}
});
}
}
// 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;
return {
response: {
text: () => response.text()
}
};
} catch (error) {
console.error('Gemini API error:', error);
throw error;
}
}
};
}
/**
* Creates a Gemini chat session for multi-turn conversations.
* @param {GoogleGenerativeAI} client - The Gemini client instance.
* @param {string} [model='gemini-2.5-flash'] - The model to use.
* @param {object} [config={}] - Configuration options.
* @returns {object} Chat session object
*/
function createGeminiChat(client, model = 'gemini-2.5-flash', config = {}) {
const genAI = client;
const geminiModel = genAI.getGenerativeModel({
model: model,
systemInstruction: config.systemInstruction
});
const chat = geminiModel.startChat({
history: config.history || [],
generationConfig: {
temperature: config.temperature || 0.7,
maxOutputTokens: config.maxOutputTokens || 8192,
}
});
return {
sendMessage: async (message) => {
const result = await chat.sendMessage(message);
const response = await result.response;
return {
text: response.text()
};
},
sendMessageStream: async function* (message) {
const result = await chat.sendMessageStream(message);
for await (const chunk of result.stream) {
yield {
text: chunk.text()
};
}
},
getHistory: () => chat.getHistory()
};
}
// async function connectToGeminiSession(apiKey, { language = 'en-US', callbacks = {} } = {}) {
// const liveClient = new GoogleGenAI({
// vertexai: false, // Vertex AI 사용 안함
// apiKey,
// });
// // 라이브 STT 세션 열기
// const session = await liveClient.live.connect({
// model: 'gemini-live-2.5-flash-preview',
// callbacks,
// config: {
// inputAudioTranscription: {}, // 실시간 STT 필수
// speechConfig: { languageCode: language },
// },
// });
// return {
// sendRealtimeInput: async data => session.send({
// audio: { data, mimeType: 'audio/pcm;rate=24000' }
// }),
// close: async () => session.close(),
// };
// }
async function connectToGeminiSession(apiKey, { language = 'en-US', callbacks = {} } = {}) {
// ① 옛날 스타일 helper 재사용
const liveClient = new GoogleGenAI({ vertexai: false, apiKey });
// ② 언어 코드 강제 BCP-47 변환
const lang = language.includes('-') ? language : `${language}-US`;
const session = await liveClient.live.connect({
model: 'gemini-live-2.5-flash-preview',
callbacks,
config: {
inputAudioTranscription: {},
speechConfig: { languageCode: lang },
},
});
// ③ SDK 0.5+ : sendRealtimeInput 가 정식 이름
return {
sendRealtimeInput: async payload => session.sendRealtimeInput(payload),
close: async () => session.close(),
};
}
module.exports = {
createGeminiClient,
getGeminiGenerativeModel,
createGeminiChat,
connectToGeminiSession,
};

View File

@ -0,0 +1,329 @@
const Store = require('electron-store');
const fetch = require('node-fetch');
const { ipcMain, webContents } = require('electron');
const { PROVIDERS } = require('../ai/factory');
class ModelStateService {
constructor(authService) {
this.authService = authService;
this.store = new Store({ name: 'pickle-glass-model-state' });
this.state = {};
}
initialize() {
this._loadStateForCurrentUser();
this.setupIpcHandlers();
console.log('[ModelStateService] Initialized.');
}
_logCurrentSelection() {
const llmModel = this.state.selectedModels.llm;
const sttModel = this.state.selectedModels.stt;
const llmProvider = this.getProviderForModel('llm', llmModel) || 'None';
const sttProvider = this.getProviderForModel('stt', sttModel) || 'None';
console.log(`[ModelStateService] 🌟 Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
}
_autoSelectAvailableModels() {
console.log('[ModelStateService] Running auto-selection for models...');
const types = ['llm', 'stt'];
types.forEach(type => {
const currentModelId = this.state.selectedModels[type];
let isCurrentModelValid = false;
if (currentModelId) {
const provider = this.getProviderForModel(type, currentModelId);
if (provider && this.getApiKey(provider)) {
isCurrentModelValid = true;
}
}
if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`);
const availableModels = this.getAvailableModels(type);
if (availableModels.length > 0) {
this.state.selectedModels[type] = availableModels[0].id;
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${availableModels[0].id}`);
} else {
this.state.selectedModels[type] = null;
}
}
});
}
_loadStateForCurrentUser() {
const userId = this.authService.getCurrentUserId();
const initialApiKeys = Object.keys(PROVIDERS).reduce((acc, key) => {
acc[key] = null;
return acc;
}, {});
const defaultState = {
apiKeys: initialApiKeys,
selectedModels: { llm: null, stt: null },
};
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();
}
_saveState() {
const userId = this.authService.getCurrentUserId();
this.store.set(`users.${userId}`, this.state);
console.log(`[ModelStateService] State saved for user: ${userId}`);
this._logCurrentSelection();
}
async validateApiKey(provider, key) {
if (!key || key.trim() === '') {
return { success: false, error: 'API key cannot be empty.' };
}
let validationUrl, headers;
const body = undefined;
switch (provider) {
case 'openai':
validationUrl = 'https://api.openai.com/v1/models';
headers = { 'Authorization': `Bearer ${key}` };
break;
case 'gemini':
validationUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`;
headers = {};
break;
case 'anthropic': {
if (!key.startsWith('sk-ant-')) {
throw new Error('Invalid Anthropic key format.');
}
const response = await fetch("https://api.anthropic.com/v1/messages", {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": key,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: "claude-3-haiku-20240307",
max_tokens: 1,
messages: [{ role: "user", content: "Hi" }],
}),
});
if (!response.ok && response.status !== 400) {
const errorData = await response.json().catch(() => ({}));
return { success: false, error: errorData.error?.message || `Validation failed with status: ${response.status}` };
}
console.log(`[ModelStateService] API key for ${provider} is valid.`);
this.setApiKey(provider, key);
return { success: true };
}
default:
return { success: false, error: 'Unknown provider.' };
}
try {
const response = await fetch(validationUrl, { headers, body });
if (response.ok) {
console.log(`[ModelStateService] API key for ${provider} is valid.`);
this.setApiKey(provider, key);
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
const message = errorData.error?.message || `Validation failed with status: ${response.status}`;
console.log(`[ModelStateService] API key for ${provider} is invalid: ${message}`);
return { success: false, error: message };
}
} catch (error) {
console.error(`[ModelStateService] Network error during ${provider} key validation:`, error);
return { success: false, error: 'A network error occurred during validation.' };
}
}
setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key (for openai-glass).`);
this.state.apiKeys['openai-glass'] = virtualKey;
const llmModels = PROVIDERS['openai-glass']?.llmModels;
const sttModels = PROVIDERS['openai-glass']?.sttModels;
if (!this.state.selectedModels.llm && llmModels?.length > 0) {
this.state.selectedModels.llm = llmModels[0].id;
}
if (!this.state.selectedModels.stt && sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id;
}
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
}
setApiKey(provider, key) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = key;
const llmModels = PROVIDERS[provider]?.llmModels;
const sttModels = PROVIDERS[provider]?.sttModels;
if (!this.state.selectedModels.llm && llmModels?.length > 0) {
this.state.selectedModels.llm = llmModels[0].id;
}
if (!this.state.selectedModels.stt && sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id;
}
this._saveState();
this._logCurrentSelection();
return true;
}
return false;
}
getApiKey(provider) {
return this.state.apiKeys[provider] || null;
}
getAllApiKeys() {
const { 'openai-glass': _, ...displayKeys } = this.state.apiKeys;
return displayKeys;
}
removeApiKey(provider) {
if (provider in this.state.apiKeys) {
this.state.apiKeys[provider] = null;
const llmProvider = this.getProviderForModel('llm', this.state.selectedModels.llm);
if (llmProvider === provider) this.state.selectedModels.llm = null;
const sttProvider = this.getProviderForModel('stt', this.state.selectedModels.stt);
if (sttProvider === provider) this.state.selectedModels.stt = null;
this._autoSelectAvailableModels();
this._saveState();
this._logCurrentSelection();
return true;
}
return false;
}
getProviderForModel(type, modelId) {
if (!modelId) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models.some(m => m.id === modelId)) {
return providerId;
}
}
return null;
}
getCurrentProvider(type) {
const selectedModel = this.state.selectedModels[type];
return this.getProviderForModel(type, selectedModel);
}
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
}
areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true;
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => key && PROVIDERS[provider]?.llmModels.length > 0);
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => key && PROVIDERS[provider]?.sttModels.length > 0);
return hasLlmKey && hasSttKey;
}
getAvailableModels(type) {
const available = [];
const modelList = type === 'llm' ? 'llmModels' : 'sttModels';
Object.entries(this.state.apiKeys).forEach(([providerId, key]) => {
if (key && PROVIDERS[providerId]?.[modelList]) {
available.push(...PROVIDERS[providerId][modelList]);
}
});
return [...new Map(available.map(item => [item.id, item])).values()];
}
getSelectedModels() {
return this.state.selectedModels;
}
setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(type, modelId);
if (provider && this.state.apiKeys[provider]) {
this.state.selectedModels[type] = modelId;
this._saveState();
return true;
}
return false;
}
/**
*
* @param {('llm' | 'stt')} type
* @returns {{provider: string, model: string, apiKey: string} | null}
*/
getCurrentModelInfo(type) {
this._logCurrentSelection();
const model = this.state.selectedModels[type];
if (!model) {
return null;
}
const provider = this.getProviderForModel(type, model);
if (!provider) {
return null;
}
const apiKey = this.getApiKey(provider);
return { provider, model, apiKey };
}
setupIpcHandlers() {
ipcMain.handle('model:validate-key', (e, { provider, key }) => this.validateApiKey(provider, key));
ipcMain.handle('model:get-all-keys', () => this.getAllApiKeys());
ipcMain.handle('model:set-api-key', (e, { provider, key }) => this.setApiKey(provider, key));
ipcMain.handle('model:remove-api-key', (e, { provider }) => {
const success = this.removeApiKey(provider);
if (success) {
const selectedModels = this.getSelectedModels();
if (!selectedModels.llm || !selectedModels.stt) {
webContents.getAllWebContents().forEach(wc => {
wc.send('force-show-apikey-header');
});
}
}
return success;
});
ipcMain.handle('model:get-selected-models', () => this.getSelectedModels());
ipcMain.handle('model:set-selected-model', (e, { type, modelId }) => this.setSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', (e, { type }) => this.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', () => this.areProvidersConfigured());
ipcMain.handle('model:get-current-model-info', (e, { type }) => this.getCurrentModelInfo(type));
ipcMain.handle('model:get-provider-config', () => {
const serializableProviders = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
serializableProviders[key] = rest;
}
return serializableProviders;
});
}
}
module.exports = ModelStateService;

View File

@ -1,177 +0,0 @@
const OpenAI = require('openai');
const WebSocket = require('ws');
/**
* Creates and returns an OpenAI client instance for STT (Speech-to-Text).
* @param {string} apiKey - The API key for authentication.
* @returns {OpenAI} The initialized OpenAI client.
*/
function createOpenAiClient(apiKey) {
return new OpenAI({
apiKey: apiKey,
});
}
/**
* Creates and returns an OpenAI client instance for text/image generation.
* @param {string} apiKey - The API key for authentication.
* @returns {OpenAI} The initialized OpenAI client.
*/
function createOpenAiGenerativeClient(apiKey) {
return new OpenAI({
apiKey: apiKey,
});
}
/**
* Connects to an OpenAI Realtime WebSocket session for STT.
* @param {string} key - Portkey vKey or OpenAI apiKey.
* @param {object} config - The configuration object for the realtime session.
* @param {'apiKey'|'vKey'} keyType - key type ('apiKey' | 'vKey').
* @returns {Promise<object>} A promise that resolves to the session object with send and close methods.
*/
async function connectToOpenAiSession(key, config, keyType) {
if (keyType !== 'apiKey' && keyType !== 'vKey') {
throw new Error('keyType must be either "apiKey" or "vKey".');
}
const wsUrl = keyType === 'apiKey'
? 'wss://api.openai.com/v1/realtime?intent=transcription'
: 'wss://api.portkey.ai/v1/realtime?intent=transcription';
const headers = keyType === 'apiKey'
? {
'Authorization': `Bearer ${key}`,
'OpenAI-Beta' : 'realtime=v1',
}
: {
'x-portkey-api-key' : 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
'x-portkey-virtual-key': key,
'OpenAI-Beta' : 'realtime=v1',
};
const ws = new WebSocket(wsUrl, { headers });
return new Promise((resolve, reject) => {
ws.onopen = () => {
console.log("WebSocket session opened.");
const sessionConfig = {
type: 'transcription_session.update',
session: {
input_audio_format: 'pcm16',
input_audio_transcription: {
model: 'gpt-4o-mini-transcribe',
prompt: config.prompt || '',
language: config.language || 'en'
},
turn_detection: {
type: 'server_vad',
threshold: 0.5,
prefix_padding_ms: 50,
silence_duration_ms: 25,
},
input_audio_noise_reduction: {
type: 'near_field'
}
}
};
ws.send(JSON.stringify(sessionConfig));
resolve({
sendRealtimeInput: (audioData) => {
if (ws.readyState === WebSocket.OPEN) {
const message = {
type: 'input_audio_buffer.append',
audio: audioData
};
ws.send(JSON.stringify(message));
}
},
close: () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'session.close' }));
ws.close(1000, 'Client initiated close.');
}
}
});
};
ws.onmessage = (event) => {
const message = JSON.parse(event.data);
if (config.callbacks && config.callbacks.onmessage) {
config.callbacks.onmessage(message);
}
};
ws.onerror = (error) => {
console.error('WebSocket error:', error.message);
if (config.callbacks && config.callbacks.onerror) {
config.callbacks.onerror(error);
}
reject(error);
};
ws.onclose = (event) => {
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
if (config.callbacks && config.callbacks.onclose) {
config.callbacks.onclose(event);
}
};
});
}
/**
* Gets a GPT model for text/image generation.
* @param {OpenAI} client - The OpenAI client instance.
* @param {string} [model='gpt-4.1'] - The name for the text/vision model.
* @returns {object} Model object with generateContent method
*/
function getOpenAiGenerativeModel(client, model = 'gpt-4.1') {
return {
generateContent: async (parts) => {
const messages = [];
let systemPrompt = '';
let userContent = [];
for (const part of parts) {
if (typeof part === 'string') {
if (systemPrompt === '' && part.includes('You are')) {
systemPrompt = part;
} else {
userContent.push({ type: 'text', text: part });
}
} else if (part.inlineData) {
userContent.push({
type: 'image_url',
image_url: { url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` }
});
}
}
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
if (userContent.length > 0) messages.push({ role: 'user', content: userContent });
const response = await client.chat.completions.create({
model: model,
messages: messages,
temperature: 0.7,
max_tokens: 2048
});
return {
response: {
text: () => response.choices[0].message.content
}
};
}
};
}
module.exports = {
createOpenAiClient,
connectToOpenAiSession,
createOpenAiGenerativeClient,
getOpenAiGenerativeModel,
};

View File

@ -1,4 +1,4 @@
const sqlite3 = require('sqlite3').verbose();
const Database = require('better-sqlite3');
const path = require('path');
const LATEST_SCHEMA = require('../config/schema');
@ -10,75 +10,62 @@ class SQLiteClient {
}
connect(dbPath) {
return new Promise((resolve, reject) => {
if (this.db) {
console.log('[SQLiteClient] Already connected.');
return resolve();
return;
}
try {
this.dbPath = dbPath;
this.db = new sqlite3.Database(this.dbPath, (err) => {
if (err) {
console.error('[SQLiteClient] Could not connect to database', err);
return reject(err);
}
this.db = new Database(this.dbPath);
this.db.pragma('journal_mode = WAL');
console.log('[SQLiteClient] Connected successfully to:', this.dbPath);
this.db.run('PRAGMA journal_mode = WAL;', (err) => {
if (err) {
return reject(err);
} catch (err) {
console.error('[SQLiteClient] Could not connect to database', err);
throw err;
}
resolve();
});
});
});
}
async synchronizeSchema() {
getDb() {
if (!this.db) {
throw new Error("Database not connected. Call connect() first.");
}
return this.db;
}
synchronizeSchema() {
console.log('[DB Sync] Starting schema synchronization...');
const tablesInDb = await this.getTablesFromDb();
const tablesInDb = this.getTablesFromDb();
for (const tableName of Object.keys(LATEST_SCHEMA)) {
const tableSchema = LATEST_SCHEMA[tableName];
if (!tablesInDb.includes(tableName)) {
// Table doesn't exist, create it
await this.createTable(tableName, tableSchema);
this.createTable(tableName, tableSchema);
} else {
// Table exists, check for missing columns
await this.updateTable(tableName, tableSchema);
this.updateTable(tableName, tableSchema);
}
}
console.log('[DB Sync] Schema synchronization finished.');
}
async getTablesFromDb() {
return new Promise((resolve, reject) => {
this.db.all("SELECT name FROM sqlite_master WHERE type='table'", (err, tables) => {
if (err) return reject(err);
resolve(tables.map(t => t.name));
});
});
getTablesFromDb() {
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
return tables.map(t => t.name);
}
async createTable(tableName, tableSchema) {
return new Promise((resolve, reject) => {
createTable(tableName, tableSchema) {
const columnDefs = tableSchema.columns.map(col => `"${col.name}" ${col.type}`).join(', ');
const query = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs})`;
console.log(`[DB Sync] Creating table: ${tableName}`);
this.db.run(query, (err) => {
if (err) return reject(err);
resolve();
});
});
this.db.prepare(query).run();
}
async updateTable(tableName, tableSchema) {
return new Promise((resolve, reject) => {
this.db.all(`PRAGMA table_info("${tableName}")`, async (err, existingColumns) => {
if (err) return reject(err);
updateTable(tableName, tableSchema) {
const existingColumns = this.db.prepare(`PRAGMA table_info("${tableName}")`).all();
const existingColumnNames = existingColumns.map(c => c.name);
const columnsToAdd = tableSchema.columns.filter(col => !existingColumnNames.includes(col.name));
@ -86,28 +73,16 @@ class SQLiteClient {
console.log(`[DB Sync] Updating table: ${tableName}. Adding columns: ${columnsToAdd.map(c=>c.name).join(', ')}`);
for (const column of columnsToAdd) {
const addColumnQuery = `ALTER TABLE "${tableName}" ADD COLUMN "${column.name}" ${column.type}`;
try {
await this.runQuery(addColumnQuery);
} catch (alterErr) {
return reject(alterErr);
this.db.prepare(addColumnQuery).run();
}
}
}
resolve();
});
});
}
async runQuery(query, params = []) {
return new Promise((resolve, reject) => {
this.db.run(query, params, function(err) {
if (err) return reject(err);
resolve(this);
});
});
runQuery(query, params = []) {
return this.db.prepare(query).run(params);
}
async cleanupEmptySessions() {
cleanupEmptySessions() {
console.log('[DB Cleanup] Checking for empty sessions...');
const query = `
SELECT s.id FROM sessions s
@ -117,16 +92,11 @@ class SQLiteClient {
WHERE t.id IS NULL AND a.id IS NULL AND su.session_id IS NULL
`;
return new Promise((resolve, reject) => {
this.db.all(query, [], (err, rows) => {
if (err) {
console.error('[DB Cleanup] Error finding empty sessions:', err);
return reject(err);
}
const rows = this.db.prepare(query).all();
if (rows.length === 0) {
console.log('[DB Cleanup] No empty sessions found.');
return resolve();
return;
}
const idsToDelete = rows.map(r => r.id);
@ -134,36 +104,23 @@ class SQLiteClient {
const deleteQuery = `DELETE FROM sessions WHERE id IN (${placeholders})`;
console.log(`[DB Cleanup] Found ${idsToDelete.length} empty sessions. Deleting...`);
this.db.run(deleteQuery, idsToDelete, function(deleteErr) {
if (deleteErr) {
console.error('[DB Cleanup] Error deleting empty sessions:', deleteErr);
return reject(deleteErr);
}
console.log(`[DB Cleanup] Successfully deleted ${this.changes} empty sessions.`);
resolve();
});
});
});
const result = this.db.prepare(deleteQuery).run(idsToDelete);
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
}
async initTables() {
await this.synchronizeSchema();
await this.initDefaultData();
initTables() {
this.synchronizeSchema();
this.initDefaultData();
}
async initDefaultData() {
return new Promise((resolve, reject) => {
initDefaultData() {
const now = Math.floor(Date.now() / 1000);
const initUserQuery = `
INSERT OR IGNORE INTO users (uid, display_name, email, created_at)
VALUES (?, ?, ?, ?)
`;
this.db.run(initUserQuery, [this.defaultUserId, 'Default User', 'contact@pickle.com', now], (err) => {
if (err) {
console.error('Failed to initialize default user:', err);
return reject(err);
}
this.db.prepare(initUserQuery).run(this.defaultUserId, 'Default User', 'contact@pickle.com', now);
const defaultPresets = [
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],
@ -182,284 +139,52 @@ class SQLiteClient {
stmt.run(preset[0], this.defaultUserId, preset[1], preset[2], preset[3], now);
}
stmt.finalize((err) => {
if (err) {
console.error('Failed to finalize preset statement:', err);
return reject(err);
}
console.log('Default data initialized.');
resolve();
});
});
});
}
async findOrCreateUser(user) {
return new Promise((resolve, reject) => {
const { uid, display_name, email } = user;
const now = Math.floor(Date.now() / 1000);
const query = `
INSERT INTO users (uid, display_name, email, created_at)
VALUES (?, ?, ?, ?)
ON CONFLICT(uid) DO UPDATE SET
display_name=excluded.display_name,
email=excluded.email
`;
this.db.run(query, [uid, display_name, email, now], (err) => {
if (err) {
console.error('Failed to find or create user in SQLite:', err);
return reject(err);
}
this.getUser(uid).then(resolve).catch(reject);
});
});
}
async getUser(uid) {
return new Promise((resolve, reject) => {
this.db.get('SELECT * FROM users WHERE uid = ?', [uid], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
async saveApiKey(apiKey, uid = this.defaultUserId, provider = 'openai') {
return new Promise((resolve, reject) => {
this.db.run(
'UPDATE users SET api_key = ?, provider = ? WHERE uid = ?',
[apiKey, provider, uid],
function(err) {
if (err) {
console.error('SQLite: Failed to save API key:', err);
reject(err);
} else {
console.log(`SQLite: API key saved for user ${uid} with provider ${provider}.`);
resolve({ changes: this.changes });
}
}
markPermissionsAsCompleted() {
return this.query(
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
['permissions_completed', 'true']
);
});
}
async getPresets(uid) {
return new Promise((resolve, reject) => {
const query = `
SELECT * FROM prompt_presets
WHERE uid = ? OR is_default = 1
ORDER BY is_default DESC, title ASC
`;
this.db.all(query, [uid], (err, rows) => {
if (err) {
console.error('SQLite: Failed to get presets:', err);
reject(err);
} else {
resolve(rows);
}
});
});
}
async getPresetTemplates() {
return new Promise((resolve, reject) => {
const query = `
SELECT * FROM prompt_presets
WHERE is_default = 1
ORDER BY title ASC
`;
this.db.all(query, [], (err, rows) => {
if (err) {
console.error('SQLite: Failed to get preset templates:', err);
reject(err);
} else {
resolve(rows);
}
});
});
}
async getSession(id) {
return new Promise((resolve, reject) => {
this.db.get('SELECT * FROM sessions WHERE id = ?', [id], (err, row) => {
if (err) reject(err);
else resolve(row);
});
});
}
async updateSessionType(id, type) {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const query = 'UPDATE sessions SET session_type = ?, updated_at = ? WHERE id = ?';
this.db.run(query, [type, now, id], function(err) {
if (err) {
reject(err);
} else {
resolve({ changes: this.changes });
}
});
});
}
async touchSession(id) {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const query = 'UPDATE sessions SET updated_at = ? WHERE id = ?';
this.db.run(query, [now, id], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
}
async createSession(uid, type = 'ask') {
return new Promise((resolve, reject) => {
const sessionId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO sessions (id, uid, title, session_type, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`;
this.db.run(query, [sessionId, uid, `Session @ ${new Date().toLocaleTimeString()}`, type, now, now], function(err) {
if (err) {
console.error('SQLite: Failed to create session:', err);
reject(err);
} else {
console.log(`SQLite: Created session ${sessionId} for user ${uid} (type: ${type})`);
resolve(sessionId);
}
});
});
}
async endSession(sessionId) {
return new Promise((resolve, reject) => {
const now = Math.floor(Date.now() / 1000);
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE id = ?`;
this.db.run(query, [now, now, sessionId], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
}
async addTranscript({ sessionId, speaker, text }) {
return new Promise((resolve, reject) => {
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
const transcriptId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO transcripts (id, session_id, start_at, speaker, text, created_at) VALUES (?, ?, ?, ?, ?, ?)`;
this.db.run(query, [transcriptId, sessionId, now, speaker, text, now], function(err) {
if (err) reject(err);
else resolve({ id: transcriptId });
});
});
}
async addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) {
return new Promise((resolve, reject) => {
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
const messageId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO ai_messages (id, session_id, sent_at, role, content, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`;
this.db.run(query, [messageId, sessionId, now, role, content, model, now], function(err) {
if (err) reject(err);
else resolve({ id: messageId });
});
});
}
async saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) {
return new Promise((resolve, reject) => {
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
const now = Math.floor(Date.now() / 1000);
const query = `
INSERT INTO summaries (session_id, generated_at, model, text, tldr, bullet_json, action_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
generated_at=excluded.generated_at,
model=excluded.model,
text=excluded.text,
tldr=excluded.tldr,
bullet_json=excluded.bullet_json,
action_json=excluded.action_json,
updated_at=excluded.updated_at
`;
this.db.run(query, [sessionId, now, model, text, tldr, bullet_json, action_json, now], function(err) {
if (err) reject(err);
else resolve({ changes: this.changes });
});
});
}
async runMigrations() {
return new Promise((resolve, reject) => {
console.log('[DB Migration] Checking schema for `sessions` table...');
this.db.all("PRAGMA table_info(sessions)", (err, columns) => {
if (err) {
console.error('[DB Migration] Error checking sessions table schema:', err);
return reject(err);
}
const hasSessionTypeCol = columns.some(col => col.name === 'session_type');
if (!hasSessionTypeCol) {
console.log('[DB Migration] `session_type` column missing. Altering table...');
this.db.run("ALTER TABLE sessions ADD COLUMN session_type TEXT DEFAULT 'ask'", (alterErr) => {
if (alterErr) {
console.error('[DB Migration] Failed to add `session_type` column:', alterErr);
return reject(alterErr);
}
console.log('[DB Migration] `sessions` table updated successfully.');
resolve();
});
} else {
console.log('[DB Migration] Schema is up to date.');
resolve();
}
});
});
checkPermissionsCompleted() {
const result = this.query(
'SELECT value FROM system_settings WHERE key = ?',
['permissions_completed']
);
return result.length > 0 && result[0].value === 'true';
}
close() {
if (this.db) {
this.db.close((err) => {
if (err) {
console.error('SQLite connection close failed:', err);
} else {
try {
this.db.close();
console.log('SQLite connection closed.');
} catch (err) {
console.error('SQLite connection close failed:', err);
}
});
this.db = null;
}
}
async query(sql, params = []) {
return new Promise((resolve, reject) => {
query(sql, params = []) {
if (!this.db) {
return reject(new Error('Database not connected'));
throw new Error('Database not connected');
}
try {
if (sql.toUpperCase().startsWith('SELECT')) {
this.db.all(sql, params, (err, rows) => {
if (err) {
return this.db.prepare(sql).all(params);
} else {
const result = this.db.prepare(sql).run(params);
return { changes: result.changes, lastID: result.lastID };
}
} catch (err) {
console.error('Query error:', err);
reject(err);
} else {
resolve(rows);
throw err;
}
});
} else {
this.db.run(sql, params, function(err) {
if (err) {
console.error('Query error:', err);
reject(err);
} else {
resolve({ changes: this.changes, lastID: this.lastID });
}
});
}
});
}
}

View File

@ -0,0 +1,227 @@
const { screen } = require('electron');
class SmoothMovementManager {
constructor(windowPool, getDisplayById, getCurrentDisplay, updateLayout) {
this.windowPool = windowPool;
this.getDisplayById = getDisplayById;
this.getCurrentDisplay = getCurrentDisplay;
this.updateLayout = updateLayout;
this.stepSize = 80;
this.animationDuration = 300;
this.headerPosition = { x: 0, y: 0 };
this.isAnimating = false;
this.hiddenPosition = null;
this.lastVisiblePosition = null;
this.currentDisplayId = null;
this.animationFrameId = null;
}
/**
* @param {BrowserWindow} win
* @returns {boolean}
*/
_isWindowValid(win) {
if (!win || win.isDestroyed()) {
if (this.isAnimating) {
console.warn('[MovementManager] Window destroyed mid-animation. Halting.');
this.isAnimating = false;
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
}
return false;
}
return true;
}
moveToDisplay(displayId) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const targetDisplay = this.getDisplayById(displayId);
if (!targetDisplay) return;
const currentBounds = header.getBounds();
const currentDisplay = this.getCurrentDisplay(header);
if (currentDisplay.id === targetDisplay.id) return;
const relativeX = (currentBounds.x - currentDisplay.workArea.x) / currentDisplay.workAreaSize.width;
const relativeY = (currentBounds.y - currentDisplay.workArea.y) / currentDisplay.workAreaSize.height;
const targetX = targetDisplay.workArea.x + targetDisplay.workAreaSize.width * relativeX;
const targetY = targetDisplay.workArea.y + targetDisplay.workAreaSize.height * relativeY;
const finalX = Math.max(targetDisplay.workArea.x, Math.min(targetDisplay.workArea.x + targetDisplay.workAreaSize.width - currentBounds.width, targetX));
const finalY = Math.max(targetDisplay.workArea.y, Math.min(targetDisplay.workArea.y + targetDisplay.workAreaSize.height - currentBounds.height, targetY));
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, finalX, finalY);
this.currentDisplayId = targetDisplay.id;
}
hideToEdge(edge, callback, { instant = false } = {}) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
const { x, y } = header.getBounds();
this.lastVisiblePosition = { x, y };
this.hiddenPosition = { edge };
if (instant) {
header.hide();
if (typeof callback === 'function') callback();
return;
}
header.webContents.send('window-hide-animation');
setTimeout(() => {
if (!header.isDestroyed()) header.hide();
if (typeof callback === 'function') callback();
}, 5);
}
showFromEdge(callback) {
const header = this.windowPool.get('header');
if (!header || header.isDestroyed()) {
if (typeof callback === 'function') callback();
return;
}
// 숨기기 전에 기억해둔 위치 복구
if (this.lastVisiblePosition) {
header.setPosition(
this.lastVisiblePosition.x,
this.lastVisiblePosition.y,
false // animate: false
);
}
header.show();
header.webContents.send('window-show-animation');
// 내부 상태 초기화
this.hiddenPosition = null;
this.lastVisiblePosition = null;
if (typeof callback === 'function') callback();
}
moveStep(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const currentBounds = header.getBounds();
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
let targetX = this.headerPosition.x;
let targetY = this.headerPosition.y;
switch (direction) {
case 'left': targetX -= this.stepSize; break;
case 'right': targetX += this.stepSize; break;
case 'up': targetY -= this.stepSize; break;
case 'down': targetY += this.stepSize; break;
default: return;
}
const displays = screen.getAllDisplays();
let validPosition = displays.some(d => (
targetX >= d.workArea.x && targetX + currentBounds.width <= d.workArea.x + d.workArea.width &&
targetY >= d.workArea.y && targetY + currentBounds.height <= d.workArea.y + d.workArea.height
));
if (!validPosition) {
const nearestDisplay = screen.getDisplayNearestPoint({ x: targetX, y: targetY });
const { x, y, width, height } = nearestDisplay.workArea;
targetX = Math.max(x, Math.min(x + width - currentBounds.width, targetX));
targetY = Math.max(y, Math.min(y + height - currentBounds.height, targetY));
}
if (targetX === this.headerPosition.x && targetY === this.headerPosition.y) return;
this.animateToPosition(header, targetX, targetY);
}
animateToPosition(header, targetX, targetY) {
if (!this._isWindowValid(header)) return;
this.isAnimating = true;
const startX = this.headerPosition.x;
const startY = this.headerPosition.y;
const startTime = Date.now();
if (!Number.isFinite(targetX) || !Number.isFinite(targetY) || !Number.isFinite(startX) || !Number.isFinite(startY)) {
this.isAnimating = false;
return;
}
const animate = () => {
if (!this._isWindowValid(header)) return;
const elapsed = Date.now() - startTime;
const progress = Math.min(elapsed / this.animationDuration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const currentX = startX + (targetX - startX) * eased;
const currentY = startY + (targetY - startY) * eased;
if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) {
this.isAnimating = false;
return;
}
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(currentX), Math.round(currentY));
if (progress < 1) {
this.animationFrameId = setTimeout(animate, 8);
} else {
this.animationFrameId = null;
this.headerPosition = { x: targetX, y: targetY };
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
if (!this._isWindowValid(header)) return;
header.setPosition(Math.round(targetX), Math.round(targetY));
}
this.isAnimating = false;
this.updateLayout();
}
};
animate();
}
moveToEdge(direction) {
const header = this.windowPool.get('header');
if (!this._isWindowValid(header) || !header.isVisible() || this.isAnimating) return;
const display = this.getCurrentDisplay(header);
const { width, height } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerBounds = header.getBounds();
const currentBounds = header.getBounds();
let targetX = currentBounds.x;
let targetY = currentBounds.y;
switch (direction) {
case 'left': targetX = workAreaX; break;
case 'right': targetX = workAreaX + width - headerBounds.width; break;
case 'up': targetY = workAreaY; break;
case 'down': targetY = workAreaY + height - headerBounds.height; break;
}
this.headerPosition = { x: currentBounds.x, y: currentBounds.y };
this.animateToPosition(header, targetX, targetY);
}
destroy() {
if (this.animationFrameId) {
clearTimeout(this.animationFrameId);
this.animationFrameId = null;
}
this.isAnimating = false;
console.log('[Movement] Manager destroyed');
}
}
module.exports = SmoothMovementManager;

View File

@ -0,0 +1,217 @@
const { screen } = require('electron');
/**
* 주어진 창이 현재 어느 디스플레이에 속해 있는지 반환합니다.
* @param {BrowserWindow} window - 확인할 객체
* @returns {Display} Electron의 Display 객체
*/
function getCurrentDisplay(window) {
if (!window || window.isDestroyed()) return screen.getPrimaryDisplay();
const windowBounds = window.getBounds();
const windowCenter = {
x: windowBounds.x + windowBounds.width / 2,
y: windowBounds.y + windowBounds.height / 2,
};
return screen.getDisplayNearestPoint(windowCenter);
}
class WindowLayoutManager {
/**
* @param {Map<string, BrowserWindow>} windowPool - 관리할 창들의
*/
constructor(windowPool) {
this.windowPool = windowPool;
this.isUpdating = false;
this.PADDING = 80;
}
/**
* 모든 창의 레이아웃 업데이트를 요청합니다.
* 중복 실행을 방지하기 위해 isUpdating 플래그를 사용합니다.
*/
updateLayout() {
if (this.isUpdating) return;
this.isUpdating = true;
setImmediate(() => {
this.positionWindows();
this.isUpdating = false;
});
}
/**
* 헤더 창을 기준으로 모든 기능 창들의 위치를 계산하고 배치합니다.
*/
positionWindows() {
const header = this.windowPool.get('header');
if (!header?.getBounds) return;
const headerBounds = header.getBounds();
const display = getCurrentDisplay(header);
const { width: screenWidth, height: screenHeight } = display.workAreaSize;
const { x: workAreaX, y: workAreaY } = display.workArea;
const headerCenterX = headerBounds.x - workAreaX + headerBounds.width / 2;
const headerCenterY = headerBounds.y - workAreaY + headerBounds.height / 2;
const relativeX = headerCenterX / screenWidth;
const relativeY = headerCenterY / screenHeight;
const strategy = this.determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY);
this.positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
this.positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY);
}
/**
* 헤더 창의 위치에 따라 기능 창들을 배치할 최적의 전략을 결정합니다.
* @returns {{name: string, primary: string, secondary: string}} 레이아웃 전략
*/
determineLayoutStrategy(headerBounds, screenWidth, screenHeight, relativeX, relativeY) {
const spaceBelow = screenHeight - (headerBounds.y + headerBounds.height);
const spaceAbove = headerBounds.y;
const spaceLeft = headerBounds.x;
const spaceRight = screenWidth - (headerBounds.x + headerBounds.width);
if (spaceBelow >= 400) {
return { name: 'below', primary: 'below', secondary: relativeX < 0.5 ? 'right' : 'left' };
} else if (spaceAbove >= 400) {
return { name: 'above', primary: 'above', secondary: relativeX < 0.5 ? 'right' : 'left' };
} else if (relativeX < 0.3 && spaceRight >= 800) {
return { name: 'right-side', primary: 'right', secondary: spaceBelow > spaceAbove ? 'below' : 'above' };
} else if (relativeX > 0.7 && spaceLeft >= 800) {
return { name: 'left-side', primary: 'left', secondary: spaceBelow > spaceAbove ? 'below' : 'above' };
} else {
return { name: 'adaptive', primary: spaceBelow > spaceAbove ? 'below' : 'above', secondary: spaceRight > spaceLeft ? 'right' : 'left' };
}
}
/**
* 'ask' 'listen' 창의 위치를 조정합니다.
*/
positionFeatureWindows(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const ask = this.windowPool.get('ask');
const listen = this.windowPool.get('listen');
const askVisible = ask && ask.isVisible() && !ask.isDestroyed();
const listenVisible = listen && listen.isVisible() && !listen.isDestroyed();
if (!askVisible && !listenVisible) return;
const PAD = 8;
const headerCenterXRel = headerBounds.x - workAreaX + headerBounds.width / 2;
let askBounds = askVisible ? ask.getBounds() : null;
let listenBounds = listenVisible ? listen.getBounds() : null;
if (askVisible && listenVisible) {
const combinedWidth = listenBounds.width + PAD + askBounds.width;
let groupStartXRel = headerCenterXRel - combinedWidth / 2;
let listenXRel = groupStartXRel;
let askXRel = groupStartXRel + listenBounds.width + PAD;
if (listenXRel < PAD) {
listenXRel = PAD;
askXRel = listenXRel + listenBounds.width + PAD;
}
if (askXRel + askBounds.width > screenWidth - PAD) {
askXRel = screenWidth - PAD - askBounds.width;
listenXRel = askXRel - listenBounds.width - PAD;
}
let yRel = (strategy.primary === 'above')
? headerBounds.y - workAreaY - Math.max(askBounds.height, listenBounds.height) - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD;
listen.setBounds({ x: Math.round(listenXRel + workAreaX), y: Math.round(yRel + workAreaY), width: listenBounds.width, height: listenBounds.height });
ask.setBounds({ x: Math.round(askXRel + workAreaX), y: Math.round(yRel + workAreaY), width: askBounds.width, height: askBounds.height });
} else {
const win = askVisible ? ask : listen;
const winBounds = askVisible ? askBounds : listenBounds;
let xRel = headerCenterXRel - winBounds.width / 2;
let yRel = (strategy.primary === 'above')
? headerBounds.y - workAreaY - winBounds.height - PAD
: headerBounds.y - workAreaY + headerBounds.height + PAD;
xRel = Math.max(PAD, Math.min(screenWidth - winBounds.width - PAD, xRel));
yRel = Math.max(PAD, Math.min(screenHeight - winBounds.height - PAD, yRel));
win.setBounds({ x: Math.round(xRel + workAreaX), y: Math.round(yRel + workAreaY), width: winBounds.width, height: winBounds.height });
}
}
/**
* 'settings' 창의 위치를 조정합니다.
*/
positionSettingsWindow(headerBounds, strategy, screenWidth, screenHeight, workAreaX, workAreaY) {
const settings = this.windowPool.get('settings');
if (!settings?.getBounds || !settings.isVisible()) return;
if (settings.__lockedByButton) {
const headerDisplay = getCurrentDisplay(this.windowPool.get('header'));
const settingsDisplay = getCurrentDisplay(settings);
if (headerDisplay.id !== settingsDisplay.id) {
settings.__lockedByButton = false;
} else {
return;
}
}
const settingsBounds = settings.getBounds();
const PAD = 5;
const buttonPadding = 17;
let x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
let y = headerBounds.y + headerBounds.height + PAD;
const otherVisibleWindows = [];
['listen', 'ask'].forEach(name => {
const win = this.windowPool.get(name);
if (win && win.isVisible() && !win.isDestroyed()) {
otherVisibleWindows.push({ name, bounds: win.getBounds() });
}
});
const settingsNewBounds = { x, y, width: settingsBounds.width, height: settingsBounds.height };
let hasOverlap = otherVisibleWindows.some(otherWin => this.boundsOverlap(settingsNewBounds, otherWin.bounds));
if (hasOverlap) {
x = headerBounds.x + headerBounds.width + PAD;
y = headerBounds.y;
if (x + settingsBounds.width > screenWidth - 10) {
x = headerBounds.x - settingsBounds.width - PAD;
}
if (x < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width - buttonPadding;
y = headerBounds.y - settingsBounds.height - PAD;
if (y < 10) {
x = headerBounds.x + headerBounds.width - settingsBounds.width;
y = headerBounds.y + headerBounds.height + PAD;
}
}
}
x = Math.max(workAreaX + 10, Math.min(workAreaX + screenWidth - settingsBounds.width - 10, x));
y = Math.max(workAreaY + 10, Math.min(workAreaY + screenHeight - settingsBounds.height - 10, y));
settings.setBounds({ x: Math.round(x), y: Math.round(y) });
settings.moveTop();
}
/**
* 사각형 영역이 겹치는지 확인합니다.
* @param {Rectangle} bounds1
* @param {Rectangle} bounds2
* @returns {boolean} 겹침 여부
*/
boundsOverlap(bounds1, bounds2) {
const margin = 10;
return !(
bounds1.x + bounds1.width + margin < bounds2.x ||
bounds2.x + bounds2.width + margin < bounds1.x ||
bounds1.y + bounds1.height + margin < bounds2.y ||
bounds2.y + bounds2.height + margin < bounds1.y
);
}
}
module.exports = WindowLayoutManager;

File diff suppressed because it is too large Load Diff

View File

@ -596,6 +596,42 @@ export class AskView extends LitElement {
color: rgba(255, 255, 255, 0.5);
font-size: 14px;
}
/* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) .ask-container,
:host-context(body.has-glass) .response-header,
:host-context(body.has-glass) .response-icon,
:host-context(body.has-glass) .copy-button,
:host-context(body.has-glass) .close-button,
:host-context(body.has-glass) .line-copy-button,
:host-context(body.has-glass) .text-input-container,
:host-context(body.has-glass) .response-container pre,
:host-context(body.has-glass) .response-container p code,
:host-context(body.has-glass) .response-container pre code {
background: transparent !important;
border: none !important;
outline: none !important;
box-shadow: none !important;
filter: none !important;
backdrop-filter: none !important;
}
:host-context(body.has-glass) .ask-container::before {
display: none !important;
}
:host-context(body.has-glass) .copy-button:hover,
:host-context(body.has-glass) .close-button:hover,
:host-context(body.has-glass) .line-copy-button,
:host-context(body.has-glass) .line-copy-button:hover,
:host-context(body.has-glass) .response-line:hover {
background: transparent !important;
}
:host-context(body.has-glass) .response-container::-webkit-scrollbar-track,
:host-context(body.has-glass) .response-container::-webkit-scrollbar-thumb {
background: transparent !important;
}
`;
constructor() {
@ -628,6 +664,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 ---
@ -709,9 +747,10 @@ export class AskView extends LitElement {
handleWindowBlur() {
if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
const askWindow = window.require('electron').remote.getCurrentWindow();
if (!askWindow.isFocused()) {
this.closeIfNoContent();
// If there's no active content, ask the main process to close this window.
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('close-ask-window-if-empty');
}
}
}
@ -799,13 +838,10 @@ export class AskView extends LitElement {
this.processAssistantQuestion(question);
};
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('ask-global-send', this.handleGlobalSendRequest);
ipcRenderer.on('toggle-text-input', this.handleToggleTextInput);
ipcRenderer.on('clear-ask-content', this.clearResponseContent);
ipcRenderer.on('receive-question-from-assistant', this.handleQuestionFromAssistant);
ipcRenderer.on('hide-text-input', () => {
console.log('📤 Hide text input signal received');
@ -829,17 +865,15 @@ export class AskView extends LitElement {
ipcRenderer.on('window-blur', this.handleWindowBlur);
ipcRenderer.on('window-did-show', () => {
if (!this.currentResponse && !this.isLoading && !this.isStreaming) {
setTimeout(() => {
const textInput = this.shadowRoot?.getElementById('textInput');
if (textInput) {
textInput.focus();
}
}, 100);
this.focusTextInput();
}
});
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 이벤트 리스너 등록 완료');
}
}
@ -871,7 +905,6 @@ export class AskView extends LitElement {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeListener('ask-global-send', this.handleGlobalSendRequest);
ipcRenderer.removeListener('toggle-text-input', this.handleToggleTextInput);
ipcRenderer.removeListener('clear-ask-content', this.clearResponseContent);
ipcRenderer.removeListener('clear-ask-response', () => {});
ipcRenderer.removeListener('hide-text-input', () => {});
ipcRenderer.removeListener('window-hide-animation', () => {});
@ -879,10 +912,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) {
@ -1060,8 +1108,6 @@ export class AskView extends LitElement {
}, 1500);
}
renderMarkdown(content) {
if (!content) return '';
@ -1131,7 +1177,9 @@ export class AskView extends LitElement {
this.requestUpdate();
this.renderContent();
window.pickleGlass.sendMessage(question).catch(error => {
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('ask:sendMessage', question).catch(error => {
console.error('Error processing assistant question:', error);
this.isLoading = false;
this.isStreaming = false;
@ -1139,6 +1187,7 @@ export class AskView extends LitElement {
this.renderContent();
});
}
}
async handleCopy() {
if (this.copyState === 'copied') return;
@ -1227,7 +1276,9 @@ export class AskView extends LitElement {
this.requestUpdate();
this.renderContent();
window.pickleGlass.sendMessage(text).catch(error => {
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('ask:sendMessage', text).catch(error => {
console.error('Error sending text:', error);
this.isLoading = false;
this.isStreaming = false;
@ -1235,8 +1286,14 @@ export class AskView extends LitElement {
this.renderContent();
});
}
}
handleTextKeydown(e) {
// Fix for IME composition issue: Ignore Enter key presses while composing.
if (e.isComposing) {
return;
}
const isPlainEnter = e.key === 'Enter' && !e.shiftKey && !e.metaKey && !e.ctrlKey;
const isModifierEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
@ -1255,6 +1312,19 @@ export class AskView extends LitElement {
if (changedProperties.has('showTextInput') || changedProperties.has('isLoading')) {
this.adjustWindowHeightThrottled();
}
if (changedProperties.has('showTextInput') && this.showTextInput) {
this.focusTextInput();
}
}
focusTextInput(){
requestAnimationFrame(() => {
const textInput = this.shadowRoot?.getElementById('textInput');
if (textInput){
textInput.focus();
}
});
}
firstUpdated() {
@ -1378,9 +1448,9 @@ export class AskView extends LitElement {
const responseHeight = responseEl.scrollHeight;
const inputHeight = (inputEl && !inputEl.classList.contains('hidden')) ? inputEl.offsetHeight : 0;
const idealHeight = headerHeight + responseHeight + inputHeight + 20; // padding
const idealHeight = headerHeight + responseHeight + inputHeight;
const targetHeight = Math.min(700, Math.max(200, idealHeight));
const targetHeight = Math.min(700, idealHeight);
const { ipcRenderer } = window.require('electron');
ipcRenderer.invoke('adjust-window-height', targetHeight);

View File

@ -0,0 +1,144 @@
const { ipcMain, BrowserWindow } = require('electron');
const { createStreamingLLM } = require('../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../electron/windowManager');
const authService = require('../../common/services/authService');
const sessionRepository = require('../../common/repositories/session');
const askRepository = require('./repositories');
const { getSystemPrompt } = require('../../common/prompts/promptBuilder');
function formatConversationForPrompt(conversationTexts) {
if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.';
return conversationTexts.slice(-30).join('\n');
}
// Access conversation history via the global listenService instance created in index.js
function getConversationHistory() {
const listenService = global.listenService;
return listenService ? listenService.getConversationHistory() : [];
}
async function sendMessage(userPrompt) {
if (!userPrompt || userPrompt.trim().length === 0) {
console.warn('[AskService] Cannot process empty message');
return { success: false, error: 'Empty message' };
}
const askWindow = windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
askWindow.webContents.send('hide-text-input');
}
try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.');
}
console.log(`[AskService] Using model: ${modelInfo.model} for provider: ${modelInfo.provider}`);
const screenshotResult = await captureScreenshot({ quality: 'medium' });
const screenshotBase64 = screenshotResult.success ? screenshotResult.base64 : null;
const conversationHistoryRaw = getConversationHistory();
const conversationHistory = formatConversationForPrompt(conversationHistoryRaw);
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
const messages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
],
},
];
if (screenshotBase64) {
messages[1].content.push({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
});
}
const streamingLLM = createStreamingLLM(modelInfo.provider, {
apiKey: modelInfo.apiKey,
model: modelInfo.model,
temperature: 0.7,
maxTokens: 2048,
usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
});
const response = await streamingLLM.streamChat(messages);
// --- Stream Processing ---
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullResponse = '';
const askWin = windowPool.get('ask');
if (!askWin || askWin.isDestroyed()) {
console.error("[AskService] Ask window is not available to send stream to.");
reader.cancel();
return;
}
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim() !== '');
for (const line of lines) {
if (line.startsWith('data: ')) {
const data = line.substring(6);
if (data === '[DONE]') {
askWin.webContents.send('ask-response-stream-end');
// Save to DB
try {
const uid = authService.getCurrentUserId();
if (!uid) throw new Error("User not logged in, cannot save message.");
const sessionId = await sessionRepository.getOrCreateActive(uid, 'ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
console.log(`[AskService] DB: Saved ask/answer pair to session ${sessionId}`);
} catch(dbError) {
console.error("[AskService] DB: Failed to save ask/answer pair:", dbError);
}
return { success: true, response: fullResponse };
}
try {
const json = JSON.parse(data);
const token = json.choices[0]?.delta?.content || '';
if (token) {
fullResponse += token;
askWin.webContents.send('ask-response-chunk', { token });
}
} catch (error) {
// Ignore parsing errors for now
}
}
}
}
} catch (error) {
console.error('[AskService] Error processing message:', error);
return { success: false, error: error.message };
}
}
function initialize() {
ipcMain.handle('ask:sendMessage', async (event, userPrompt) => {
return sendMessage(userPrompt);
});
console.log('[AskService] Initialized and ready.');
}
module.exports = {
initialize,
};

View File

@ -0,0 +1,18 @@
const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository'); // Future implementation
const authService = require('../../../common/services/authService');
function getRepository() {
// In the future, we can check the user's login status from authService
// const user = authService.getCurrentUser();
// if (user.isLoggedIn) {
// return firebaseRepository;
// }
return sqliteRepository;
}
// Directly export functions for ease of use, decided by the strategy
module.exports = {
addAiMessage: (...args) => getRepository().addAiMessage(...args),
getAllAiMessagesBySessionId: (...args) => getRepository().getAllAiMessagesBySessionId(...args),
};

View File

@ -0,0 +1,27 @@
const sqliteClient = require('../../../common/services/sqliteClient');
function addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) {
const db = sqliteClient.getDb();
const messageId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO ai_messages (id, session_id, sent_at, role, content, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`;
try {
db.prepare(query).run(messageId, sessionId, now, role, content, model, now);
return { id: messageId };
} catch (err) {
console.error('SQLite: Failed to add AI message:', err);
throw err;
}
}
function getAllAiMessagesBySessionId(sessionId) {
const db = sqliteClient.getDb();
const query = "SELECT * FROM ai_messages WHERE session_id = ? ORDER BY sent_at ASC";
return db.prepare(query).all(sessionId);
}
module.exports = {
addAiMessage,
getAllAiMessagesBySessionId
};

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,179 +0,0 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
export class SetupView extends LitElement {
static styles = css`
* {
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
cursor: default;
user-select: none;
}
.welcome {
color: white;
font-size: 12px;
font-family: 'Helvetica Neue', sans-serif;
font-weight: 500;
margin-bottom: 8px;
margin-top: auto;
}
.input-group {
display: flex;
gap: 12px;
margin-bottom: 20px;
}
.input-group input {
flex: 1;
}
input {
background: rgba(255, 255, 255, 0.20);
border-radius: 8px;
outline: 1px rgba(255, 255, 255, 0.50) solid;
outline-offset: -1px;
backdrop-filter: blur(0.50px);
border: none;
color: white;
padding: 12px 16px;
width: 100%;
font-size: 14px;
font-family: 'Helvetica Neue', sans-serif;
font-weight: 400;
}
input:focus {
outline: none;
}
input::placeholder {
color: rgba(255, 255, 255, 0.5);
}
.start-button {
background: var(--start-button-background);
color: var(--start-button-color);
border: 1px solid var(--start-button-border);
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
}
.start-button:hover {
background: var(--start-button-hover-background);
border-color: var(--start-button-hover-border);
}
.start-button.initializing {
opacity: 0.5;
}
.start-button.initializing:hover {
background: var(--start-button-background);
border-color: var(--start-button-border);
}
.description {
color: var(--description-color);
font-size: 14px;
margin-bottom: 24px;
line-height: 1.5;
}
.link {
color: var(--link-color);
text-decoration: underline;
cursor: pointer;
}
:host {
height: 100%;
display: flex;
flex-direction: column;
width: 100%;
max-width: 500px;
}
`;
static properties = {
onStart: { type: Function },
onAPIKeyHelp: { type: Function },
isInitializing: { type: Boolean },
onLayoutModeChange: { type: Function },
};
constructor() {
super();
this.onStart = () => {};
this.onAPIKeyHelp = () => {};
this.isInitializing = false;
this.onLayoutModeChange = () => {};
}
connectedCallback() {
super.connectedCallback();
window.electron?.ipcRenderer?.on('session-initializing', (event, isInitializing) => {
this.isInitializing = isInitializing;
});
// Load and apply layout mode on startup
this.loadLayoutMode();
}
disconnectedCallback() {
super.disconnectedCallback();
window.electron?.ipcRenderer?.removeAllListeners('session-initializing');
}
handleInput(e) {
localStorage.setItem('apiKey', e.target.value);
}
handleStartClick() {
if (this.isInitializing) {
return;
}
this.onStart();
}
handleAPIKeyHelpClick() {
this.onAPIKeyHelp();
}
handleResetOnboarding() {
localStorage.removeItem('onboardingCompleted');
// Refresh the page to trigger onboarding
window.location.reload();
}
loadLayoutMode() {
const savedLayoutMode = localStorage.getItem('layoutMode');
if (savedLayoutMode && savedLayoutMode !== 'normal') {
// Notify parent component to apply the saved layout mode
this.onLayoutModeChange(savedLayoutMode);
}
}
render() {
return html`
<div class="welcome">Welcome</div>
<div class="input-group">
<input
type="password"
placeholder="Enter your openai API Key"
.value=${localStorage.getItem('apiKey') || ''}
@input=${this.handleInput}
/>
<button @click=${this.handleStartClick} class="start-button ${this.isInitializing ? 'initializing' : ''}">Start Session</button>
</div>
<p class="description">
dont have an api key?
<span @click=${this.handleAPIKeyHelpClick} class="link">get one here</span>
</p>
`;
}
}
customElements.define('setup-view', SetupView);

View File

@ -1,123 +0,0 @@
const fs = require('fs');
const path = require('path');
function pcmToWav(pcmBuffer, outputPath, sampleRate = 24000, channels = 1, bitDepth = 16) {
const byteRate = sampleRate * channels * (bitDepth / 8);
const blockAlign = channels * (bitDepth / 8);
const dataSize = pcmBuffer.length;
const header = Buffer.alloc(44);
header.write('RIFF', 0);
header.writeUInt32LE(dataSize + 36, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(channels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(byteRate, 28);
header.writeUInt16LE(blockAlign, 32);
header.writeUInt16LE(bitDepth, 34);
header.write('data', 36);
header.writeUInt32LE(dataSize, 40);
const wavBuffer = Buffer.concat([header, pcmBuffer]);
fs.writeFileSync(outputPath, wavBuffer);
return outputPath;
}
function analyzeAudioBuffer(buffer, label = 'Audio') {
const int16Array = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.length / 2);
let minValue = 32767;
let maxValue = -32768;
let avgValue = 0;
let rmsValue = 0;
let silentSamples = 0;
for (let i = 0; i < int16Array.length; i++) {
const sample = int16Array[i];
minValue = Math.min(minValue, sample);
maxValue = Math.max(maxValue, sample);
avgValue += sample;
rmsValue += sample * sample;
if (Math.abs(sample) < 100) {
silentSamples++;
}
}
avgValue /= int16Array.length;
rmsValue = Math.sqrt(rmsValue / int16Array.length);
const silencePercentage = (silentSamples / int16Array.length) * 100;
console.log(`${label} Analysis:`);
console.log(` Samples: ${int16Array.length}`);
console.log(` Min: ${minValue}, Max: ${maxValue}`);
console.log(` Average: ${avgValue.toFixed(2)}`);
console.log(` RMS: ${rmsValue.toFixed(2)}`);
console.log(` Silence: ${silencePercentage.toFixed(1)}%`);
console.log(` Dynamic Range: ${20 * Math.log10(maxValue / (rmsValue || 1))} dB`);
return {
minValue,
maxValue,
avgValue,
rmsValue,
silencePercentage,
sampleCount: int16Array.length,
};
}
function saveDebugAudio(buffer, type, timestamp = Date.now()) {
const homeDir = require('os').homedir();
const debugDir = path.join(homeDir, '.pickle-glass', 'debug');
if (!fs.existsSync(debugDir)) {
fs.mkdirSync(debugDir, { recursive: true });
}
const pcmPath = path.join(debugDir, `${type}_${timestamp}.pcm`);
const wavPath = path.join(debugDir, `${type}_${timestamp}.wav`);
const metaPath = path.join(debugDir, `${type}_${timestamp}.json`);
fs.writeFileSync(pcmPath, buffer);
pcmToWav(buffer, wavPath);
const analysis = analyzeAudioBuffer(buffer, type);
fs.writeFileSync(
metaPath,
JSON.stringify(
{
timestamp,
type,
bufferSize: buffer.length,
analysis,
format: {
sampleRate: 24000,
channels: 1,
bitDepth: 16,
},
},
null,
2
)
);
console.log(`Debug audio saved: ${wavPath}`);
return { pcmPath, wavPath, metaPath };
}
module.exports = {
pcmToWav,
analyzeAudioBuffer,
saveDebugAudio,
};

View File

@ -0,0 +1,300 @@
const { BrowserWindow, app } = require('electron');
const SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService');
const authService = require('../../common/services/authService');
const sessionRepository = require('../../common/repositories/session');
const sttRepository = require('./stt/repositories');
class ListenService {
constructor() {
this.sttService = new SttService();
this.summaryService = new SummaryService();
this.currentSessionId = null;
this.isInitializingSession = false;
this.setupServiceCallbacks();
}
setupServiceCallbacks() {
// STT service callbacks
this.sttService.setCallbacks({
onTranscriptionComplete: (speaker, text) => {
this.handleTranscriptionComplete(speaker, text);
},
onStatusUpdate: (status) => {
this.sendToRenderer('update-status', status);
}
});
// Summary service callbacks
this.summaryService.setCallbacks({
onAnalysisComplete: (data) => {
console.log('📊 Analysis completed:', data);
},
onStatusUpdate: (status) => {
this.sendToRenderer('update-status', status);
}
});
}
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
});
}
async handleTranscriptionComplete(speaker, text) {
console.log(`[ListenService] Transcription complete: ${speaker} - ${text}`);
// Save to database
await this.saveConversationTurn(speaker, text);
// Add to summary service for analysis
this.summaryService.addConversationTurn(speaker, text);
}
async saveConversationTurn(speaker, transcription) {
if (!this.currentSessionId) {
console.error('[DB] Cannot save turn, no active session ID.');
return;
}
if (transcription.trim() === '') return;
try {
await sessionRepository.touch(this.currentSessionId);
await sttRepository.addTranscript({
sessionId: this.currentSessionId,
speaker: speaker,
text: transcription.trim(),
});
console.log(`[DB] Saved transcript for session ${this.currentSessionId}: (${speaker})`);
} catch (error) {
console.error('Failed to save transcript to DB:', error);
}
}
async initializeNewSession() {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
throw new Error("Cannot initialize session: user not logged in.");
}
this.currentSessionId = await sessionRepository.getOrCreateActive(uid, 'listen');
console.log(`[DB] New listen session ensured: ${this.currentSessionId}`);
// Set session ID for summary service
this.summaryService.setSessionId(this.currentSessionId);
// Reset conversation history
this.summaryService.resetConversationHistory();
console.log('New conversation session started:', this.currentSessionId);
return true;
} catch (error) {
console.error('Failed to initialize new session in DB:', error);
this.currentSessionId = null;
return false;
}
}
async initializeSession(language = 'en') {
if (this.isInitializingSession) {
console.log('Session initialization already in progress.');
return false;
}
this.isInitializingSession = true;
this.sendToRenderer('session-initializing', true);
this.sendToRenderer('update-status', 'Initializing sessions...');
try {
// Initialize database session
const sessionInitialized = await this.initializeNewSession();
if (!sessionInitialized) {
throw new Error('Failed to initialize database session');
}
/* ---------- 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.');
this.sendToRenderer('session-state-changed', { isActive: true });
this.sendToRenderer('update-status', 'Connected. Ready to listen.');
return true;
} catch (error) {
console.error('❌ Failed to initialize listen service:', error);
this.sendToRenderer('update-status', 'Initialization failed.');
return false;
} finally {
this.isInitializingSession = false;
this.sendToRenderer('session-initializing', false);
}
}
async sendAudioContent(data, mimeType) {
return await this.sttService.sendAudioContent(data, mimeType);
}
async startMacOSAudioCapture() {
if (process.platform !== 'darwin') {
throw new Error('macOS audio capture only available on macOS');
}
return await this.sttService.startMacOSAudioCapture();
}
async stopMacOSAudioCapture() {
this.sttService.stopMacOSAudioCapture();
}
isSessionActive() {
return this.sttService.isSessionActive();
}
async closeSession() {
try {
// Close STT sessions
await this.sttService.closeSessions();
// End database session
if (this.currentSessionId) {
await sessionRepository.end(this.currentSessionId);
console.log(`[DB] Session ${this.currentSessionId} ended.`);
}
// Reset state
this.currentSessionId = null;
this.summaryService.resetConversationHistory();
this.sendToRenderer('session-state-changed', { isActive: false });
this.sendToRenderer('session-did-close');
console.log('Listen service session closed.');
return { success: true };
} catch (error) {
console.error('Error closing listen service session:', error);
return { success: false, error: error.message };
}
}
getCurrentSessionData() {
return {
sessionId: this.currentSessionId,
conversationHistory: this.summaryService.getConversationHistory(),
totalTexts: this.summaryService.getConversationHistory().length,
analysisData: this.summaryService.getCurrentAnalysisData(),
};
}
getConversationHistory() {
return this.summaryService.getConversationHistory();
}
setupIpcHandlers() {
const { ipcMain } = require('electron');
ipcMain.handle('is-session-active', async () => {
const isActive = this.isSessionActive();
console.log(`Checking session status. Active: ${isActive}`);
return isActive;
});
ipcMain.handle('initialize-openai', async (event, profile = 'interview', language = 'en') => {
console.log(`Received initialize-openai request with profile: ${profile}, language: ${language}`);
const success = await this.initializeSession(language);
return success;
});
ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
try {
await this.sendAudioContent(data, mimeType);
return { success: true };
} catch (e) {
console.error('Error sending user audio:', e);
return { success: false, error: e.message };
}
});
ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => {
try {
await this.sttService.sendSystemAudioContent(data, mimeType);
// Send system audio data back to renderer for AEC reference (like macOS does)
this.sendToRenderer('system-audio-data', { data });
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('start-macos-audio', async () => {
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, error: null };
} catch (error) {
console.error('Error starting macOS audio capture:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('stop-macos-audio', async () => {
try {
this.stopMacOSAudioCapture();
return { success: true };
} catch (error) {
console.error('Error stopping macOS audio capture:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('close-session', async () => {
return await this.closeSession();
});
ipcMain.handle('update-google-search-setting', async (event, enabled) => {
try {
console.log('Google Search setting updated to:', enabled);
return { success: true };
} catch (error) {
console.error('Error updating Google Search setting:', error);
return { success: false, error: error.message };
}
});
console.log('✅ Listen service IPC handlers registered');
}
}
module.exports = ListenService;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,706 @@
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
// ---------------------------
const SAMPLE_RATE = 24000;
const AUDIO_CHUNK_DURATION = 0.1;
const BUFFER_SIZE = 4096;
const isLinux = process.platform === 'linux';
const isMacOS = process.platform === 'darwin';
let mediaStream = null;
let micMediaStream = null;
let screenshotInterval = null;
let audioContext = null;
let audioProcessor = null;
let systemAudioContext = null;
let systemAudioProcessor = null;
let currentImageQuality = 'medium';
let lastScreenshotBase64 = null;
let systemAudioBuffer = [];
const MAX_SYSTEM_BUFFER_SIZE = 10;
// ---------------------------
// Utility helpers (exact from renderer.js)
// ---------------------------
function isVoiceActive(audioFloat32Array, threshold = 0.005) {
if (!audioFloat32Array || audioFloat32Array.length === 0) {
return false;
}
let sumOfSquares = 0;
for (let i = 0; i < audioFloat32Array.length; i++) {
sumOfSquares += audioFloat32Array[i] * audioFloat32Array[i];
}
const rms = Math.sqrt(sumOfSquares / audioFloat32Array.length);
// console.log(`VAD RMS: ${rms.toFixed(4)}`); // For debugging VAD threshold
return rms > threshold;
}
function base64ToFloat32Array(base64) {
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
const int16Array = new Int16Array(bytes.buffer);
const float32Array = new Float32Array(int16Array.length);
for (let i = 0; i < int16Array.length; i++) {
float32Array[i] = int16Array[i] / 32768.0;
}
return float32Array;
}
function convertFloat32ToInt16(float32Array) {
const int16Array = new Int16Array(float32Array.length);
for (let i = 0; i < float32Array.length; i++) {
// Improved scaling to prevent clipping
const s = Math.max(-1, Math.min(1, float32Array[i]));
int16Array[i] = s < 0 ? s * 0x8000 : s * 0x7fff;
}
return int16Array;
}
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary);
}
/* ───────────────────────── 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;
}
// System audio data handler
ipcRenderer.on('system-audio-data', (event, { data }) => {
systemAudioBuffer.push({
data: data,
timestamp: Date.now(),
});
// 오래된 데이터 제거
if (systemAudioBuffer.length > MAX_SYSTEM_BUFFER_SIZE) {
systemAudioBuffer = systemAudioBuffer.slice(-MAX_SYSTEM_BUFFER_SIZE);
}
});
// ---------------------------
// Complete token tracker (exact from renderer.js)
// ---------------------------
let tokenTracker = {
tokens: [],
audioStartTime: null,
addTokens(count, type = 'image') {
const now = Date.now();
this.tokens.push({
timestamp: now,
count: count,
type: type,
});
this.cleanOldTokens();
},
calculateImageTokens(width, height) {
const pixels = width * height;
if (pixels <= 384 * 384) {
return 85;
}
const tiles = Math.ceil(pixels / (768 * 768));
return tiles * 85;
},
trackAudioTokens() {
if (!this.audioStartTime) {
this.audioStartTime = Date.now();
return;
}
const now = Date.now();
const elapsedSeconds = (now - this.audioStartTime) / 1000;
const audioTokens = Math.floor(elapsedSeconds * 16);
if (audioTokens > 0) {
this.addTokens(audioTokens, 'audio');
this.audioStartTime = now;
}
},
cleanOldTokens() {
const oneMinuteAgo = Date.now() - 60 * 1000;
this.tokens = this.tokens.filter(token => token.timestamp > oneMinuteAgo);
},
getTokensInLastMinute() {
this.cleanOldTokens();
return this.tokens.reduce((total, token) => total + token.count, 0);
},
shouldThrottle() {
const throttleEnabled = localStorage.getItem('throttleTokens') === 'true';
if (!throttleEnabled) {
return false;
}
const maxTokensPerMin = parseInt(localStorage.getItem('maxTokensPerMin') || '500000', 10);
const throttleAtPercent = parseInt(localStorage.getItem('throttleAtPercent') || '75', 10);
const currentTokens = this.getTokensInLastMinute();
const throttleThreshold = Math.floor((maxTokensPerMin * throttleAtPercent) / 100);
console.log(`Token check: ${currentTokens}/${maxTokensPerMin} (throttle at ${throttleThreshold})`);
return currentTokens >= throttleThreshold;
},
// Reset the tracker
reset() {
this.tokens = [];
this.audioStartTime = null;
},
};
// Track audio tokens every few seconds
setInterval(() => {
tokenTracker.trackAudioTokens();
}, 2000);
// ---------------------------
// Audio processing functions (exact from renderer.js)
// ---------------------------
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 = (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); // 기본값
// ───────────────── WASM AEC ─────────────────
if (systemAudioBuffer.length > 0) {
const latest = systemAudioBuffer[systemAudioBuffer.length - 1];
const sysF32 = base64ToFloat32Array(latest.data);
// **음성 구간일 때만 런**
processedChunk = runAecSync(new Float32Array(chunk), sysF32);
console.log('🔊 Applied WASM-AEC (speex)');
} else {
console.log('🔊 No system audio for AEC reference');
}
const pcm16 = convertFloat32ToInt16(processedChunk);
const b64 = arrayBufferToBase64(pcm16.buffer);
ipcRenderer.invoke('send-audio-content', {
data: b64,
mimeType: 'audio/pcm;rate=24000',
});
}
};
micSource.connect(micProcessor);
micProcessor.connect(micAudioContext.destination);
audioProcessor = micProcessor;
return { context: micAudioContext, processor: micProcessor };
}
function setupLinuxMicProcessing(micStream) {
// Setup microphone audio processing for Linux
const micAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
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 => {
const inputData = e.inputBuffer.getChannelData(0);
audioBuffer.push(...inputData);
// Process audio in chunks
while (audioBuffer.length >= samplesPerChunk) {
const chunk = audioBuffer.splice(0, samplesPerChunk);
const pcmData16 = convertFloat32ToInt16(chunk);
const base64Data = arrayBufferToBase64(pcmData16.buffer);
await ipcRenderer.invoke('send-audio-content', {
data: base64Data,
mimeType: 'audio/pcm;rate=24000',
});
}
};
micSource.connect(micProcessor);
micProcessor.connect(micAudioContext.destination);
// Store processor reference for cleanup
audioProcessor = micProcessor;
}
function setupSystemAudioProcessing(systemStream) {
const systemAudioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
const systemSource = systemAudioContext.createMediaStreamSource(systemStream);
const systemProcessor = systemAudioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
let audioBuffer = [];
const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
systemProcessor.onaudioprocess = async e => {
const inputData = e.inputBuffer.getChannelData(0);
if (!inputData || inputData.length === 0) return;
audioBuffer.push(...inputData);
while (audioBuffer.length >= samplesPerChunk) {
const chunk = audioBuffer.splice(0, samplesPerChunk);
const pcmData16 = convertFloat32ToInt16(chunk);
const base64Data = arrayBufferToBase64(pcmData16.buffer);
try {
await ipcRenderer.invoke('send-system-audio-content', {
data: base64Data,
mimeType: 'audio/pcm;rate=24000',
});
} catch (error) {
console.error('Failed to send system audio:', error);
}
}
};
systemSource.connect(systemProcessor);
systemProcessor.connect(systemAudioContext.destination);
return { context: systemAudioContext, processor: systemProcessor };
}
// ---------------------------
// Screenshot functions (exact from renderer.js)
// ---------------------------
async function captureScreenshot(imageQuality = 'medium', isManual = false) {
console.log(`Capturing ${isManual ? 'manual' : 'automated'} screenshot...`);
// Check rate limiting for automated screenshots only
if (!isManual && tokenTracker.shouldThrottle()) {
console.log('⚠️ Automated screenshot skipped due to rate limiting');
return;
}
try {
// Request screenshot from main process
const result = await ipcRenderer.invoke('capture-screenshot', {
quality: imageQuality,
});
if (result.success && result.base64) {
// Store the latest screenshot
lastScreenshotBase64 = result.base64;
// Note: sendResult is not defined in the original, this was likely an error
// Commenting out this section as it references undefined variable
/*
if (sendResult.success) {
// Track image tokens after successful send
const imageTokens = tokenTracker.calculateImageTokens(result.width || 1920, result.height || 1080);
tokenTracker.addTokens(imageTokens, 'image');
console.log(`📊 Image sent successfully - ${imageTokens} tokens used (${result.width}x${result.height})`);
} else {
console.error('Failed to send image:', sendResult.error);
}
*/
} else {
console.error('Failed to capture screenshot:', result.error);
}
} catch (error) {
console.error('Error capturing screenshot:', error);
}
}
async function captureManualScreenshot(imageQuality = null) {
console.log('Manual screenshot triggered');
const quality = imageQuality || currentImageQuality;
await captureScreenshot(quality, true);
}
async function getCurrentScreenshot() {
try {
// First try to get a fresh screenshot from main process
const result = await ipcRenderer.invoke('get-current-screenshot');
if (result.success && result.base64) {
console.log('📸 Got fresh screenshot from main process');
return result.base64;
}
// If no screenshot available, capture one now
console.log('📸 No screenshot available, capturing new one');
const captureResult = await ipcRenderer.invoke('capture-screenshot', {
quality: currentImageQuality,
});
if (captureResult.success && captureResult.base64) {
lastScreenshotBase64 = captureResult.base64;
return captureResult.base64;
}
// Fallback to last stored screenshot
if (lastScreenshotBase64) {
console.log('📸 Using cached screenshot');
return lastScreenshotBase64;
}
throw new Error('Failed to get screenshot');
} catch (error) {
console.error('Error getting current screenshot:', error);
return null;
}
}
// ---------------------------
// Main capture functions (exact from renderer.js)
// ---------------------------
async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'medium') {
// Store the image quality for manual screenshots
currentImageQuality = imageQuality;
// Reset token tracker when starting new capture session
tokenTracker.reset();
console.log('🎯 Token tracker reset for new capture session');
try {
if (isMacOS) {
// On macOS, use SystemAudioDump for audio and getDisplayMedia for screen
console.log('Starting macOS capture with SystemAudioDump...');
// Start macOS audio capture
const audioResult = await ipcRenderer.invoke('start-macos-audio');
if (!audioResult.success) {
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
const screenResult = await ipcRenderer.invoke('start-screen-capture');
if (!screenResult.success) {
throw new Error('Failed to start screen capture: ' + screenResult.error);
}
try {
micMediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: SAMPLE_RATE,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
console.log('macOS microphone capture started');
const { context, processor } = await setupMicProcessing(micMediaStream);
audioContext = context;
audioProcessor = processor;
} catch (micErr) {
console.warn('Failed to get microphone on macOS:', micErr);
}
////////// for index & subjects //////////
console.log('macOS screen capture started - audio handled by SystemAudioDump');
} else if (isLinux) {
// Linux - use display media for screen capture and getUserMedia for microphone
mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: {
frameRate: 1,
width: { ideal: 1920 },
height: { ideal: 1080 },
},
audio: false, // Don't use system audio loopback on Linux
});
// Get microphone input for Linux
let micMediaStream = null;
try {
micMediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: SAMPLE_RATE,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
console.log('Linux microphone capture started');
// Setup audio processing for microphone on Linux
setupLinuxMicProcessing(micMediaStream);
} catch (micError) {
console.warn('Failed to get microphone access on Linux:', micError);
// Continue without microphone if permission denied
}
console.log('Linux screen capture started');
} else {
// Windows - capture mic and system audio separately using native loopback
console.log('Starting Windows capture with native loopback audio...');
// Start screen capture in main process for screenshots
const screenResult = await ipcRenderer.invoke('start-screen-capture');
if (!screenResult.success) {
throw new Error('Failed to start screen capture: ' + screenResult.error);
}
// Ensure STT sessions are initialized before starting audio capture
const sessionActive = await ipcRenderer.invoke('is-session-active');
if (!sessionActive) {
throw new Error('STT sessions not initialized - please wait for initialization to complete');
}
// 1. Get user's microphone
try {
micMediaStream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: SAMPLE_RATE,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
video: false,
});
console.log('Windows microphone capture started');
const { context, processor } = await setupMicProcessing(micMediaStream);
audioContext = context;
audioProcessor = processor;
} catch (micErr) {
console.warn('Could not get microphone access on Windows:', micErr);
}
// 2. Get system audio using native Electron loopback
try {
mediaStream = await navigator.mediaDevices.getDisplayMedia({
video: true,
audio: true // This will now use native loopback from our handler
});
// Verify we got audio tracks
const audioTracks = mediaStream.getAudioTracks();
if (audioTracks.length === 0) {
throw new Error('No audio track in native loopback stream');
}
console.log('Windows native loopback audio capture started');
const { context, processor } = setupSystemAudioProcessing(mediaStream);
systemAudioContext = context;
systemAudioProcessor = processor;
} catch (sysAudioErr) {
console.error('Failed to start Windows native loopback audio:', sysAudioErr);
// Continue without system audio
}
}
// Start capturing screenshots - check if manual mode
if (screenshotIntervalSeconds === 'manual' || screenshotIntervalSeconds === 'Manual') {
console.log('Manual mode enabled - screenshots will be captured on demand only');
// Don't start automatic capture in manual mode
} else {
// 스크린샷 기능 활성화 (chatModel에서 사용)
const intervalMilliseconds = parseInt(screenshotIntervalSeconds) * 1000;
screenshotInterval = setInterval(() => captureScreenshot(imageQuality), intervalMilliseconds);
// Capture first screenshot immediately
setTimeout(() => captureScreenshot(imageQuality), 100);
console.log(`📸 Screenshot capture enabled with ${screenshotIntervalSeconds}s interval`);
}
} catch (err) {
console.error('Error starting capture:', err);
// Note: pickleGlass.e() is not available in this context, commenting out
// pickleGlass.e().setStatus('error');
}
}
function stopCapture() {
if (screenshotInterval) {
clearInterval(screenshotInterval);
screenshotInterval = null;
}
// Clean up microphone resources
if (audioProcessor) {
audioProcessor.disconnect();
audioProcessor = null;
}
if (audioContext) {
audioContext.close();
audioContext = null;
}
// Clean up system audio resources
if (systemAudioProcessor) {
systemAudioProcessor.disconnect();
systemAudioProcessor = null;
}
if (systemAudioContext) {
systemAudioContext.close();
systemAudioContext = null;
}
// Stop and release media stream tracks
if (mediaStream) {
mediaStream.getTracks().forEach(track => track.stop());
mediaStream = null;
}
if (micMediaStream) {
micMediaStream.getTracks().forEach(t => t.stop());
micMediaStream = null;
}
// Stop screen capture in main process
ipcRenderer.invoke('stop-screen-capture').catch(err => {
console.error('Error stopping screen capture:', err);
});
// Stop macOS audio capture if running
if (isMacOS) {
ipcRenderer.invoke('stop-macos-audio').catch(err => {
console.error('Error stopping macOS audio:', err);
});
}
}
// ---------------------------
// Exports & global registration
// ---------------------------
module.exports = {
getAec, // 새로 만든 초기화 함수
runAecSync, // sync 버전
disposeAec, // 필요시 Rust 객체 파괴
startCapture,
stopCapture,
captureManualScreenshot,
getCurrentScreenshot,
isLinux,
isMacOS,
};
// Expose functions to global scope for external access (exact from renderer.js)
if (typeof window !== 'undefined') {
window.captureManualScreenshot = captureManualScreenshot;
window.listenCapture = module.exports;
window.pickleGlass = window.pickleGlass || {};
window.pickleGlass.startCapture = startCapture;
window.pickleGlass.stopCapture = stopCapture;
window.pickleGlass.captureManualScreenshot = captureManualScreenshot;
window.pickleGlass.getCurrentScreenshot = getCurrentScreenshot;
}

View File

@ -0,0 +1,138 @@
// renderer.js
const { ipcRenderer } = require('electron');
const listenCapture = require('./listenCapture.js');
let realtimeConversationHistory = [];
async function queryLoginState() {
const userState = await ipcRenderer.invoke('get-current-user');
return userState;
}
function pickleGlassElement() {
return document.getElementById('pickle-glass');
}
async function initializeopenai(profile = 'interview', language = 'en') {
// The API key is now handled in the main process from .env file.
// We just need to trigger the initialization.
try {
console.log(`Requesting OpenAI initialization with profile: ${profile}, language: ${language}`);
const success = await ipcRenderer.invoke('initialize-openai', profile, language);
if (success) {
// The status will be updated via 'update-status' event from the main process.
console.log('OpenAI initialization successful.');
} else {
console.error('OpenAI initialization failed.');
const appElement = pickleGlassElement();
if (appElement && typeof appElement.setStatus === 'function') {
appElement.setStatus('Initialization Failed');
}
}
} catch (error) {
console.error('Error during OpenAI initialization IPC call:', error);
const appElement = pickleGlassElement();
if (appElement && typeof appElement.setStatus === 'function') {
appElement.setStatus('Error');
}
}
}
// Listen for status updates
ipcRenderer.on('update-status', (event, status) => {
console.log('Status update:', status);
pickleGlass.e().setStatus(status);
});
// Listen for real-time STT updates
ipcRenderer.on('stt-update', (event, data) => {
console.log('Renderer.js stt-update', data);
const { speaker, text, isFinal, isPartial, timestamp } = data;
if (isPartial) {
console.log(`🔄 [${speaker} - partial]: ${text}`);
} else if (isFinal) {
console.log(`✅ [${speaker} - final]: ${text}`);
const speakerText = speaker.toLowerCase();
const conversationText = `${speakerText}: ${text.trim()}`;
realtimeConversationHistory.push(conversationText);
if (realtimeConversationHistory.length > 30) {
realtimeConversationHistory = realtimeConversationHistory.slice(-30);
}
console.log(`📝 Updated realtime conversation history: ${realtimeConversationHistory.length} texts`);
console.log(`📋 Latest text: ${conversationText}`);
}
if (pickleGlass.e() && typeof pickleGlass.e().updateRealtimeTranscription === 'function') {
pickleGlass.e().updateRealtimeTranscription({
speaker,
text,
isFinal,
isPartial,
timestamp,
});
}
});
ipcRenderer.on('update-structured-data', (_, structuredData) => {
console.log('📥 Received structured data update:', structuredData);
window.pickleGlass.structuredData = structuredData;
window.pickleGlass.setStructuredData(structuredData);
});
window.pickleGlass.structuredData = {
summary: [],
topic: { header: '', bullets: [] },
actions: [],
};
window.pickleGlass.setStructuredData = data => {
window.pickleGlass.structuredData = data;
pickleGlass.e()?.updateStructuredData?.(data);
};
function formatRealtimeConversationHistory() {
if (realtimeConversationHistory.length === 0) return 'No conversation history available.';
return realtimeConversationHistory.slice(-30).join('\n');
}
window.pickleGlass = {
initializeopenai,
startCapture: listenCapture.startCapture,
stopCapture: listenCapture.stopCapture,
isLinux: listenCapture.isLinux,
isMacOS: listenCapture.isMacOS,
captureManualScreenshot: listenCapture.captureManualScreenshot,
getCurrentScreenshot: listenCapture.getCurrentScreenshot,
e: pickleGlassElement,
};
// -------------------------------------------------------
// 🔔 React to session state changes from the main process
// When the session ends (isActive === false), ensure we stop
// all local capture pipelines (mic, screen, etc.).
// -------------------------------------------------------
ipcRenderer.on('session-state-changed', (_event, { isActive }) => {
if (!isActive) {
console.log('[Renderer] Session ended stopping local capture');
listenCapture.stopCapture();
} else {
console.log('[Renderer] New session started clearing in-memory history and summaries');
// Reset live conversation & analysis caches
realtimeConversationHistory = [];
const blankData = {
summary: [],
topic: { header: '', bullets: [] },
actions: [],
followUps: [],
};
window.pickleGlass.setStructuredData(blankData);
}
});

View File

@ -0,0 +1,228 @@
import { html, css, LitElement } from '../../../assets/lit-core-2.7.4.min.js';
export class SttView extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
}
/* Inherit font styles from parent */
.transcription-container {
overflow-y: auto;
padding: 12px 12px 16px 12px;
display: flex;
flex-direction: column;
gap: 8px;
min-height: 150px;
max-height: 600px;
position: relative;
z-index: 1;
flex: 1;
}
/* Visibility handled by parent component */
.transcription-container::-webkit-scrollbar {
width: 8px;
}
.transcription-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.transcription-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.transcription-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.stt-message {
padding: 8px 12px;
border-radius: 12px;
max-width: 80%;
word-wrap: break-word;
word-break: break-word;
line-height: 1.5;
font-size: 13px;
margin-bottom: 4px;
box-sizing: border-box;
}
.stt-message.them {
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.9);
align-self: flex-start;
border-bottom-left-radius: 4px;
margin-right: auto;
}
.stt-message.me {
background: rgba(0, 122, 255, 0.8);
color: white;
align-self: flex-end;
border-bottom-right-radius: 4px;
margin-left: auto;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: italic;
}
`;
static properties = {
sttMessages: { type: Array },
isVisible: { type: Boolean },
};
constructor() {
super();
this.sttMessages = [];
this.isVisible = true;
this.messageIdCounter = 0;
this._shouldScrollAfterUpdate = false;
this.handleSttUpdate = this.handleSttUpdate.bind(this);
}
connectedCallback() {
super.connectedCallback();
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('stt-update', this.handleSttUpdate);
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeListener('stt-update', this.handleSttUpdate);
}
}
// Handle session reset from parent
resetTranscript() {
this.sttMessages = [];
this.requestUpdate();
}
handleSttUpdate(event, { speaker, text, isFinal, isPartial }) {
if (text === undefined) return;
const container = this.shadowRoot.querySelector('.transcription-container');
this._shouldScrollAfterUpdate = container ? container.scrollTop + container.clientHeight >= container.scrollHeight - 10 : false;
const findLastPartialIdx = spk => {
for (let i = this.sttMessages.length - 1; i >= 0; i--) {
const m = this.sttMessages[i];
if (m.speaker === spk && m.isPartial) return i;
}
return -1;
};
const newMessages = [...this.sttMessages];
const targetIdx = findLastPartialIdx(speaker);
if (isPartial) {
if (targetIdx !== -1) {
newMessages[targetIdx] = {
...newMessages[targetIdx],
text,
isPartial: true,
isFinal: false,
};
} else {
newMessages.push({
id: this.messageIdCounter++,
speaker,
text,
isPartial: true,
isFinal: false,
});
}
} else if (isFinal) {
if (targetIdx !== -1) {
newMessages[targetIdx] = {
...newMessages[targetIdx],
text,
isPartial: false,
isFinal: true,
};
} else {
newMessages.push({
id: this.messageIdCounter++,
speaker,
text,
isPartial: false,
isFinal: true,
});
}
}
this.sttMessages = newMessages;
// Notify parent component about message updates
this.dispatchEvent(new CustomEvent('stt-messages-updated', {
detail: { messages: this.sttMessages },
bubbles: true
}));
}
scrollToBottom() {
setTimeout(() => {
const container = this.shadowRoot.querySelector('.transcription-container');
if (container) {
container.scrollTop = container.scrollHeight;
}
}, 0);
}
getSpeakerClass(speaker) {
return speaker.toLowerCase() === 'me' ? 'me' : 'them';
}
getTranscriptText() {
return this.sttMessages.map(msg => `${msg.speaker}: ${msg.text}`).join('\n');
}
updated(changedProperties) {
super.updated(changedProperties);
if (changedProperties.has('sttMessages')) {
if (this._shouldScrollAfterUpdate) {
this.scrollToBottom();
this._shouldScrollAfterUpdate = false;
}
}
}
render() {
if (!this.isVisible) {
return html`<div style="display: none;"></div>`;
}
return html`
<div class="transcription-container">
${this.sttMessages.length === 0
? html`<div class="empty-state">Waiting for speech...</div>`
: this.sttMessages.map(msg => html`
<div class="stt-message ${this.getSpeakerClass(msg.speaker)}">
${msg.text}
</div>
`)
}
</div>
`;
}
}
customElements.define('stt-view', SttView);

View File

@ -0,0 +1,5 @@
const sttRepository = require('./sqlite.repository');
module.exports = {
...sttRepository,
};

View File

@ -0,0 +1,27 @@
const sqliteClient = require('../../../../common/services/sqliteClient');
function addTranscript({ sessionId, speaker, text }) {
const db = sqliteClient.getDb();
const transcriptId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `INSERT INTO transcripts (id, session_id, start_at, speaker, text, created_at) VALUES (?, ?, ?, ?, ?, ?)`;
try {
db.prepare(query).run(transcriptId, sessionId, now, speaker, text, now);
return { id: transcriptId };
} catch (err) {
console.error('Error adding transcript:', err);
throw err;
}
}
function getAllTranscriptsBySessionId(sessionId) {
const db = sqliteClient.getDb();
const query = "SELECT * FROM transcripts WHERE session_id = ? ORDER BY start_at ASC";
return db.prepare(query).all(sessionId);
}
module.exports = {
addTranscript,
getAllTranscriptsBySessionId,
};

View File

@ -0,0 +1,526 @@
const { BrowserWindow } = require('electron');
const { spawn } = require('child_process');
const { createSTT } = require('../../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager');
const COMPLETION_DEBOUNCE_MS = 2000;
class SttService {
constructor() {
this.mySttSession = null;
this.theirSttSession = null;
this.myCurrentUtterance = '';
this.theirCurrentUtterance = '';
// Turn-completion debouncing
this.myCompletionBuffer = '';
this.theirCompletionBuffer = '';
this.myCompletionTimer = null;
this.theirCompletionTimer = null;
// System audio capture
this.systemAudioProc = null;
// Callbacks
this.onTranscriptionComplete = null;
this.onStatusUpdate = null;
this.modelInfo = null;
}
setCallbacks({ onTranscriptionComplete, onStatusUpdate }) {
this.onTranscriptionComplete = onTranscriptionComplete;
this.onStatusUpdate = onStatusUpdate;
}
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
});
}
flushMyCompletion() {
const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return;
// Notify completion callback
if (this.onTranscriptionComplete) {
this.onTranscriptionComplete('Me', finalText);
}
// Send to renderer as final
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: finalText,
isPartial: false,
isFinal: true,
timestamp: Date.now(),
});
this.myCompletionBuffer = '';
this.myCompletionTimer = null;
this.myCurrentUtterance = '';
if (this.onStatusUpdate) {
this.onStatusUpdate('Listening...');
}
}
flushTheirCompletion() {
const finalText = (this.theirCompletionBuffer + this.theirCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return;
// Notify completion callback
if (this.onTranscriptionComplete) {
this.onTranscriptionComplete('Them', finalText);
}
// Send to renderer as final
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: finalText,
isPartial: false,
isFinal: true,
timestamp: Date.now(),
});
this.theirCompletionBuffer = '';
this.theirCompletionTimer = null;
this.theirCurrentUtterance = '';
if (this.onStatusUpdate) {
this.onStatusUpdate('Listening...');
}
}
debounceMyCompletion(text) {
if (this.modelInfo?.provider === 'gemini') {
this.myCompletionBuffer += text;
} else {
this.myCompletionBuffer += (this.myCompletionBuffer ? ' ' : '') + text;
}
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = setTimeout(() => this.flushMyCompletion(), COMPLETION_DEBOUNCE_MS);
}
debounceTheirCompletion(text) {
if (this.modelInfo?.provider === 'gemini') {
this.theirCompletionBuffer += text;
} else {
this.theirCompletionBuffer += (this.theirCompletionBuffer ? ' ' : '') + text;
}
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = setTimeout(() => this.flushTheirCompletion(), COMPLETION_DEBOUNCE_MS);
}
async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
const modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.');
}
this.modelInfo = modelInfo;
console.log(`[SttService] Initializing STT for ${modelInfo.provider} using model ${modelInfo.model}`);
const handleMyMessage = message => {
if (!this.modelInfo) {
console.log('[SttService] Ignoring message - session already closed');
return;
}
if (this.modelInfo.provider === 'gemini') {
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) || '';
if (type === 'conversation.item.input_audio_transcription.delta') {
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null;
this.myCurrentUtterance += text;
const continuousText = this.myCompletionBuffer + (this.myCompletionBuffer ? ' ' : '') + this.myCurrentUtterance;
if (text && !text.includes('vq_lbr_audio_')) {
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else if (type === 'conversation.item.input_audio_transcription.completed') {
if (text && text.trim()) {
const finalUtteranceText = text.trim();
this.myCurrentUtterance = '';
this.debounceMyCompletion(finalUtteranceText);
}
}
}
if (message.error) {
console.error('[Me] STT Session Error:', message.error);
}
};
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') {
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) || '';
if (type === 'conversation.item.input_audio_transcription.delta') {
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = null;
this.theirCurrentUtterance += text;
const continuousText = this.theirCompletionBuffer + (this.theirCompletionBuffer ? ' ' : '') + this.theirCurrentUtterance;
if (text && !text.includes('vq_lbr_audio_')) {
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else if (type === 'conversation.item.input_audio_transcription.completed') {
if (text && text.trim()) {
const finalUtteranceText = text.trim();
this.theirCurrentUtterance = '';
this.debounceTheirCompletion(finalUtteranceText);
}
}
}
if (message.error) {
console.error('[Them] STT Session Error:', message.error);
}
};
const mySttConfig = {
language: effectiveLanguage,
callbacks: {
onmessage: handleMyMessage,
onerror: error => console.error('My STT session error:', error.message),
onclose: event => console.log('My STT session closed:', event.reason),
},
};
const theirSttConfig = {
language: effectiveLanguage,
callbacks: {
onmessage: handleTheirMessage,
onerror: error => console.error('Their STT session error:', error.message),
onclose: event => console.log('Their STT session closed:', event.reason),
},
};
// Determine auth options for providers that support it
// const authService = require('../../../common/services/authService');
// const userState = authService.getCurrentUser();
// const loggedIn = userState.isLoggedIn;
const sttOptions = {
apiKey: this.modelInfo.apiKey,
language: effectiveLanguage,
usePortkey: this.modelInfo.provider === 'openai-glass',
portkeyVirtualKey: this.modelInfo.provider === 'openai-glass' ? this.modelInfo.apiKey : undefined,
};
[this.mySttSession, this.theirSttSession] = await Promise.all([
createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: mySttConfig.callbacks }),
createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: theirSttConfig.callbacks }),
]);
console.log('✅ Both STT sessions initialized successfully.');
return true;
}
async sendAudioContent(data, mimeType) {
// const provider = await this.getAiProvider();
// const isGemini = provider === 'gemini';
if (!this.mySttSession) {
throw new Error('User STT session not active');
}
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
const payload = modelInfo.provider === 'gemini'
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
: data;
await this.mySttSession.sendRealtimeInput(payload);
}
async sendSystemAudioContent(data, mimeType) {
if (!this.theirSttSession) {
throw new Error('Their STT session not active');
}
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
const payload = modelInfo.provider === 'gemini'
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } }
: data;
await this.theirSttSession.sendRealtimeInput(payload);
}
killExistingSystemAudioDump() {
return new Promise(resolve => {
console.log('Checking for existing SystemAudioDump processes...');
const killProc = spawn('pkill', ['-f', 'SystemAudioDump'], {
stdio: 'ignore',
});
killProc.on('close', code => {
if (code === 0) {
console.log('Killed existing SystemAudioDump processes');
} else {
console.log('No existing SystemAudioDump processes found');
}
resolve();
});
killProc.on('error', err => {
console.log('Error checking for existing processes (this is normal):', err.message);
resolve();
});
setTimeout(() => {
killProc.kill();
resolve();
}, 2000);
});
}
async startMacOSAudioCapture() {
if (process.platform !== 'darwin' || !this.theirSttSession) return false;
await this.killExistingSystemAudioDump();
console.log('Starting macOS audio capture for "Them"...');
const { app } = require('electron');
const path = require('path');
const systemAudioPath = app.isPackaged
? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump')
: path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump');
console.log('SystemAudioDump path:', systemAudioPath);
this.systemAudioProc = spawn(systemAudioPath, [], {
stdio: ['ignore', 'pipe', 'pipe'],
});
if (!this.systemAudioProc.pid) {
console.error('Failed to start SystemAudioDump');
return false;
}
console.log('SystemAudioDump started with PID:', this.systemAudioProc.pid);
const CHUNK_DURATION = 0.1;
const SAMPLE_RATE = 24000;
const BYTES_PER_SAMPLE = 2;
const CHANNELS = 2;
const CHUNK_SIZE = SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS * CHUNK_DURATION;
let audioBuffer = Buffer.alloc(0);
// const provider = await this.getAiProvider();
// const isGemini = provider === 'gemini';
let modelInfo = this.modelInfo;
if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' });
}
if (!modelInfo) {
throw new Error('STT model info could not be retrieved.');
}
this.systemAudioProc.stdout.on('data', async data => {
audioBuffer = Buffer.concat([audioBuffer, data]);
while (audioBuffer.length >= CHUNK_SIZE) {
const chunk = audioBuffer.slice(0, CHUNK_SIZE);
audioBuffer = audioBuffer.slice(CHUNK_SIZE);
const monoChunk = CHANNELS === 2 ? this.convertStereoToMono(chunk) : chunk;
const base64Data = monoChunk.toString('base64');
this.sendToRenderer('system-audio-data', { data: base64Data });
if (this.theirSttSession) {
try {
const payload = modelInfo.provider === 'gemini'
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } }
: base64Data;
await this.theirSttSession.sendRealtimeInput(payload);
} catch (err) {
console.error('Error sending system audio:', err.message);
}
}
}
});
this.systemAudioProc.stderr.on('data', data => {
console.error('SystemAudioDump stderr:', data.toString());
});
this.systemAudioProc.on('close', code => {
console.log('SystemAudioDump process closed with code:', code);
this.systemAudioProc = null;
});
this.systemAudioProc.on('error', err => {
console.error('SystemAudioDump process error:', err);
this.systemAudioProc = null;
});
return true;
}
convertStereoToMono(stereoBuffer) {
const samples = stereoBuffer.length / 4;
const monoBuffer = Buffer.alloc(samples * 2);
for (let i = 0; i < samples; i++) {
const leftSample = stereoBuffer.readInt16LE(i * 4);
monoBuffer.writeInt16LE(leftSample, i * 2);
}
return monoBuffer;
}
stopMacOSAudioCapture() {
if (this.systemAudioProc) {
console.log('Stopping SystemAudioDump...');
this.systemAudioProc.kill('SIGTERM');
this.systemAudioProc = null;
}
}
isSessionActive() {
return !!this.mySttSession && !!this.theirSttSession;
}
async closeSessions() {
this.stopMacOSAudioCapture();
// Clear timers
if (this.myCompletionTimer) {
clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null;
}
if (this.theirCompletionTimer) {
clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = null;
}
const closePromises = [];
if (this.mySttSession) {
closePromises.push(this.mySttSession.close());
this.mySttSession = null;
}
if (this.theirSttSession) {
closePromises.push(this.theirSttSession.close());
this.theirSttSession = null;
}
await Promise.all(closePromises);
console.log('All STT sessions closed.');
// Reset state
this.myCurrentUtterance = '';
this.theirCurrentUtterance = '';
this.myCompletionBuffer = '';
this.theirCompletionBuffer = '';
this.modelInfo = null;
}
}
module.exports = SttService;

View File

@ -0,0 +1,559 @@
import { html, css, LitElement } from '../../../assets/lit-core-2.7.4.min.js';
export class SummaryView extends LitElement {
static styles = css`
:host {
display: block;
width: 100%;
}
/* Inherit font styles from parent */
/* highlight.js 스타일 추가 */
.insights-container pre {
background: rgba(0, 0, 0, 0.4) !important;
border-radius: 8px !important;
padding: 12px !important;
margin: 8px 0 !important;
overflow-x: auto !important;
border: 1px solid rgba(255, 255, 255, 0.1) !important;
white-space: pre !important;
word-wrap: normal !important;
word-break: normal !important;
}
.insights-container code {
font-family: 'Monaco', 'Menlo', 'Consolas', monospace !important;
font-size: 11px !important;
background: transparent !important;
white-space: pre !important;
word-wrap: normal !important;
word-break: normal !important;
}
.insights-container pre code {
white-space: pre !important;
word-wrap: normal !important;
word-break: normal !important;
display: block !important;
}
.insights-container p code {
background: rgba(255, 255, 255, 0.1) !important;
padding: 2px 4px !important;
border-radius: 3px !important;
color: #ffd700 !important;
}
.hljs-keyword {
color: #ff79c6 !important;
}
.hljs-string {
color: #f1fa8c !important;
}
.hljs-comment {
color: #6272a4 !important;
}
.hljs-number {
color: #bd93f9 !important;
}
.hljs-function {
color: #50fa7b !important;
}
.hljs-variable {
color: #8be9fd !important;
}
.hljs-built_in {
color: #ffb86c !important;
}
.hljs-title {
color: #50fa7b !important;
}
.hljs-attr {
color: #50fa7b !important;
}
.hljs-tag {
color: #ff79c6 !important;
}
.insights-container {
overflow-y: auto;
padding: 12px 16px 16px 16px;
position: relative;
z-index: 1;
min-height: 150px;
max-height: 600px;
flex: 1;
}
/* Visibility handled by parent component */
.insights-container::-webkit-scrollbar {
width: 8px;
}
.insights-container::-webkit-scrollbar-track {
background: rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.insights-container::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 4px;
}
.insights-container::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
insights-title {
color: rgba(255, 255, 255, 0.8);
font-size: 15px;
font-weight: 500;
font-family: 'Helvetica Neue', sans-serif;
margin: 12px 0 8px 0;
display: block;
}
.insights-container h4 {
color: #ffffff;
font-size: 12px;
font-weight: 600;
margin: 12px 0 8px 0;
padding: 4px 8px;
border-radius: 4px;
background: transparent;
cursor: default;
}
.insights-container h4:hover {
background: transparent;
}
.insights-container h4:first-child {
margin-top: 0;
}
.outline-item {
color: #ffffff;
font-size: 11px;
line-height: 1.4;
margin: 4px 0;
padding: 6px 8px;
border-radius: 4px;
background: transparent;
transition: background-color 0.15s ease;
cursor: pointer;
word-wrap: break-word;
}
.outline-item:hover {
background: rgba(255, 255, 255, 0.1);
}
.request-item {
color: #ffffff;
font-size: 12px;
line-height: 1.2;
margin: 4px 0;
padding: 6px 8px;
border-radius: 4px;
background: transparent;
cursor: default;
word-wrap: break-word;
transition: background-color 0.15s ease;
}
.request-item.clickable {
cursor: pointer;
transition: all 0.15s ease;
}
.request-item.clickable:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(2px);
}
/* 마크다운 렌더링된 콘텐츠 스타일 */
.markdown-content {
color: #ffffff;
font-size: 11px;
line-height: 1.4;
margin: 4px 0;
padding: 6px 8px;
border-radius: 4px;
background: transparent;
cursor: pointer;
word-wrap: break-word;
transition: all 0.15s ease;
}
.markdown-content:hover {
background: rgba(255, 255, 255, 0.1);
transform: translateX(2px);
}
.markdown-content p {
margin: 4px 0;
}
.markdown-content ul,
.markdown-content ol {
margin: 4px 0;
padding-left: 16px;
}
.markdown-content li {
margin: 2px 0;
}
.markdown-content a {
color: #8be9fd;
text-decoration: none;
}
.markdown-content a:hover {
text-decoration: underline;
}
.markdown-content strong {
font-weight: 600;
color: #f8f8f2;
}
.markdown-content em {
font-style: italic;
color: #f1fa8c;
}
.empty-state {
display: flex;
align-items: center;
justify-content: center;
height: 100px;
color: rgba(255, 255, 255, 0.6);
font-size: 12px;
font-style: italic;
}
`;
static properties = {
structuredData: { type: Object },
isVisible: { type: Boolean },
hasCompletedRecording: { type: Boolean },
};
constructor() {
super();
this.structuredData = {
summary: [],
topic: { header: '', bullets: [] },
actions: [],
followUps: [],
};
this.isVisible = true;
this.hasCompletedRecording = false;
// 마크다운 라이브러리 초기화
this.marked = null;
this.hljs = null;
this.isLibrariesLoaded = false;
this.DOMPurify = null;
this.isDOMPurifyLoaded = false;
this.loadLibraries();
}
connectedCallback() {
super.connectedCallback();
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('update-structured-data', (event, data) => {
this.structuredData = data;
this.requestUpdate();
});
}
}
disconnectedCallback() {
super.disconnectedCallback();
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.removeAllListeners('update-structured-data');
}
}
// Handle session reset from parent
resetAnalysis() {
this.structuredData = {
summary: [],
topic: { header: '', bullets: [] },
actions: [],
followUps: [],
};
this.requestUpdate();
}
async loadLibraries() {
try {
if (!window.marked) {
await this.loadScript('../../../assets/marked-4.3.0.min.js');
}
if (!window.hljs) {
await this.loadScript('../../../assets/highlight-11.9.0.min.js');
}
if (!window.DOMPurify) {
await this.loadScript('../../../assets/dompurify-3.0.7.min.js');
}
this.marked = window.marked;
this.hljs = window.hljs;
this.DOMPurify = window.DOMPurify;
if (this.marked && this.hljs) {
this.marked.setOptions({
highlight: (code, lang) => {
if (lang && this.hljs.getLanguage(lang)) {
try {
return this.hljs.highlight(code, { language: lang }).value;
} catch (err) {
console.warn('Highlight error:', err);
}
}
try {
return this.hljs.highlightAuto(code).value;
} catch (err) {
console.warn('Auto highlight error:', err);
}
return code;
},
breaks: true,
gfm: true,
pedantic: false,
smartypants: false,
xhtml: false,
});
this.isLibrariesLoaded = true;
console.log('Markdown libraries loaded successfully');
}
if (this.DOMPurify) {
this.isDOMPurifyLoaded = true;
console.log('DOMPurify loaded successfully in SummaryView');
}
} catch (error) {
console.error('Failed to load libraries:', error);
}
}
loadScript(src) {
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = src;
script.onload = resolve;
script.onerror = reject;
document.head.appendChild(script);
});
}
parseMarkdown(text) {
if (!text) return '';
if (!this.isLibrariesLoaded || !this.marked) {
return text;
}
try {
return this.marked(text);
} catch (error) {
console.error('Markdown parsing error:', error);
return text;
}
}
handleMarkdownClick(originalText) {
this.handleRequestClick(originalText);
}
renderMarkdownContent() {
if (!this.isLibrariesLoaded || !this.marked) {
return;
}
const markdownElements = this.shadowRoot.querySelectorAll('[data-markdown-id]');
markdownElements.forEach(element => {
const originalText = element.getAttribute('data-original-text');
if (originalText) {
try {
let parsedHTML = this.parseMarkdown(originalText);
if (this.isDOMPurifyLoaded && this.DOMPurify) {
parsedHTML = this.DOMPurify.sanitize(parsedHTML);
if (this.DOMPurify.removed && this.DOMPurify.removed.length > 0) {
console.warn('Unsafe content detected in insights, showing plain text');
element.textContent = '⚠️ ' + originalText;
return;
}
}
element.innerHTML = parsedHTML;
} catch (error) {
console.error('Error rendering markdown for element:', error);
element.textContent = originalText;
}
}
});
}
async handleRequestClick(requestText) {
console.log('🔥 Analysis request clicked:', requestText);
if (window.require) {
const { ipcRenderer } = window.require('electron');
try {
const isAskViewVisible = await ipcRenderer.invoke('is-window-visible', 'ask');
if (!isAskViewVisible) {
await ipcRenderer.invoke('toggle-feature', 'ask');
await new Promise(resolve => setTimeout(resolve, 100));
}
const result = await ipcRenderer.invoke('send-question-to-ask', requestText);
if (result.success) {
console.log('✅ Question sent to AskView successfully');
} else {
console.error('❌ Failed to send question to AskView:', result.error);
}
} catch (error) {
console.error('❌ Error in handleRequestClick:', error);
}
}
}
getSummaryText() {
const data = this.structuredData || { summary: [], topic: { header: '', bullets: [] }, actions: [] };
let sections = [];
if (data.summary && data.summary.length > 0) {
sections.push(`Current Summary:\n${data.summary.map(s => `${s}`).join('\n')}`);
}
if (data.topic && data.topic.header && data.topic.bullets.length > 0) {
sections.push(`\n${data.topic.header}:\n${data.topic.bullets.map(b => `${b}`).join('\n')}`);
}
if (data.actions && data.actions.length > 0) {
sections.push(`\nActions:\n${data.actions.map(a => `${a}`).join('\n')}`);
}
if (data.followUps && data.followUps.length > 0) {
sections.push(`\nFollow-Ups:\n${data.followUps.map(f => `${f}`).join('\n')}`);
}
return sections.join('\n\n').trim();
}
updated(changedProperties) {
super.updated(changedProperties);
this.renderMarkdownContent();
}
render() {
if (!this.isVisible) {
return html`<div style="display: none;"></div>`;
}
const data = this.structuredData || {
summary: [],
topic: { header: '', bullets: [] },
actions: [],
};
const hasAnyContent = data.summary.length > 0 || data.topic.bullets.length > 0 || data.actions.length > 0;
return html`
<div class="insights-container">
${!hasAnyContent
? html`<div class="empty-state">No insights yet...</div>`
: html`
<insights-title>Current Summary</insights-title>
${data.summary.length > 0
? data.summary
.slice(0, 5)
.map(
(bullet, index) => html`
<div
class="markdown-content"
data-markdown-id="summary-${index}"
data-original-text="${bullet}"
@click=${() => this.handleMarkdownClick(bullet)}
>
${bullet}
</div>
`
)
: html` <div class="request-item">No content yet...</div> `}
${data.topic.header
? html`
<insights-title>${data.topic.header}</insights-title>
${data.topic.bullets
.slice(0, 3)
.map(
(bullet, index) => html`
<div
class="markdown-content"
data-markdown-id="topic-${index}"
data-original-text="${bullet}"
@click=${() => this.handleMarkdownClick(bullet)}
>
${bullet}
</div>
`
)}
`
: ''}
${data.actions.length > 0
? html`
<insights-title>Actions</insights-title>
${data.actions
.slice(0, 5)
.map(
(action, index) => html`
<div
class="markdown-content"
data-markdown-id="action-${index}"
data-original-text="${action}"
@click=${() => this.handleMarkdownClick(action)}
>
${action}
</div>
`
)}
`
: ''}
${this.hasCompletedRecording && data.followUps && data.followUps.length > 0
? html`
<insights-title>Follow-Ups</insights-title>
${data.followUps.map(
(followUp, index) => html`
<div
class="markdown-content"
data-markdown-id="followup-${index}"
data-original-text="${followUp}"
@click=${() => this.handleMarkdownClick(followUp)}
>
${followUp}
</div>
`
)}
`
: ''}
`}
</div>
`;
}
}
customElements.define('summary-view', SummaryView);

View File

@ -0,0 +1,5 @@
const summaryRepository = require('./sqlite.repository');
module.exports = {
...summaryRepository,
};

View File

@ -0,0 +1,39 @@
const sqliteClient = require('../../../../common/services/sqliteClient');
function saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) {
return new Promise((resolve, reject) => {
try {
const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000);
const query = `
INSERT INTO summaries (session_id, generated_at, model, text, tldr, bullet_json, action_json, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(session_id) DO UPDATE SET
generated_at=excluded.generated_at,
model=excluded.model,
text=excluded.text,
tldr=excluded.tldr,
bullet_json=excluded.bullet_json,
action_json=excluded.action_json,
updated_at=excluded.updated_at
`;
const result = db.prepare(query).run(sessionId, now, model, text, tldr, bullet_json, action_json, now);
resolve({ changes: result.changes });
} catch (err) {
console.error('Error saving summary:', err);
reject(err);
}
});
}
function getSummaryBySessionId(sessionId) {
const db = sqliteClient.getDb();
const query = "SELECT * FROM summaries WHERE session_id = ?";
return db.prepare(query).get(sessionId) || null;
}
module.exports = {
saveSummary,
getSummaryBySessionId,
};

View File

@ -0,0 +1,355 @@
const { BrowserWindow } = require('electron');
const { getSystemPrompt } = require('../../../common/prompts/promptBuilder.js');
const { createLLM } = require('../../../common/ai/factory');
const authService = require('../../../common/services/authService');
const sessionRepository = require('../../../common/repositories/session');
const summaryRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager');
class SummaryService {
constructor() {
this.previousAnalysisResult = null;
this.analysisHistory = [];
this.conversationHistory = [];
this.currentSessionId = null;
// Callbacks
this.onAnalysisComplete = null;
this.onStatusUpdate = null;
}
setCallbacks({ onAnalysisComplete, onStatusUpdate }) {
this.onAnalysisComplete = onAnalysisComplete;
this.onStatusUpdate = onStatusUpdate;
}
setSessionId(sessionId) {
this.currentSessionId = sessionId;
}
// async getApiKey() {
// const storedKey = await getStoredApiKey();
// if (storedKey) {
// console.log('[SummaryService] Using stored API key');
// return storedKey;
// }
// const envKey = process.env.OPENAI_API_KEY;
// if (envKey) {
// console.log('[SummaryService] Using environment API key');
// return envKey;
// }
// console.error('[SummaryService] No API key found in storage or environment');
// return null;
// }
sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send(channel, data);
}
});
}
addConversationTurn(speaker, text) {
const conversationText = `${speaker.toLowerCase()}: ${text.trim()}`;
this.conversationHistory.push(conversationText);
console.log(`💬 Added conversation text: ${conversationText}`);
console.log(`📈 Total conversation history: ${this.conversationHistory.length} texts`);
// Trigger analysis if needed
this.triggerAnalysisIfNeeded();
}
getConversationHistory() {
return this.conversationHistory;
}
resetConversationHistory() {
this.conversationHistory = [];
this.previousAnalysisResult = null;
this.analysisHistory = [];
console.log('🔄 Conversation history and analysis state reset');
}
/**
* Converts conversation history into text to include in the prompt.
* @param {Array<string>} conversationTexts - Array of conversation texts ["me: ~~~", "them: ~~~", ...]
* @param {number} maxTurns - Maximum number of recent turns to include
* @returns {string} - Formatted conversation string for the prompt
*/
formatConversationForPrompt(conversationTexts, maxTurns = 30) {
if (conversationTexts.length === 0) return '';
return conversationTexts.slice(-maxTurns).join('\n');
}
async makeOutlineAndRequests(conversationTexts, maxTurns = 30) {
console.log(`🔍 makeOutlineAndRequests called - conversationTexts: ${conversationTexts.length}`);
if (conversationTexts.length === 0) {
console.log('⚠️ No conversation texts available for analysis');
return null;
}
const recentConversation = this.formatConversationForPrompt(conversationTexts, maxTurns);
// 이전 분석 결과를 프롬프트에 포함
let contextualPrompt = '';
if (this.previousAnalysisResult) {
contextualPrompt = `
Previous Analysis Context:
- Main Topic: ${this.previousAnalysisResult.topic.header}
- Key Points: ${this.previousAnalysisResult.summary.slice(0, 3).join(', ')}
- Last Actions: ${this.previousAnalysisResult.actions.slice(0, 2).join(', ')}
Please build upon this context while analyzing the new conversation segments.
`;
}
const basePrompt = getSystemPrompt('pickle_glass_analysis', '', false);
const systemPrompt = basePrompt.replace('{{CONVERSATION_HISTORY}}', recentConversation);
try {
if (this.currentSessionId) {
await sessionRepository.touch(this.currentSessionId);
}
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.');
}
console.log(`🤖 Sending analysis request to ${modelInfo.provider} using model ${modelInfo.model}`);
const messages = [
{
role: 'system',
content: systemPrompt,
},
{
role: 'user',
content: `${contextualPrompt}
Analyze the conversation and provide a structured summary. Format your response as follows:
**Summary Overview**
- Main discussion point with context
**Key Topic: [Topic Name]**
- First key insight
- Second key insight
- Third key insight
**Extended Explanation**
Provide 2-3 sentences explaining the context and implications.
**Suggested Questions**
1. First follow-up question?
2. Second follow-up question?
3. Third follow-up question?
Keep all points concise and build upon previous analysis if provided.`,
},
];
console.log('🤖 Sending analysis request to AI...');
const llm = createLLM(modelInfo.provider, {
apiKey: modelInfo.apiKey,
model: modelInfo.model,
temperature: 0.7,
maxTokens: 1024,
usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
});
const completion = await llm.chat(messages);
const responseText = completion.content;
console.log(`✅ Analysis response received: ${responseText}`);
const structuredData = this.parseResponseText(responseText, this.previousAnalysisResult);
if (this.currentSessionId) {
try {
summaryRepository.saveSummary({
sessionId: this.currentSessionId,
text: responseText,
tldr: structuredData.summary.join('\n'),
bullet_json: JSON.stringify(structuredData.topic.bullets),
action_json: JSON.stringify(structuredData.actions),
model: modelInfo.model
});
} catch (err) {
console.error('[DB] Failed to save summary:', err);
}
}
// 분석 결과 저장
this.previousAnalysisResult = structuredData;
this.analysisHistory.push({
timestamp: Date.now(),
data: structuredData,
conversationLength: conversationTexts.length,
});
if (this.analysisHistory.length > 10) {
this.analysisHistory.shift();
}
return structuredData;
} catch (error) {
console.error('❌ Error during analysis generation:', error.message);
return this.previousAnalysisResult; // 에러 시 이전 결과 반환
}
}
parseResponseText(responseText, previousResult) {
const structuredData = {
summary: [],
topic: { header: '', bullets: [] },
actions: [],
followUps: ['✉️ Draft a follow-up email', '✅ Generate action items', '📝 Show summary'],
};
// 이전 결과가 있으면 기본값으로 사용
if (previousResult) {
structuredData.topic.header = previousResult.topic.header;
structuredData.summary = [...previousResult.summary];
}
try {
const lines = responseText.split('\n');
let currentSection = '';
let isCapturingTopic = false;
let topicName = '';
for (const line of lines) {
const trimmedLine = line.trim();
// 섹션 헤더 감지
if (trimmedLine.startsWith('**Summary Overview**')) {
currentSection = 'summary-overview';
continue;
} else if (trimmedLine.startsWith('**Key Topic:')) {
currentSection = 'topic';
isCapturingTopic = true;
topicName = trimmedLine.match(/\*\*Key Topic: (.+?)\*\*/)?.[1] || '';
if (topicName) {
structuredData.topic.header = topicName + ':';
}
continue;
} else if (trimmedLine.startsWith('**Extended Explanation**')) {
currentSection = 'explanation';
continue;
} else if (trimmedLine.startsWith('**Suggested Questions**')) {
currentSection = 'questions';
continue;
}
// 컨텐츠 파싱
if (trimmedLine.startsWith('-') && currentSection === 'summary-overview') {
const summaryPoint = trimmedLine.substring(1).trim();
if (summaryPoint && !structuredData.summary.includes(summaryPoint)) {
// 기존 summary 업데이트 (최대 5개 유지)
structuredData.summary.unshift(summaryPoint);
if (structuredData.summary.length > 5) {
structuredData.summary.pop();
}
}
} else if (trimmedLine.startsWith('-') && currentSection === 'topic') {
const bullet = trimmedLine.substring(1).trim();
if (bullet && structuredData.topic.bullets.length < 3) {
structuredData.topic.bullets.push(bullet);
}
} else if (currentSection === 'explanation' && trimmedLine) {
// explanation을 topic bullets에 추가 (문장 단위로)
const sentences = trimmedLine
.split(/\.\s+/)
.filter(s => s.trim().length > 0)
.map(s => s.trim() + (s.endsWith('.') ? '' : '.'));
sentences.forEach(sentence => {
if (structuredData.topic.bullets.length < 3 && !structuredData.topic.bullets.includes(sentence)) {
structuredData.topic.bullets.push(sentence);
}
});
} else if (trimmedLine.match(/^\d+\./) && currentSection === 'questions') {
const question = trimmedLine.replace(/^\d+\.\s*/, '').trim();
if (question && question.includes('?')) {
structuredData.actions.push(`${question}`);
}
}
}
// 기본 액션 추가
const defaultActions = ['✨ What should I say next?', '💬 Suggest follow-up questions'];
defaultActions.forEach(action => {
if (!structuredData.actions.includes(action)) {
structuredData.actions.push(action);
}
});
// 액션 개수 제한
structuredData.actions = structuredData.actions.slice(0, 5);
// 유효성 검증 및 이전 데이터 병합
if (structuredData.summary.length === 0 && previousResult) {
structuredData.summary = previousResult.summary;
}
if (structuredData.topic.bullets.length === 0 && previousResult) {
structuredData.topic.bullets = previousResult.topic.bullets;
}
} catch (error) {
console.error('❌ Error parsing response text:', error);
// 에러 시 이전 결과 반환
return (
previousResult || {
summary: [],
topic: { header: 'Analysis in progress', bullets: [] },
actions: ['✨ What should I say next?', '💬 Suggest follow-up questions'],
followUps: ['✉️ Draft a follow-up email', '✅ Generate action items', '📝 Show summary'],
}
);
}
console.log('📊 Final structured data:', JSON.stringify(structuredData, null, 2));
return structuredData;
}
/**
* Triggers analysis when conversation history reaches 5 texts.
*/
async triggerAnalysisIfNeeded() {
if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`);
this.makeOutlineAndRequests(this.conversationHistory)
.then(data => {
if (data) {
console.log('📤 Sending structured data to renderer');
this.sendToRenderer('update-structured-data', data);
// Notify callback
if (this.onAnalysisComplete) {
this.onAnalysisComplete(data);
}
} else {
console.log('❌ No analysis data returned from non-blocking call');
}
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
}
}
getCurrentAnalysisData() {
return {
previousResult: this.previousAnalysisResult,
history: this.analysisHistory,
conversationLength: this.conversationHistory.length,
};
}
}
module.exports = SummaryService;

View File

@ -1,372 +0,0 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
export class OnboardingView extends LitElement {
static styles = css`
* {
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
cursor: default;
user-select: none;
}
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
position: relative;
overflow: hidden;
}
.slide {
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
padding: 20px;
text-align: left;
border-radius: 16px;
height: 100%;
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 50px;
transition: transform 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease;
opacity: 0;
transform: translateX(100%);
}
.slide.active {
opacity: 1;
transform: translateX(0);
}
.slide.prev {
transform: translateX(-100%);
}
.slide-1 {
background: linear-gradient(135deg, #2d1b69 0%, #11998e 100%);
color: white;
}
.slide-2 {
background: linear-gradient(135deg, #8e2de2 0%, #4a00e0 100%);
color: white;
}
.slide-3 {
background: linear-gradient(135deg, #2c5aa0 0%, #1a237e 100%);
color: white;
}
.slide-4 {
background: linear-gradient(135deg, #fc466b 0%, #3f5efb 100%);
color: white;
}
.slide-5 {
background: linear-gradient(135deg, #833ab4 0%, #fd1d1d 100%);
color: white;
}
.slide-title {
font-size: 24px;
font-weight: 700;
margin-bottom: 12px;
margin-top: 10px;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.slide-content {
font-size: 14px;
line-height: 1.5;
max-width: 100%;
margin-bottom: 20px;
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
}
.context-textarea {
width: 100%;
max-width: 100%;
height: 80px;
padding: 12px;
border: 2px solid rgba(255, 255, 255, 0.3);
border-radius: 8px;
background: rgba(255, 255, 255, 0.1);
color: white;
font-size: 14px;
resize: vertical;
box-sizing: border-box;
}
.context-textarea::placeholder {
color: rgba(255, 255, 255, 0.7);
}
.context-textarea:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.15);
}
.navigation {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
}
.nav-button {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 16px;
border-radius: 20px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
}
.nav-button:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: none;
}
.nav-button:disabled:hover {
background: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
.progress-dots {
display: flex;
gap: 12px;
align-items: center;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.3);
transition: all 0.3s ease;
}
.dot.active {
background: white;
transform: scale(1.2);
}
.emoji {
position: absolute;
top: 15px;
right: 15px;
font-size: 40px;
transform: rotate(15deg);
z-index: 1;
}
.feature-list {
text-align: left;
max-width: 100%;
}
.feature-item {
display: flex;
align-items: center;
margin-bottom: 8px;
font-size: 14px;
}
.feature-icon {
font-size: 16px;
margin-right: 8px;
}
`;
static properties = {
currentSlide: { type: Number },
contextText: { type: String },
onComplete: { type: Function },
onClose: { type: Function },
};
constructor() {
super();
this.currentSlide = 0;
this.contextText = '';
this.onComplete = () => {};
this.onClose = () => {};
}
nextSlide() {
if (this.currentSlide < 4) {
this.currentSlide++;
} else {
this.completeOnboarding();
}
}
prevSlide() {
if (this.currentSlide > 0) {
this.currentSlide--;
}
}
handleContextInput(e) {
this.contextText = e.target.value;
}
completeOnboarding() {
// Save the context text to localStorage as custom prompt
if (this.contextText.trim()) {
localStorage.setItem('customPrompt', this.contextText.trim());
}
// Mark onboarding as completed
localStorage.setItem('onboardingCompleted', 'true');
// Call the completion callback
this.onComplete();
}
renderSlide1() {
return html`
<div class="slide slide-1 ${this.currentSlide === 0 ? 'active' : ''}">
<div class="emoji">👋</div>
<div class="slide-title">Welcome to Pickle Glass!</div>
<div class="slide-content">
Pickle Glass hears what you hear and sees what you see, then generates AI-powered suggestions without any user input needed.
</div>
</div>
`;
}
renderSlide2() {
return html`
<div class="slide slide-2 ${this.currentSlide === 1 ? 'active' : ''}">
<div class="emoji">🔒</div>
<div class="slide-title">Completely Private</div>
<div class="slide-content">
Your secret weapon is completely invisible! It won't show up on screen sharing apps, keeping your assistance completely private
during interviews and meetings.
</div>
</div>
`;
}
renderSlide3() {
return html`
<div class="slide slide-3 ${this.currentSlide === 2 ? 'active' : ''}">
<div class="emoji">📝</div>
<div class="slide-title">Tell Us Your Context</div>
<div class="slide-content">
Help the AI understand your situation better by sharing your context - like your resume, the job description, or interview
details.
</div>
<textarea
class="context-textarea"
placeholder="Paste your resume, job description, interview context, or any relevant information here..."
.value=${this.contextText}
@input=${this.handleContextInput}
></textarea>
</div>
`;
}
renderSlide4() {
return html`
<div class="slide slide-4 ${this.currentSlide === 3 ? 'active' : ''}">
<div class="emoji"></div>
<div class="slide-title">Explore More Features</div>
<div class="feature-list">
<div class="feature-item">
<span class="feature-icon">🎨</span>
Customize settings and AI profiles
</div>
<div class="feature-item">
<span class="feature-icon">📚</span>
View your conversation history
</div>
<div class="feature-item">
<span class="feature-icon">🔧</span>
Adjust screenshot intervals and quality
</div>
</div>
</div>
`;
}
renderSlide5() {
return html`
<div class="slide slide-5 ${this.currentSlide === 4 ? 'active' : ''}">
<div class="emoji">🎉</div>
<div class="slide-title">You're All Set!</div>
<div class="slide-content">
Pickle Glass is completely free to use. Just add your openai API key and start getting AI-powered assistance in your interviews
and meetings!
</div>
</div>
`;
}
render() {
return html`
${this.renderSlide1()} ${this.renderSlide2()} ${this.renderSlide3()} ${this.renderSlide4()} ${this.renderSlide5()}
<div class="navigation">
<button class="nav-button" @click=${this.prevSlide} ?disabled=${this.currentSlide === 0}>
<svg
width="16px"
height="16px"
stroke-width="1.7"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
color="#ffffff"
>
<path d="M15 6L9 12L15 18" stroke="#ffffff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<div class="progress-dots">
${[0, 1, 2, 3, 4].map(index => html` <div class="dot ${index === this.currentSlide ? 'active' : ''}"></div> `)}
</div>
<button class="nav-button" @click=${this.nextSlide}>
${this.currentSlide === 4
? 'Get Started'
: html`
<svg
width="16px"
height="16px"
stroke-width="1.7"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
color="#ffffff"
>
<path d="M9 6L15 12L9 18" stroke="#ffffff" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
`}
</button>
</div>
`;
}
}
customElements.define('onboarding-view', OnboardingView);

File diff suppressed because it is too large Load Diff

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

@ -0,0 +1,21 @@
const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository'); // Future implementation
const authService = require('../../../common/services/authService');
function getRepository() {
// In the future, we can check the user's login status from authService
// const user = authService.getCurrentUser();
// if (user.isLoggedIn) {
// return firebaseRepository;
// }
return sqliteRepository;
}
// Directly export functions for ease of use, decided by the strategy
module.exports = {
getPresets: (...args) => getRepository().getPresets(...args),
getPresetTemplates: (...args) => getRepository().getPresetTemplates(...args),
createPreset: (...args) => getRepository().createPreset(...args),
updatePreset: (...args) => getRepository().updatePreset(...args),
deletePreset: (...args) => getRepository().deletePreset(...args),
};

View File

@ -0,0 +1,99 @@
const sqliteClient = require('../../../common/services/sqliteClient');
function getPresets(uid) {
const db = sqliteClient.getDb();
const query = `
SELECT * FROM prompt_presets
WHERE uid = ? OR is_default = 1
ORDER BY is_default DESC, title ASC
`;
try {
return db.prepare(query).all(uid) || [];
} catch (err) {
console.error('SQLite: Failed to get presets:', err);
throw err;
}
}
function getPresetTemplates() {
const db = sqliteClient.getDb();
const query = `
SELECT * FROM prompt_presets
WHERE is_default = 1
ORDER BY title ASC
`;
try {
return db.prepare(query).all() || [];
} catch (err) {
console.error('SQLite: Failed to get preset templates:', err);
throw err;
}
}
function createPreset({ uid, title, prompt }) {
const db = sqliteClient.getDb();
const id = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000);
const query = `
INSERT INTO prompt_presets (id, uid, title, prompt, is_default, created_at, sync_state)
VALUES (?, ?, ?, ?, 0, ?, 'dirty')
`;
try {
db.prepare(query).run(id, uid, title, prompt, now);
return { id };
} catch (err) {
console.error('SQLite: Failed to create preset:', err);
throw err;
}
}
function updatePreset(id, { title, prompt }, uid) {
const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000);
const query = `
UPDATE prompt_presets
SET title = ?, prompt = ?, sync_state = 'dirty', updated_at = ?
WHERE id = ? AND uid = ? AND is_default = 0
`;
try {
const result = db.prepare(query).run(title, prompt, now, id, uid);
if (result.changes === 0) {
throw new Error('Preset not found, is default, or permission denied');
}
return { changes: result.changes };
} catch (err) {
console.error('SQLite: Failed to update preset:', err);
throw err;
}
}
function deletePreset(id, uid) {
const db = sqliteClient.getDb();
const query = `
DELETE FROM prompt_presets
WHERE id = ? AND uid = ? AND is_default = 0
`;
try {
const result = db.prepare(query).run(id, uid);
if (result.changes === 0) {
throw new Error('Preset not found, is default, or permission denied');
}
return { changes: result.changes };
} catch (err) {
console.error('SQLite: Failed to delete preset:', err);
throw err;
}
}
module.exports = {
getPresets,
getPresetTemplates,
createPreset,
updatePreset,
deletePreset
};

View File

@ -0,0 +1,462 @@
const { ipcMain, BrowserWindow } = require('electron');
const Store = require('electron-store');
const authService = require('../../common/services/authService');
const userRepository = require('../../common/repositories/user');
const settingsRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../electron/windowManager');
const store = new Store({
name: 'pickle-glass-settings',
defaults: {
users: {}
}
});
// Configuration constants
const NOTIFICATION_CONFIG = {
RELEVANT_WINDOW_TYPES: ['settings', 'main'],
DEBOUNCE_DELAY: 300, // prevent spam during bulk operations (ms)
MAX_RETRY_ATTEMPTS: 3,
RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
};
// window targeting system
class WindowNotificationManager {
constructor() {
this.pendingNotifications = new Map();
}
/**
* Send notifications only to relevant windows
* @param {string} event - Event name
* @param {*} data - Event data
* @param {object} options - Notification options
*/
notifyRelevantWindows(event, data = null, options = {}) {
const {
windowTypes = NOTIFICATION_CONFIG.RELEVANT_WINDOW_TYPES,
debounce = NOTIFICATION_CONFIG.DEBOUNCE_DELAY
} = options;
if (debounce > 0) {
this.debounceNotification(event, () => {
this.sendToTargetWindows(event, data, windowTypes);
}, debounce);
} else {
this.sendToTargetWindows(event, data, windowTypes);
}
}
sendToTargetWindows(event, data, windowTypes) {
const relevantWindows = this.getRelevantWindows(windowTypes);
if (relevantWindows.length === 0) {
console.log(`[WindowNotificationManager] No relevant windows found for event: ${event}`);
return;
}
console.log(`[WindowNotificationManager] Sending ${event} to ${relevantWindows.length} relevant windows`);
relevantWindows.forEach(win => {
try {
if (data) {
win.webContents.send(event, data);
} else {
win.webContents.send(event);
}
} catch (error) {
console.warn(`[WindowNotificationManager] Failed to send ${event} to window:`, error.message);
}
});
}
getRelevantWindows(windowTypes) {
const allWindows = BrowserWindow.getAllWindows();
const relevantWindows = [];
allWindows.forEach(win => {
if (win.isDestroyed()) return;
for (const [windowName, poolWindow] of windowPool || []) {
if (poolWindow === win && windowTypes.includes(windowName)) {
if (windowName === 'settings' || win.isVisible()) {
relevantWindows.push(win);
}
break;
}
}
});
return relevantWindows;
}
debounceNotification(key, fn, delay) {
// Clear existing timeout
if (this.pendingNotifications.has(key)) {
clearTimeout(this.pendingNotifications.get(key));
}
// Set new timeout
const timeoutId = setTimeout(() => {
fn();
this.pendingNotifications.delete(key);
}, delay);
this.pendingNotifications.set(key, timeoutId);
}
cleanup() {
// Clear all pending notifications
this.pendingNotifications.forEach(timeoutId => clearTimeout(timeoutId));
this.pendingNotifications.clear();
}
}
// Global instance
const windowNotificationManager = new WindowNotificationManager();
// Default keybinds configuration
const DEFAULT_KEYBINDS = {
mac: {
moveUp: 'Cmd+Up',
moveDown: 'Cmd+Down',
moveLeft: 'Cmd+Left',
moveRight: 'Cmd+Right',
toggleVisibility: 'Cmd+\\',
toggleClickThrough: 'Cmd+M',
nextStep: 'Cmd+Enter',
manualScreenshot: 'Cmd+Shift+S',
previousResponse: 'Cmd+[',
nextResponse: 'Cmd+]',
scrollUp: 'Cmd+Shift+Up',
scrollDown: 'Cmd+Shift+Down',
},
windows: {
moveUp: 'Ctrl+Up',
moveDown: 'Ctrl+Down',
moveLeft: 'Ctrl+Left',
moveRight: 'Ctrl+Right',
toggleVisibility: 'Ctrl+\\',
toggleClickThrough: 'Ctrl+M',
nextStep: 'Ctrl+Enter',
manualScreenshot: 'Ctrl+Shift+S',
previousResponse: 'Ctrl+[',
nextResponse: 'Ctrl+]',
scrollUp: 'Ctrl+Shift+Up',
scrollDown: 'Ctrl+Shift+Down',
}
};
// Service state
let currentSettings = null;
function getDefaultSettings() {
const isMac = process.platform === 'darwin';
return {
profile: 'school',
language: 'en',
screenshotInterval: '5000',
imageQuality: '0.8',
layoutMode: 'stacked',
keybinds: isMac ? DEFAULT_KEYBINDS.mac : DEFAULT_KEYBINDS.windows,
throttleTokens: 500,
maxTokens: 2000,
throttlePercent: 80,
googleSearchEnabled: false,
backgroundTransparency: 0.5,
fontSize: 14,
contentProtection: true
};
}
async function getSettings() {
try {
const uid = authService.getCurrentUserId();
const userSettingsKey = uid ? `users.${uid}` : 'users.default';
const defaultSettings = getDefaultSettings();
const savedSettings = store.get(userSettingsKey, {});
currentSettings = { ...defaultSettings, ...savedSettings };
return currentSettings;
} catch (error) {
console.error('[SettingsService] Error getting settings from store:', error);
return getDefaultSettings();
}
}
async function saveSettings(settings) {
try {
const uid = authService.getCurrentUserId();
const userSettingsKey = uid ? `users.${uid}` : 'users.default';
const currentSaved = store.get(userSettingsKey, {});
const newSettings = { ...currentSaved, ...settings };
store.set(userSettingsKey, newSettings);
currentSettings = newSettings;
// Use smart notification system
windowNotificationManager.notifyRelevantWindows('settings-updated', currentSettings);
return { success: true };
} catch (error) {
console.error('[SettingsService] Error saving settings to store:', error);
return { success: false, error: error.message };
}
}
async function getPresets() {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
// Logged out users only see default presets
return await settingsRepository.getPresetTemplates();
}
const presets = await settingsRepository.getPresets(uid);
return presets;
} catch (error) {
console.error('[SettingsService] Error getting presets:', error);
return [];
}
}
async function getPresetTemplates() {
try {
const templates = await settingsRepository.getPresetTemplates();
return templates;
} catch (error) {
console.error('[SettingsService] Error getting preset templates:', error);
return [];
}
}
async function createPreset(title, prompt) {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
throw new Error("User not logged in, cannot create preset.");
}
const result = await settingsRepository.createPreset({ uid, title, prompt });
windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'created',
presetId: result.id,
title
});
return { success: true, id: result.id };
} catch (error) {
console.error('[SettingsService] Error creating preset:', error);
return { success: false, error: error.message };
}
}
async function updatePreset(id, title, prompt) {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
throw new Error("User not logged in, cannot update preset.");
}
await settingsRepository.updatePreset(id, { title, prompt }, uid);
windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'updated',
presetId: id,
title
});
return { success: true };
} catch (error) {
console.error('[SettingsService] Error updating preset:', error);
return { success: false, error: error.message };
}
}
async function deletePreset(id) {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
throw new Error("User not logged in, cannot delete preset.");
}
await settingsRepository.deletePreset(id, uid);
windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'deleted',
presetId: id
});
return { success: true };
} catch (error) {
console.error('[SettingsService] Error deleting preset:', error);
return { success: false, error: error.message };
}
}
async function saveApiKey(apiKey, provider = 'openai') {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
// For non-logged-in users, save to local storage
const { app } = require('electron');
const Store = require('electron-store');
const store = new Store();
store.set('apiKey', apiKey);
store.set('provider', provider);
// Notify windows
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send('api-key-validated', apiKey);
}
});
return { success: true };
}
// For logged-in users, save to database
await userRepository.saveApiKey(apiKey, uid, provider);
// Notify windows
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send('api-key-validated', apiKey);
}
});
return { success: true };
} catch (error) {
console.error('[SettingsService] Error saving API key:', error);
return { success: false, error: error.message };
}
}
async function removeApiKey() {
try {
const uid = authService.getCurrentUserId();
if (!uid) {
// For non-logged-in users, remove from local storage
const { app } = require('electron');
const Store = require('electron-store');
const store = new Store();
store.delete('apiKey');
store.delete('provider');
} else {
// For logged-in users, remove from database
await userRepository.saveApiKey(null, uid, null);
}
// Notify windows
BrowserWindow.getAllWindows().forEach(win => {
if (!win.isDestroyed()) {
win.webContents.send('api-key-removed');
}
});
return { success: true };
} catch (error) {
console.error('[SettingsService] Error removing API key:', error);
return { success: false, error: error.message };
}
}
async function updateContentProtection(enabled) {
try {
const settings = await getSettings();
settings.contentProtection = enabled;
// Update content protection in main window
const { app } = require('electron');
const mainWindow = windowPool.get('main');
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.setContentProtection(enabled);
}
return await saveSettings(settings);
} catch (error) {
console.error('[SettingsService] Error updating content protection:', error);
return { success: false, error: error.message };
}
}
function initialize() {
// cleanup
windowNotificationManager.cleanup();
// IPC handlers for settings
ipcMain.handle('settings:getSettings', async () => {
return await getSettings();
});
ipcMain.handle('settings:saveSettings', async (event, settings) => {
return await saveSettings(settings);
});
// IPC handlers for presets
ipcMain.handle('settings:getPresets', async () => {
return await getPresets();
});
ipcMain.handle('settings:getPresetTemplates', async () => {
return await getPresetTemplates();
});
ipcMain.handle('settings:createPreset', async (event, title, prompt) => {
return await createPreset(title, prompt);
});
ipcMain.handle('settings:updatePreset', async (event, id, title, prompt) => {
return await updatePreset(id, title, prompt);
});
ipcMain.handle('settings:deletePreset', async (event, id) => {
return await deletePreset(id);
});
ipcMain.handle('settings:saveApiKey', async (event, apiKey, provider) => {
return await saveApiKey(apiKey, provider);
});
ipcMain.handle('settings:removeApiKey', async () => {
return await removeApiKey();
});
ipcMain.handle('settings:updateContentProtection', async (event, enabled) => {
return await updateContentProtection(enabled);
});
console.log('[SettingsService] Initialized and ready.');
}
// Cleanup function
function cleanup() {
windowNotificationManager.cleanup();
console.log('[SettingsService] Cleaned up resources.');
}
function notifyPresetUpdate(action, presetId, title = null) {
const data = { action, presetId };
if (title) data.title = title;
windowNotificationManager.notifyRelevantWindows('presets-updated', data);
}
module.exports = {
initialize,
cleanup,
notifyPresetUpdate,
getSettings,
saveSettings,
getPresets,
getPresetTemplates,
createPreset,
updatePreset,
deletePreset,
saveApiKey,
removeApiKey,
updateContentProtection,
};

View File

@ -11,153 +11,247 @@ if (require('electron-squirrel-startup')) {
process.exit(0);
}
const { app, BrowserWindow, shell, ipcMain, dialog } = require('electron');
const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
const { createWindows } = require('./electron/windowManager.js');
const { setupLiveSummaryIpcHandlers, stopMacOSAudioCapture } = require('./features/listen/liveSummaryService.js');
const ListenService = require('./features/listen/listenService');
const { initializeFirebase } = require('./common/services/firebaseClient');
const databaseInitializer = require('./common/services/databaseInitializer');
const dataService = require('./common/services/dataService');
const authService = require('./common/services/authService');
const path = require('node:path');
const { Deeplink } = require('electron-deeplink');
const express = require('express');
const fetch = require('node-fetch');
const { autoUpdater } = require('electron-updater');
const { EventEmitter } = require('events');
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 eventBridge = new EventEmitter();
let WEB_PORT = 3000;
const openaiSessionRef = { current: null };
let deeplink = null; // Initialize as null
let pendingDeepLinkUrl = null; // Store any deep link that arrives before initialization
const listenService = new ListenService();
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
global.listenService = listenService;
function createMainWindows() {
createWindows();
//////// after_modelStateService ////////
const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService;
//////// after_modelStateService ////////
const { windowPool } = require('./electron/windowManager');
const headerWindow = windowPool.get('header');
// Native deep link handling - cross-platform compatible
let pendingDeepLinkUrl = null;
// Initialize deeplink after windows are created
if (!deeplink && headerWindow) {
function setupProtocolHandling() {
// Protocol registration - must be done before app is ready
try {
deeplink = new Deeplink({
app,
mainWindow: headerWindow,
protocol: 'pickleglass',
isDev: !app.isPackaged,
debugLogging: true
});
deeplink.on('received', (url) => {
console.log('[deeplink] received:', url);
handleCustomUrl(url);
});
console.log('[deeplink] Initialized with main window');
// Handle any pending deep link
if (pendingDeepLinkUrl) {
console.log('[deeplink] Processing pending deep link:', pendingDeepLinkUrl);
handleCustomUrl(pendingDeepLinkUrl);
pendingDeepLinkUrl = null;
if (!app.isDefaultProtocolClient('pickleglass')) {
const success = app.setAsDefaultProtocolClient('pickleglass');
if (success) {
console.log('[Protocol] Successfully set as default protocol client for pickleglass://');
} else {
console.warn('[Protocol] Failed to set as default protocol client - this may affect deep linking');
}
} else {
console.log('[Protocol] Already registered as default protocol client for pickleglass://');
}
} catch (error) {
console.error('[deeplink] Failed to initialize deep link:', error);
deeplink = null;
console.error('[Protocol] Error during protocol registration:', error);
}
// Handle protocol URLs on Windows/Linux
app.on('second-instance', (event, commandLine, workingDirectory) => {
console.log('[Protocol] Second instance command line:', commandLine);
focusMainWindow();
let protocolUrl = null;
// Search through all command line arguments for a valid protocol URL
for (const arg of commandLine) {
if (arg && typeof arg === 'string' && arg.startsWith('pickleglass://')) {
// Clean up the URL by removing problematic characters
const cleanUrl = arg.replace(/[\\₩]/g, '');
// Additional validation for Windows
if (process.platform === 'win32') {
// On Windows, ensure the URL doesn't contain file path indicators
if (!cleanUrl.includes(':') || cleanUrl.indexOf('://') === cleanUrl.lastIndexOf(':')) {
protocolUrl = cleanUrl;
break;
}
} else {
protocolUrl = cleanUrl;
break;
}
}
}
app.whenReady().then(async () => {
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
return;
if (protocolUrl) {
console.log('[Protocol] Valid URL found from second instance:', protocolUrl);
handleCustomUrl(protocolUrl);
} else {
app.on('second-instance', (event, commandLine, workingDirectory) => {
console.log('[Protocol] No valid protocol URL found in command line arguments');
console.log('[Protocol] Command line args:', commandLine);
}
});
// Handle protocol URLs on macOS
app.on('open-url', (event, url) => {
event.preventDefault();
console.log('[Protocol] Received URL via open-url:', url);
if (!url || !url.startsWith('pickleglass://')) {
console.warn('[Protocol] Invalid URL format:', url);
return;
}
if (app.isReady()) {
handleCustomUrl(url);
} else {
pendingDeepLinkUrl = url;
console.log('[Protocol] App not ready, storing URL for later');
}
});
}
function focusMainWindow() {
const { windowPool } = require('./electron/windowManager');
if (windowPool) {
const header = windowPool.get('header');
if (header) {
if (header && !header.isDestroyed()) {
if (header.isMinimized()) header.restore();
header.focus();
return;
return true;
}
}
// Fallback: focus any available window
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
const mainWindow = windows[0];
if (!mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.focus();
return true;
}
}
return false;
}
if (process.platform === 'win32') {
for (const arg of process.argv) {
if (arg && typeof arg === 'string' && arg.startsWith('pickleglass://')) {
// Clean up the URL by removing problematic characters (korean characters issue...)
const cleanUrl = arg.replace(/[\\₩]/g, '');
if (!cleanUrl.includes(':') || cleanUrl.indexOf('://') === cleanUrl.lastIndexOf(':')) {
console.log('[Protocol] Found protocol URL in initial arguments:', cleanUrl);
pendingDeepLinkUrl = cleanUrl;
break;
}
}
}
console.log('[Protocol] Initial process.argv:', process.argv);
}
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
process.exit(0);
}
// setup protocol after single instance lock
setupProtocolHandling();
app.whenReady().then(async () => {
// Setup native loopback audio capture for Windows
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
desktopCapturer.getSources({ types: ['screen'] }).then((sources) => {
// Grant access to the first screen found with loopback audio
callback({ video: sources[0], audio: 'loopback' });
}).catch((error) => {
console.error('Failed to get desktop capturer sources:', error);
callback({});
});
});
}
const dbInitSuccess = await databaseInitializer.initialize();
if (!dbInitSuccess) {
console.error('>>> [index.js] Database initialization failed - some features may not work');
} else {
// Initialize core services
initializeFirebase();
try {
await databaseInitializer.initialize();
console.log('>>> [index.js] Database initialized successfully');
}
// Clean up zombie sessions from previous runs first
sessionRepository.endAllActiveSessions();
authService.initialize();
//////// after_modelStateService ////////
modelStateService.initialize();
//////// after_modelStateService ////////
listenService.setupIpcHandlers();
askService.initialize();
settingsService.initialize();
setupGeneralIpcHandlers();
// Start web server and create windows ONLY after all initializations are successful
WEB_PORT = await startWebStack();
console.log('Web front-end listening on', WEB_PORT);
setupLiveSummaryIpcHandlers(openaiSessionRef);
setupGeneralIpcHandlers();
createWindows();
createMainWindows();
} catch (err) {
console.error('>>> [index.js] Database initialization failed - some features may not work', err);
// Optionally, show an error dialog to the user
dialog.showErrorBox(
'Application Error',
'A critical error occurred during startup. Some features might be disabled. Please restart the application.'
);
}
initAutoUpdater();
// Process any pending deep link after everything is initialized
if (pendingDeepLinkUrl) {
console.log('[Protocol] Processing pending URL:', pendingDeepLinkUrl);
handleCustomUrl(pendingDeepLinkUrl);
pendingDeepLinkUrl = null;
}
});
app.on('window-all-closed', () => {
stopMacOSAudioCapture();
listenService.stopMacOSAudioCapture();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
stopMacOSAudioCapture();
app.on('before-quit', async () => {
console.log('[Shutdown] App is about to quit.');
listenService.stopMacOSAudioCapture();
await sessionRepository.endAllActiveSessions();
databaseInitializer.close();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindows();
createWindows();
}
});
// Add macOS native deep link handling as fallback
app.on('open-url', (event, url) => {
event.preventDefault();
console.log('[app] open-url received:', url);
if (!deeplink) {
// Store the URL if deeplink isn't ready yet
pendingDeepLinkUrl = url;
console.log('[app] Deep link stored for later processing');
} else {
handleCustomUrl(url);
}
});
// Ensure app can handle the protocol
app.setAsDefaultProtocolClient('pickleglass');
function setupGeneralIpcHandlers() {
ipcMain.handle('open-external', async (event, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error('Error opening external URL:', error);
return { success: false, error: error.message };
}
});
const userRepository = require('./common/repositories/user');
const presetRepository = require('./common/repositories/preset');
ipcMain.handle('save-api-key', async (event, apiKey) => {
ipcMain.handle('save-api-key', (event, apiKey) => {
try {
await dataService.saveApiKey(apiKey);
userRepository.saveApiKey(apiKey, authService.getCurrentUserId());
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('api-key-updated');
});
@ -168,21 +262,12 @@ function setupGeneralIpcHandlers() {
}
});
ipcMain.handle('check-api-key', async () => {
return await dataService.checkApiKey();
ipcMain.handle('get-user-presets', () => {
return presetRepository.getPresets(authService.getCurrentUserId());
});
ipcMain.handle('get-user-presets', async () => {
return await dataService.getUserPresets();
});
ipcMain.handle('get-preset-templates', async () => {
return await dataService.getPresetTemplates();
});
ipcMain.on('set-current-user', (event, uid) => {
console.log(`[IPC] set-current-user: ${uid}`);
dataService.setCurrentUser(uid);
ipcMain.handle('get-preset-templates', () => {
return presetRepository.getPresetTemplates();
});
ipcMain.handle('start-firebase-auth', async () => {
@ -197,69 +282,144 @@ function setupGeneralIpcHandlers() {
}
});
ipcMain.on('firebase-auth-success', async (event, firebaseUser) => {
console.log('[IPC] firebase-auth-success:', firebaseUser.uid);
try {
await dataService.findOrCreateUser(firebaseUser);
dataService.setCurrentUser(firebaseUser.uid);
BrowserWindow.getAllWindows().forEach(win => {
if (win !== event.sender.getOwnerBrowserWindow()) {
win.webContents.send('user-changed', firebaseUser);
}
});
} catch (error) {
console.error('[IPC] Failed to handle firebase-auth-success:', error);
}
});
ipcMain.handle('get-api-url', () => {
return process.env.pickleglass_API_URL || 'http://localhost:9001';
});
ipcMain.handle('get-web-url', () => {
return process.env.pickleglass_WEB_URL || 'http://localhost:3000';
});
ipcMain.on('get-api-url-sync', (event) => {
event.returnValue = process.env.pickleglass_API_URL || 'http://localhost:9001';
ipcMain.handle('get-current-user', () => {
return authService.getCurrentUser();
});
ipcMain.handle('get-database-status', async () => {
return await databaseInitializer.getStatus();
});
// --- Web UI Data Handlers (New) ---
setupWebDataHandlers();
}
ipcMain.handle('reset-database', async () => {
return await databaseInitializer.reset();
});
function setupWebDataHandlers() {
const sessionRepository = require('./common/repositories/session');
const sttRepository = require('./features/listen/stt/repositories');
const summaryRepository = require('./features/listen/summary/repositories');
const askRepository = require('./features/ask/repositories');
const userRepository = require('./common/repositories/user');
const presetRepository = require('./common/repositories/preset');
ipcMain.handle('get-current-user', async () => {
const handleRequest = (channel, responseChannel, payload) => {
let result;
const currentUserId = authService.getCurrentUserId();
try {
const user = await dataService.sqliteClient.getUser(dataService.currentUserId);
if (user) {
return {
id: user.uid,
name: user.display_name,
isAuthenticated: user.uid !== 'default_user'
};
switch (channel) {
// SESSION
case 'get-sessions':
result = sessionRepository.getAllByUserId(currentUserId);
break;
case 'get-session-details':
const session = sessionRepository.getById(payload);
if (!session) {
result = null;
break;
}
throw new Error('User not found in DataService');
} catch (error) {
console.error('Failed to get current user via DataService:', error);
return {
id: 'default_user',
name: 'Default User',
isAuthenticated: false
};
const transcripts = sttRepository.getAllTranscriptsBySessionId(payload);
const ai_messages = askRepository.getAllAiMessagesBySessionId(payload);
const summary = summaryRepository.getSummaryBySessionId(payload);
result = { session, transcripts, ai_messages, summary };
break;
case 'delete-session':
result = sessionRepository.deleteWithRelatedData(payload);
break;
case 'create-session':
const id = sessionRepository.create(currentUserId, 'ask');
if (payload.title) {
sessionRepository.updateTitle(id, payload.title);
}
});
result = { id };
break;
// USER
case 'get-user-profile':
result = userRepository.getById(currentUserId);
break;
case 'update-user-profile':
result = userRepository.update({ uid: currentUserId, ...payload });
break;
case 'find-or-create-user':
result = userRepository.findOrCreate(payload);
break;
case 'save-api-key':
result = userRepository.saveApiKey(payload, currentUserId);
break;
case 'check-api-key-status':
const user = userRepository.getById(currentUserId);
result = { hasApiKey: !!user?.api_key && user.api_key.length > 0 };
break;
case 'delete-account':
result = userRepository.deleteById(currentUserId);
break;
// PRESET
case 'get-presets':
result = presetRepository.getPresets(currentUserId);
break;
case 'create-preset':
result = presetRepository.create({ ...payload, uid: currentUserId });
settingsService.notifyPresetUpdate('created', result.id, payload.title);
break;
case 'update-preset':
result = presetRepository.update(payload.id, payload.data, currentUserId);
settingsService.notifyPresetUpdate('updated', payload.id, payload.data.title);
break;
case 'delete-preset':
result = presetRepository.delete(payload, currentUserId);
settingsService.notifyPresetUpdate('deleted', payload);
break;
// BATCH
case 'get-batch-data':
const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions'];
const batchResult = {};
if (includes.includes('profile')) {
batchResult.profile = userRepository.getById(currentUserId);
}
if (includes.includes('presets')) {
batchResult.presets = presetRepository.getPresets(currentUserId);
}
if (includes.includes('sessions')) {
batchResult.sessions = sessionRepository.getAllByUserId(currentUserId);
}
result = batchResult;
break;
default:
throw new Error(`Unknown web data channel: ${channel}`);
}
eventBridge.emit(responseChannel, { success: true, data: result });
} catch (error) {
console.error(`Error handling web data request for ${channel}:`, error);
eventBridge.emit(responseChannel, { success: false, error: error.message });
}
};
eventBridge.on('web-data-request', handleRequest);
}
async function handleCustomUrl(url) {
try {
console.log('[Custom URL] Processing URL:', url);
// Validate and clean URL
if (!url || typeof url !== 'string' || !url.startsWith('pickleglass://')) {
console.error('[Custom URL] Invalid URL format:', url);
return;
}
// Clean up URL by removing problematic characters
const cleanUrl = url.replace(/[\\₩]/g, '');
// Additional validation
if (cleanUrl !== url) {
console.log('[Custom URL] Cleaned URL from:', url, 'to:', cleanUrl);
url = cleanUrl;
}
const urlObj = new URL(url);
const action = urlObj.hostname;
const params = Object.fromEntries(urlObj.searchParams);
@ -293,18 +453,12 @@ async function handleCustomUrl(url) {
}
async function handleFirebaseAuthCallback(params) {
const userRepository = require('./common/repositories/user');
const { token: idToken } = params;
if (!idToken) {
console.error('[Auth] Firebase auth callback is missing ID token.');
const { windowPool } = require('./electron/windowManager');
const header = windowPool.get('header');
if (header) {
header.webContents.send('login-successful', {
error: 'authentication_failed',
message: 'ID token not provided in deep link.'
});
}
// No need to send IPC, the UI won't transition without a successful auth state change.
return;
}
@ -320,8 +474,6 @@ async function handleFirebaseAuthCallback(params) {
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Failed to exchange token.');
}
@ -336,71 +488,32 @@ async function handleFirebaseAuthCallback(params) {
photoURL: user.picture
};
await dataService.findOrCreateUser(firebaseUser);
dataService.setCurrentUser(user.uid);
// 1. Sync user data to local DB
userRepository.findOrCreate(firebaseUser);
console.log('[Auth] User data synced with local DB.');
// if (firebaseUser.email && idToken) {
// try {
// const { getVirtualKeyByEmail, setApiKey } = require('./electron/windowManager');
// console.log('[Auth] Fetching virtual key for:', firebaseUser.email);
// const vKey = await getVirtualKeyByEmail(firebaseUser.email, idToken);
// console.log('[Auth] Virtual key fetched successfully');
// await setApiKey(vKey);
// console.log('[Auth] Virtual key saved successfully');
// const { setCurrentFirebaseUser } = require('./electron/windowManager');
// setCurrentFirebaseUser(firebaseUser);
// const { windowPool } = require('./electron/windowManager');
// windowPool.forEach(win => {
// if (win && !win.isDestroyed()) {
// win.webContents.send('api-key-updated');
// win.webContents.send('firebase-user-updated', firebaseUser);
// }
// });
// } catch (error) {
// console.error('[Auth] Virtual key fetch failed:', error);
// }
// }
// 2. Sign in using the authService in the main process
await authService.signInWithCustomToken(customToken);
console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...');
// 3. Focus the app window
const { windowPool } = require('./electron/windowManager');
const header = windowPool.get('header');
if (header) {
if (header.isMinimized()) header.restore();
header.focus();
console.log('[Auth] Sending custom token to renderer for sign-in.');
header.webContents.send('login-successful', {
customToken: customToken,
user: firebaseUser,
success: true
});
BrowserWindow.getAllWindows().forEach(win => {
if (win !== header) {
win.webContents.send('user-changed', firebaseUser);
}
});
console.log('[Auth] Firebase authentication completed successfully');
} else {
console.error('[Auth] Header window not found after getting custom token.');
console.error('[Auth] Header window not found after auth callback.');
}
} catch (error) {
console.error('[Auth] Error during custom token exchange:', error);
console.error('[Auth] Error during custom token exchange or sign-in:', error);
// The UI will not change, and the user can try again.
// Optionally, send a generic error event to the renderer.
const { windowPool } = require('./electron/windowManager');
const header = windowPool.get('header');
if (header) {
header.webContents.send('login-successful', {
error: 'authentication_failed',
message: error.message
});
header.webContents.send('auth-failed', { message: error.message });
}
}
}
@ -462,7 +575,7 @@ async function startWebStack() {
});
const createBackendApp = require('../pickleglass_web/backend_node');
const nodeApi = createBackendApp();
const nodeApi = createBackendApp(eventBridge);
const staticDir = app.isPackaged
? path.join(process.resourcesPath, 'out')

View File

@ -1,41 +1,2 @@
// See the Electron documentation for details on how to use preload scripts:
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('ipcRenderer', {
send: (channel, data) => {
const validChannels = ['set-current-user', 'firebase-auth-success'];
if (validChannels.includes(channel)) {
ipcRenderer.send(channel, data);
}
},
sendSync: (channel, data) => {
const validChannels = ['get-api-url-sync'];
if (validChannels.includes(channel)) {
return ipcRenderer.sendSync(channel, data);
}
},
on: (channel, func) => {
const validChannels = ['api-key-updated'];
if (validChannels.includes(channel)) {
const newCallback = (_, ...args) => func(...args);
ipcRenderer.on(channel, newCallback);
return () => {
ipcRenderer.removeListener(channel, newCallback);
};
}
},
invoke: (channel, ...args) => {
const validChannels = ['save-api-key'];
if (validChannels.includes(channel)) {
return ipcRenderer.invoke(channel, ...args);
}
},
removeAllListeners: (channel) => {
const validChannels = ['api-key-updated'];
if (validChannels.includes(channel)) {
ipcRenderer.removeAllListeners(channel);
}
},
});

View File

@ -1,50 +0,0 @@
# Code Formatting Test
Test various code blocks to ensure proper formatting:
## Python Example (from screenshot)
```python
class Solution:
def subsets(self, nums: List[int]) -> List[List[int]]:
res = []
def backtrack(start, path):
res.append(path[:])
for i in range(start, len(nums)):
path.append(nums[i])
backtrack(i + 1, path)
path.pop()
backtrack(0, [])
return res
```
## JavaScript Example
```javascript
function calculateSum(numbers) {
let sum = 0;
for (let i = 0; i < numbers.length; i++) {
sum += numbers[i];
}
return sum;
}
```
## Indented Code with Complex Structure
```python
def complex_function():
if True:
for i in range(10):
if i % 2 == 0:
print(f"Even: {i}")
else:
print(f"Odd: {i}")
if i > 5:
print(" Greater than 5")
nested = {
"key": "value",
"nested": {
"deep": "structure"
}
}
```