Compare commits

...

90 Commits

Author SHA1 Message Date
sanio
7455907835 fix auto adujusting height 2025-07-15 21:43:46 +09:00
samtiz
60a8c30157 Merge branch 'main' into feature/encryption 2025-07-15 20:16:48 +09:00
samtiz
dad74875a0 keychain permission + modelStateService rely only on db 2025-07-15 20:16:38 +09:00
sanio
bba38ac56f refactor windowmanager finished 2025-07-15 20:10:46 +09:00
sanio
ecae4050bb refactored layoutmanager, movementmanager 2025-07-15 18:21:22 +09:00
sanio
f755fdb9e3 cleaning dependency in windowmanager 2025-07-15 18:00:31 +09:00
samtiz
a27ab05fa8 Merge branch 'refactor/localmodel' into feature/encryption 2025-07-15 16:29:16 +09:00
samtiz
8592d1c4ed remove providerSettings firebaseRepository + minor refactor 2025-07-15 16:10:32 +09:00
samtiz
ab23c10006 WIP encryption cleanup + providerSetting refactor 2025-07-15 16:01:34 +09:00
jhyang0
fc16532cd9 whisper install url fix 2025-07-15 15:48:45 +09:00
jhyang0
7f98acb5e3 whisper install fix 2025-07-15 15:32:24 +09:00
sanio
698473007a delete movementmanager dependency in shortcutsservice 2025-07-15 14:59:14 +09:00
jhyang0
9359b32c01 Add localAIManager 2025-07-15 14:05:50 +09:00
jhyang0
6ece74737b Refactor: Implement local AI service management system
- Add LocalAIServiceManager for centralized local AI service lifecycle management
- Refactor provider settings to support local AI service configuration
- Remove userModelSelections in favor of provider settings integration
- Update whisper service to use new local AI management system
- Implement lazy loading and auto-cleanup for local AI services
- Update UI components to reflect new local AI service architecture
2025-07-15 14:04:34 +09:00
sanio
c0cf74273a add deepgram 2025-07-15 03:47:47 +09:00
sanio
4d93df09e2 centralized window layout/movement feature to windowmanager 2025-07-15 01:01:17 +09:00
samtiz
94ae002d83 fix: remove authservice injection on userRepo 2025-07-14 04:29:12 +09:00
samtiz
a2f57cbfa9 authService injection on init 2025-07-14 04:11:38 +09:00
jhyang0
e244ce1d4d add smd 2025-07-14 03:23:53 +09:00
sanio
f764ad5362 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-14 03:16:37 +09:00
sanio
bcc8a59882 add screen only ask, retrieve loading dot 2025-07-14 03:16:29 +09:00
samtiz
c464098951 icon path fix 2025-07-14 03:10:49 +09:00
samtiz
2a3c7db200 header privacy button fix 2025-07-14 03:00:28 +09:00
sanio
aa14a1d0b6 fix askview focus logic 2025-07-14 02:47:37 +09:00
sanio
fbe5c22aa4 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-14 02:24:03 +09:00
sanio
a509e87b22 add fade animation to window 2025-07-14 02:24:00 +09:00
jhyang0
290ee0ed29 minor update + merge 2025-07-14 02:13:14 +09:00
sanio
2bfcadecb4 delete legacy code 2025-07-14 01:53:26 +09:00
sanio
8da13dcb27 fix window animation 2025-07-14 00:23:46 +09:00
Ho Jin Yu
7d33ea9ca8
[Refactor] full refactor and file structure changed (#125)
* refactoring the bridge

* Update aec submodule

* folder structure refactor

* fixing ask logic

* resolve import err

* fix askview

* fix header content html path

* fix systemaudiodump path

* centralized ask logic

* delete legacy code

* change askservice to class

* settingsService facade

* fix getCurrentModelInfo

* common service ipc moved to featureBridge

* featureBridge init

* ui fix

* add featureBridge func for listenservice

* fix preload conflict

* shortcuts seperated

* refactor ask

* transfer roles from askview to askservice

* modifying windowBridge

* delete legacy ask code

* retrieve conversation history for askserice

* fix legacy code

* shortcut moved

* change naming for featurebridge

* screenshot moved from windowManager

* rough refactor done

---------

Co-authored-by: sanio <sanio@pickle.com>
Co-authored-by: jhyang0 <junhyuck0819@gmail.com>
2025-07-13 15:31:24 +09:00
jhyang0
9f29fa5873 Refactor: Migrate Settings from electron-store to Centralized Database #113 2025-07-12 15:56:26 +09:00
sanio
9e0c74eed4 Update aec submodule: fix build 2025-07-12 04:20:56 +09:00
sanio
fc81c05dbe fix aec build 2025-07-12 04:15:33 +09:00
sanio
9d913f052c debugging AEC 2025-07-11 20:14:34 +09:00
sanio
68f9042a69 seperate toggleFeature in windowmanager 2025-07-11 18:52:02 +09:00
sanio
ffcc1cb9a3 fix get-header-position deletion on windowmanger 2025-07-11 18:21:46 +09:00
sanio
2ce82a7edc retrieve mouth dragging on button of main header 2025-07-11 17:58:31 +09:00
sanio
50ffaa0894 Merge branch 'pr-107' 2025-07-11 17:33:59 +09:00
sanio
5698791d46 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-11 17:32:42 +09:00
sanio
a10cc77dfb liquid glass ui beta 2025-07-11 17:30:54 +09:00
sanio
d1c81ce012 merging pr-107 2025-07-11 17:07:06 +09:00
sanio
6ec45e138f Merge branch 'main' into pr-107 2025-07-11 17:06:18 +09:00
Janvi_Chauhan
77443beeaa
Fix: Added Clear and Submit Buttons (#95) (#108)
* Fix: Added Clear and Submit Buttons (#95)

* Update: Removed clear button
2025-07-11 17:04:00 +09:00
sanio
9b0ad9607b Merge branch 'main' into feat/centralize-anim 2025-07-11 05:46:55 +09:00
sanio
bcefa75154 centralize mainheader listen session logic 2025-07-11 05:46:11 +09:00
sanio
e86c2db464 toggle listen button logic improved 2025-07-11 02:39:12 +09:00
samtiz
dfb430aadd Windows codesigning 2025-07-10 19:21:01 +09:00
jhyang0
3031d0d288 minor fix 2025-07-10 17:30:33 +09:00
samtiz
2a1edb6ed8 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-10 04:51:08 +09:00
samtiz
0e9df73caa remove electron forge dependency 2025-07-10 04:50:34 +09:00
sanio
88cacb4bc3 remove movementmanager dependency on toggleAllWindowsVisibility 2025-07-10 04:17:16 +09:00
sanio
5e5fb4d0e9 fix settings window show, hide, cancel-hide naming 2025-07-10 03:38:02 +09:00
sanio
fde34ef3de centralized keyframe, liquid glass bypass beta 2025-07-10 03:29:32 +09:00
sanio
682e6391c8 Merge branch 'main' into feature/animation_refactoring 2025-07-10 03:25:41 +09:00
jhyang0
e2c286fc81 use pickle == pickle provide model 2025-07-10 01:59:14 +09:00
avarayr
c0d2331712
fix: true glass like the demos 2025-07-09 12:08:07 -04:00
jhyang0
1ab3ebe88d whisper fix 2025-07-10 01:05:03 +09:00
samtiz
aa9d49136d fix assign other error 2025-07-10 00:50:56 +09:00
samtiz
ab00ac7d9a resolve assign error 2025-07-10 00:33:44 +09:00
samtiz
980083a211 Merge branch 'main' of https://github.com/pickle-com/glass 2025-07-10 00:01:46 +09:00
samtiz
a975ba94f9 contribution guide update + templates 2025-07-10 00:01:29 +09:00
jhyang0
0ff9f4b74e Add local LLM, STT 2025-07-09 23:57:47 +09:00
sanio
186f0a9839 centralized keyframe 2025-07-09 22:43:28 +09:00
samtiz
594f9e8d19 Merge branch 'main' into feature/firebase 2025-07-09 21:17:45 +09:00
samtiz
c497234230 firestore + full end-to-end encryption 2025-07-09 21:17:16 +09:00
sanio
ae24d49fe5 solve conflict to use windowsize parm in movestep 2025-07-09 18:56:46 +09:00
sanio
a4e61c73e5 Merge branch 'main' into pr-90 2025-07-09 18:54:06 +09:00
sanio
c285c7e477 Merge branch 'main' into pr-88 2025-07-09 18:42:18 +09:00
sanio
dd8d217a97 retrieve anamation 2025-07-09 18:36:51 +09:00
sanio
338eef7112 Merge remote-tracking branch 'origin/main' into pr-82 2025-07-09 18:25:52 +09:00
samtiz
bf344268e7 Merge branch 'main' into feature/firebase 2025-07-09 17:51:58 +09:00
samtiz
e87c73058a refactor autoUpdate 2025-07-09 17:25:26 +09:00
Ronnie Ghose
ee28516a4e
Add app auto-update toggle option (#87) 2025-07-09 16:14:23 +09:00
Cursor Agent
1b48151f08 Clean up documentation files
- Removed FIXES_SUMMARY.md
- Removed test_fixes.js
- Keeping only the core code fixes
2025-07-09 00:29:16 +00:00
Cursor Agent
8d857e091a Clean up temporary test documentation
- Removed test_window_behavior.md as it's been superseded by FIXES_SUMMARY.md
- Keeping comprehensive documentation in FIXES_SUMMARY.md and test_fixes.js
2025-07-09 00:27:41 +00:00
Cursor Agent
1f9250fef4 Add comprehensive testing documentation and fix summary
- Added test_fixes.js script for automated testing
- Added FIXES_SUMMARY.md with detailed explanation of fixes
- Included testing instructions and expected results
- Documented root cause analysis and solution implementation
2025-07-09 00:27:27 +00:00
Cursor Agent
181bd2bcc4 Fix window resize and movement issues (#65)
- Fixed resize-header-window handler to properly center window without width increase
- Fixed movement clamping logic to prevent progressive restriction
- Added proper DPI handling to prevent pixelation
- Added comprehensive debug logging for troubleshooting
- Improved position tracking in animation system

Addresses:
- Unexpected width increase during long-press
- Progressive restriction of downward movement
- UI pixelation issues
2025-07-09 00:26:27 +00:00
samtiz
d161102bc0 release v0.2.3 2025-07-09 04:57:29 +09:00
samtiz
fae6962297 firebase WIP 2025-07-09 04:56:20 +09:00
Sarang19
a1acce1a3f
Merge branch 'main' into feature/selectable-text 2025-07-09 00:00:41 +05:30
Sarang19
5ea5e86621 Fix: Enable selectable text in assistant responses (#56) 2025-07-08 23:56:53 +05:30
samtiz
9977387fbc stt UI fix + more responsibility 2025-07-09 00:19:46 +09:00
sanio
55961c956a Merge branch 'pr-84' 2025-07-08 22:43:39 +09:00
sanio
f6540ef3ec
Merge pull request #86 from pickle-com/revert-85-revert-84-main
Revert "Revert "Fix : Gemini JSON Format Answer fixed to plain Text answer""
2025-07-08 22:42:21 +09:00
sanio
194507cb50
Revert "Revert "Fix : Gemini JSON Format Answer fixed to plain Text answer"" 2025-07-08 22:42:08 +09:00
sanio
159dbca683
Merge pull request #85 from pickle-com/revert-84-main
Revert "Fix : Gemini JSON Format Answer fixed to plain Text answer"
2025-07-08 22:33:32 +09:00
sanio
2bb5fcfae7 Merge remote-tracking branch 'origin/main' into pr-84 2025-07-08 22:21:54 +09:00
Surya
9caa3dc062
Merge branch 'main' into main 2025-07-08 18:05:56 +05:30
Surya
5cc0d2b83a Fix : Gemini JSON Format Answer fixed to plain text answer 2025-07-08 15:54:04 +05:30
Aditya U
8aab4feb22
fix: prevent window resizing during movement with Ctrl+Arrow and edge
Fixed window resizing issue when moving with Ctrl+Arrow keys by  setting window bounds
2025-07-08 13:27:20 +05:30
135 changed files with 16786 additions and 10824 deletions

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@ -0,0 +1,31 @@
---
name: Bug Report
about: Create a report to help us improve
title: "[BUG] "
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Environment (please complete the following information):**
- OS: [e.g. macOS, Windows]
- App Version [e.g. 1.0.0]
**Additional context**
Add any other context about the problem here.

View File

@ -0,0 +1,20 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEAT] "
labels: feature
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

28
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View File

@ -0,0 +1,28 @@
---
name: Pull Request
about: Propose a change to the codebase
---
## Summary of Changes
Please provide a brief, high-level summary of the changes in this pull request.
## Related Issue
- Closes #XXX
*Please replace `XXX` with the issue number that this pull request resolves. If it does not resolve a specific issue, please explain why this change is needed.*
## Contributor's Self-Review Checklist
Please check the boxes that apply. This is a reminder of what we look for in a good pull request.
- [ ] I have read the [CONTRIBUTING.md](https://github.com/your-org/your-repo/blob/main/CONTRIBUTING.md) document.
- [ ] My code follows the project's coding style and architectural patterns as described in [DESIGN_PATTERNS.md](https://github.com/your-org/your-repo/blob/main/docs/DESIGN_PATTERNS.md).
- [ ] I have added or updated relevant tests for my changes.
- [ ] I have updated the documentation to reflect my changes (if applicable).
- [ ] My changes have been tested locally and are working as expected.
## Additional Context (Optional)
Add any other context or screenshots about the pull request here.

62
.github/workflows/assign-on-comment.yml vendored Normal file
View File

@ -0,0 +1,62 @@
name: Assign on Comment
on:
issue_comment:
types: [created]
jobs:
# Job 1: Any contributor can self-assign
self-assign:
# Only run if the comment is exactly '/assign'
if: startsWith(github.event.comment.body, '/assign') && !contains(github.event.comment.body, '@')
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Assign commenter to the issue
uses: actions/github-script@v7
with:
script: |
// Assign the commenter as the assignee
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
assignees: [context.actor]
});
// Add a rocket (🚀) reaction to indicate success
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: 'rocket'
});
# Job 2: Admin can assign others
assign-others:
# Only run if the comment starts with '/assign @' and the commenter is in the admin group
if: startsWith(github.event.comment.body, '/assign @') && contains(fromJson('["OWNER", "COLLABORATOR", "MEMBER"]'), github.event.comment.author_association)
runs-on: ubuntu-latest
permissions:
issues: write
steps:
- name: Assign mentioned user
uses: actions/github-script@v7
with:
script: |
const mention = context.payload.comment.body.split(' ')[1];
const assignee = mention.substring(1); // Remove '@'
// Assign the mentioned user as the assignee
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
assignees: [assignee]
});
// Add a thumbs up (+1) reaction to indicate success
await github.rest.reactions.createForIssueComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: context.payload.comment.id,
content: '+1'
});

View File

@ -1,2 +1,2 @@
src/assets src/ui/assets
node_modules node_modules

View File

@ -2,52 +2,57 @@
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. 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 This document guides you through the entire contribution process, from finding an issue to getting your pull request merged.
### 👥 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 ## 🚀 Contribution Workflow
| Issue Type | Priority | To ensure a smooth and effective workflow, all contributions must go through the following process. Please follow these steps carefully.
|----------------------------------------------------|---------------------|
| 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 |
|
### 1. Find or Create an Issue
All work begins with an issue. This is the central place to discuss new ideas and track progress.
- Browse our existing [**Issues**](https://github.com/pickle-com/glass/issues) to find something you'd like to work on. We recommend looking for issues labeled `good first issue` if you're new!
- If you have a new idea or find a bug that hasn't been reported, please **create a new issue** using our templates.
### 2. Claim the Issue
To avoid duplicate work, you must claim an issue before you start coding.
- On the issue you want to work on, leave a comment with the command:
```
/assign
```
- Our GitHub bot will automatically assign the issue to you. Once your profile appears in the **`Assignees`** section on the right, you are ready to start development.
### 3. Fork & Create a Branch
Now it's time to set up your local environment.
1. **Fork** the repository to your own GitHub account.
2. **Clone** your forked repository to your local machine.
3. **Create a new branch** from `main`. A clear branch name is recommended.
- For new features: `feat/short-description` (e.g., `feat/user-login-ui`)
- For bug fixes: `fix/short-description` (e.g., `fix/header-rendering-bug`)
### 4. Develop
Write your code! As you work, please adhere to our quality standards.
- **Code Style & Quality**: Our project uses `Prettier` and `ESLint` to maintain a consistent code style.
- **Architecture & Design Patterns**: All new code must be consistent with the project's architecture. Please read our **[Design Patterns Guide](https://github.com/pickle-com/glass/blob/main/docs/DESIGN_PATTERNS.md)** before making significant changes.
### 5. Create a Pull Request (PR)
Once your work is ready, create a Pull Request to the `main` branch of the original repository.
- **Fill out the PR Template**: Our template will appear automatically. Please provide a clear summary of your changes.
- **Link the Issue**: In the PR description, include the line `Closes #XXX` (e.g., `Closes #123`) to link it to the issue you resolved. This is mandatory.
- **Code Review**: A maintainer will review your code, provide feedback, and merge it.
---
# Developing # Developing
@ -85,11 +90,4 @@ Please ensure that you can make a full production build before pushing code.
npm run lint npm run lint
``` ```
If you get errors, be sure to fix them before committing. 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

@ -69,7 +69,7 @@ npm run setup
**Currently Supporting:** **Currently Supporting:**
- OpenAI API: Get OpenAI API Key [here](https://platform.openai.com/api-keys) - 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) - Gemini API: Get Gemini API Key [here](https://aistudio.google.com/apikey)
- Local LLM (WIP) - Local LLM Ollama & Whisper
### Liquid Glass Design (coming soon) ### Liquid Glass Design (coming soon)
@ -115,8 +115,6 @@ We have a list of [help wanted](https://github.com/pickle-com/glass/issues?q=is%
| Status | Issue | Description | | Status | Issue | Description |
|--------|--------------------------------|---------------------------------------------------| |--------|--------------------------------|---------------------------------------------------|
| 🚧 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 | Liquid Glass | Liquid Glass UI for MacOS 26 | | 🚧 WIP | Liquid Glass | Liquid Glass UI for MacOS 26 |
### Changelog ### Changelog
@ -125,7 +123,7 @@ We have a list of [help wanted](https://github.com/pickle-com/glass/issues?q=is%
- Jul 6: Full code refactoring has done. - Jul 6: Full code refactoring has done.
- Jul 7: Now support Claude, LLM/STT model selection - 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) - Jul 8: Now support Windows(beta), Improved AEC by Rust(to seperate mic/system audio), shortcut editing(beta)
- Jul 8: Now support Local LLM & STT, Firebase Data Storage
## About Pickle ## About Pickle

2
aec

@ -1 +1 @@
Subproject commit f00bb1fb948053c752b916adfee19f90644a0b2f Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163

View File

@ -14,8 +14,8 @@ const baseConfig = {
}; };
const entryPoints = [ const entryPoints = [
{ in: 'src/app/HeaderController.js', out: 'public/build/header' }, { in: 'src/ui/app/HeaderController.js', out: 'public/build/header' },
{ in: 'src/app/PickleGlassApp.js', out: 'public/build/content' }, { in: 'src/ui/app/PickleGlassApp.js', out: 'public/build/content' },
]; ];
async function build() { async function build() {

126
docs/DESIGN_PATTERNS.md Normal file
View File

@ -0,0 +1,126 @@
# Glass: Design Patterns and Architectural Overview
Welcome to the Glass project! This document is the definitive guide to the architectural patterns, conventions, and design philosophy that guide our development. Adhering to these principles is essential for building new features, maintaining the quality of our codebase, and ensuring a stable, consistent developer experience.
The architecture is designed to be modular, robust, and clear, with a strict separation of concerns.
---
## Core Architectural Principles
These are the fundamental rules that govern the entire application.
1. **Centralized Data Logic**: All data persistence logic (reading from or writing to a database) is centralized within the **Electron Main Process**. The UI layers (both Electron's renderer and the web dashboard) are forbidden from accessing data sources directly.
2. **Feature-Based Modularity**: Code is organized by feature (`src/features`) to promote encapsulation and separation of concerns. A new feature should be self-contained within its own directory.
3. **Dual-Database Repositories**: The data access layer uses a **Repository Pattern** that abstracts away the underlying database. Every repository that handles user data **must** have two implementations: one for the local `SQLite` database and one for the cloud `Firebase` database. Both must expose an identical interface.
4. **AI Provider Abstraction**: AI model interactions are abstracted using a **Factory Pattern**. To add a new provider (e.g., a new LLM), you only need to create a new provider module that conforms to the base interface in `src/common/ai/providers/` and register it in the `factory.js`.
5. **Single Source of Truth for Schema**: The schema for the local SQLite database is defined in a single location: `src/common/config/schema.js`. Any change to the database structure **must** be updated here.
6. **Encryption by Default**: All sensitive user data **must** be encrypted before being persisted to Firebase. This includes, but is not limited to, API keys, conversation titles, transcription text, and AI-generated summaries. This is handled automatically by the `createEncryptedConverter` Firestore helper.
---
## I. Electron Application Architecture (`src/`)
This section details the architecture of the core desktop application.
### 1. Overall Pattern: Service-Repository
The Electron app's logic is primarily built on a **Service-Repository** pattern, with the Views being the HTML/JS files in the `src/app` and `src/features` directories.
- **Views** (`*.html`, `*View.js`): The UI layer. Views are responsible for rendering the interface and capturing user interactions. They are intentionally kept "dumb" and delegate all significant logic to a corresponding Service.
- **Services** (`*Service.js`): Services contain the application's business logic. They act as the intermediary between Views and Repositories. For example, `sttService` contains the logic for STT, while `summaryService` handles the logic for generating summaries.
- **Repositories** (`*.repository.js`): Repositories are responsible for all data access. They are the *only* part of the application that directly interacts with `sqliteClient` or `firebaseClient`.
**Location of Modules:**
- **Feature-Specific**: If a service or repository is used by only one feature, it should reside within that feature's directory (e.g., `src/features/listen/summary/summaryService.js`).
- **Common**: If a service or repository is shared across multiple features (like `authService` or `userRepository`), it must be placed in `src/common/services/` or `src/common/repositories/` respectively.
### 2. Data Persistence: The Dual Repository Factory
The application dynamically switches between using the local SQLite database and the cloud-based Firebase Firestore.
- **SQLite**: The default data store for all users, especially those not logged in. This ensures full offline functionality. The low-level client is `src/common/services/sqliteClient.js`.
- **Firebase**: Used exclusively for users who are authenticated. This enables data synchronization across devices and with the web dashboard.
The selection mechanism is a sophisticated **Factory and Adapter Pattern** located in the `index.js` file of each repository directory (e.g., `src/common/repositories/session/index.js`).
**How it works:**
1. **Service Call**: A service makes a call to a high-level repository function, like `sessionRepository.create('ask')`. The service is unaware of the user's state or the underlying database.
2. **Repository Selection (Factory)**: The `index.js` adapter logic first determines which underlying repository to use. It imports and calls `authService.getCurrentUser()` to check the login state. If the user is logged in, it selects `firebase.repository.js`; otherwise, it defaults to `sqlite.repository.js`.
3. **UID Injection (Adapter)**: The adapter then retrieves the current user's ID (`uid`) from `authService.getCurrentUserId()`. It injects this `uid` into the actual, low-level repository call (e.g., `firebaseRepository.create(uid, 'ask')`).
4. **Execution**: The selected repository (`sqlite` or `firebase`) executes the data operation.
This powerful pattern accomplishes two critical goals:
- It makes the services completely agnostic about the underlying data source.
- It frees the services from the responsibility of managing and passing user IDs for every database query.
**Visualizing the Data Flow**
```mermaid
graph TD
subgraph "Electron Main Process"
A -- User Action --> B[Service Layer];
B -- Data Request --> C[Repository Factory];
C -- Check Login Status --> D{Decision};
D -- No --> E[SQLite Repository];
D -- Yes --> F[Firebase Repository];
E -- Access Local DB --> G[(SQLite)];
F -- Access Cloud DB --> H[(Firebase)];
G -- Return Data --> B;
H -- Return Data --> B;
B -- Update UI --> A;
end
style A fill:#D6EAF8,stroke:#3498DB
style G fill:#E8DAEF,stroke:#8E44AD
style H fill:#FADBD8,stroke:#E74C3C
```
---
## II. Web Dashboard Architecture (`pickleglass_web/`)
This section details the architecture of the Next.js web application, which serves as the user-facing dashboard for account management and cloud data viewing.
### 1. Frontend, Backend, and Main Process Communication
The web dashboard has a more complex, three-part architecture:
1. **Next.js Frontend (`app/`):** The React-based user interface.
2. **Node.js Backend (`backend_node/`):** An Express.js server that acts as an intermediary.
3. **Electron Main Process (`src/`):** The ultimate authority for all local data access.
Crucially, **the web dashboard's backend cannot access the local SQLite database directly**. It must communicate with the Electron main process to request data.
### 2. The IPC Data Flow
When the web frontend needs data that resides in the local SQLite database (e.g., viewing a non-synced session), it follows this precise flow:
1. **HTTP Request**: The Next.js frontend makes a standard API call to its own Node.js backend (e.g., `GET /api/conversations`).
2. **IPC Request**: The Node.js backend receives the HTTP request. It **does not** contain any database logic. Instead, it uses the `ipcRequest` helper from `backend_node/ipcBridge.js`.
3. **IPC Emission**: `ipcRequest` sends an event to the Electron main process over an IPC channel (`web-data-request`). It passes three things: the desired action (e.g., `'get-sessions'`), a unique channel name for the response, and a payload.
4. **Main Process Listener**: The Electron main process has a listener (`ipcMain.on('web-data-request', ...)`) that receives this request. It identifies the action and calls the appropriate **Service** or **Repository** to fetch the data from the SQLite database.
5. **IPC Response**: Once the data is retrieved, the main process sends it back to the web backend using the unique response channel provided in the request.
6. **HTTP Response**: The web backend's `ipcRequest` promise resolves with the data, and the backend sends it back to the Next.js frontend as a standard JSON HTTP response.
This round-trip ensures our core principle of centralizing data logic in the main process is never violated.
**Visualizing the IPC Data Flow**
```mermaid
sequenceDiagram
participant FE as Next.js Frontend
participant BE as Node.js Backend
participant Main as Electron Main Process
FE->>+BE: 1. HTTP GET /api/local-data
Note over BE: Receives local data request
BE->>+Main: 2. ipcRequest('get-data', responseChannel)
Note over Main: Receives request, fetches data from SQLite<br/>via Service/Repository
Main-->>-BE: 3. ipcResponse on responseChannel (data)
Note over BE: Receives data, prepares HTTP response
BE-->>-FE: 4. HTTP 200 OK (JSON data)
```

19
docs/refactor-plan.md Normal file
View File

@ -0,0 +1,19 @@
# Refactor Plan: Non-Window Logic Migration from windowManager.js
## Goal
`windowManager.js`를 순수 창 관리 모듈로 만들기 위해 비즈니스 로직을 해당 서비스와 `featureBridge.js`로 이전.
## Steps (based on initial plan)
1. **Shortcuts**: Completed. Logic moved to `shortcutsService.js` and IPC to `featureBridge.js`. Used `internalBridge` for coordination.
2. **Screenshot**: Next. Move `captureScreenshot` function and related IPC handlers from `windowManager.js` to `askService.js` (since it's primarily used there). Update `askService.js` to use its own screenshot method. Add IPC handlers to `featureBridge.js` if needed.
3. **System Permissions**: Create new `permissionService.js` in `src/features/common/services/`. Move all permission-related logic (check, request, open preferences, mark completed, etc.) and IPC handlers from `windowManager.js` to the new service and `featureBridge.js`.
4. **API Key / Model State**: Completely remove from `windowManager.js` (e.g., `setupApiKeyIPC` and helpers). Ensure all usages (e.g., in `askService.js`) directly require and use `modelStateService.js` instead.
## Notes
- Maintain original logic without changes.
- Break circular dependencies if found.
- Use `internalBridge` for inter-module communication where appropriate.
- After each step, verify no errors and test functionality.

View File

@ -33,19 +33,24 @@ extraResources:
to: out to: out
asarUnpack: asarUnpack:
- "src/assets/SystemAudioDump" - "src/ui/assets/SystemAudioDump"
- "**/node_modules/sharp/**/*"
- "**/node_modules/@img/**/*"
# Windows configuration # Windows configuration
win: win:
icon: src/assets/logo.ico icon: src/ui/assets/logo.ico
target: target:
- target: nsis - target: nsis
arch: x64 arch: x64
- target: portable - target: portable
arch: x64 arch: x64
requestedExecutionLevel: asInvoker requestedExecutionLevel: asInvoker
# Disable code signing to avoid symbolic link issues on Windows signAndEditExecutable: true
signAndEditExecutable: false cscLink: build\certs\glass-dev.pfx
cscKeyPassword: "${env.CSC_KEY_PASSWORD}"
signtoolOptions:
certificateSubjectName: "Glass Dev Code Signing"
# NSIS installer configuration for Windows # NSIS installer configuration for Windows
nsis: nsis:
@ -62,7 +67,7 @@ mac:
# The application category type # The application category type
category: public.app-category.utilities category: public.app-category.utilities
# Path to the .icns icon file # Path to the .icns icon file
icon: src/assets/logo.icns icon: src/ui/assets/logo.icns
# Minimum macOS version (supports both Intel and Apple Silicon) # Minimum macOS version (supports both Intel and Apple Silicon)
minimumSystemVersion: '11.0' minimumSystemVersion: '11.0'
hardenedRuntime: true hardenedRuntime: true

View File

@ -1,87 +0,0 @@
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
const { notarizeApp } = require('./notarize');
module.exports = {
packagerConfig: {
asar: {
unpack: '**/*.node,**/*.dylib,' + '**/node_modules/{sharp,@img}/**/*',
},
extraResource: ['./src/assets/SystemAudioDump', './pickleglass_web/out'],
name: 'Glass',
icon: 'src/assets/logo',
appBundleId: 'com.pickle.glass',
arch: 'universal',
protocols: [
{
name: 'PickleGlass Protocol',
schemes: ['pickleglass'],
},
],
asarUnpack: [
'**/*.node',
'**/*.dylib',
'node_modules/@img/sharp-darwin-x64/**',
'node_modules/@img/sharp-libvips-darwin-x64/**',
'node_modules/@img/sharp-darwin-arm64/**',
'node_modules/@img/sharp-libvips-darwin-arm64/**',
],
osxSign: {
identity: process.env.APPLE_SIGNING_IDENTITY,
'hardened-runtime': true,
entitlements: 'entitlements.plist',
'entitlements-inherit': 'entitlements.plist',
},
osxNotarize: {
tool: 'notarytool',
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_ID_PASSWORD,
teamId: process.env.APPLE_TEAM_ID,
},
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
name: 'pickle-glass',
productName: 'Glass',
shortcutName: 'Glass',
createDesktopShortcut: true,
createStartMenuShortcut: true,
},
},
{
name: '@electron-forge/maker-dmg',
platforms: ['darwin'],
},
{
name: '@electron-forge/maker-deb',
config: {},
},
{
name: '@electron-forge/maker-rpm',
config: {},
},
],
hooks: {
afterSign: async (context, forgeConfig, platform, arch, appPath) => {
await notarizeApp(context, forgeConfig, platform, arch, appPath);
},
},
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {},
},
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: false,
}),
],
};

4776
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,15 +1,13 @@
{ {
"name": "pickle-glass", "name": "pickle-glass",
"productName": "Glass", "productName": "Glass",
"version": "0.2.4",
"version": "0.2.2",
"description": "Cl*ely for Free", "description": "Cl*ely for Free",
"main": "src/index.js", "main": "src/index.js",
"scripts": { "scripts": {
"setup": "npm install && cd pickleglass_web && npm install && npm run build && cd .. && npm start", "setup": "npm install && cd pickleglass_web && npm install && npm run build && cd .. && npm start",
"start": "npm run build:renderer && electron-forge start", "start": "npm run build:renderer && electron .",
"package": "npm run build:renderer && electron-forge package", "package": "npm run build:all && electron-builder --dir",
"make": "npm run build:renderer && electron-forge make", "make": "npm run build:renderer && electron-forge make",
"build": "npm run build:all && electron-builder --config electron-builder.yml --publish never", "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", "build:win": "npm run build:all && electron-builder --win --x64 --publish never",
@ -35,10 +33,11 @@
"license": "GPL-3.0", "license": "GPL-3.0",
"dependencies": { "dependencies": {
"@anthropic-ai/sdk": "^0.56.0", "@anthropic-ai/sdk": "^0.56.0",
"@deepgram/sdk": "^4.9.1",
"@google/genai": "^1.8.0", "@google/genai": "^1.8.0",
"@google/generative-ai": "^0.24.1", "@google/generative-ai": "^0.24.1",
"axios": "^1.10.0", "axios": "^1.10.0",
"better-sqlite3": "^9.4.3", "better-sqlite3": "^9.6.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dotenv": "^17.0.0", "dotenv": "^17.0.0",
"electron-squirrel-startup": "^1.0.1", "electron-squirrel-startup": "^1.0.1",
@ -48,8 +47,10 @@
"firebase": "^11.10.0", "firebase": "^11.10.0",
"firebase-admin": "^13.4.0", "firebase-admin": "^13.4.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keytar": "^7.9.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"openai": "^4.70.0", "openai": "^4.70.0",
"portkey-ai": "^1.10.1",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
"sharp": "^0.34.2", "sharp": "^0.34.2",
"validator": "^13.11.0", "validator": "^13.11.0",
@ -57,20 +58,13 @@
"ws": "^8.18.0" "ws": "^8.18.0"
}, },
"devDependencies": { "devDependencies": {
"@electron-forge/cli": "^7.8.1",
"@electron-forge/maker-deb": "^7.8.1",
"@electron-forge/maker-dmg": "^7.8.1",
"@electron-forge/maker-rpm": "^7.8.1",
"@electron-forge/maker-squirrel": "^7.8.1",
"@electron-forge/maker-zip": "^7.8.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
"@electron-forge/plugin-fuses": "^7.8.1",
"@electron/fuses": "^1.8.0", "@electron/fuses": "^1.8.0",
"@electron/notarize": "^2.5.0", "@electron/notarize": "^2.5.0",
"electron": "^30.5.1", "electron": "^30.5.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-reloader": "^1.2.3", "electron-reloader": "^1.2.3",
"esbuild": "^0.25.5" "esbuild": "^0.25.5",
"prettier": "^3.6.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"electron-liquid-glass": "^1.0.1" "electron-liquid-glass": "^1.0.1"

View File

@ -43,9 +43,10 @@ export default function LoginPage() {
window.location.href = deepLinkUrl window.location.href = deepLinkUrl
setTimeout(() => { // Maybe we don't need this
alert('Login completed. Please return to Pickle Glass app.') // setTimeout(() => {
}, 1000) // alert('Login completed. Please return to Pickle Glass app.')
// }, 1000)
} catch (error) { } catch (error) {
console.error('❌ Deep link processing failed:', error) console.error('❌ Deep link processing failed:', error)

View File

@ -2,7 +2,7 @@ const crypto = require('crypto');
function ipcRequest(req, channel, payload) { function ipcRequest(req, channel, payload) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// 즉시 브리지 상태 확인 - 문제있으면 바로 실패 // Immediately check bridge status and fail if it's not available.
if (!req.bridge || typeof req.bridge.emit !== 'function') { if (!req.bridge || typeof req.bridge.emit !== 'function') {
reject(new Error('IPC bridge is not available')); reject(new Error('IPC bridge is not available'));
return; return;

View File

@ -46,7 +46,8 @@ router.post('/find-or-create', async (req, res) => {
router.post('/api-key', async (req, res) => { router.post('/api-key', async (req, res) => {
try { try {
await ipcRequest(req, 'save-api-key', req.body.apiKey); const { apiKey, provider = 'openai' } = req.body;
await ipcRequest(req, 'save-api-key', { apiKey, provider });
res.json({ message: 'API key saved successfully' }); res.json({ message: 'API key saved successfully' });
} catch (error) { } catch (error) {
console.error('Failed to save API key via IPC:', error); console.error('Failed to save API key via IPC:', error);

View File

@ -42,27 +42,21 @@
} }
}, },
"node_modules/@emnapi/core": { "node_modules/@emnapi/core": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.4.tgz",
"integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==", "integrity": "sha512-A9CnAbC6ARNMKcIcrQwq6HeHCjpcBZ5wSx4U01WXCqEKlrzB9F9315WDNHkrs2xbx7YjjSxbUYxuN6EQzpcY2g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.0.3", "@emnapi/wasi-threads": "1.0.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@emnapi/runtime": { "node_modules/@emnapi/runtime": {
"version": "1.4.4", "version": "1.4.4",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.4.tgz",
"integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==", "integrity": "sha512-hHyapA4A3gPaDCNfiqyZUStTMqIkKRshqPIuDOXv1hcBnD4U3l8cP0T1HMCfGRxQ6V64TGCcoswChANyOAwbQg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -71,11 +65,9 @@
} }
}, },
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.0.3", "version": "1.0.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.0.3.tgz",
"integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==", "integrity": "sha512-8K5IFFsQqF9wQNJptGbS6FNKgUTsSRYnTqNCG1vPP8jFdjSv18n2mQfJpkt2Oibo9iBEzcDnDxNwKTzC7svlJw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
@ -2675,11 +2667,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/electron-to-chromium": { "node_modules/electron-to-chromium": {
"version": "1.5.180", "version": "1.5.180",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.180.tgz",
"integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==", "integrity": "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/emoji-regex": { "node_modules/emoji-regex": {

View File

@ -1,551 +0,0 @@
import { html, css, LitElement } from "../assets/lit-core-2.7.4.min.js"
export class ApiKeyHeader extends LitElement {
//////// after_modelStateService ////////
static properties = {
llmApiKey: { type: String },
sttApiKey: { type: String },
llmProvider: { type: String },
sttProvider: { type: String },
isLoading: { type: Boolean },
errorMessage: { type: String },
providers: { type: Object, state: true },
}
//////// after_modelStateService ////////
static styles = css`
:host {
display: block;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
transition: opacity 0.25s ease-out;
}
:host(.sliding-out) {
animation: slideOutUp 0.3s ease-in forwards;
will-change: opacity, transform;
}
:host(.hidden) {
opacity: 0;
pointer-events: none;
}
@keyframes slideOutUp {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-20px);
}
}
* {
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
cursor: default;
user-select: none;
box-sizing: border-box;
}
.container {
width: 350px;
min-height: 260px;
padding: 18px 20px;
background: rgba(0, 0, 0, 0.3);
border-radius: 16px;
overflow: visible;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
}
.container::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 16px;
padding: 1px;
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: destination-out;
mask-composite: exclude;
pointer-events: none;
}
.close-button {
position: absolute;
top: 10px;
right: 10px;
width: 14px;
height: 14px;
background: rgba(255, 255, 255, 0.1);
border: none;
border-radius: 3px;
color: rgba(255, 255, 255, 0.7);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
z-index: 10;
font-size: 14px;
line-height: 1;
padding: 0;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
.close-button:active {
transform: scale(0.95);
}
.title {
color: white;
font-size: 16px;
font-weight: 500; /* Medium */
margin: 0;
text-align: center;
flex-shrink: 0;
}
.form-content {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
margin-top: auto;
}
.error-message {
color: rgba(239, 68, 68, 0.9);
font-weight: 500;
font-size: 11px;
height: 14px;
text-align: center;
margin-bottom: 4px;
}
.api-input {
width: 100%;
height: 34px;
background: rgba(255, 255, 255, 0.1);
border-radius: 10px;
border: none;
padding: 0 10px;
color: white;
font-size: 12px;
font-weight: 400; /* Regular */
margin-bottom: 6px;
text-align: center;
user-select: text;
cursor: text;
}
.api-input::placeholder {
color: rgba(255, 255, 255, 0.6);
}
.api-input:focus {
outline: none;
}
.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;
margin-bottom: 6px;
}
.provider-select option { background: #1a1a1a; color: white; }
.provider-select:hover {
background-color: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.3);
}
.provider-select:focus {
outline: none;
background-color: rgba(255, 255, 255, 0.15);
border-color: rgba(255, 255, 255, 0.4);
}
.action-button {
width: 100%;
height: 34px;
background: rgba(255, 255, 255, 0.2);
border: none;
border-radius: 10px;
color: white;
font-size: 12px;
font-weight: 500; /* Medium */
cursor: pointer;
transition: background 0.15s ease;
position: relative;
overflow: visible;
}
.action-button::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: 10px;
padding: 1px;
background: linear-gradient(169deg, rgba(255, 255, 255, 0.5) 0%, rgba(255, 255, 255, 0) 50%, rgba(255, 255, 255, 0.5) 100%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: destination-out;
mask-composite: exclude;
pointer-events: none;
}
.action-button:hover {
background: rgba(255, 255, 255, 0.3);
}
.action-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.or-text {
color: rgba(255, 255, 255, 0.5);
font-size: 12px;
font-weight: 500; /* Medium */
margin: 10px 0;
}
/* ────────────────[ 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.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)
}
reset() {
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
}
e.preventDefault()
const { ipcRenderer } = window.require("electron")
const initialPosition = await ipcRenderer.invoke("get-header-position")
this.dragState = {
initialMouseX: e.screenX,
initialMouseY: e.screenY,
initialWindowX: initialPosition.x,
initialWindowY: initialPosition.y,
moved: false,
}
window.addEventListener("mousemove", this.handleMouseMove)
window.addEventListener("mouseup", this.handleMouseUp, { once: true })
}
handleMouseMove(e) {
if (!this.dragState) return
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
}
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)
}
handleMouseUp(e) {
if (!this.dragState) return
const wasDragged = this.dragState.moved
window.removeEventListener("mousemove", this.handleMouseMove)
this.dragState = null
if (wasDragged) {
this.wasJustDragged = true
setTimeout(() => {
this.wasJustDragged = false
}, 200)
}
}
handleInput(e) {
this.apiKey = e.target.value
this.errorMessage = ""
console.log("Input changed:", this.apiKey?.length || 0, "chars")
this.requestUpdate()
this.updateComplete.then(() => {
const inputField = this.shadowRoot?.querySelector(".apikey-input")
if (inputField && this.isInputFocused) {
inputField.focus()
}
})
}
handleProviderChange(e) {
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) + "...")
if (clipboardText) {
this.apiKey = clipboardText.trim()
const inputElement = e.target
inputElement.value = this.apiKey
}
this.requestUpdate()
this.updateComplete.then(() => {
const inputField = this.shadowRoot?.querySelector(".apikey-input")
if (inputField) {
inputField.focus()
inputField.setSelectionRange(inputField.value.length, inputField.value.length)
}
})
}
handleKeyPress(e) {
if (e.key === "Enter") {
e.preventDefault()
this.handleSubmit()
}
}
//////// after_modelStateService ////////
async handleSubmit() {
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;
}
this.isLoading = true;
this.errorMessage = "";
this.requestUpdate();
const { ipcRenderer } = window.require('electron');
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();
} else {
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(' | ');
}
this.isLoading = false;
this.requestUpdate();
}
//////// after_modelStateService ////////
startSlideOutAnimation() {
console.log('[ApiKeyHeader] startSlideOutAnimation: Starting slide out animation.');
this.classList.add("sliding-out")
}
handleUsePicklesKey(e) {
e.preventDefault()
if (this.wasJustDragged) return
console.log("Requesting Firebase authentication from main process...")
if (window.require) {
window.require("electron").ipcRenderer.invoke("start-firebase-auth")
}
}
handleClose() {
console.log("Close button clicked")
if (window.require) {
window.require("electron").ipcRenderer.invoke("quit-application")
}
}
//////// after_modelStateService ////////
handleAnimationEnd(e) {
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);
});
}
//////// after_modelStateService ////////
connectedCallback() {
super.connectedCallback()
this.addEventListener("animationend", this.handleAnimationEnd)
}
disconnectedCallback() {
super.disconnectedCallback()
this.removeEventListener("animationend", this.handleAnimationEnd)
}
render() {
const isButtonDisabled = this.isLoading || !this.llmApiKey.trim() || !this.sttApiKey.trim();
return html`
<div class="container" @mousedown=${this.handleMouseDown}>
<h1 class="title">Enter Your API Keys</h1>
<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="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)

View File

@ -1,311 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="content-security-policy" content="script-src 'self' 'unsafe-inline'" />
<title>Pickle Glass Content</title>
<style>
:root {
--background-transparent: transparent;
--text-color: #e5e5e7;
--border-color: rgba(255, 255, 255, 0.2);
--header-background: rgba(0, 0, 0, 0.8);
--header-actions-color: rgba(255, 255, 255, 0.6);
--main-content-background: rgba(0, 0, 0, 0.8);
--button-background: rgba(0, 0, 0, 0.5);
--button-border: rgba(255, 255, 255, 0.1);
--icon-button-color: rgb(229, 229, 231);
--hover-background: rgba(255, 255, 255, 0.1);
--input-background: rgba(0, 0, 0, 0.3);
--placeholder-color: rgba(255, 255, 255, 0.4);
--focus-border-color: #007aff;
--focus-box-shadow: rgba(0, 122, 255, 0.2);
--input-focus-background: rgba(0, 0, 0, 0.5);
--scrollbar-track: rgba(0, 0, 0, 0.2);
--scrollbar-thumb: rgba(255, 255, 255, 0.2);
--scrollbar-thumb-hover: rgba(255, 255, 255, 0.3);
--preview-video-background: rgba(0, 0, 0, 0.9);
--preview-video-border: rgba(255, 255, 255, 0.15);
--option-label-color: rgba(255, 255, 255, 0.8);
--screen-option-background: rgba(0, 0, 0, 0.4);
--screen-option-hover-background: rgba(0, 0, 0, 0.6);
--screen-option-selected-background: rgba(0, 122, 255, 0.15);
--screen-option-text: rgba(255, 255, 255, 0.7);
--description-color: rgba(255, 255, 255, 0.6);
--start-button-background: white;
--start-button-color: black;
--start-button-border: white;
--start-button-hover-background: rgba(255, 255, 255, 0.8);
--start-button-hover-border: rgba(0, 0, 0, 0.2);
--text-input-button-background: #007aff;
--text-input-button-hover: #0056b3;
--link-color: #007aff;
--key-background: rgba(255, 255, 255, 0.1);
--scrollbar-background: rgba(0, 0, 0, 0.4);
/* Layout-specific variables */
--header-padding: 10px 20px;
--header-font-size: 16px;
--header-gap: 12px;
--header-button-padding: 8px 16px;
--header-icon-padding: 8px;
--header-font-size-small: 13px;
--main-content-padding: 20px;
--main-content-margin-top: 10px;
--icon-size: 24px;
--border-radius: 7px;
--content-border-radius: 7px;
}
/* Compact layout styles */
:root.compact-layout {
--header-padding: 6px 12px;
--header-font-size: 13px;
--header-gap: 6px;
--header-button-padding: 4px 8px;
--header-icon-padding: 4px;
--header-font-size-small: 10px;
--main-content-padding: 10px;
--main-content-margin-top: 2px;
--icon-size: 16px;
--border-radius: 4px;
--content-border-radius: 4px;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100%;
overflow: hidden;
background: transparent;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
}
* {
box-sizing: border-box;
}
pickle-glass-app {
display: block;
width: 100%;
transform: translate3d(0, 0, 0);
backface-visibility: hidden;
perspective: 1000px;
transform-origin: center center;
contain: layout style paint;
transition: transform 0.25s cubic-bezier(0.23, 1, 0.32, 1), opacity 0.25s ease-out;
}
.window-sliding-down {
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.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
will-change: transform, opacity;
transform-style: preserve-3d;
}
.window-hidden {
opacity: 0;
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
pointer-events: none;
will-change: auto;
contain: layout style paint;
}
.listen-window-moving {
transition: transform 0.35s cubic-bezier(0.25, 0.46, 0.45, 0.94);
will-change: transform;
}
.listen-window-center {
transform: translate3d(0, 0, 0);
}
.listen-window-left {
transform: translate3d(-110px, 0, 0);
}
@keyframes slideDownFromHeader {
0% {
opacity: 0;
transform: translate3d(0, -18px, 0) scale3d(0.96, 0.96, 1);
}
25% {
opacity: 0.4;
transform: translate3d(0, -10px, 0) scale3d(0.98, 0.98, 1);
}
50% {
opacity: 0.7;
transform: translate3d(0, -3px, 0) scale3d(1.01, 1.01, 1);
}
75% {
opacity: 0.9;
transform: translate3d(0, -0.5px, 0) scale3d(1.005, 1.005, 1);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
}
.settings-window-show {
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.10s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
transform-origin: 85% 0%;
will-change: transform, opacity;
transform-style: preserve-3d;
}
@keyframes settingsPopFromButton {
0% {
opacity: 0;
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
}
40% {
opacity: 0.8;
transform: translate3d(0, -2px, 0) scale3d(1.05, 1.05, 1);
}
70% {
opacity: 0.95;
transform: translate3d(0, 0, 0) scale3d(1.02, 1.02, 1);
}
100% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
}
@keyframes settingsCollapseToButton {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
30% {
opacity: 0.8;
transform: translate3d(0, -1px, 0) scale3d(0.9, 0.9, 1);
}
70% {
opacity: 0.3;
transform: translate3d(0, -5px, 0) scale3d(0.7, 0.7, 1);
}
100% {
opacity: 0;
transform: translate3d(0, -8px, 0) scale3d(0.5, 0.5, 1);
}
}
@keyframes slideUpToHeader {
0% {
opacity: 1;
transform: translate3d(0, 0, 0) scale3d(1, 1, 1);
}
30% {
opacity: 0.6;
transform: translate3d(0, -6px, 0) scale3d(0.98, 0.98, 1);
}
65% {
opacity: 0.2;
transform: translate3d(0, -14px, 0) scale3d(0.95, 0.95, 1);
}
100% {
opacity: 0;
transform: translate3d(0, -18px, 0) scale3d(0.93, 0.93, 1);
}
}
</style>
</head>
<body>
<script src="../assets/marked-4.3.0.min.js"></script>
<script type="module" src="../../public/build/content.js"></script>
<pickle-glass-app id="pickle-glass"></pickle-glass-app>
<script>
window.addEventListener('DOMContentLoaded', () => {
const app = document.getElementById('pickle-glass');
let animationTimeout = null;
if (window.require) {
const { ipcRenderer } = window.require('electron');
ipcRenderer.on('window-show-animation', () => {
console.log('Starting window show animation');
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
app.classList.add('window-sliding-down');
if (animationTimeout) clearTimeout(animationTimeout);
animationTimeout = setTimeout(() => {
app.classList.remove('window-sliding-down');
}, 120);
});
ipcRenderer.on('window-hide-animation', () => {
console.log('Starting window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('window-sliding-up');
if (animationTimeout) clearTimeout(animationTimeout);
animationTimeout = setTimeout(() => {
app.classList.remove('window-sliding-up');
app.classList.add('window-hidden');
}, 100);
});
ipcRenderer.on('settings-window-hide-animation', () => {
console.log('Starting settings window hide animation');
app.classList.remove('window-sliding-down', 'settings-window-show');
app.classList.add('settings-window-hide');
if (animationTimeout) clearTimeout(animationTimeout);
animationTimeout = setTimeout(() => {
app.classList.remove('settings-window-hide');
app.classList.add('window-hidden');
}, 100);
});
ipcRenderer.on('listen-window-move-to-center', () => {
console.log('Moving listen window to center');
app.classList.add('listen-window-moving');
app.classList.remove('listen-window-left');
app.classList.add('listen-window-center');
setTimeout(() => {
app.classList.remove('listen-window-moving');
}, 350);
});
ipcRenderer.on('listen-window-move-to-left', () => {
console.log('Moving listen window to left');
app.classList.add('listen-window-moving');
app.classList.remove('listen-window-center');
app.classList.add('listen-window-left');
setTimeout(() => {
app.classList.remove('listen-window-moving');
}, 350);
});
}
});
</script>
<script>
const params = new URLSearchParams(window.location.search);
if (params.get('glass') === 'true') {
document.body.classList.add('has-glass');
}
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

239
src/bridge/featureBridge.js Normal file
View File

@ -0,0 +1,239 @@
// src/bridge/featureBridge.js
const { ipcMain, app, BrowserWindow } = require('electron');
const settingsService = require('../features/settings/settingsService');
const authService = require('../features/common/services/authService');
const whisperService = require('../features/common/services/whisperService');
const ollamaService = require('../features/common/services/ollamaService');
const modelStateService = require('../features/common/services/modelStateService');
const shortcutsService = require('../features/shortcuts/shortcutsService');
const presetRepository = require('../features/common/repositories/preset');
const localAIManager = require('../features/common/services/localAIManager');
const askService = require('../features/ask/askService');
const listenService = require('../features/listen/listenService');
const permissionService = require('../features/common/services/permissionService');
const encryptionService = require('../features/common/services/encryptionService');
module.exports = {
// Renderer로부터의 요청을 수신하고 서비스로 전달
initialize() {
// Settings Service
ipcMain.handle('settings:getPresets', async () => await settingsService.getPresets());
ipcMain.handle('settings:get-auto-update', async () => await settingsService.getAutoUpdateSetting());
ipcMain.handle('settings:set-auto-update', async (event, isEnabled) => await settingsService.setAutoUpdateSetting(isEnabled));
ipcMain.handle('settings:get-model-settings', async () => await settingsService.getModelSettings());
ipcMain.handle('settings:clear-api-key', async (e, { provider }) => await settingsService.clearApiKey(provider));
ipcMain.handle('settings:set-selected-model', async (e, { type, modelId }) => await settingsService.setSelectedModel(type, modelId));
ipcMain.handle('settings:get-ollama-status', async () => await settingsService.getOllamaStatus());
ipcMain.handle('settings:ensure-ollama-ready', async () => await settingsService.ensureOllamaReady());
ipcMain.handle('settings:shutdown-ollama', async () => await settingsService.shutdownOllama());
// Shortcuts
ipcMain.handle('settings:getCurrentShortcuts', async () => await shortcutsService.loadKeybinds());
ipcMain.handle('shortcut:getDefaultShortcuts', async () => await shortcutsService.handleRestoreDefaults());
ipcMain.handle('shortcut:closeShortcutSettingsWindow', async () => await shortcutsService.closeShortcutSettingsWindow());
ipcMain.handle('shortcut:openShortcutSettingsWindow', async () => await shortcutsService.openShortcutSettingsWindow());
ipcMain.handle('shortcut:saveShortcuts', async (event, newKeybinds) => await shortcutsService.handleSaveShortcuts(newKeybinds));
ipcMain.handle('shortcut:toggleAllWindowsVisibility', async () => await shortcutsService.toggleAllWindowsVisibility());
// Permissions
ipcMain.handle('check-system-permissions', async () => await permissionService.checkSystemPermissions());
ipcMain.handle('request-microphone-permission', async () => await permissionService.requestMicrophonePermission());
ipcMain.handle('open-system-preferences', async (event, section) => await permissionService.openSystemPreferences(section));
ipcMain.handle('mark-keychain-completed', async () => await permissionService.markKeychainCompleted());
ipcMain.handle('check-keychain-completed', async () => await permissionService.checkKeychainCompleted());
ipcMain.handle('initialize-encryption-key', async () => {
const userId = authService.getCurrentUserId();
await encryptionService.initializeKey(userId);
return { success: true };
});
// User/Auth
ipcMain.handle('get-current-user', () => authService.getCurrentUser());
ipcMain.handle('start-firebase-auth', async () => await authService.startFirebaseAuthFlow());
ipcMain.handle('firebase-logout', async () => await authService.signOut());
// App
ipcMain.handle('quit-application', () => app.quit());
// Whisper
ipcMain.handle('whisper:download-model', async (event, modelId) => await whisperService.handleDownloadModel(modelId));
ipcMain.handle('whisper:get-installed-models', async () => await whisperService.handleGetInstalledModels());
// General
ipcMain.handle('get-preset-templates', () => presetRepository.getPresetTemplates());
ipcMain.handle('get-web-url', () => process.env.pickleglass_WEB_URL || 'http://localhost:3000');
// Ollama
ipcMain.handle('ollama:get-status', async () => await ollamaService.handleGetStatus());
ipcMain.handle('ollama:install', async () => await ollamaService.handleInstall());
ipcMain.handle('ollama:start-service', async () => await ollamaService.handleStartService());
ipcMain.handle('ollama:ensure-ready', async () => await ollamaService.handleEnsureReady());
ipcMain.handle('ollama:get-models', async () => await ollamaService.handleGetModels());
ipcMain.handle('ollama:get-model-suggestions', async () => await ollamaService.handleGetModelSuggestions());
ipcMain.handle('ollama:pull-model', async (event, modelName) => await ollamaService.handlePullModel(modelName));
ipcMain.handle('ollama:is-model-installed', async (event, modelName) => await ollamaService.handleIsModelInstalled(modelName));
ipcMain.handle('ollama:warm-up-model', async (event, modelName) => await ollamaService.handleWarmUpModel(modelName));
ipcMain.handle('ollama:auto-warm-up', async () => await ollamaService.handleAutoWarmUp());
ipcMain.handle('ollama:get-warm-up-status', async () => await ollamaService.handleGetWarmUpStatus());
ipcMain.handle('ollama:shutdown', async (event, force = false) => await ollamaService.handleShutdown(force));
// Ask
ipcMain.handle('ask:sendQuestionFromAsk', async (event, userPrompt) => await askService.sendMessage(userPrompt));
ipcMain.handle('ask:sendQuestionFromSummary', async (event, userPrompt) => await askService.sendMessage(userPrompt));
ipcMain.handle('ask:toggleAskButton', async () => await askService.toggleAskButton());
ipcMain.handle('ask:closeAskWindow', async () => await askService.closeAskWindow());
// Listen
ipcMain.handle('listen:sendMicAudio', async (event, { data, mimeType }) => await listenService.handleSendMicAudioContent(data, mimeType));
ipcMain.handle('listen:sendSystemAudio', async (event, { data, mimeType }) => {
const result = await listenService.sttService.sendSystemAudioContent(data, mimeType);
if(result.success) {
listenService.sendToRenderer('system-audio-data', { data });
}
return result;
});
ipcMain.handle('listen:startMacosSystemAudio', async () => await listenService.handleStartMacosAudio());
ipcMain.handle('listen:stopMacosSystemAudio', async () => await listenService.handleStopMacosAudio());
ipcMain.handle('update-google-search-setting', async (event, enabled) => await listenService.handleUpdateGoogleSearchSetting(enabled));
ipcMain.handle('listen:isSessionActive', async () => await listenService.isSessionActive());
ipcMain.handle('listen:changeSession', async (event, listenButtonText) => {
console.log('[FeatureBridge] listen:changeSession from mainheader', listenButtonText);
try {
await listenService.handleListenRequest(listenButtonText);
return { success: true };
} catch (error) {
console.error('[FeatureBridge] listen:changeSession failed', error.message);
return { success: false, error: error.message };
}
});
// ModelStateService
ipcMain.handle('model:validate-key', async (e, { provider, key }) => await modelStateService.handleValidateKey(provider, key));
ipcMain.handle('model:get-all-keys', async () => await modelStateService.getAllApiKeys());
ipcMain.handle('model:set-api-key', async (e, { provider, key }) => await modelStateService.setApiKey(provider, key));
ipcMain.handle('model:remove-api-key', async (e, provider) => await modelStateService.handleRemoveApiKey(provider));
ipcMain.handle('model:get-selected-models', async () => await modelStateService.getSelectedModels());
ipcMain.handle('model:set-selected-model', async (e, { type, modelId }) => await modelStateService.handleSetSelectedModel(type, modelId));
ipcMain.handle('model:get-available-models', async (e, { type }) => await modelStateService.getAvailableModels(type));
ipcMain.handle('model:are-providers-configured', async () => await modelStateService.areProvidersConfigured());
ipcMain.handle('model:get-provider-config', () => modelStateService.getProviderConfig());
ipcMain.handle('model:re-initialize-state', async () => await modelStateService.initialize());
// LocalAIManager 이벤트를 모든 윈도우에 브로드캐스트
localAIManager.on('install-progress', (service, data) => {
const event = { service, ...data };
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:install-progress', event);
}
});
});
localAIManager.on('installation-complete', (service) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:installation-complete', { service });
}
});
});
localAIManager.on('error', (error) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:error-occurred', error);
}
});
});
// Handle error-occurred events from LocalAIManager's error handling
localAIManager.on('error-occurred', (error) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:error-occurred', error);
}
});
});
localAIManager.on('model-ready', (data) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:model-ready', data);
}
});
});
localAIManager.on('state-changed', (service, state) => {
const event = { service, ...state };
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('localai:service-status-changed', event);
}
});
});
// 주기적 상태 동기화 시작
localAIManager.startPeriodicSync();
// ModelStateService 이벤트를 모든 윈도우에 브로드캐스트
modelStateService.on('state-updated', (state) => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('model-state:updated', state);
}
});
});
modelStateService.on('settings-updated', () => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('settings-updated');
}
});
});
modelStateService.on('force-show-apikey-header', () => {
BrowserWindow.getAllWindows().forEach(win => {
if (win && !win.isDestroyed()) {
win.webContents.send('force-show-apikey-header');
}
});
});
// LocalAI 통합 핸들러 추가
ipcMain.handle('localai:install', async (event, { service, options }) => {
return await localAIManager.installService(service, options);
});
ipcMain.handle('localai:get-status', async (event, service) => {
return await localAIManager.getServiceStatus(service);
});
ipcMain.handle('localai:start-service', async (event, service) => {
return await localAIManager.startService(service);
});
ipcMain.handle('localai:stop-service', async (event, service) => {
return await localAIManager.stopService(service);
});
ipcMain.handle('localai:install-model', async (event, { service, modelId, options }) => {
return await localAIManager.installModel(service, modelId, options);
});
ipcMain.handle('localai:get-installed-models', async (event, service) => {
return await localAIManager.getInstalledModels(service);
});
ipcMain.handle('localai:run-diagnostics', async (event, service) => {
return await localAIManager.runDiagnostics(service);
});
ipcMain.handle('localai:repair-service', async (event, service) => {
return await localAIManager.repairService(service);
});
// 에러 처리 핸들러
ipcMain.handle('localai:handle-error', async (event, { service, errorType, details }) => {
return await localAIManager.handleError(service, errorType, details);
});
// 전체 상태 조회
ipcMain.handle('localai:get-all-states', async (event) => {
return await localAIManager.getAllServiceStates();
});
console.log('[FeatureBridge] Initialized with all feature handlers.');
},
// Renderer로 상태를 전송
sendAskProgress(win, progress) {
win.webContents.send('feature:ask:progress', progress);
},
};

View File

@ -0,0 +1,11 @@
// src/bridge/internalBridge.js
const { EventEmitter } = require('events');
// FeatureCore와 WindowCore를 잇는 내부 이벤트 버스
const internalBridge = new EventEmitter();
module.exports = internalBridge;
// 예시 이벤트
// internalBridge.on('content-protection-changed', (enabled) => {
// // windowManager에서 처리
// });

View File

@ -0,0 +1,34 @@
// src/bridge/windowBridge.js
const { ipcMain, shell } = require('electron');
// Bridge는 단순히 IPC 핸들러를 등록하는 역할만 함 (비즈니스 로직 없음)
module.exports = {
initialize() {
// initialize 시점에 windowManager를 require하여 circular dependency 문제 해결
const windowManager = require('../window/windowManager');
// 기존 IPC 핸들러들
ipcMain.handle('toggle-content-protection', () => windowManager.toggleContentProtection());
ipcMain.handle('resize-header-window', (event, args) => windowManager.resizeHeaderWindow(args));
ipcMain.handle('get-content-protection-status', () => windowManager.getContentProtectionStatus());
ipcMain.on('show-settings-window', () => windowManager.showSettingsWindow());
ipcMain.on('hide-settings-window', () => windowManager.hideSettingsWindow());
ipcMain.on('cancel-hide-settings-window', () => windowManager.cancelHideSettingsWindow());
ipcMain.handle('open-login-page', () => windowManager.openLoginPage());
ipcMain.handle('open-personalize-page', () => windowManager.openLoginPage());
ipcMain.handle('move-window-step', (event, direction) => windowManager.moveWindowStep(direction));
ipcMain.handle('open-external', (event, url) => shell.openExternal(url));
// Newly moved handlers from windowManager
ipcMain.on('header-state-changed', (event, state) => windowManager.handleHeaderStateChanged(state));
ipcMain.on('header-animation-finished', (event, state) => windowManager.handleHeaderAnimationFinished(state));
ipcMain.handle('get-header-position', () => windowManager.getHeaderPosition());
ipcMain.handle('move-header-to', (event, newX, newY) => windowManager.moveHeaderTo(newX, newY));
ipcMain.handle('adjust-window-height', (event, { winName, height }) => windowManager.adjustWindowHeight(winName, height));
},
notifyFocusChange(win, isFocused) {
win.webContents.send('window:focus-change', isFocused);
}
};

View File

@ -1,337 +0,0 @@
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
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Gemini API key
* @param {string} [opts.model='gemini-2.5-flash'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=8192] - Max tokens
* @returns {object} LLM instance
*/
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 });
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;
}
},
// For compatibility with chat-style interfaces
chat: async (messages) => {
// Extract system instruction if present
let systemInstruction = '';
const history = [];
let lastMessage;
messages.forEach((msg, index) => {
if (msg.role === 'system') {
systemInstruction = msg.content;
return;
}
// Gemini's history format
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
});
const chat = geminiModel.startChat({
history: history,
generationConfig: {
temperature: temperature,
maxOutputTokens: maxTokens,
}
});
// Get the last user message content
let content = lastMessage.content;
// Handle multimodal content for the last message
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) {
// Convert base64 image to Gemini format
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 {
content: response.text(),
raw: result
};
}
};
}
/**
* Creates a Gemini streaming LLM instance
* @param {object} opts - Configuration options
* @param {string} opts.apiKey - Gemini API key
* @param {string} [opts.model='gemini-2.5-flash'] - Model name
* @param {number} [opts.temperature=0.7] - Temperature
* @param {number} [opts.maxTokens=8192] - Max tokens
* @returns {object} Streaming LLM instance
*/
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');
// 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 geminiModel = client.getGenerativeModel({
model: model,
systemInstruction: systemInstruction || undefined
});
const chat = geminiModel.startChat({
history: [],
generationConfig: {
temperature,
maxOutputTokens: maxTokens || 8192,
}
});
// Create a ReadableStream to handle Gemini's streaming
const stream = new ReadableStream({
async start(controller) {
try {
console.log('[Gemini Provider] Processing messages:', nonSystemMessages.length, 'messages (excluding system)');
// Get the last user message
const lastMessage = nonSystemMessages[nonSystemMessages.length - 1];
let lastUserMessage = lastMessage.content;
// Handle case where content might be an array (multimodal)
if (Array.isArray(lastUserMessage)) {
// Extract text content from array
const textParts = lastUserMessage.filter(part =>
typeof part === 'string' || (part && part.type === 'text')
);
lastUserMessage = textParts.map(part =>
typeof part === 'string' ? part : part.text
).join(' ');
}
console.log('[Gemini Provider] Sending message to Gemini:',
typeof lastUserMessage === 'string' ? lastUserMessage.substring(0, 100) + '...' : 'multimodal content');
// Prepare the message content for Gemini
let geminiContent = [];
// Handle multimodal content properly
if (Array.isArray(lastMessage.content)) {
for (const part of lastMessage.content) {
if (typeof part === 'string') {
geminiContent.push(part);
} else if (part.type === 'text') {
geminiContent.push(part.text);
} else if (part.type === 'image_url' && part.image_url) {
// Convert base64 image to Gemini format
const base64Data = part.image_url.url.split(',')[1];
geminiContent.push({
inlineData: {
mimeType: 'image/png',
data: base64Data
}
});
}
}
} else {
geminiContent = [lastUserMessage];
}
console.log('[Gemini Provider] Prepared Gemini content:',
geminiContent.length, 'parts');
// Stream the response
let chunkCount = 0;
let totalContent = '';
const contentParts = geminiContent.map(part => {
if (typeof part === 'string') {
return { text: part };
} else if (part.inlineData) {
return { inlineData: part.inlineData };
}
return part;
});
const result = await geminiModel.generateContentStream({
contents: [{
role: 'user',
parts: contentParts
}],
generationConfig: {
temperature,
maxOutputTokens: maxTokens || 8192,
}
});
for await (const chunk of result.stream) {
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(`[Gemini Provider] Streamed ${chunkCount} chunks, total length: ${totalContent.length} chars`);
// Send the final done message
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
controller.close();
console.log('[Gemini Provider] Streaming completed successfully');
} catch (error) {
console.error('[Gemini Provider] Streaming error:', error);
controller.error(error);
}
}
});
// Create a Response object with the stream
return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
}
});
}
};
}
module.exports = {
createSTT,
createLLM,
createStreamingLLM
};

View File

@ -1,19 +0,0 @@
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

@ -1,26 +0,0 @@
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

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

View File

@ -1,19 +0,0 @@
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

@ -1,160 +0,0 @@
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,329 +0,0 @@
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,227 +0,0 @@
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

@ -1,217 +0,0 @@
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

@ -1,144 +1,450 @@
const { ipcMain, BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { createStreamingLLM } = require('../../common/ai/factory'); const { createStreamingLLM } = require('../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo, windowPool, captureScreenshot } = require('../../electron/windowManager'); // Lazy require helper to avoid circular dependency issues
const authService = require('../../common/services/authService'); const getWindowManager = () => require('../../window/windowManager');
const sessionRepository = require('../../common/repositories/session'); const internalBridge = require('../../bridge/internalBridge');
const askRepository = require('./repositories');
const { getSystemPrompt } = require('../../common/prompts/promptBuilder');
function formatConversationForPrompt(conversationTexts) { const getWindowPool = () => {
if (!conversationTexts || conversationTexts.length === 0) return 'No conversation history available.'; try {
return conversationTexts.slice(-30).join('\n'); return getWindowManager().windowPool;
} } catch {
return null;
// 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()) { const sessionRepository = require('../common/repositories/session');
askWindow.webContents.send('hide-text-input'); const askRepository = require('./repositories');
const { getSystemPrompt } = require('../common/prompts/promptBuilder');
const path = require('node:path');
const fs = require('node:fs');
const os = require('os');
const util = require('util');
const execFile = util.promisify(require('child_process').execFile);
const { desktopCapturer } = require('electron');
const modelStateService = require('../common/services/modelStateService');
// Try to load sharp, but don't fail if it's not available
let sharp;
try {
sharp = require('sharp');
console.log('[AskService] Sharp module loaded successfully');
} catch (error) {
console.warn('[AskService] Sharp module not available:', error.message);
console.warn('[AskService] Screenshot functionality will work with reduced image processing capabilities');
sharp = null;
}
let lastScreenshot = null;
async function captureScreenshot(options = {}) {
if (process.platform === 'darwin') {
try {
const tempPath = path.join(os.tmpdir(), `screenshot-${Date.now()}.jpg`);
await execFile('screencapture', ['-x', '-t', 'jpg', tempPath]);
const imageBuffer = await fs.promises.readFile(tempPath);
await fs.promises.unlink(tempPath);
if (sharp) {
try {
// Try using sharp for optimal image processing
const resizedBuffer = await sharp(imageBuffer)
.resize({ height: 384 })
.jpeg({ quality: 80 })
.toBuffer();
const base64 = resizedBuffer.toString('base64');
const metadata = await sharp(resizedBuffer).metadata();
lastScreenshot = {
base64,
width: metadata.width,
height: metadata.height,
timestamp: Date.now(),
};
return { success: true, base64, width: metadata.width, height: metadata.height };
} catch (sharpError) {
console.warn('Sharp module failed, falling back to basic image processing:', sharpError.message);
}
}
// Fallback: Return the original image without resizing
console.log('[AskService] Using fallback image processing (no resize/compression)');
const base64 = imageBuffer.toString('base64');
lastScreenshot = {
base64,
width: null, // We don't have metadata without sharp
height: null,
timestamp: Date.now(),
};
return { success: true, base64, width: null, height: null };
} catch (error) {
console.error('Failed to capture screenshot:', error);
return { success: false, error: error.message };
}
} }
try { try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); const sources = await desktopCapturer.getSources({
types: ['screen'],
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); thumbnailSize: {
if (!modelInfo || !modelInfo.apiKey) { width: 1920,
throw new Error('AI model or API key not configured.'); height: 1080,
}
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); if (sources.length === 0) {
throw new Error('No screen sources available');
// --- 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;
} }
const source = sources[0];
const buffer = source.thumbnail.toJPEG(70);
const base64 = buffer.toString('base64');
const size = source.thumbnail.getSize();
while (true) { return {
const { done, value } = await reader.read(); success: true,
if (done) break; base64,
width: size.width,
const chunk = decoder.decode(value); height: size.height,
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) { } catch (error) {
console.error('[AskService] Error processing message:', error); console.error('Failed to capture screenshot using desktopCapturer:', error);
return { success: false, error: error.message }; return {
success: false,
error: error.message,
};
} }
} }
function initialize() { /**
ipcMain.handle('ask:sendMessage', async (event, userPrompt) => { * @class
return sendMessage(userPrompt); * @description
}); */
console.log('[AskService] Initialized and ready.'); class AskService {
constructor() {
this.abortController = null;
this.state = {
isVisible: false,
isLoading: false,
isStreaming: false,
currentQuestion: '',
currentResponse: '',
showTextInput: true,
};
console.log('[AskService] Service instance created.');
}
_broadcastState() {
const askWindow = getWindowPool()?.get('ask');
if (askWindow && !askWindow.isDestroyed()) {
askWindow.webContents.send('ask:stateUpdate', this.state);
}
}
async toggleAskButton(inputScreenOnly = false) {
const askWindow = getWindowPool()?.get('ask');
let shouldSendScreenOnly = false;
if (inputScreenOnly && this.state.showTextInput && askWindow && askWindow.isVisible()) {
shouldSendScreenOnly = true;
await this.sendMessage('', []);
return;
}
const hasContent = this.state.isLoading || this.state.isStreaming || (this.state.currentResponse && this.state.currentResponse.length > 0);
if (askWindow && askWindow.isVisible() && hasContent) {
this.state.showTextInput = !this.state.showTextInput;
this._broadcastState();
} else {
if (askWindow && askWindow.isVisible()) {
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
this.state.isVisible = false;
} else {
console.log('[AskService] Showing hidden Ask window');
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
this.state.isVisible = true;
}
if (this.state.isVisible) {
this.state.showTextInput = true;
this._broadcastState();
}
}
}
async closeAskWindow () {
if (this.abortController) {
this.abortController.abort('Window closed by user');
this.abortController = null;
}
this.state = {
isVisible : false,
isLoading : false,
isStreaming : false,
currentQuestion: '',
currentResponse: '',
showTextInput : true,
};
this._broadcastState();
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: false });
return { success: true };
}
/**
*
* @param {string[]} conversationTexts
* @returns {string}
* @private
*/
_formatConversationForPrompt(conversationTexts) {
if (!conversationTexts || conversationTexts.length === 0) {
return 'No conversation history available.';
}
return conversationTexts.slice(-30).join('\n');
}
/**
*
* @param {string} userPrompt
* @returns {Promise<{success: boolean, response?: string, error?: string}>}
*/
async sendMessage(userPrompt, conversationHistoryRaw=[]) {
internalBridge.emit('window:requestVisibility', { name: 'ask', visible: true });
this.state = {
...this.state,
isLoading: true,
isStreaming: false,
currentQuestion: userPrompt,
currentResponse: '',
showTextInput: false,
};
this._broadcastState();
if (this.abortController) {
this.abortController.abort('New request received.');
}
this.abortController = new AbortController();
const { signal } = this.abortController;
let sessionId;
try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
sessionId = await sessionRepository.getOrCreateActive('ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
const modelInfo = await modelStateService.getCurrentModelInfo('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 conversationHistory = this._formatConversationForPrompt(conversationHistoryRaw);
const systemPrompt = getSystemPrompt('pickle_glass_analysis', conversationHistory, false);
const messages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: [
{ type: 'text', text: `User Request: ${userPrompt.trim()}` },
],
},
];
if (screenshotBase64) {
messages[1].content.push({
type: 'image_url',
image_url: { url: `data:image/jpeg;base64,${screenshotBase64}` },
});
}
const streamingLLM = createStreamingLLM(modelInfo.provider, {
apiKey: modelInfo.apiKey,
model: modelInfo.model,
temperature: 0.7,
maxTokens: 2048,
usePortkey: modelInfo.provider === 'openai-glass',
portkeyVirtualKey: modelInfo.provider === 'openai-glass' ? modelInfo.apiKey : undefined,
});
try {
const response = await streamingLLM.streamChat(messages);
const askWin = getWindowPool()?.get('ask');
if (!askWin || askWin.isDestroyed()) {
console.error("[AskService] Ask window is not available to send stream to.");
response.body.getReader().cancel();
return { success: false, error: 'Ask window is not available.' };
}
const reader = response.body.getReader();
signal.addEventListener('abort', () => {
console.log(`[AskService] Aborting stream reader. Reason: ${signal.reason}`);
reader.cancel(signal.reason).catch(() => { /* 이미 취소된 경우의 오류는 무시 */ });
});
await this._processStream(reader, askWin, sessionId, signal);
return { success: true };
} catch (multimodalError) {
// 멀티모달 요청이 실패했고 스크린샷이 포함되어 있다면 텍스트만으로 재시도
if (screenshotBase64 && this._isMultimodalError(multimodalError)) {
console.log(`[AskService] Multimodal request failed, retrying with text-only: ${multimodalError.message}`);
// 텍스트만으로 메시지 재구성
const textOnlyMessages = [
{ role: 'system', content: systemPrompt },
{
role: 'user',
content: `User Request: ${userPrompt.trim()}`
}
];
const fallbackResponse = await streamingLLM.streamChat(textOnlyMessages);
const askWin = getWindowPool()?.get('ask');
if (!askWin || askWin.isDestroyed()) {
console.error("[AskService] Ask window is not available for fallback response.");
fallbackResponse.body.getReader().cancel();
return { success: false, error: 'Ask window is not available.' };
}
const fallbackReader = fallbackResponse.body.getReader();
signal.addEventListener('abort', () => {
console.log(`[AskService] Aborting fallback stream reader. Reason: ${signal.reason}`);
fallbackReader.cancel(signal.reason).catch(() => {});
});
await this._processStream(fallbackReader, askWin, sessionId, signal);
return { success: true };
} else {
// 다른 종류의 에러이거나 스크린샷이 없었다면 그대로 throw
throw multimodalError;
}
}
} catch (error) {
console.error('[AskService] Error during message processing:', error);
this.state = {
...this.state,
isLoading: false,
isStreaming: false,
showTextInput: true,
};
this._broadcastState();
const askWin = getWindowPool()?.get('ask');
if (askWin && !askWin.isDestroyed()) {
const streamError = error.message || 'Unknown error occurred';
askWin.webContents.send('ask-response-stream-error', { error: streamError });
}
return { success: false, error: error.message };
}
}
/**
*
* @param {ReadableStreamDefaultReader} reader
* @param {BrowserWindow} askWin
* @param {number} sessionId
* @param {AbortSignal} signal
* @returns {Promise<void>}
* @private
*/
async _processStream(reader, askWin, sessionId, signal) {
const decoder = new TextDecoder();
let fullResponse = '';
try {
this.state.isLoading = false;
this.state.isStreaming = true;
this._broadcastState();
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]') {
return;
}
try {
const json = JSON.parse(data);
const token = json.choices[0]?.delta?.content || '';
if (token) {
fullResponse += token;
this.state.currentResponse = fullResponse;
this._broadcastState();
}
} catch (error) {
}
}
}
}
} catch (streamError) {
if (signal.aborted) {
console.log(`[AskService] Stream reading was intentionally cancelled. Reason: ${signal.reason}`);
} else {
console.error('[AskService] Error while processing stream:', streamError);
if (askWin && !askWin.isDestroyed()) {
askWin.webContents.send('ask-response-stream-error', { error: streamError.message });
}
}
} finally {
this.state.isStreaming = false;
this.state.currentResponse = fullResponse;
this._broadcastState();
if (fullResponse) {
try {
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
console.log(`[AskService] DB: Saved partial or full assistant response to session ${sessionId} after stream ended.`);
} catch(dbError) {
console.error("[AskService] DB: Failed to save assistant response after stream ended:", dbError);
}
}
}
}
/**
* 멀티모달 관련 에러인지 판단
* @private
*/
_isMultimodalError(error) {
const errorMessage = error.message?.toLowerCase() || '';
return (
errorMessage.includes('vision') ||
errorMessage.includes('image') ||
errorMessage.includes('multimodal') ||
errorMessage.includes('unsupported') ||
errorMessage.includes('image_url') ||
errorMessage.includes('400') || // Bad Request often for unsupported features
errorMessage.includes('invalid') ||
errorMessage.includes('not supported')
);
}
} }
module.exports = { const askService = new AskService();
initialize,
}; module.exports = askService;

View File

@ -0,0 +1,38 @@
const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter');
const aiMessageConverter = createEncryptedConverter(['content']);
function aiMessagesCol(sessionId) {
if (!sessionId) throw new Error("Session ID is required to access AI messages.");
const db = getFirestoreInstance();
return collection(db, `sessions/${sessionId}/ai_messages`).withConverter(aiMessageConverter);
}
async function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) {
const now = Timestamp.now();
const newMessage = {
uid, // To identify the author of the message
session_id: sessionId,
sent_at: now,
role,
content,
model,
created_at: now,
};
const docRef = await addDoc(aiMessagesCol(sessionId), newMessage);
return { id: docRef.id };
}
async function getAllAiMessagesBySessionId(sessionId) {
const q = query(aiMessagesCol(sessionId), orderBy('sent_at', 'asc'));
const querySnapshot = await getDocs(q);
return querySnapshot.docs.map(doc => doc.data());
}
module.exports = {
addAiMessage,
getAllAiMessagesBySessionId,
};

View File

@ -1,18 +1,25 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository'); // Future implementation const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService'); const authService = require('../../common/services/authService');
function getRepository() { function getBaseRepository() {
// In the future, we can check the user's login status from authService const user = authService.getCurrentUser();
// const user = authService.getCurrentUser(); if (user && user.isLoggedIn) {
// if (user.isLoggedIn) { return firebaseRepository;
// return firebaseRepository; }
// }
return sqliteRepository; return sqliteRepository;
} }
// Directly export functions for ease of use, decided by the strategy // The adapter layer that injects the UID
module.exports = { const askRepositoryAdapter = {
addAiMessage: (...args) => getRepository().addAiMessage(...args), addAiMessage: ({ sessionId, role, content, model }) => {
getAllAiMessagesBySessionId: (...args) => getRepository().getAllAiMessagesBySessionId(...args), const uid = authService.getCurrentUserId();
}; return getBaseRepository().addAiMessage({ uid, sessionId, role, content, model });
},
getAllAiMessagesBySessionId: (sessionId) => {
// This function does not require a UID at the service level.
return getBaseRepository().getAllAiMessagesBySessionId(sessionId);
}
};
module.exports = askRepositoryAdapter;

View File

@ -1,6 +1,7 @@
const sqliteClient = require('../../../common/services/sqliteClient'); const sqliteClient = require('../../common/services/sqliteClient');
function addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) { function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) {
// uid is ignored in the SQLite implementation
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const messageId = require('crypto').randomUUID(); const messageId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);

View File

@ -57,6 +57,42 @@ const PROVIDERS = {
], ],
sttModels: [], sttModels: [],
}, },
'deepgram': {
name: 'Deepgram',
handler: () => require("./providers/deepgram"),
llmModels: [],
sttModels: [
{ id: 'nova-3', name: 'Nova-3 (General)' },
],
},
'ollama': {
name: 'Ollama (Local)',
handler: () => require("./providers/ollama"),
llmModels: [], // Dynamic models populated from installed Ollama models
sttModels: [], // Ollama doesn't support STT yet
},
'whisper': {
name: 'Whisper (Local)',
handler: () => {
// This needs to remain a function due to its conditional logic for renderer/main process
if (typeof window === 'undefined') {
const { WhisperProvider } = require("./providers/whisper");
return new WhisperProvider();
}
// Return a dummy object for the renderer process
return {
validateApiKey: async () => ({ success: true }), // Mock validate for renderer
createSTT: () => { throw new Error('Whisper STT is only available in main process'); },
};
},
llmModels: [],
sttModels: [
{ id: 'whisper-tiny', name: 'Whisper Tiny (39M)' },
{ id: 'whisper-base', name: 'Whisper Base (74M)' },
{ id: 'whisper-small', name: 'Whisper Small (244M)' },
{ id: 'whisper-medium', name: 'Whisper Medium (769M)' },
],
},
}; };
function sanitizeModelId(model) { function sanitizeModelId(model) {
@ -102,6 +138,33 @@ function createStreamingLLM(provider, opts) {
return handler.createStreamingLLM(opts); return handler.createStreamingLLM(opts);
} }
function getProviderClass(providerId) {
const providerConfig = PROVIDERS[providerId];
if (!providerConfig) return null;
// Handle special cases for glass providers
let actualProviderId = providerId;
if (providerId === 'openai-glass') {
actualProviderId = 'openai';
}
// The handler function returns the module, from which we get the class.
const module = providerConfig.handler();
// Map provider IDs to their actual exported class names
const classNameMap = {
'openai': 'OpenAIProvider',
'anthropic': 'AnthropicProvider',
'gemini': 'GeminiProvider',
'deepgram': 'DeepgramProvider',
'ollama': 'OllamaProvider',
'whisper': 'WhisperProvider'
};
const className = classNameMap[actualProviderId];
return className ? module[className] : null;
}
function getAvailableProviders() { function getAvailableProviders() {
const stt = []; const stt = [];
const llm = []; const llm = [];
@ -117,5 +180,6 @@ module.exports = {
createSTT, createSTT,
createLLM, createLLM,
createStreamingLLM, createStreamingLLM,
getProviderClass,
getAvailableProviders, getAvailableProviders,
}; };

View File

@ -1,4 +1,38 @@
const Anthropic = require("@anthropic-ai/sdk") const { Anthropic } = require("@anthropic-ai/sdk")
class AnthropicProvider {
static async validateApiKey(key) {
if (!key || typeof key !== 'string' || !key.startsWith('sk-ant-')) {
return { success: false, error: 'Invalid Anthropic API key format.' };
}
try {
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) { // 400 is a valid response for a bad request, not a bad key
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
return { success: false, error: errorData.error?.message || `Validation failed with status: ${response.status}` };
}
} catch (error) {
console.error(`[AnthropicProvider] Network error during key validation:`, error);
return { success: false, error: 'A network error occurred during validation.' };
}
}
}
/** /**
* Creates an Anthropic STT session * Creates an Anthropic STT session
@ -286,7 +320,8 @@ function createStreamingLLM({
} }
module.exports = { module.exports = {
createSTT, AnthropicProvider,
createLLM, createSTT,
createStreamingLLM, createLLM,
} createStreamingLLM
};

View File

@ -0,0 +1,111 @@
// providers/deepgram.js
const { createClient, LiveTranscriptionEvents } = require('@deepgram/sdk');
const WebSocket = require('ws');
/**
* Deepgram Provider 클래스. API 유효성 검사를 담당합니다.
*/
class DeepgramProvider {
/**
* Deepgram API 키의 유효성을 검사합니다.
* @param {string} key - 검사할 Deepgram API
* @returns {Promise<{success: boolean, error?: string}>}
*/
static async validateApiKey(key) {
if (!key || typeof key !== 'string') {
return { success: false, error: 'Invalid Deepgram API key format.' };
}
try {
// ✨ 변경점: SDK 대신 직접 fetch로 API를 호출하여 안정성 확보 (openai.js 방식)
const response = await fetch('https://api.deepgram.com/v1/projects', {
headers: { 'Authorization': `Token ${key}` }
});
if (response.ok) {
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
const message = errorData.err_msg || `Validation failed with status: ${response.status}`;
return { success: false, error: message };
}
} catch (error) {
console.error(`[DeepgramProvider] Network error during key validation:`, error);
return { success: false, error: error.message || 'A network error occurred during validation.' };
}
}
}
function createSTT({
apiKey,
language = 'en-US',
sampleRate = 24000,
callbacks = {},
}) {
const qs = new URLSearchParams({
model: 'nova-3',
encoding: 'linear16',
sample_rate: sampleRate.toString(),
language,
smart_format: 'true',
interim_results: 'true',
channels: '1',
});
const url = `wss://api.deepgram.com/v1/listen?${qs}`;
const ws = new WebSocket(url, {
headers: { Authorization: `Token ${apiKey}` },
});
ws.binaryType = 'arraybuffer';
return new Promise((resolve, reject) => {
const to = setTimeout(() => {
ws.terminate();
reject(new Error('DG open timeout (10s)'));
}, 10_000);
ws.on('open', () => {
clearTimeout(to);
resolve({
sendRealtimeInput: (buf) => ws.send(buf),
close: () => ws.close(1000, 'client'),
});
});
ws.on('message', raw => {
let msg;
try { msg = JSON.parse(raw.toString()); } catch { return; }
if (msg.channel?.alternatives?.[0]?.transcript !== undefined) {
callbacks.onmessage?.({ provider: 'deepgram', ...msg });
}
});
ws.on('close', (code, reason) =>
callbacks.onclose?.({ code, reason: reason.toString() })
);
ws.on('error', err => {
clearTimeout(to);
callbacks.onerror?.(err);
reject(err);
});
});
}
// ... (LLM 관련 Placeholder 함수들은 그대로 유지) ...
function createLLM(opts) {
console.warn("[Deepgram] LLM not supported.");
return { generateContent: async () => { throw new Error("Deepgram does not support LLM functionality."); } };
}
function createStreamingLLM(opts) {
console.warn("[Deepgram] Streaming LLM not supported.");
return { streamChat: async () => { throw new Error("Deepgram does not support Streaming LLM functionality."); } };
}
module.exports = {
DeepgramProvider,
createSTT,
createLLM,
createStreamingLLM
};

View File

@ -0,0 +1,328 @@
const { GoogleGenerativeAI } = require("@google/generative-ai")
const { GoogleGenAI } = require("@google/genai")
class GeminiProvider {
static async validateApiKey(key) {
if (!key || typeof key !== 'string') {
return { success: false, error: 'Invalid Gemini API key format.' };
}
try {
const validationUrl = `https://generativelanguage.googleapis.com/v1beta/models?key=${key}`;
const response = await fetch(validationUrl);
if (response.ok) {
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
const message = errorData.error?.message || `Validation failed with status: ${response.status}`;
return { success: false, error: message };
}
} catch (error) {
console.error(`[GeminiProvider] Network error during key validation:`, error);
return { success: false, error: 'A network error occurred during validation.' };
}
}
}
/**
* 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 = {
GeminiProvider,
createSTT,
createLLM,
createStreamingLLM
};

View File

@ -0,0 +1,342 @@
const http = require('http');
const fetch = require('node-fetch');
// Request Queue System for Ollama API (only for non-streaming requests)
class RequestQueue {
constructor() {
this.queue = [];
this.processing = false;
this.streamingActive = false;
}
async addStreamingRequest(requestFn) {
// Streaming requests have priority - wait for current processing to finish
while (this.processing) {
await new Promise(resolve => setTimeout(resolve, 50));
}
this.streamingActive = true;
console.log('[Ollama Queue] Starting streaming request (priority)');
try {
const result = await requestFn();
return result;
} finally {
this.streamingActive = false;
console.log('[Ollama Queue] Streaming request completed');
}
}
async add(requestFn) {
return new Promise((resolve, reject) => {
this.queue.push({ requestFn, resolve, reject });
this.process();
});
}
async process() {
if (this.processing || this.queue.length === 0) {
return;
}
// Wait if streaming is active
if (this.streamingActive) {
setTimeout(() => this.process(), 100);
return;
}
this.processing = true;
while (this.queue.length > 0) {
// Check if streaming started while processing queue
if (this.streamingActive) {
this.processing = false;
setTimeout(() => this.process(), 100);
return;
}
const { requestFn, resolve, reject } = this.queue.shift();
try {
console.log(`[Ollama Queue] Processing queued request (${this.queue.length} remaining)`);
const result = await requestFn();
resolve(result);
} catch (error) {
console.error('[Ollama Queue] Request failed:', error);
reject(error);
}
}
this.processing = false;
}
}
// Global request queue instance
const requestQueue = new RequestQueue();
class OllamaProvider {
static async validateApiKey() {
try {
const response = await fetch('http://localhost:11434/api/tags');
if (response.ok) {
return { success: true };
} else {
return { success: false, error: 'Ollama service is not running. Please start Ollama first.' };
}
} catch (error) {
return { success: false, error: 'Cannot connect to Ollama. Please ensure Ollama is installed and running.' };
}
}
}
function convertMessagesToOllamaFormat(messages) {
return messages.map(msg => {
if (Array.isArray(msg.content)) {
let textContent = '';
const images = [];
for (const part of msg.content) {
if (part.type === 'text') {
textContent += part.text;
} else if (part.type === 'image_url') {
const base64 = part.image_url.url.replace(/^data:image\/[^;]+;base64,/, '');
images.push(base64);
}
}
return {
role: msg.role,
content: textContent,
...(images.length > 0 && { images })
};
} else {
return msg;
}
});
}
function createLLM({
model,
temperature = 0.7,
maxTokens = 2048,
baseUrl = 'http://localhost:11434',
...config
}) {
if (!model) {
throw new Error('Model parameter is required for Ollama LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
}
return {
generateContent: async (parts) => {
let systemPrompt = '';
const 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) {
userContent.push({
type: 'image',
image: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}`
});
}
}
const messages = [];
if (systemPrompt) {
messages.push({ role: 'system', content: systemPrompt });
}
messages.push({ role: 'user', content: userContent.join('\n') });
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
response: {
text: () => result.message.content
},
raw: result
};
} catch (error) {
console.error('Ollama LLM error:', error);
throw error;
}
});
},
chat: async (messages) => {
const ollamaMessages = convertMessagesToOllamaFormat(messages);
// Use request queue to prevent concurrent API calls
return await requestQueue.add(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: false,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
const result = await response.json();
return {
content: result.message.content,
raw: result
};
} catch (error) {
console.error('Ollama chat error:', error);
throw error;
}
});
}
};
}
function createStreamingLLM({
model,
temperature = 0.7,
maxTokens = 2048,
baseUrl = 'http://localhost:11434',
...config
}) {
if (!model) {
throw new Error('Model parameter is required for Ollama streaming LLM. Please specify a model name (e.g., "llama3.2:latest", "gemma3:4b")');
}
return {
streamChat: async (messages) => {
console.log('[Ollama Provider] Starting streaming request');
const ollamaMessages = convertMessagesToOllamaFormat(messages);
console.log('[Ollama Provider] Converted messages for Ollama:', ollamaMessages);
// Streaming requests have priority over queued requests
return await requestQueue.addStreamingRequest(async () => {
try {
const response = await fetch(`${baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model,
messages: ollamaMessages,
stream: true,
options: {
temperature,
num_predict: maxTokens,
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status} ${response.statusText}`);
}
console.log('[Ollama Provider] Got streaming response');
const stream = new ReadableStream({
async start(controller) {
let buffer = '';
try {
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
try {
const data = JSON.parse(line);
if (data.message?.content) {
const sseData = JSON.stringify({
choices: [{
delta: {
content: data.message.content
}
}]
});
controller.enqueue(new TextEncoder().encode(`data: ${sseData}\n\n`));
}
if (data.done) {
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
}
} catch (e) {
console.error('[Ollama Provider] Failed to parse chunk:', e);
}
}
});
response.body.on('end', () => {
controller.close();
console.log('[Ollama Provider] Streaming completed');
});
response.body.on('error', (error) => {
console.error('[Ollama Provider] Streaming error:', error);
controller.error(error);
});
} catch (error) {
console.error('[Ollama Provider] Streaming setup error:', error);
controller.error(error);
}
}
});
return {
ok: true,
body: stream
};
} catch (error) {
console.error('[Ollama Provider] Request error:', error);
throw error;
}
});
}
};
}
module.exports = {
OllamaProvider,
createLLM,
createStreamingLLM,
convertMessagesToOllamaFormat
};

View File

@ -1,5 +1,35 @@
const OpenAI = require('openai'); const OpenAI = require('openai');
const WebSocket = require('ws'); const WebSocket = require('ws');
const { Portkey } = require('portkey-ai');
const { Readable } = require('stream');
const { getProviderForModel } = require('../factory.js');
class OpenAIProvider {
static async validateApiKey(key) {
if (!key || typeof key !== 'string' || !key.startsWith('sk-')) {
return { success: false, error: 'Invalid OpenAI API key format.' };
}
try {
const response = await fetch('https://api.openai.com/v1/models', {
headers: { 'Authorization': `Bearer ${key}` }
});
if (response.ok) {
return { success: true };
} else {
const errorData = await response.json().catch(() => ({}));
const message = errorData.error?.message || `Validation failed with status: ${response.status}`;
return { success: false, error: message };
}
} catch (error) {
console.error(`[OpenAIProvider] Network error during key validation:`, error);
return { success: false, error: 'A network error occurred during validation.' };
}
}
}
/** /**
* Creates an OpenAI STT session * Creates an OpenAI STT session
@ -48,8 +78,8 @@ async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey =
turn_detection: { turn_detection: {
type: 'server_vad', type: 'server_vad',
threshold: 0.5, threshold: 0.5,
prefix_padding_ms: 50, prefix_padding_ms: 200,
silence_duration_ms: 25, silence_duration_ms: 100,
}, },
input_audio_noise_reduction: { input_audio_noise_reduction: {
type: 'near_field' type: 'near_field'
@ -206,7 +236,7 @@ function createLLM({ apiKey, model = 'gpt-4.1', temperature = 0.7, maxTokens = 2
}; };
} }
/** /**
* Creates an OpenAI streaming LLM instance * Creates an OpenAI streaming LLM instance
* @param {object} opts - Configuration options * @param {object} opts - Configuration options
* @param {string} opts.apiKey - OpenAI API key * @param {string} opts.apiKey - OpenAI API key
@ -257,7 +287,8 @@ function createStreamingLLM({ apiKey, model = 'gpt-4.1', temperature = 0.7, maxT
} }
module.exports = { module.exports = {
createSTT, OpenAIProvider,
createLLM, createSTT,
createStreamingLLM createLLM,
createStreamingLLM
}; };

View File

@ -0,0 +1,241 @@
let spawn, path, EventEmitter;
if (typeof window === 'undefined') {
spawn = require('child_process').spawn;
path = require('path');
EventEmitter = require('events').EventEmitter;
} else {
class DummyEventEmitter {
on() {}
emit() {}
removeAllListeners() {}
}
EventEmitter = DummyEventEmitter;
}
class WhisperSTTSession extends EventEmitter {
constructor(model, whisperService, sessionId) {
super();
this.model = model;
this.whisperService = whisperService;
this.sessionId = sessionId || `session_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
this.process = null;
this.isRunning = false;
this.audioBuffer = Buffer.alloc(0);
this.processingInterval = null;
this.lastTranscription = '';
}
async initialize() {
try {
await this.whisperService.ensureModelAvailable(this.model);
this.isRunning = true;
this.startProcessingLoop();
return true;
} catch (error) {
console.error('[WhisperSTT] Initialization error:', error);
this.emit('error', error);
return false;
}
}
startProcessingLoop() {
this.processingInterval = setInterval(async () => {
const minBufferSize = 16000 * 2 * 0.15;
if (this.audioBuffer.length >= minBufferSize && !this.process) {
console.log(`[WhisperSTT-${this.sessionId}] Processing audio chunk, buffer size: ${this.audioBuffer.length}`);
await this.processAudioChunk();
}
}, 1500);
}
async processAudioChunk() {
if (!this.isRunning || this.audioBuffer.length === 0) return;
const audioData = this.audioBuffer;
this.audioBuffer = Buffer.alloc(0);
try {
const tempFile = await this.whisperService.saveAudioToTemp(audioData, this.sessionId);
if (!tempFile || typeof tempFile !== 'string') {
console.error('[WhisperSTT] Invalid temp file path:', tempFile);
return;
}
const whisperPath = await this.whisperService.getWhisperPath();
const modelPath = await this.whisperService.getModelPath(this.model);
if (!whisperPath || !modelPath) {
console.error('[WhisperSTT] Invalid whisper or model path:', { whisperPath, modelPath });
return;
}
this.process = spawn(whisperPath, [
'-m', modelPath,
'-f', tempFile,
'--no-timestamps',
'--output-txt',
'--output-json',
'--language', 'auto',
'--threads', '4',
'--print-progress', 'false'
]);
let output = '';
let errorOutput = '';
this.process.stdout.on('data', (data) => {
output += data.toString();
});
this.process.stderr.on('data', (data) => {
errorOutput += data.toString();
});
this.process.on('close', async (code) => {
this.process = null;
if (code === 0 && output.trim()) {
const transcription = output.trim();
if (transcription && transcription !== this.lastTranscription) {
this.lastTranscription = transcription;
console.log(`[WhisperSTT-${this.sessionId}] Transcription: "${transcription}"`);
this.emit('transcription', {
text: transcription,
timestamp: Date.now(),
confidence: 1.0,
sessionId: this.sessionId
});
}
} else if (errorOutput) {
console.error(`[WhisperSTT-${this.sessionId}] Process error:`, errorOutput);
}
await this.whisperService.cleanupTempFile(tempFile);
});
} catch (error) {
console.error('[WhisperSTT] Processing error:', error);
this.emit('error', error);
}
}
sendRealtimeInput(audioData) {
if (!this.isRunning) {
console.warn(`[WhisperSTT-${this.sessionId}] Session not running, cannot accept audio`);
return;
}
if (typeof audioData === 'string') {
try {
audioData = Buffer.from(audioData, 'base64');
} catch (error) {
console.error('[WhisperSTT] Failed to decode base64 audio data:', error);
return;
}
} else if (audioData instanceof ArrayBuffer) {
audioData = Buffer.from(audioData);
} else if (!Buffer.isBuffer(audioData) && !(audioData instanceof Uint8Array)) {
console.error('[WhisperSTT] Invalid audio data type:', typeof audioData);
return;
}
if (!Buffer.isBuffer(audioData)) {
audioData = Buffer.from(audioData);
}
if (audioData.length > 0) {
this.audioBuffer = Buffer.concat([this.audioBuffer, audioData]);
// Log every 10th audio chunk to avoid spam
if (Math.random() < 0.1) {
console.log(`[WhisperSTT-${this.sessionId}] Received audio chunk: ${audioData.length} bytes, total buffer: ${this.audioBuffer.length} bytes`);
}
}
}
async close() {
console.log(`[WhisperSTT-${this.sessionId}] Closing session`);
this.isRunning = false;
if (this.processingInterval) {
clearInterval(this.processingInterval);
this.processingInterval = null;
}
if (this.process) {
this.process.kill('SIGTERM');
this.process = null;
}
this.removeAllListeners();
}
}
class WhisperProvider {
static async validateApiKey() {
// Whisper is a local service, no API key validation needed.
return { success: true };
}
constructor() {
this.whisperService = null;
}
async initialize() {
if (!this.whisperService) {
this.whisperService = require('../../services/whisperService');
if (!this.whisperService.isInitialized) {
await this.whisperService.initialize();
}
}
}
async createSTT(config) {
await this.initialize();
const model = config.model || 'whisper-tiny';
const sessionType = config.sessionType || 'unknown';
console.log(`[WhisperProvider] Creating ${sessionType} STT session with model: ${model}`);
// Create unique session ID based on type
const sessionId = `${sessionType}_${Date.now()}_${Math.random().toString(36).substr(2, 6)}`;
const session = new WhisperSTTSession(model, this.whisperService, sessionId);
// Log session creation
console.log(`[WhisperProvider] Created session: ${sessionId}`);
const initialized = await session.initialize();
if (!initialized) {
throw new Error('Failed to initialize Whisper STT session');
}
if (config.callbacks) {
if (config.callbacks.onmessage) {
session.on('transcription', config.callbacks.onmessage);
}
if (config.callbacks.onerror) {
session.on('error', config.callbacks.onerror);
}
if (config.callbacks.onclose) {
session.on('close', config.callbacks.onclose);
}
}
return session;
}
async createLLM() {
throw new Error('Whisper provider does not support LLM functionality');
}
async createStreamingLLM() {
console.warn('[WhisperProvider] Streaming LLM is not supported by Whisper.');
throw new Error('Whisper does not support LLM.');
}
}
module.exports = {
WhisperProvider,
WhisperSTTSession
};

View File

@ -0,0 +1,54 @@
const DOWNLOAD_CHECKSUMS = {
ollama: {
dmg: {
url: 'https://ollama.com/download/Ollama.dmg',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
exe: {
url: 'https://ollama.com/download/OllamaSetup.exe',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
linux: {
url: 'curl -fsSL https://ollama.com/install.sh | sh',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
}
},
whisper: {
models: {
'whisper-tiny': {
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-tiny.bin',
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
},
'whisper-base': {
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-base.bin',
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
},
'whisper-small': {
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-small.bin',
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
},
'whisper-medium': {
url: 'https://huggingface.co/ggml-org/whisper.cpp/resolve/main/ggml-medium.bin',
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208'
}
},
binaries: {
'v1.7.6': {
mac: {
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-mac-x64.zip',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
windows: {
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
},
linux: {
url: 'https://github.com/ggml-org/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
sha256: null // TODO: 실제 체크섬 추가 필요 - null일 경우 체크섬 검증 스킵됨
}
}
}
}
};
module.exports = { DOWNLOAD_CHECKSUMS };

View File

@ -5,8 +5,8 @@ const LATEST_SCHEMA = {
{ name: 'display_name', type: 'TEXT NOT NULL' }, { name: 'display_name', type: 'TEXT NOT NULL' },
{ name: 'email', type: 'TEXT NOT NULL' }, { name: 'email', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'api_key', type: 'TEXT' }, { name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' },
{ name: 'provider', type: 'TEXT DEFAULT \'openai\'' } { name: 'has_migrated_to_firebase', type: 'INTEGER DEFAULT 0' }
] ]
}, },
sessions: { sessions: {
@ -71,6 +71,49 @@ const LATEST_SCHEMA = {
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' } { name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' }
] ]
},
ollama_models: {
columns: [
{ name: 'name', type: 'TEXT PRIMARY KEY' },
{ name: 'size', type: 'TEXT NOT NULL' },
{ name: 'installed', type: 'INTEGER DEFAULT 0' },
{ name: 'installing', type: 'INTEGER DEFAULT 0' }
]
},
whisper_models: {
columns: [
{ name: 'id', type: 'TEXT PRIMARY KEY' },
{ name: 'name', type: 'TEXT NOT NULL' },
{ name: 'size', type: 'TEXT NOT NULL' },
{ name: 'installed', type: 'INTEGER DEFAULT 0' },
{ name: 'installing', type: 'INTEGER DEFAULT 0' }
]
},
provider_settings: {
columns: [
{ name: 'provider', type: 'TEXT NOT NULL' },
{ name: 'api_key', type: 'TEXT' },
{ name: 'selected_llm_model', type: 'TEXT' },
{ name: 'selected_stt_model', type: 'TEXT' },
{ name: 'is_active_llm', type: 'INTEGER DEFAULT 0' },
{ name: 'is_active_stt', type: 'INTEGER DEFAULT 0' },
{ name: 'created_at', type: 'INTEGER' },
{ name: 'updated_at', type: 'INTEGER' }
],
constraints: ['PRIMARY KEY (provider)']
},
shortcuts: {
columns: [
{ name: 'action', type: 'TEXT PRIMARY KEY' },
{ name: 'accelerator', type: 'TEXT NOT NULL' },
{ name: 'created_at', type: 'INTEGER' }
]
},
permissions: {
columns: [
{ name: 'uid', type: 'TEXT PRIMARY KEY' },
{ name: 'keychain_completed', type: 'INTEGER DEFAULT 0' }
]
} }
}; };

View File

@ -0,0 +1,60 @@
const encryptionService = require('../services/encryptionService');
const { Timestamp } = require('firebase/firestore');
/**
* Creates a Firestore converter that automatically encrypts and decrypts specified fields.
* @param {string[]} fieldsToEncrypt - An array of field names to encrypt.
* @returns {import('@firebase/firestore').FirestoreDataConverter<T>} A Firestore converter.
* @template T
*/
function createEncryptedConverter(fieldsToEncrypt = []) {
return {
/**
* @param {import('@firebase/firestore').DocumentData} appObject
*/
toFirestore: (appObject) => {
const firestoreData = { ...appObject };
for (const field of fieldsToEncrypt) {
if (Object.prototype.hasOwnProperty.call(firestoreData, field) && firestoreData[field] != null) {
firestoreData[field] = encryptionService.encrypt(firestoreData[field]);
}
}
// Ensure there's a timestamp for the last modification
firestoreData.updated_at = Timestamp.now();
return firestoreData;
},
/**
* @param {import('@firebase/firestore').QueryDocumentSnapshot} snapshot
* @param {import('@firebase/firestore').SnapshotOptions} options
*/
fromFirestore: (snapshot, options) => {
const firestoreData = snapshot.data(options);
const appObject = { ...firestoreData, id: snapshot.id }; // include the document ID
for (const field of fieldsToEncrypt) {
if (Object.prototype.hasOwnProperty.call(appObject, field) && appObject[field] != null) {
try {
appObject[field] = encryptionService.decrypt(appObject[field]);
} catch (error) {
console.warn(`[FirestoreConverter] Failed to decrypt field '${field}' (possibly plaintext or key mismatch):`, error.message);
// Keep the original value instead of failing
// appObject[field] remains as is
}
}
}
// Convert Firestore Timestamps back to Unix timestamps (seconds) for app-wide consistency
for (const key in appObject) {
if (appObject[key] instanceof Timestamp) {
appObject[key] = appObject[key].seconds;
}
}
return appObject;
}
};
}
module.exports = {
createEncryptedConverter,
};

View File

@ -0,0 +1,20 @@
const sqliteRepository = require('./sqlite.repository');
// For now, we only use SQLite repository
// In the future, we could add cloud sync support
function getRepository() {
return sqliteRepository;
}
// Export all repository methods
module.exports = {
getAllModels: (...args) => getRepository().getAllModels(...args),
getModel: (...args) => getRepository().getModel(...args),
upsertModel: (...args) => getRepository().upsertModel(...args),
updateInstallStatus: (...args) => getRepository().updateInstallStatus(...args),
initializeDefaultModels: (...args) => getRepository().initializeDefaultModels(...args),
deleteModel: (...args) => getRepository().deleteModel(...args),
getInstalledModels: (...args) => getRepository().getInstalledModels(...args),
getInstallingModels: (...args) => getRepository().getInstallingModels(...args)
};

View File

@ -0,0 +1,137 @@
const sqliteClient = require('../../services/sqliteClient');
/**
* Get all Ollama models
*/
function getAllModels() {
const db = sqliteClient.getDb();
const query = 'SELECT * FROM ollama_models ORDER BY name';
try {
return db.prepare(query).all() || [];
} catch (err) {
console.error('[OllamaModel Repository] Failed to get models:', err);
throw err;
}
}
/**
* Get a specific model by name
*/
function getModel(name) {
const db = sqliteClient.getDb();
const query = 'SELECT * FROM ollama_models WHERE name = ?';
try {
return db.prepare(query).get(name);
} catch (err) {
console.error('[OllamaModel Repository] Failed to get model:', err);
throw err;
}
}
/**
* Create or update a model entry
*/
function upsertModel({ name, size, installed = false, installing = false }) {
const db = sqliteClient.getDb();
const query = `
INSERT INTO ollama_models (name, size, installed, installing)
VALUES (?, ?, ?, ?)
ON CONFLICT(name) DO UPDATE SET
size = excluded.size,
installed = excluded.installed,
installing = excluded.installing
`;
try {
db.prepare(query).run(name, size, installed ? 1 : 0, installing ? 1 : 0);
return { success: true };
} catch (err) {
console.error('[OllamaModel Repository] Failed to upsert model:', err);
throw err;
}
}
/**
* Update installation status for a model
*/
function updateInstallStatus(name, installed, installing = false) {
const db = sqliteClient.getDb();
const query = 'UPDATE ollama_models SET installed = ?, installing = ? WHERE name = ?';
try {
const result = db.prepare(query).run(installed ? 1 : 0, installing ? 1 : 0, name);
return { success: true, changes: result.changes };
} catch (err) {
console.error('[OllamaModel Repository] Failed to update install status:', err);
throw err;
}
}
/**
* Initialize default models - now done dynamically based on installed models
*/
function initializeDefaultModels() {
// Default models are now detected dynamically from Ollama installation
// This function maintains compatibility but doesn't hardcode any models
console.log('[OllamaModel Repository] Default models initialization skipped - using dynamic detection');
return { success: true };
}
/**
* Delete a model entry
*/
function deleteModel(name) {
const db = sqliteClient.getDb();
const query = 'DELETE FROM ollama_models WHERE name = ?';
try {
const result = db.prepare(query).run(name);
return { success: true, changes: result.changes };
} catch (err) {
console.error('[OllamaModel Repository] Failed to delete model:', err);
throw err;
}
}
/**
* Get installed models
*/
function getInstalledModels() {
const db = sqliteClient.getDb();
const query = 'SELECT * FROM ollama_models WHERE installed = 1 ORDER BY name';
try {
return db.prepare(query).all() || [];
} catch (err) {
console.error('[OllamaModel Repository] Failed to get installed models:', err);
throw err;
}
}
/**
* Get models currently being installed
*/
function getInstallingModels() {
const db = sqliteClient.getDb();
const query = 'SELECT * FROM ollama_models WHERE installing = 1 ORDER BY name';
try {
return db.prepare(query).all() || [];
} catch (err) {
console.error('[OllamaModel Repository] Failed to get installing models:', err);
throw err;
}
}
module.exports = {
getAllModels,
getModel,
upsertModel,
updateInstallStatus,
initializeDefaultModels,
deleteModel,
getInstalledModels,
getInstallingModels
};

View File

@ -6,6 +6,6 @@ function getRepository() {
} }
module.exports = { module.exports = {
markPermissionsAsCompleted: (...args) => getRepository().markPermissionsAsCompleted(...args), markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args),
checkPermissionsCompleted: (...args) => getRepository().checkPermissionsCompleted(...args), checkKeychainCompleted: (...args) => getRepository().checkKeychainCompleted(...args),
}; };

View File

@ -0,0 +1,18 @@
const sqliteClient = require('../../services/sqliteClient');
function markKeychainCompleted(uid) {
return sqliteClient.query(
'INSERT OR REPLACE INTO permissions (uid, keychain_completed) VALUES (?, 1)',
[uid]
);
}
function checkKeychainCompleted(uid) {
const row = sqliteClient.query('SELECT keychain_completed FROM permissions WHERE uid = ?', [uid]);
return row.length > 0 && row[0].keychain_completed === 1;
}
module.exports = {
markKeychainCompleted,
checkKeychainCompleted
};

View File

@ -0,0 +1,107 @@
const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter');
const encryptionService = require('../../services/encryptionService');
const userPresetConverter = createEncryptedConverter(['prompt', 'title']);
const defaultPresetConverter = {
toFirestore: (data) => data,
fromFirestore: (snapshot, options) => {
const data = snapshot.data(options);
return { ...data, id: snapshot.id };
}
};
function userPresetsCol() {
const db = getFirestoreInstance();
return collection(db, 'prompt_presets').withConverter(userPresetConverter);
}
function defaultPresetsCol() {
const db = getFirestoreInstance();
// Path must have an odd number of segments. 'v1' is a placeholder document.
return collection(db, 'defaults/v1/prompt_presets').withConverter(defaultPresetConverter);
}
async function getPresets(uid) {
const userPresetsQuery = query(userPresetsCol(), where('uid', '==', uid));
const defaultPresetsQuery = query(defaultPresetsCol()); // Defaults have no owner
const [userSnapshot, defaultSnapshot] = await Promise.all([
getDocs(userPresetsQuery),
getDocs(defaultPresetsQuery)
]);
const presets = [
...defaultSnapshot.docs.map(d => d.data()),
...userSnapshot.docs.map(d => d.data())
];
return presets.sort((a, b) => {
if (a.is_default && !b.is_default) return -1;
if (!a.is_default && b.is_default) return 1;
return a.title.localeCompare(b.title);
});
}
async function getPresetTemplates() {
const q = query(defaultPresetsCol(), orderBy('title', 'asc'));
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => doc.data());
}
async function create({ uid, title, prompt }) {
const now = Timestamp.now();
const newPreset = {
uid: uid,
title,
prompt,
is_default: 0,
created_at: now,
};
const docRef = await addDoc(userPresetsCol(), newPreset);
return { id: docRef.id };
}
async function update(id, { title, prompt }, uid) {
const docRef = doc(userPresetsCol(), id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) {
throw new Error("Preset not found or permission denied to update.");
}
// Encrypt sensitive fields before sending to Firestore because `updateDoc` bypasses converters.
const updates = {};
if (title !== undefined) {
updates.title = encryptionService.encrypt(title);
}
if (prompt !== undefined) {
updates.prompt = encryptionService.encrypt(prompt);
}
updates.updated_at = Timestamp.now();
await updateDoc(docRef, updates);
return { changes: 1 };
}
async function del(id, uid) {
const docRef = doc(userPresetsCol(), id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) {
throw new Error("Preset not found or permission denied to delete.");
}
await deleteDoc(docRef);
return { changes: 1 };
}
module.exports = {
getPresets,
getPresetTemplates,
create,
update,
delete: del,
};

View File

@ -0,0 +1,39 @@
const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository');
const authService = require('../../services/authService');
function getBaseRepository() {
const user = authService.getCurrentUser();
if (user && user.isLoggedIn) {
return firebaseRepository;
}
return sqliteRepository;
}
const presetRepositoryAdapter = {
getPresets: () => {
const uid = authService.getCurrentUserId();
return getBaseRepository().getPresets(uid);
},
getPresetTemplates: () => {
return getBaseRepository().getPresetTemplates();
},
create: (options) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().create({ uid, ...options });
},
update: (id, options) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().update(id, options, uid);
},
delete: (id) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().delete(id, uid);
},
};
module.exports = presetRepositoryAdapter;

View File

@ -0,0 +1,68 @@
const sqliteRepository = require('./sqlite.repository');
function getBaseRepository() {
// For now, we only have sqlite. This could be expanded later.
return sqliteRepository;
}
const providerSettingsRepositoryAdapter = {
// Core CRUD operations
async getByProvider(provider) {
const repo = getBaseRepository();
return await repo.getByProvider(provider);
},
async getAll() {
const repo = getBaseRepository();
return await repo.getAll();
},
async upsert(provider, settings) {
const repo = getBaseRepository();
const now = Date.now();
const settingsWithMeta = {
...settings,
provider,
updated_at: now,
created_at: settings.created_at || now
};
return await repo.upsert(provider, settingsWithMeta);
},
async remove(provider) {
const repo = getBaseRepository();
return await repo.remove(provider);
},
async removeAll() {
const repo = getBaseRepository();
return await repo.removeAll();
},
async getRawApiKeys() {
// This function should always target the local sqlite DB,
// as it's part of the local-first boot sequence.
return await sqliteRepository.getRawApiKeys();
},
async getActiveProvider(type) {
const repo = getBaseRepository();
return await repo.getActiveProvider(type);
},
async setActiveProvider(provider, type) {
const repo = getBaseRepository();
return await repo.setActiveProvider(provider, type);
},
async getActiveSettings() {
const repo = getBaseRepository();
return await repo.getActiveSettings();
}
};
module.exports = {
...providerSettingsRepositoryAdapter
};

View File

@ -0,0 +1,160 @@
const sqliteClient = require('../../services/sqliteClient');
const encryptionService = require('../../services/encryptionService');
function getByProvider(provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings WHERE provider = ?');
const result = stmt.get(provider) || null;
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
return result;
}
function getAll() {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT * FROM provider_settings ORDER BY provider');
const results = stmt.all();
return results.map(result => {
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
return result;
});
}
function upsert(provider, settings) {
// Validate: prevent direct setting of active status
if (settings.is_active_llm || settings.is_active_stt) {
console.warn('[ProviderSettings] Warning: is_active_llm/is_active_stt should not be set directly. Use setActiveProvider() instead.');
}
const db = sqliteClient.getDb();
// Use SQLite's UPSERT syntax (INSERT ... ON CONFLICT ... DO UPDATE)
const stmt = db.prepare(`
INSERT INTO provider_settings (provider, api_key, selected_llm_model, selected_stt_model, is_active_llm, is_active_stt, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(provider) DO UPDATE SET
api_key = excluded.api_key,
selected_llm_model = excluded.selected_llm_model,
selected_stt_model = excluded.selected_stt_model,
-- is_active_llm and is_active_stt are NOT updated here
-- Use setActiveProvider() to change active status
updated_at = excluded.updated_at
`);
const result = stmt.run(
provider,
settings.api_key || null,
settings.selected_llm_model || null,
settings.selected_stt_model || null,
0, // is_active_llm - always 0, use setActiveProvider to activate
0, // is_active_stt - always 0, use setActiveProvider to activate
settings.created_at || Date.now(),
settings.updated_at
);
return { changes: result.changes };
}
function remove(provider) {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings WHERE provider = ?');
const result = stmt.run(provider);
return { changes: result.changes };
}
function removeAll() {
const db = sqliteClient.getDb();
const stmt = db.prepare('DELETE FROM provider_settings');
const result = stmt.run();
return { changes: result.changes };
}
function getRawApiKeys() {
const db = sqliteClient.getDb();
const stmt = db.prepare('SELECT api_key FROM provider_settings');
return stmt.all();
}
// Get active provider for a specific type (llm or stt)
function getActiveProvider(type) {
const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
const stmt = db.prepare(`SELECT * FROM provider_settings WHERE ${column} = 1`);
const result = stmt.get() || null;
if (result && result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
return result;
}
// Set active provider for a specific type
function setActiveProvider(provider, type) {
const db = sqliteClient.getDb();
const column = type === 'llm' ? 'is_active_llm' : 'is_active_stt';
// Start transaction to ensure only one provider is active
db.transaction(() => {
// First, deactivate all providers for this type
const deactivateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 0`);
deactivateStmt.run();
// Then activate the specified provider
if (provider) {
const activateStmt = db.prepare(`UPDATE provider_settings SET ${column} = 1 WHERE provider = ?`);
activateStmt.run(provider);
}
})();
return { success: true };
}
// Get all active settings (both llm and stt)
function getActiveSettings() {
const db = sqliteClient.getDb();
const stmt = db.prepare(`
SELECT * FROM provider_settings
WHERE (is_active_llm = 1 OR is_active_stt = 1)
ORDER BY provider
`);
const results = stmt.all();
// Decrypt API keys and organize by type
const activeSettings = {
llm: null,
stt: null
};
results.forEach(result => {
if (result.api_key && encryptionService.looksEncrypted(result.api_key)) {
result.api_key = encryptionService.decrypt(result.api_key);
}
if (result.is_active_llm) {
activeSettings.llm = result;
}
if (result.is_active_stt) {
activeSettings.stt = result;
}
});
return activeSettings;
}
module.exports = {
getByProvider,
getAll,
upsert,
remove,
removeAll,
getRawApiKeys,
getActiveProvider,
setActiveProvider,
getActiveSettings
};

View File

@ -0,0 +1,161 @@
const { doc, getDoc, collection, addDoc, query, where, getDocs, writeBatch, orderBy, limit, updateDoc, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter');
const encryptionService = require('../../services/encryptionService');
const sessionConverter = createEncryptedConverter(['title']);
function sessionsCol() {
const db = getFirestoreInstance();
return collection(db, 'sessions').withConverter(sessionConverter);
}
// Sub-collection references are now built from the top-level
function subCollections(sessionId) {
const db = getFirestoreInstance();
const sessionPath = `sessions/${sessionId}`;
return {
transcripts: collection(db, `${sessionPath}/transcripts`),
ai_messages: collection(db, `${sessionPath}/ai_messages`),
summary: collection(db, `${sessionPath}/summary`),
}
}
async function getById(id) {
const docRef = doc(sessionsCol(), id);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? docSnap.data() : null;
}
async function create(uid, type = 'ask') {
const now = Timestamp.now();
const newSession = {
uid: uid,
members: [uid], // For future sharing functionality
title: `Session @ ${new Date().toLocaleTimeString()}`,
session_type: type,
started_at: now,
updated_at: now,
ended_at: null,
};
const docRef = await addDoc(sessionsCol(), newSession);
console.log(`Firebase: Created session ${docRef.id} for user ${uid}`);
return docRef.id;
}
async function getAllByUserId(uid) {
const q = query(sessionsCol(), where('members', 'array-contains', uid), orderBy('started_at', 'desc'));
const querySnapshot = await getDocs(q);
return querySnapshot.docs.map(doc => doc.data());
}
async function updateTitle(id, title) {
const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, {
title: encryptionService.encrypt(title),
updated_at: Timestamp.now()
});
return { changes: 1 };
}
async function deleteWithRelatedData(id) {
const db = getFirestoreInstance();
const batch = writeBatch(db);
const { transcripts, ai_messages, summary } = subCollections(id);
const [transcriptsSnap, aiMessagesSnap, summarySnap] = await Promise.all([
getDocs(query(transcripts)),
getDocs(query(ai_messages)),
getDocs(query(summary)),
]);
transcriptsSnap.forEach(d => batch.delete(d.ref));
aiMessagesSnap.forEach(d => batch.delete(d.ref));
summarySnap.forEach(d => batch.delete(d.ref));
const sessionRef = doc(sessionsCol(), id);
batch.delete(sessionRef);
await batch.commit();
return { success: true };
}
async function end(id) {
const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, { ended_at: Timestamp.now() });
return { changes: 1 };
}
async function updateType(id, type) {
const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, { session_type: type });
return { changes: 1 };
}
async function touch(id) {
const docRef = doc(sessionsCol(), id);
await updateDoc(docRef, { updated_at: Timestamp.now() });
return { changes: 1 };
}
async function getOrCreateActive(uid, requestedType = 'ask') {
const findQuery = query(
sessionsCol(),
where('uid', '==', uid),
where('ended_at', '==', null),
orderBy('session_type', 'desc'),
limit(1)
);
const activeSessionSnap = await getDocs(findQuery);
if (!activeSessionSnap.empty) {
const activeSessionDoc = activeSessionSnap.docs[0];
const sessionRef = doc(sessionsCol(), activeSessionDoc.id);
const activeSession = activeSessionDoc.data();
console.log(`[Repo] Found active Firebase session ${activeSession.id}`);
const updates = { updated_at: Timestamp.now() };
if (activeSession.session_type === 'ask' && requestedType === 'listen') {
updates.session_type = 'listen';
console.log(`[Repo] Promoted Firebase session ${activeSession.id} to 'listen' type.`);
}
await updateDoc(sessionRef, updates);
return activeSessionDoc.id;
} else {
console.log(`[Repo] No active Firebase session for user ${uid}. Creating new.`);
return create(uid, requestedType);
}
}
async function endAllActiveSessions(uid) {
const q = query(sessionsCol(), where('uid', '==', uid), where('ended_at', '==', null));
const snapshot = await getDocs(q);
if (snapshot.empty) return { changes: 0 };
const batch = writeBatch(getFirestoreInstance());
const now = Timestamp.now();
snapshot.forEach(d => {
batch.update(d.ref, { ended_at: now });
});
await batch.commit();
console.log(`[Repo] Ended ${snapshot.size} active session(s) for user ${uid}.`);
return { changes: snapshot.size };
}
module.exports = {
getById,
create,
getAllByUserId,
updateTitle,
deleteWithRelatedData,
end,
updateType,
touch,
getOrCreateActive,
endAllActiveSessions,
};

View File

@ -0,0 +1,60 @@
const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository');
let authService = null;
function setAuthService(service) {
authService = service;
}
function getBaseRepository() {
if (!authService) {
// Fallback or error if authService is not set, to prevent crashes.
// During initial load, it might not be set, so we default to sqlite.
return sqliteRepository;
}
const user = authService.getCurrentUser();
if (user && user.isLoggedIn) {
return firebaseRepository;
}
return sqliteRepository;
}
// The adapter layer that injects the UID
const sessionRepositoryAdapter = {
setAuthService, // Expose the setter
getById: (id) => getBaseRepository().getById(id),
create: (type = 'ask') => {
const uid = authService.getCurrentUserId();
return getBaseRepository().create(uid, type);
},
getAllByUserId: () => {
const uid = authService.getCurrentUserId();
return getBaseRepository().getAllByUserId(uid);
},
updateTitle: (id, title) => getBaseRepository().updateTitle(id, title),
deleteWithRelatedData: (id) => getBaseRepository().deleteWithRelatedData(id),
end: (id) => getBaseRepository().end(id),
updateType: (id, type) => getBaseRepository().updateType(id, type),
touch: (id) => getBaseRepository().touch(id),
getOrCreateActive: (requestedType = 'ask') => {
const uid = authService.getCurrentUserId();
return getBaseRepository().getOrCreateActive(uid, requestedType);
},
endAllActiveSessions: () => {
const uid = authService.getCurrentUserId();
return getBaseRepository().endAllActiveSessions(uid);
},
};
module.exports = sessionRepositoryAdapter;

View File

@ -108,14 +108,15 @@ function getOrCreateActive(uid, requestedType = 'ask') {
} }
} }
function endAllActiveSessions() { function endAllActiveSessions(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE ended_at IS NULL`; // Filter by uid to match the Firebase repository's behavior.
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE ended_at IS NULL AND uid = ?`;
try { try {
const result = db.prepare(query).run(now, now); const result = db.prepare(query).run(now, now, uid);
console.log(`[Repo] Ended ${result.changes} active session(s).`); console.log(`[Repo] Ended ${result.changes} active SQLite session(s) for user ${uid}.`);
return { changes: result.changes }; return { changes: result.changes };
} catch (err) { } catch (err) {
console.error('SQLite: Failed to end all active sessions:', err); console.error('SQLite: Failed to end all active sessions:', err);

View File

@ -0,0 +1,86 @@
const { doc, getDoc, setDoc, deleteDoc, writeBatch, query, where, getDocs, collection, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../services/firebaseClient');
const { createEncryptedConverter } = require('../firestoreConverter');
const encryptionService = require('../../services/encryptionService');
const userConverter = createEncryptedConverter([]);
function usersCol() {
const db = getFirestoreInstance();
return collection(db, 'users').withConverter(userConverter);
}
// These functions are mostly correct as they already operate on a top-level collection.
// We just need to ensure the signatures are consistent.
async function findOrCreate(user) {
if (!user || !user.uid) throw new Error('User object and uid are required');
const { uid, displayName, email } = user;
const now = Timestamp.now();
const docRef = doc(usersCol(), uid);
const docSnap = await getDoc(docRef);
if (docSnap.exists()) {
await setDoc(docRef, {
display_name: displayName || docSnap.data().display_name || 'User',
email: email || docSnap.data().email || 'no-email@example.com'
}, { merge: true });
} else {
await setDoc(docRef, { uid, display_name: displayName || 'User', email: email || 'no-email@example.com', created_at: now });
}
const finalDoc = await getDoc(docRef);
return finalDoc.data();
}
async function getById(uid) {
const docRef = doc(usersCol(), uid);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? docSnap.data() : null;
}
async function update({ uid, displayName }) {
const docRef = doc(usersCol(), uid);
await setDoc(docRef, { display_name: displayName }, { merge: true });
return { changes: 1 };
}
async function deleteById(uid) {
const db = getFirestoreInstance();
const batch = writeBatch(db);
// 1. Delete all sessions owned by the user
const sessionsQuery = query(collection(db, 'sessions'), where('uid', '==', uid));
const sessionsSnapshot = await getDocs(sessionsQuery);
for (const sessionDoc of sessionsSnapshot.docs) {
// Recursively delete sub-collections
const subcollectionsToDelete = ['transcripts', 'ai_messages', 'summary'];
for (const sub of subcollectionsToDelete) {
const subColPath = `sessions/${sessionDoc.id}/${sub}`;
const subSnapshot = await getDocs(query(collection(db, subColPath)));
subSnapshot.forEach(d => batch.delete(d.ref));
}
batch.delete(sessionDoc.ref);
}
// 2. Delete all presets owned by the user
const presetsQuery = query(collection(db, 'prompt_presets'), where('uid', '==', uid));
const presetsSnapshot = await getDocs(presetsQuery);
presetsSnapshot.forEach(doc => batch.delete(doc.ref));
// 3. Delete the user document itself
const userRef = doc(usersCol(), uid);
batch.delete(userRef);
await batch.commit();
return { success: true };
}
module.exports = {
findOrCreate,
getById,
update,
deleteById,
};

View File

@ -0,0 +1,51 @@
const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository');
let authService = null;
function getAuthService() {
if (!authService) {
authService = require('../../services/authService');
}
return authService;
}
function getBaseRepository() {
const service = getAuthService();
if (!service) {
throw new Error('AuthService could not be loaded for the user repository.');
}
const user = service.getCurrentUser();
if (user && user.isLoggedIn) {
return firebaseRepository;
}
return sqliteRepository;
}
const userRepositoryAdapter = {
findOrCreate: (user) => {
// This function receives the full user object, which includes the uid. No need to inject.
return getBaseRepository().findOrCreate(user);
},
getById: () => {
const uid = getAuthService().getCurrentUserId();
return getBaseRepository().getById(uid);
},
update: (updateData) => {
const uid = getAuthService().getCurrentUserId();
return getBaseRepository().update({ uid, ...updateData });
},
deleteById: () => {
const uid = getAuthService().getCurrentUserId();
return getBaseRepository().deleteById(uid);
}
};
module.exports = {
...userRepositoryAdapter
};

View File

@ -40,17 +40,7 @@ function getById(uid) {
return db.prepare('SELECT * FROM users WHERE uid = ?').get(uid); 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 }) { function update({ uid, displayName }) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
@ -58,6 +48,16 @@ function update({ uid, displayName }) {
return { changes: result.changes }; return { changes: result.changes };
} }
function setMigrationComplete(uid) {
const db = sqliteClient.getDb();
const stmt = db.prepare('UPDATE users SET has_migrated_to_firebase = 1 WHERE uid = ?');
const result = stmt.run(uid);
if (result.changes > 0) {
console.log(`[Repo] Marked migration as complete for user ${uid}.`);
}
return result;
}
function deleteById(uid) { function deleteById(uid) {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid); const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid);
@ -86,7 +86,7 @@ function deleteById(uid) {
module.exports = { module.exports = {
findOrCreate, findOrCreate,
getById, getById,
saveApiKey,
update, update,
setMigrationComplete,
deleteById deleteById
}; };

View File

@ -0,0 +1,53 @@
const BaseModelRepository = require('../baseModel');
class WhisperModelRepository extends BaseModelRepository {
constructor(db, tableName = 'whisper_models') {
super(db, tableName);
}
async initializeModels(availableModels) {
const existingModels = await this.getAll();
const existingIds = new Set(existingModels.map(m => m.id));
for (const [modelId, modelInfo] of Object.entries(availableModels)) {
if (!existingIds.has(modelId)) {
await this.create({
id: modelId,
name: modelInfo.name,
size: modelInfo.size,
installed: 0,
installing: 0
});
}
}
}
async getInstalledModels() {
return this.findAll({ installed: 1 });
}
async setInstalled(modelId, installed = true) {
return this.update({ id: modelId }, {
installed: installed ? 1 : 0,
installing: 0
});
}
async setInstalling(modelId, installing = true) {
return this.update({ id: modelId }, {
installing: installing ? 1 : 0
});
}
async isInstalled(modelId) {
const model = await this.findOne({ id: modelId });
return model && model.installed === 1;
}
async isInstalling(modelId) {
const model = await this.findOne({ id: modelId });
return model && model.installing === 1;
}
}
module.exports = WhisperModelRepository;

View File

@ -0,0 +1,211 @@
const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
const { BrowserWindow, shell } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient');
const fetch = require('node-fetch');
const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session');
const providerSettingsRepository = require('../repositories/providerSettings');
const permissionService = require('./permissionService');
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;
// This ensures the key is ready before any login/logout state change.
this.initializationPromise = null;
sessionRepository.setAuthService(this);
}
initialize() {
if (this.isInitialized) return this.initializationPromise;
this.initializationPromise = new Promise((resolve) => {
const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => {
const previousUser = this.currentUser;
if (user) {
// User signed IN
console.log(`[AuthService] Firebase user signed in:`, user.uid);
this.currentUser = user;
this.currentUserId = user.uid;
this.currentUserMode = 'firebase';
// Clean up any zombie sessions from a previous run for this user.
await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the logged-in user if permissions are already granted **
if (process.platform === 'darwin' && !(await permissionService.checkKeychainCompleted(this.currentUserId))) {
console.warn('[AuthService] Keychain permission not yet completed for this user. Deferring key initialization.');
} else {
await encryptionService.initializeKey(user.uid);
}
// ** Check for and run data migration for the user **
// No 'await' here, so it runs in the background without blocking startup.
migrationService.checkAndRunMigration(user);
// ***** CRITICAL: Wait for the virtual key and model state update to complete *****
try {
const idToken = await user.getIdToken(true);
const virtualKey = await getVirtualKeyByEmail(user.email, idToken);
if (global.modelStateService) {
// The model state service now writes directly to the DB, no in-memory state.
await global.modelStateService.setFirebaseVirtualKey(virtualKey);
}
console.log(`[AuthService] Virtual key for ${user.email} has been processed and state updated.`);
} catch (error) {
console.error('[AuthService] Failed to fetch or save virtual key:', error);
// This is not critical enough to halt the login, but we should log it.
}
} 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) {
// The model state service now writes directly to the DB.
await global.modelStateService.setFirebaseVirtualKey(null);
}
}
this.currentUser = null;
this.currentUserId = 'default_user';
this.currentUserMode = 'local';
// End active sessions for the local/default user as well.
await sessionRepository.endAllActiveSessions();
encryptionService.resetSessionKey();
}
this.broadcastUserState();
if (!this.isInitialized) {
this.isInitialized = true;
console.log('[AuthService] Initialized and resolved initialization promise.');
resolve();
}
});
});
return this.initializationPromise;
}
async startFirebaseAuthFlow() {
try {
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
const authUrl = `${webUrl}/login?mode=electron`;
console.log(`[AuthService] Opening Firebase auth URL in browser: ${authUrl}`);
await shell.openExternal(authUrl);
return { success: true };
} catch (error) {
console.error('[AuthService] Failed to open Firebase auth URL:', error);
return { success: false, error: error.message };
}
}
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 {
// End all active sessions for the current user BEFORE signing out.
await sessionRepository.endAllActiveSessions();
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

@ -10,10 +10,13 @@ class DatabaseInitializer {
// 최종적으로 사용될 DB 경로 (쓰기 가능한 위치) // 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
const userDataPath = app.getPath('userData'); const userDataPath = app.getPath('userData');
// In both development and production mode, the database is stored in the userData directory:
// macOS: ~/Library/Application Support/Glass/pickleglass.db
// Windows: %APPDATA%\Glass\pickleglass.db
this.dbPath = path.join(userDataPath, 'pickleglass.db'); this.dbPath = path.join(userDataPath, 'pickleglass.db');
this.dataDir = userDataPath; this.dataDir = userDataPath;
// 원본 DB 경로 (패키지 내 읽기 전용 위치) // The original DB path (read-only location in the package)
this.sourceDbPath = app.isPackaged this.sourceDbPath = app.isPackaged
? path.join(process.resourcesPath, 'data', 'pickleglass.db') ? path.join(process.resourcesPath, 'data', 'pickleglass.db')
: path.join(app.getAppPath(), 'data', 'pickleglass.db'); : path.join(app.getAppPath(), 'data', 'pickleglass.db');
@ -52,7 +55,7 @@ class DatabaseInitializer {
try { try {
this.ensureDatabaseExists(); this.ensureDatabaseExists();
await sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달 sqliteClient.connect(this.dbPath); // DB 경로를 인자로 전달
// This single call will now synchronize the schema and then init default data. // This single call will now synchronize the schema and then init default data.
await sqliteClient.initTables(); await sqliteClient.initTables();

View File

@ -0,0 +1,175 @@
const crypto = require('crypto');
let keytar;
// Dynamically import keytar, as it's an optional dependency.
try {
keytar = require('keytar');
} catch (error) {
console.warn('[EncryptionService] keytar is not available. Will use in-memory key for this session. Restarting the app might be required for data persistence after login.');
keytar = null;
}
const permissionService = require('./permissionService');
const SERVICE_NAME = 'com.pickle.glass'; // A unique identifier for the app in the keychain
let sessionKey = null; // In-memory fallback key
const ALGORITHM = 'aes-256-gcm';
const IV_LENGTH = 16; // For AES, this is always 16
const AUTH_TAG_LENGTH = 16;
/**
* Initializes the encryption key for a given user.
* It first tries to get the key from the OS keychain.
* If that fails, it generates a new key.
* If keytar is available, it saves the new key.
* Otherwise, it uses an in-memory key for the session.
*
* @param {string} userId - The unique identifier for the user (e.g., Firebase UID).
*/
async function initializeKey(userId) {
if (!userId) {
throw new Error('A user ID must be provided to initialize the encryption key.');
}
let keyRetrieved = false;
if (keytar) {
try {
let key = await keytar.getPassword(SERVICE_NAME, userId);
if (!key) {
console.log(`[EncryptionService] No key found for ${userId}. Creating a new one.`);
key = crypto.randomBytes(32).toString('hex');
await keytar.setPassword(SERVICE_NAME, userId, key);
console.log(`[EncryptionService] New key securely stored in keychain for ${userId}.`);
} else {
console.log(`[EncryptionService] Encryption key successfully retrieved from keychain for ${userId}.`);
keyRetrieved = true;
}
sessionKey = key;
} catch (error) {
console.error('[EncryptionService] keytar failed. Falling back to in-memory key for this session.', error);
keytar = null; // Disable keytar for the rest of the session to avoid repeated errors
sessionKey = crypto.randomBytes(32).toString('hex');
}
} else {
// keytar is not available
if (!sessionKey) {
console.warn('[EncryptionService] Using in-memory session key. Data will not persist across restarts without keytar.');
sessionKey = crypto.randomBytes(32).toString('hex');
}
}
// Mark keychain completed in permissions DB if this is the first successful retrieval or storage
try {
await permissionService.markKeychainCompleted(userId);
if (keyRetrieved) {
console.log(`[EncryptionService] Keychain completion marked in DB for ${userId}.`);
}
} catch (permErr) {
console.error('[EncryptionService] Failed to mark keychain completion:', permErr);
}
if (!sessionKey) {
throw new Error('Failed to initialize encryption key.');
}
}
function resetSessionKey() {
sessionKey = null;
}
/**
* Encrypts a given text using AES-256-GCM.
* @param {string} text The text to encrypt.
* @returns {string | null} The encrypted data, as a base64 string containing iv, authTag, and content, or the original value if it cannot be encrypted.
*/
function encrypt(text) {
if (!sessionKey) {
console.error('[EncryptionService] Encryption key is not initialized. Cannot encrypt.');
return text; // Return original if key is missing
}
if (text == null) { // checks for null or undefined
return text;
}
try {
const key = Buffer.from(sessionKey, 'hex');
const iv = crypto.randomBytes(IV_LENGTH);
const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
let encrypted = cipher.update(String(text), 'utf8', 'hex');
encrypted += cipher.final('hex');
const authTag = cipher.getAuthTag();
// Prepend IV and AuthTag to the encrypted content, then encode as base64.
return Buffer.concat([iv, authTag, Buffer.from(encrypted, 'hex')]).toString('base64');
} catch (error) {
console.error('[EncryptionService] Encryption failed:', error);
return text; // Return original on error
}
}
/**
* Decrypts a given encrypted string.
* @param {string} encryptedText The base64 encrypted text.
* @returns {string | null} The decrypted text, or the original value if it cannot be decrypted.
*/
function decrypt(encryptedText) {
if (!sessionKey) {
console.error('[EncryptionService] Encryption key is not initialized. Cannot decrypt.');
return encryptedText; // Return original if key is missing
}
if (encryptedText == null || typeof encryptedText !== 'string') {
return encryptedText;
}
try {
const data = Buffer.from(encryptedText, 'base64');
if (data.length < IV_LENGTH + AUTH_TAG_LENGTH) {
// This is not a valid encrypted string, likely plain text.
return encryptedText;
}
const key = Buffer.from(sessionKey, 'hex');
const iv = data.slice(0, IV_LENGTH);
const authTag = data.slice(IV_LENGTH, IV_LENGTH + AUTH_TAG_LENGTH);
const encryptedContent = data.slice(IV_LENGTH + AUTH_TAG_LENGTH);
const decipher = crypto.createDecipheriv(ALGORITHM, key, iv);
decipher.setAuthTag(authTag);
let decrypted = decipher.update(encryptedContent, 'hex', 'utf8');
decrypted += decipher.final('utf8');
return decrypted;
} catch (error) {
// It's common for this to fail if the data is not encrypted (e.g., legacy data).
// In that case, we return the original value.
console.error('[EncryptionService] Decryption failed:', error);
return encryptedText;
}
}
function looksEncrypted(str) {
if (!str || typeof str !== 'string') return false;
// Base64 chars + optional '=' padding
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(str)) return false;
try {
const buf = Buffer.from(str, 'base64');
// Our AES-GCM cipher text must be at least 32 bytes (IV 16 + TAG 16)
return buf.length >= 32;
} catch {
return false;
}
}
module.exports = {
initializeKey,
resetSessionKey,
encrypt,
decrypt,
looksEncrypted,
};

View File

@ -1,6 +1,9 @@
const { initializeApp } = require('firebase/app'); const { initializeApp } = require('firebase/app');
const { initializeAuth } = require('firebase/auth'); const { initializeAuth } = require('firebase/auth');
const Store = require('electron-store'); const Store = require('electron-store');
const { getFirestore, setLogLevel } = require('firebase/firestore');
// setLogLevel('debug');
/** /**
* Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*, * Firebase Auth expects the `persistence` option passed to `initializeAuth()` to be *classes*,
@ -66,6 +69,7 @@ const firebaseConfig = {
let firebaseApp = null; let firebaseApp = null;
let firebaseAuth = null; let firebaseAuth = null;
let firestoreInstance = null; // To hold the specific DB instance
function initializeFirebase() { function initializeFirebase() {
if (firebaseApp) { if (firebaseApp) {
@ -84,7 +88,11 @@ function initializeFirebase() {
persistence: [ElectronStorePersistence], persistence: [ElectronStorePersistence],
}); });
// Initialize Firestore with the specific database ID
firestoreInstance = getFirestore(firebaseApp, 'pickle-glass');
console.log('[FirebaseClient] Firebase initialized successfully with class-based electron-store persistence.'); console.log('[FirebaseClient] Firebase initialized successfully with class-based electron-store persistence.');
console.log('[FirebaseClient] Firestore instance is targeting the "pickle-glass" database.');
} catch (error) { } catch (error) {
console.error('[FirebaseClient] Firebase initialization failed:', error); console.error('[FirebaseClient] Firebase initialization failed:', error);
} }
@ -97,7 +105,15 @@ function getFirebaseAuth() {
return firebaseAuth; return firebaseAuth;
} }
function getFirestoreInstance() {
if (!firestoreInstance) {
throw new Error("Firestore has not been initialized. Call initializeFirebase() first.");
}
return firestoreInstance;
}
module.exports = { module.exports = {
initializeFirebase, initializeFirebase,
getFirebaseAuth, getFirebaseAuth,
getFirestoreInstance,
}; };

View File

@ -0,0 +1,639 @@
const { EventEmitter } = require('events');
const ollamaService = require('./ollamaService');
const whisperService = require('./whisperService');
//Central manager for managing Ollama and Whisper services
class LocalAIManager extends EventEmitter {
constructor() {
super();
// service map
this.services = {
ollama: ollamaService,
whisper: whisperService
};
// unified state management
this.state = {
ollama: {
installed: false,
running: false,
models: []
},
whisper: {
installed: false,
initialized: false,
models: []
}
};
// setup event listeners
this.setupEventListeners();
}
// subscribe to events from each service and re-emit as unified events
setupEventListeners() {
// ollama events
ollamaService.on('install-progress', (data) => {
this.emit('install-progress', 'ollama', data);
});
ollamaService.on('installation-complete', () => {
this.emit('installation-complete', 'ollama');
this.updateServiceState('ollama');
});
ollamaService.on('error', (error) => {
this.emit('error', { service: 'ollama', ...error });
});
ollamaService.on('model-pull-complete', (data) => {
this.emit('model-ready', { service: 'ollama', ...data });
this.updateServiceState('ollama');
});
ollamaService.on('state-changed', (state) => {
this.emit('state-changed', 'ollama', state);
});
// Whisper 이벤트
whisperService.on('install-progress', (data) => {
this.emit('install-progress', 'whisper', data);
});
whisperService.on('installation-complete', () => {
this.emit('installation-complete', 'whisper');
this.updateServiceState('whisper');
});
whisperService.on('error', (error) => {
this.emit('error', { service: 'whisper', ...error });
});
whisperService.on('model-download-complete', (data) => {
this.emit('model-ready', { service: 'whisper', ...data });
this.updateServiceState('whisper');
});
}
/**
* 서비스 설치
*/
async installService(serviceName, options = {}) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
try {
if (serviceName === 'ollama') {
return await service.handleInstall();
} else if (serviceName === 'whisper') {
// Whisper는 자동 설치
await service.initialize();
return { success: true };
}
} catch (error) {
this.emit('error', {
service: serviceName,
errorType: 'installation-failed',
error: error.message
});
throw error;
}
}
/**
* 서비스 상태 조회
*/
async getServiceStatus(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
if (serviceName === 'ollama') {
return await service.getStatus();
} else if (serviceName === 'whisper') {
const installed = await service.isInstalled();
const running = await service.isServiceRunning();
const models = await service.getInstalledModels();
return {
success: true,
installed,
running,
models
};
}
}
/**
* 서비스 시작
*/
async startService(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
const result = await service.startService();
await this.updateServiceState(serviceName);
return { success: result };
}
/**
* 서비스 중지
*/
async stopService(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
let result;
if (serviceName === 'ollama') {
result = await service.shutdown(false);
} else if (serviceName === 'whisper') {
result = await service.stopService();
}
// 서비스 중지 후 상태 업데이트
await this.updateServiceState(serviceName);
return result;
}
/**
* 모델 설치/다운로드
*/
async installModel(serviceName, modelId, options = {}) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
if (serviceName === 'ollama') {
return await service.pullModel(modelId);
} else if (serviceName === 'whisper') {
return await service.downloadModel(modelId);
}
}
/**
* 설치된 모델 목록 조회
*/
async getInstalledModels(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
if (serviceName === 'ollama') {
return await service.getAllModelsWithStatus();
} else if (serviceName === 'whisper') {
return await service.getInstalledModels();
}
}
/**
* 모델 워밍업 (Ollama 전용)
*/
async warmUpModel(modelName, forceRefresh = false) {
return await ollamaService.warmUpModel(modelName, forceRefresh);
}
/**
* 자동 워밍업 (Ollama 전용)
*/
async autoWarmUp() {
return await ollamaService.autoWarmUpSelectedModel();
}
/**
* 진단 실행
*/
async runDiagnostics(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
const diagnostics = {
service: serviceName,
timestamp: new Date().toISOString(),
checks: {}
};
try {
// 1. 설치 상태 확인
diagnostics.checks.installation = {
check: 'Installation',
status: await service.isInstalled() ? 'pass' : 'fail',
details: {}
};
// 2. 서비스 실행 상태
diagnostics.checks.running = {
check: 'Service Running',
status: await service.isServiceRunning() ? 'pass' : 'fail',
details: {}
};
// 3. 포트 연결 테스트 및 상세 health check (Ollama)
if (serviceName === 'ollama') {
try {
// Use comprehensive health check
const health = await service.healthCheck();
diagnostics.checks.health = {
check: 'Service Health',
status: health.healthy ? 'pass' : 'fail',
details: health
};
// Legacy port check for compatibility
diagnostics.checks.port = {
check: 'Port Connectivity',
status: health.checks.apiResponsive ? 'pass' : 'fail',
details: { connected: health.checks.apiResponsive }
};
} catch (error) {
diagnostics.checks.health = {
check: 'Service Health',
status: 'fail',
details: { error: error.message }
};
diagnostics.checks.port = {
check: 'Port Connectivity',
status: 'fail',
details: { error: error.message }
};
}
// 4. 모델 목록
if (diagnostics.checks.running.status === 'pass') {
try {
const models = await service.getInstalledModels();
diagnostics.checks.models = {
check: 'Installed Models',
status: 'pass',
details: { count: models.length, models: models.map(m => m.name) }
};
// 5. 워밍업 상태
const warmupStatus = await service.getWarmUpStatus();
diagnostics.checks.warmup = {
check: 'Model Warm-up',
status: 'pass',
details: warmupStatus
};
} catch (error) {
diagnostics.checks.models = {
check: 'Installed Models',
status: 'fail',
details: { error: error.message }
};
}
}
}
// 4. Whisper 특화 진단
if (serviceName === 'whisper') {
// 바이너리 확인
diagnostics.checks.binary = {
check: 'Whisper Binary',
status: service.whisperPath ? 'pass' : 'fail',
details: { path: service.whisperPath }
};
// 모델 디렉토리
diagnostics.checks.modelDir = {
check: 'Model Directory',
status: service.modelsDir ? 'pass' : 'fail',
details: { path: service.modelsDir }
};
}
// 전체 진단 결과
const allChecks = Object.values(diagnostics.checks);
diagnostics.summary = {
total: allChecks.length,
passed: allChecks.filter(c => c.status === 'pass').length,
failed: allChecks.filter(c => c.status === 'fail').length,
overallStatus: allChecks.every(c => c.status === 'pass') ? 'healthy' : 'unhealthy'
};
} catch (error) {
diagnostics.error = error.message;
diagnostics.summary = {
overallStatus: 'error'
};
}
return diagnostics;
}
/**
* 서비스 복구
*/
async repairService(serviceName) {
const service = this.services[serviceName];
if (!service) {
throw new Error(`Unknown service: ${serviceName}`);
}
console.log(`[LocalAIManager] Starting repair for ${serviceName}...`);
const repairLog = [];
try {
// 1. 진단 실행
repairLog.push('Running diagnostics...');
const diagnostics = await this.runDiagnostics(serviceName);
if (diagnostics.summary.overallStatus === 'healthy') {
repairLog.push('Service is already healthy, no repair needed');
return {
success: true,
repairLog,
diagnostics
};
}
// 2. 설치 문제 해결
if (diagnostics.checks.installation?.status === 'fail') {
repairLog.push('Installation missing, attempting to install...');
try {
await this.installService(serviceName);
repairLog.push('Installation completed');
} catch (error) {
repairLog.push(`Installation failed: ${error.message}`);
throw error;
}
}
// 3. 서비스 재시작
if (diagnostics.checks.running?.status === 'fail') {
repairLog.push('Service not running, attempting to start...');
// 종료 시도
try {
await this.stopService(serviceName);
repairLog.push('Stopped existing service');
} catch (error) {
repairLog.push('Service was not running');
}
// 잠시 대기
await new Promise(resolve => setTimeout(resolve, 2000));
// 시작
try {
await this.startService(serviceName);
repairLog.push('Service started successfully');
} catch (error) {
repairLog.push(`Failed to start service: ${error.message}`);
throw error;
}
}
// 4. 포트 문제 해결 (Ollama)
if (serviceName === 'ollama' && diagnostics.checks.port?.status === 'fail') {
repairLog.push('Port connectivity issue detected');
// 프로세스 강제 종료
if (process.platform === 'darwin') {
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
await execAsync('pkill -f ollama');
repairLog.push('Killed stale Ollama processes');
} catch (error) {
repairLog.push('No stale processes found');
}
}
else if (process.platform === 'win32') {
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
await execAsync('taskkill /F /IM ollama.exe');
repairLog.push('Killed stale Ollama processes');
} catch (error) {
repairLog.push('No stale processes found');
}
}
else if (process.platform === 'linux') {
try {
const { exec } = require('child_process');
const { promisify } = require('util');
const execAsync = promisify(exec);
await execAsync('pkill -f ollama');
repairLog.push('Killed stale Ollama processes');
} catch (error) {
repairLog.push('No stale processes found');
}
}
await new Promise(resolve => setTimeout(resolve, 1000));
// 재시작
await this.startService(serviceName);
repairLog.push('Restarted service after port cleanup');
}
// 5. Whisper 특화 복구
if (serviceName === 'whisper') {
// 세션 정리
if (diagnostics.checks.running?.status === 'pass') {
repairLog.push('Cleaning up Whisper sessions...');
await service.cleanup();
repairLog.push('Sessions cleaned up');
}
// 초기화
if (!service.installState.isInitialized) {
repairLog.push('Re-initializing Whisper...');
await service.initialize();
repairLog.push('Whisper re-initialized');
}
}
// 6. 최종 상태 확인
repairLog.push('Verifying repair...');
const finalDiagnostics = await this.runDiagnostics(serviceName);
const success = finalDiagnostics.summary.overallStatus === 'healthy';
repairLog.push(success ? 'Repair successful!' : 'Repair failed - manual intervention may be required');
// 성공 시 상태 업데이트
if (success) {
await this.updateServiceState(serviceName);
}
return {
success,
repairLog,
diagnostics: finalDiagnostics
};
} catch (error) {
repairLog.push(`Repair error: ${error.message}`);
return {
success: false,
repairLog,
error: error.message
};
}
}
/**
* 상태 업데이트
*/
async updateServiceState(serviceName) {
try {
const status = await this.getServiceStatus(serviceName);
this.state[serviceName] = status;
// 상태 변경 이벤트 발행
this.emit('state-changed', serviceName, status);
} catch (error) {
console.error(`[LocalAIManager] Failed to update ${serviceName} state:`, error);
}
}
/**
* 전체 상태 조회
*/
async getAllServiceStates() {
const states = {};
for (const serviceName of Object.keys(this.services)) {
try {
states[serviceName] = await this.getServiceStatus(serviceName);
} catch (error) {
states[serviceName] = {
success: false,
error: error.message
};
}
}
return states;
}
/**
* 주기적 상태 동기화 시작
*/
startPeriodicSync(interval = 30000) {
if (this.syncInterval) {
return;
}
this.syncInterval = setInterval(async () => {
for (const serviceName of Object.keys(this.services)) {
await this.updateServiceState(serviceName);
}
}, interval);
// 각 서비스의 주기적 동기화도 시작
ollamaService.startPeriodicSync();
}
/**
* 주기적 상태 동기화 중지
*/
stopPeriodicSync() {
if (this.syncInterval) {
clearInterval(this.syncInterval);
this.syncInterval = null;
}
// 각 서비스의 주기적 동기화도 중지
ollamaService.stopPeriodicSync();
}
/**
* 전체 종료
*/
async shutdown() {
this.stopPeriodicSync();
const results = {};
for (const [serviceName, service] of Object.entries(this.services)) {
try {
if (serviceName === 'ollama') {
results[serviceName] = await service.shutdown(false);
} else if (serviceName === 'whisper') {
await service.cleanup();
results[serviceName] = true;
}
} catch (error) {
results[serviceName] = false;
console.error(`[LocalAIManager] Failed to shutdown ${serviceName}:`, error);
}
}
return results;
}
/**
* 에러 처리
*/
async handleError(serviceName, errorType, details = {}) {
console.error(`[LocalAIManager] Error in ${serviceName}: ${errorType}`, details);
// 서비스별 에러 처리
switch(errorType) {
case 'installation-failed':
// 설치 실패 시 이벤트 발생
this.emit('error-occurred', {
service: serviceName,
errorType,
error: details.error || 'Installation failed',
canRetry: true
});
break;
case 'model-pull-failed':
case 'model-download-failed':
// 모델 다운로드 실패
this.emit('error-occurred', {
service: serviceName,
errorType,
model: details.model,
error: details.error || 'Model download failed',
canRetry: true
});
break;
case 'service-not-responding':
// 서비스 반응 없음
console.log(`[LocalAIManager] Attempting to repair ${serviceName}...`);
const repairResult = await this.repairService(serviceName);
this.emit('error-occurred', {
service: serviceName,
errorType,
error: details.error || 'Service not responding',
repairAttempted: true,
repairSuccessful: repairResult.success
});
break;
default:
// 기타 에러
this.emit('error-occurred', {
service: serviceName,
errorType,
error: details.error || `Unknown error: ${errorType}`,
canRetry: false
});
}
}
}
// 싱글톤
const localAIManager = new LocalAIManager();
module.exports = localAIManager;

View File

@ -0,0 +1,192 @@
const { doc, writeBatch, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../services/firebaseClient');
const encryptionService = require('../services/encryptionService');
const sqliteSessionRepo = require('../repositories/session/sqlite.repository');
const sqlitePresetRepo = require('../repositories/preset/sqlite.repository');
const sqliteUserRepo = require('../repositories/user/sqlite.repository');
const sqliteSttRepo = require('../../listen/stt/repositories/sqlite.repository');
const sqliteSummaryRepo = require('../../listen/summary/repositories/sqlite.repository');
const sqliteAiMessageRepo = require('../../ask/repositories/sqlite.repository');
const MAX_BATCH_OPERATIONS = 500;
async function checkAndRunMigration(firebaseUser) {
if (!firebaseUser || !firebaseUser.uid) {
console.log('[Migration] No user, skipping migration check.');
return;
}
console.log(`[Migration] Checking for user ${firebaseUser.uid}...`);
const localUser = sqliteUserRepo.getById(firebaseUser.uid);
if (!localUser || localUser.has_migrated_to_firebase) {
console.log(`[Migration] User ${firebaseUser.uid} is not eligible or already migrated.`);
return;
}
console.log(`[Migration] Starting data migration for user ${firebaseUser.uid}...`);
try {
const db = getFirestoreInstance();
// --- Phase 1: Migrate Parent Documents (Presets & Sessions) ---
console.log('[Migration Phase 1] Migrating parent documents...');
let phase1Batch = writeBatch(db);
let phase1OpCount = 0;
const phase1Promises = [];
const localPresets = (await sqlitePresetRepo.getPresets(firebaseUser.uid)).filter(p => !p.is_default);
console.log(`[Migration Phase 1] Found ${localPresets.length} custom presets.`);
for (const preset of localPresets) {
const presetRef = doc(db, 'prompt_presets', preset.id);
const cleanPreset = {
uid: preset.uid,
title: encryptionService.encrypt(preset.title ?? ''),
prompt: encryptionService.encrypt(preset.prompt ?? ''),
is_default: preset.is_default ?? 0,
created_at: preset.created_at ? Timestamp.fromMillis(preset.created_at * 1000) : null,
updated_at: preset.updated_at ? Timestamp.fromMillis(preset.updated_at * 1000) : null
};
phase1Batch.set(presetRef, cleanPreset);
phase1OpCount++;
if (phase1OpCount >= MAX_BATCH_OPERATIONS) {
phase1Promises.push(phase1Batch.commit());
phase1Batch = writeBatch(db);
phase1OpCount = 0;
}
}
const localSessions = await sqliteSessionRepo.getAllByUserId(firebaseUser.uid);
console.log(`[Migration Phase 1] Found ${localSessions.length} sessions.`);
for (const session of localSessions) {
const sessionRef = doc(db, 'sessions', session.id);
const cleanSession = {
uid: session.uid,
members: session.members ?? [session.uid],
title: encryptionService.encrypt(session.title ?? ''),
session_type: session.session_type ?? 'ask',
started_at: session.started_at ? Timestamp.fromMillis(session.started_at * 1000) : null,
ended_at: session.ended_at ? Timestamp.fromMillis(session.ended_at * 1000) : null,
updated_at: session.updated_at ? Timestamp.fromMillis(session.updated_at * 1000) : null
};
phase1Batch.set(sessionRef, cleanSession);
phase1OpCount++;
if (phase1OpCount >= MAX_BATCH_OPERATIONS) {
phase1Promises.push(phase1Batch.commit());
phase1Batch = writeBatch(db);
phase1OpCount = 0;
}
}
if (phase1OpCount > 0) {
phase1Promises.push(phase1Batch.commit());
}
if (phase1Promises.length > 0) {
await Promise.all(phase1Promises);
console.log(`[Migration Phase 1] Successfully committed ${phase1Promises.length} batches of parent documents.`);
} else {
console.log('[Migration Phase 1] No parent documents to migrate.');
}
// --- Phase 2: Migrate Child Documents (sub-collections) ---
console.log('[Migration Phase 2] Migrating child documents for all sessions...');
let phase2Batch = writeBatch(db);
let phase2OpCount = 0;
const phase2Promises = [];
for (const session of localSessions) {
const transcripts = await sqliteSttRepo.getAllTranscriptsBySessionId(session.id);
for (const t of transcripts) {
const transcriptRef = doc(db, `sessions/${session.id}/transcripts`, t.id);
const cleanTranscript = {
uid: firebaseUser.uid,
session_id: t.session_id,
start_at: t.start_at ? Timestamp.fromMillis(t.start_at * 1000) : null,
end_at: t.end_at ? Timestamp.fromMillis(t.end_at * 1000) : null,
speaker: t.speaker ?? null,
text: encryptionService.encrypt(t.text ?? ''),
lang: t.lang ?? 'en',
created_at: t.created_at ? Timestamp.fromMillis(t.created_at * 1000) : null
};
phase2Batch.set(transcriptRef, cleanTranscript);
phase2OpCount++;
if (phase2OpCount >= MAX_BATCH_OPERATIONS) {
phase2Promises.push(phase2Batch.commit());
phase2Batch = writeBatch(db);
phase2OpCount = 0;
}
}
const messages = await sqliteAiMessageRepo.getAllAiMessagesBySessionId(session.id);
for (const m of messages) {
const msgRef = doc(db, `sessions/${session.id}/ai_messages`, m.id);
const cleanMessage = {
uid: firebaseUser.uid,
session_id: m.session_id,
sent_at: m.sent_at ? Timestamp.fromMillis(m.sent_at * 1000) : null,
role: m.role ?? 'user',
content: encryptionService.encrypt(m.content ?? ''),
tokens: m.tokens ?? null,
model: m.model ?? 'unknown',
created_at: m.created_at ? Timestamp.fromMillis(m.created_at * 1000) : null
};
phase2Batch.set(msgRef, cleanMessage);
phase2OpCount++;
if (phase2OpCount >= MAX_BATCH_OPERATIONS) {
phase2Promises.push(phase2Batch.commit());
phase2Batch = writeBatch(db);
phase2OpCount = 0;
}
}
const summary = await sqliteSummaryRepo.getSummaryBySessionId(session.id);
if (summary) {
// Reverting to use 'data' as the document ID for summary.
const summaryRef = doc(db, `sessions/${session.id}/summary`, 'data');
const cleanSummary = {
uid: firebaseUser.uid,
session_id: summary.session_id,
generated_at: summary.generated_at ? Timestamp.fromMillis(summary.generated_at * 1000) : null,
model: summary.model ?? 'unknown',
tldr: encryptionService.encrypt(summary.tldr ?? ''),
text: encryptionService.encrypt(summary.text ?? ''),
bullet_json: encryptionService.encrypt(summary.bullet_json ?? '[]'),
action_json: encryptionService.encrypt(summary.action_json ?? '[]'),
tokens_used: summary.tokens_used ?? null,
updated_at: summary.updated_at ? Timestamp.fromMillis(summary.updated_at * 1000) : null
};
phase2Batch.set(summaryRef, cleanSummary);
phase2OpCount++;
if (phase2OpCount >= MAX_BATCH_OPERATIONS) {
phase2Promises.push(phase2Batch.commit());
phase2Batch = writeBatch(db);
phase2OpCount = 0;
}
}
}
if (phase2OpCount > 0) {
phase2Promises.push(phase2Batch.commit());
}
if (phase2Promises.length > 0) {
await Promise.all(phase2Promises);
console.log(`[Migration Phase 2] Successfully committed ${phase2Promises.length} batches of child documents.`);
} else {
console.log('[Migration Phase 2] No child documents to migrate.');
}
// --- 4. Mark migration as complete ---
sqliteUserRepo.setMigrationComplete(firebaseUser.uid);
console.log(`[Migration] ✅ Successfully marked migration as complete for ${firebaseUser.uid}.`);
} catch (error) {
console.error(`[Migration] 🔥 An error occurred during migration for user ${firebaseUser.uid}:`, error);
}
}
module.exports = {
checkAndRunMigration,
};

View File

@ -0,0 +1,437 @@
const { EventEmitter } = require('events');
const Store = require('electron-store');
const { PROVIDERS, getProviderClass } = require('../ai/factory');
const encryptionService = require('./encryptionService');
const providerSettingsRepository = require('../repositories/providerSettings');
const authService = require('./authService');
const ollamaModelRepository = require('../repositories/ollamaModel');
class ModelStateService extends EventEmitter {
constructor() {
super();
this.authService = authService;
// electron-store는 오직 레거시 데이터 마이그레이션 용도로만 사용됩니다.
this.store = new Store({ name: 'pickle-glass-model-state' });
}
async initialize() {
console.log('[ModelStateService] Initializing one-time setup...');
await this._initializeEncryption();
await this._runMigrations();
this.setupLocalAIStateSync();
await this._autoSelectAvailableModels([], true);
console.log('[ModelStateService] One-time setup complete.');
}
async _initializeEncryption() {
try {
const rows = await providerSettingsRepository.getRawApiKeys();
if (rows.some(r => r.api_key && encryptionService.looksEncrypted(r.api_key))) {
console.log('[ModelStateService] Encrypted keys detected, initializing encryption...');
const userIdForMigration = this.authService.getCurrentUserId();
await encryptionService.initializeKey(userIdForMigration);
} else {
console.log('[ModelStateService] No encrypted keys detected, skipping encryption initialization.');
}
} catch (err) {
console.warn('[ModelStateService] Error while checking encrypted keys:', err.message);
}
}
async _runMigrations() {
console.log('[ModelStateService] Checking for data migrations...');
const userId = this.authService.getCurrentUserId();
try {
const sqliteClient = require('./sqliteClient');
const db = sqliteClient.getDb();
const tableExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='user_model_selections'").get();
if (tableExists) {
const selections = db.prepare('SELECT * FROM user_model_selections WHERE uid = ?').get(userId);
if (selections) {
console.log('[ModelStateService] Migrating from user_model_selections table...');
if (selections.llm_model) {
const llmProvider = this.getProviderForModel(selections.llm_model, 'llm');
if (llmProvider) {
await this.setSelectedModel('llm', selections.llm_model);
}
}
if (selections.stt_model) {
const sttProvider = this.getProviderForModel(selections.stt_model, 'stt');
if (sttProvider) {
await this.setSelectedModel('stt', selections.stt_model);
}
}
db.prepare('DROP TABLE user_model_selections').run();
console.log('[ModelStateService] user_model_selections migration complete.');
}
}
} catch (error) {
console.error('[ModelStateService] user_model_selections migration failed:', error);
}
try {
const legacyData = this.store.get(`users.${userId}`);
if (legacyData && legacyData.apiKeys) {
console.log('[ModelStateService] Migrating from electron-store...');
for (const [provider, apiKey] of Object.entries(legacyData.apiKeys)) {
if (apiKey && PROVIDERS[provider]) {
await this.setApiKey(provider, apiKey);
}
}
if (legacyData.selectedModels?.llm) {
await this.setSelectedModel('llm', legacyData.selectedModels.llm);
}
if (legacyData.selectedModels?.stt) {
await this.setSelectedModel('stt', legacyData.selectedModels.stt);
}
this.store.delete(`users.${userId}`);
console.log('[ModelStateService] electron-store migration complete.');
}
} catch (error) {
console.error('[ModelStateService] electron-store migration failed:', error);
}
}
setupLocalAIStateSync() {
const localAIManager = require('./localAIManager');
localAIManager.on('state-changed', (service, status) => {
this.handleLocalAIStateChange(service, status);
});
}
async handleLocalAIStateChange(service, state) {
console.log(`[ModelStateService] LocalAI state changed: ${service}`, state);
if (!state.installed || !state.running) {
const types = service === 'ollama' ? ['llm'] : service === 'whisper' ? ['stt'] : [];
await this._autoSelectAvailableModels(types);
}
this.emit('state-updated', await this.getLiveState());
}
async getLiveState() {
const providerSettings = await providerSettingsRepository.getAll();
const apiKeys = {};
Object.keys(PROVIDERS).forEach(provider => {
const setting = providerSettings.find(s => s.provider === provider);
apiKeys[provider] = setting?.api_key || null;
});
const activeSettings = await providerSettingsRepository.getActiveSettings();
const selectedModels = {
llm: activeSettings.llm?.selected_llm_model || null,
stt: activeSettings.stt?.selected_stt_model || null
};
return { apiKeys, selectedModels };
}
async _autoSelectAvailableModels(forceReselectionForTypes = [], isInitialBoot = false) {
console.log(`[ModelStateService] Running auto-selection. Force re-selection for: [${forceReselectionForTypes.join(', ')}]`);
const { apiKeys, selectedModels } = await this.getLiveState();
const types = ['llm', 'stt'];
for (const type of types) {
const currentModelId = selectedModels[type];
let isCurrentModelValid = false;
const forceReselection = forceReselectionForTypes.includes(type);
if (currentModelId && !forceReselection) {
const provider = this.getProviderForModel(currentModelId, type);
const apiKey = apiKeys[provider];
if (provider && apiKey) {
isCurrentModelValid = true;
}
}
if (!isCurrentModelValid) {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected or selection forced. Finding an alternative...`);
const availableModels = await this.getAvailableModels(type);
if (availableModels.length > 0) {
const apiModel = availableModels.find(model => {
const provider = this.getProviderForModel(model.id, type);
return provider && provider !== 'ollama' && provider !== 'whisper';
});
const newModel = apiModel || availableModels[0];
await this.setSelectedModel(type, newModel.id);
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${newModel.id}`);
} else {
await providerSettingsRepository.setActiveProvider(null, type);
if (!isInitialBoot) {
this.emit('state-updated', await this.getLiveState());
}
}
}
}
}
async setFirebaseVirtualKey(virtualKey) {
console.log(`[ModelStateService] Setting Firebase virtual key.`);
// 키를 설정하기 전에, 이전에 openai-glass 키가 있었는지 확인합니다.
const previousSettings = await providerSettingsRepository.getByProvider('openai-glass');
const wasPreviouslyConfigured = !!previousSettings?.api_key;
// 항상 새로운 가상 키로 업데이트합니다.
await this.setApiKey('openai-glass', virtualKey);
if (virtualKey) {
// 이전에 설정된 적이 없는 경우 (최초 로그인)에만 모델을 강제로 변경합니다.
if (!wasPreviouslyConfigured) {
console.log('[ModelStateService] First-time setup for openai-glass, setting default models.');
const llmModel = PROVIDERS['openai-glass']?.llmModels[0];
const sttModel = PROVIDERS['openai-glass']?.sttModels[0];
if (llmModel) await this.setSelectedModel('llm', llmModel.id);
if (sttModel) await this.setSelectedModel('stt', sttModel.id);
} else {
console.log('[ModelStateService] openai-glass key updated, but respecting user\'s existing model selection.');
}
} else {
// 로그아웃 시, 현재 활성화된 모델이 openai-glass인 경우에만 다른 모델로 전환합니다.
const selected = await this.getSelectedModels();
const llmProvider = this.getProviderForModel(selected.llm, 'llm');
const sttProvider = this.getProviderForModel(selected.stt, 'stt');
const typesToReselect = [];
if (llmProvider === 'openai-glass') typesToReselect.push('llm');
if (sttProvider === 'openai-glass') typesToReselect.push('stt');
if (typesToReselect.length > 0) {
console.log('[ModelStateService] Logged out, re-selecting models for:', typesToReselect.join(', '));
await this._autoSelectAvailableModels(typesToReselect);
}
}
}
async setApiKey(provider, key) {
console.log(`[ModelStateService] setApiKey for ${provider}`);
if (!provider) {
throw new Error('Provider is required');
}
// 'openai-glass'는 자체 인증 키를 사용하므로 유효성 검사를 건너뜁니다.
if (provider !== 'openai-glass') {
const validationResult = await this.validateApiKey(provider, key);
if (!validationResult.success) {
console.warn(`[ModelStateService] API key validation failed for ${provider}: ${validationResult.error}`);
return validationResult;
}
}
const finalKey = (provider === 'ollama' || provider === 'whisper') ? 'local' : key;
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
await providerSettingsRepository.upsert(provider, { ...existingSettings, api_key: finalKey });
// 키가 추가/변경되었으므로, 해당 provider의 모델을 자동 선택할 수 있는지 확인
await this._autoSelectAvailableModels([]);
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return { success: true };
}
async getAllApiKeys() {
const allSettings = await providerSettingsRepository.getAll();
const apiKeys = {};
allSettings.forEach(s => {
if (s.provider !== 'openai-glass') {
apiKeys[s.provider] = s.api_key;
}
});
return apiKeys;
}
async removeApiKey(provider) {
const setting = await providerSettingsRepository.getByProvider(provider);
if (setting && setting.api_key) {
await providerSettingsRepository.upsert(provider, { ...setting, api_key: null });
await this._autoSelectAvailableModels(['llm', 'stt']);
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return true;
}
return false;
}
/**
* 사용자가 Firebase에 로그인했는지 확인합니다.
*/
isLoggedInWithFirebase() {
return this.authService.getCurrentUser().isLoggedIn;
}
/**
* 유효한 API 키가 하나라도 설정되어 있는지 확인합니다.
*/
async hasValidApiKey() {
if (this.isLoggedInWithFirebase()) return true;
const allSettings = await providerSettingsRepository.getAll();
return allSettings.some(s => s.api_key && s.api_key.trim().length > 0);
}
getProviderForModel(arg1, arg2) {
// Compatibility: support both (type, modelId) old order and (modelId, type) new order
let type, modelId;
if (arg1 === 'llm' || arg1 === 'stt') {
type = arg1;
modelId = arg2;
} else {
modelId = arg1;
type = arg2;
}
if (!modelId || !type) return null;
for (const providerId in PROVIDERS) {
const models = type === 'llm' ? PROVIDERS[providerId].llmModels : PROVIDERS[providerId].sttModels;
if (models && models.some(m => m.id === modelId)) {
return providerId;
}
}
if (type === 'llm') {
const installedModels = ollamaModelRepository.getInstalledModels();
if (installedModels.some(m => m.name === modelId)) return 'ollama';
}
return null;
}
async getSelectedModels() {
const active = await providerSettingsRepository.getActiveSettings();
return {
llm: active.llm?.selected_llm_model || null,
stt: active.stt?.selected_stt_model || null,
};
}
async setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(modelId, type);
if (!provider) {
console.warn(`[ModelStateService] No provider found for model ${modelId}`);
return false;
}
const existingSettings = await providerSettingsRepository.getByProvider(provider) || {};
const newSettings = { ...existingSettings };
if (type === 'llm') {
newSettings.selected_llm_model = modelId;
} else {
newSettings.selected_stt_model = modelId;
}
await providerSettingsRepository.upsert(provider, newSettings);
await providerSettingsRepository.setActiveProvider(provider, type);
console.log(`[ModelStateService] Selected ${type} model: ${modelId} (provider: ${provider})`);
if (type === 'llm' && provider === 'ollama') {
require('./localAIManager').warmUpModel(modelId).catch(err => console.warn(err));
}
this.emit('state-updated', await this.getLiveState());
this.emit('settings-updated');
return true;
}
async getAvailableModels(type) {
const allSettings = await providerSettingsRepository.getAll();
const available = [];
const modelListKey = type === 'llm' ? 'llmModels' : 'sttModels';
for (const setting of allSettings) {
if (!setting.api_key) continue;
const providerId = setting.provider;
if (providerId === 'ollama' && type === 'llm') {
const installed = ollamaModelRepository.getInstalledModels();
available.push(...installed.map(m => ({ id: m.name, name: m.name })));
} else if (PROVIDERS[providerId]?.[modelListKey]) {
available.push(...PROVIDERS[providerId][modelListKey]);
}
}
return [...new Map(available.map(item => [item.id, item])).values()];
}
async getCurrentModelInfo(type) {
const activeSetting = await providerSettingsRepository.getActiveProvider(type);
if (!activeSetting) return null;
const model = type === 'llm' ? activeSetting.selected_llm_model : activeSetting.selected_stt_model;
if (!model) return null;
return {
provider: activeSetting.provider,
model: model,
apiKey: activeSetting.api_key,
};
}
// --- 핸들러 및 유틸리티 메서드 ---
async validateApiKey(provider, key) {
if (!key || (key.trim() === '' && provider !== 'ollama' && provider !== 'whisper')) {
return { success: false, error: 'API key cannot be empty.' };
}
const ProviderClass = getProviderClass(provider);
if (!ProviderClass || typeof ProviderClass.validateApiKey !== 'function') {
return { success: true };
}
try {
return await ProviderClass.validateApiKey(key);
} catch (error) {
return { success: false, error: 'An unexpected error occurred during validation.' };
}
}
getProviderConfig() {
const config = {};
for (const key in PROVIDERS) {
const { handler, ...rest } = PROVIDERS[key];
config[key] = rest;
}
return config;
}
async handleRemoveApiKey(provider) {
const success = await this.removeApiKey(provider);
if (success) {
const selectedModels = await this.getSelectedModels();
if (!selectedModels.llm && !selectedModels.stt) {
this.emit('force-show-apikey-header');
}
}
return success;
}
/*-------------- Compatibility Helpers --------------*/
async handleValidateKey(provider, key) {
return await this.setApiKey(provider, key);
}
async handleSetSelectedModel(type, modelId) {
return await this.setSelectedModel(type, modelId);
}
async areProvidersConfigured() {
if (this.isLoggedInWithFirebase()) return true;
const allSettings = await providerSettingsRepository.getAll();
const apiKeyMap = {};
allSettings.forEach(s => apiKeyMap[s.provider] = s.api_key);
// LLM
const hasLlmKey = Object.entries(apiKeyMap).some(([provider, key]) => {
if (!key) return false;
if (provider === 'whisper') return false; // whisper는 LLM 없음
return PROVIDERS[provider]?.llmModels?.length > 0;
});
// STT
const hasSttKey = Object.entries(apiKeyMap).some(([provider, key]) => {
if (!key) return false;
if (provider === 'ollama') return false; // ollama는 STT 없음
return PROVIDERS[provider]?.sttModels?.length > 0 || provider === 'whisper';
});
return hasLlmKey && hasSttKey;
}
}
const modelStateService = new ModelStateService();
module.exports = modelStateService;

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,124 @@
const { systemPreferences, shell, desktopCapturer } = require('electron');
const permissionRepository = require('../repositories/permission');
class PermissionService {
_getAuthService() {
return require('./authService');
}
async checkSystemPermissions() {
const permissions = {
microphone: 'unknown',
screen: 'unknown',
keychain: 'unknown',
needsSetup: true
};
try {
if (process.platform === 'darwin') {
permissions.microphone = systemPreferences.getMediaAccessStatus('microphone');
permissions.screen = systemPreferences.getMediaAccessStatus('screen');
permissions.keychain = await this.checkKeychainCompleted(this._getAuthService().getCurrentUserId()) ? 'granted' : 'unknown';
permissions.needsSetup = permissions.microphone !== 'granted' || permissions.screen !== 'granted' || permissions.keychain !== 'granted';
} else {
permissions.microphone = 'granted';
permissions.screen = 'granted';
permissions.keychain = 'granted';
permissions.needsSetup = false;
}
console.log('[Permissions] System permissions status:', permissions);
return permissions;
} catch (error) {
console.error('[Permissions] Error checking permissions:', error);
return {
microphone: 'unknown',
screen: 'unknown',
keychain: 'unknown',
needsSetup: true,
error: error.message
};
}
}
async requestMicrophonePermission() {
if (process.platform !== 'darwin') {
return { success: true };
}
try {
const status = systemPreferences.getMediaAccessStatus('microphone');
console.log('[Permissions] Microphone status:', status);
if (status === 'granted') {
return { success: true, status: 'granted' };
}
const granted = await systemPreferences.askForMediaAccess('microphone');
return {
success: granted,
status: granted ? 'granted' : 'denied'
};
} catch (error) {
console.error('[Permissions] Error requesting microphone permission:', error);
return {
success: false,
error: error.message
};
}
}
async openSystemPreferences(section) {
if (process.platform !== 'darwin') {
return { success: false, error: 'Not supported on this platform' };
}
try {
if (section === 'screen-recording') {
try {
console.log('[Permissions] Triggering screen capture request to register app...');
await desktopCapturer.getSources({
types: ['screen'],
thumbnailSize: { width: 1, height: 1 }
});
console.log('[Permissions] App registered for screen recording');
} catch (captureError) {
console.log('[Permissions] Screen capture request triggered (expected to fail):', captureError.message);
}
// await shell.openExternal('x-apple.systempreferences:com.apple.preference.security?Privacy_ScreenCapture');
}
return { success: true };
} catch (error) {
console.error('[Permissions] Error opening system preferences:', error);
return { success: false, error: error.message };
}
}
async markKeychainCompleted() {
try {
await permissionRepository.markKeychainCompleted(this._getAuthService().getCurrentUserId());
console.log('[Permissions] Marked keychain as completed');
return { success: true };
} catch (error) {
console.error('[Permissions] Error marking keychain as completed:', error);
return { success: false, error: error.message };
}
}
async checkKeychainCompleted(uid) {
if (uid === "default_user") {
return true;
}
try {
const completed = permissionRepository.checkKeychainCompleted(uid);
console.log('[Permissions] Keychain completed status:', completed);
return completed;
} catch (error) {
console.error('[Permissions] Error checking keychain completed status:', error);
return false;
}
}
}
const permissionService = new PermissionService();
module.exports = permissionService;

View File

@ -33,8 +33,89 @@ class SQLiteClient {
return this.db; return this.db;
} }
synchronizeSchema() { _validateAndQuoteIdentifier(identifier) {
if (!/^[a-zA-Z0-9_]+$/.test(identifier)) {
throw new Error(`Invalid database identifier used: ${identifier}. Only alphanumeric characters and underscores are allowed.`);
}
return `"${identifier}"`;
}
_migrateProviderSettings() {
const tablesInDb = this.getTablesFromDb();
if (!tablesInDb.includes('provider_settings')) {
return; // Table doesn't exist, no migration needed.
}
const providerSettingsInfo = this.db.prepare(`PRAGMA table_info(provider_settings)`).all();
const hasUidColumn = providerSettingsInfo.some(col => col.name === 'uid');
if (hasUidColumn) {
console.log('[DB Migration] Old provider_settings schema detected. Starting robust migration...');
try {
this.db.transaction(() => {
this.db.exec('ALTER TABLE provider_settings RENAME TO provider_settings_old');
console.log('[DB Migration] Renamed provider_settings to provider_settings_old');
this.createTable('provider_settings', LATEST_SCHEMA.provider_settings);
console.log('[DB Migration] Created new provider_settings table');
// Dynamically build the migration query for robustness
const oldColumnNames = this.db.prepare(`PRAGMA table_info(provider_settings_old)`).all().map(c => c.name);
const newColumnNames = LATEST_SCHEMA.provider_settings.columns.map(c => c.name);
const commonColumns = newColumnNames.filter(name => oldColumnNames.includes(name));
if (!commonColumns.includes('provider')) {
console.warn('[DB Migration] Old table is missing the "provider" column. Aborting migration for this table.');
this.db.exec('DROP TABLE provider_settings_old');
return;
}
const orderParts = [];
if (oldColumnNames.includes('updated_at')) orderParts.push('updated_at DESC');
if (oldColumnNames.includes('created_at')) orderParts.push('created_at DESC');
const orderByClause = orderParts.length > 0 ? `ORDER BY ${orderParts.join(', ')}` : '';
const columnsForInsert = commonColumns.map(c => this._validateAndQuoteIdentifier(c)).join(', ');
const migrationQuery = `
INSERT INTO provider_settings (${columnsForInsert})
SELECT ${columnsForInsert}
FROM (
SELECT *, ROW_NUMBER() OVER(PARTITION BY provider ${orderByClause}) as rn
FROM provider_settings_old
)
WHERE rn = 1
`;
console.log(`[DB Migration] Executing robust migration query for columns: ${commonColumns.join(', ')}`);
const result = this.db.prepare(migrationQuery).run();
console.log(`[DB Migration] Migrated ${result.changes} rows to the new provider_settings table.`);
this.db.exec('DROP TABLE provider_settings_old');
console.log('[DB Migration] Dropped provider_settings_old table.');
})();
console.log('[DB Migration] provider_settings migration completed successfully.');
} catch (error) {
console.error('[DB Migration] Failed to migrate provider_settings table.', error);
// Try to recover by dropping the temp table if it exists
const oldTableExists = this.getTablesFromDb().includes('provider_settings_old');
if (oldTableExists) {
this.db.exec('DROP TABLE provider_settings_old');
console.warn('[DB Migration] Cleaned up temporary old table after failure.');
}
throw error;
}
}
}
async synchronizeSchema() {
console.log('[DB Sync] Starting schema synchronization...'); console.log('[DB Sync] Starting schema synchronization...');
// Run special migration for provider_settings before the generic sync logic
this._migrateProviderSettings();
const tablesInDb = this.getTablesFromDb(); const tablesInDb = this.getTablesFromDb();
for (const tableName of Object.keys(LATEST_SCHEMA)) { for (const tableName of Object.keys(LATEST_SCHEMA)) {
@ -57,25 +138,42 @@ class SQLiteClient {
} }
createTable(tableName, tableSchema) { createTable(tableName, tableSchema) {
const columnDefs = tableSchema.columns.map(col => `"${col.name}" ${col.type}`).join(', '); const safeTableName = this._validateAndQuoteIdentifier(tableName);
const query = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs})`; const columnDefs = tableSchema.columns
.map(col => `${this._validateAndQuoteIdentifier(col.name)} ${col.type}`)
.join(', ');
const constraints = tableSchema.constraints || [];
const constraintsDef = constraints.length > 0 ? ', ' + constraints.join(', ') : '';
const query = `CREATE TABLE IF NOT EXISTS ${safeTableName} (${columnDefs}${constraintsDef})`;
console.log(`[DB Sync] Creating table: ${tableName}`); console.log(`[DB Sync] Creating table: ${tableName}`);
this.db.prepare(query).run(); this.db.exec(query);
} }
updateTable(tableName, tableSchema) { updateTable(tableName, tableSchema) {
const existingColumns = this.db.prepare(`PRAGMA table_info("${tableName}")`).all(); const safeTableName = this._validateAndQuoteIdentifier(tableName);
const existingColumnNames = existingColumns.map(c => c.name);
const columnsToAdd = tableSchema.columns.filter(col => !existingColumnNames.includes(col.name)); // Get current columns
const currentColumns = this.db.prepare(`PRAGMA table_info(${safeTableName})`).all();
const currentColumnNames = currentColumns.map(col => col.name);
if (columnsToAdd.length > 0) { // Check for new columns to add
console.log(`[DB Sync] Updating table: ${tableName}. Adding columns: ${columnsToAdd.map(c=>c.name).join(', ')}`); const newColumns = tableSchema.columns.filter(col => !currentColumnNames.includes(col.name));
for (const column of columnsToAdd) {
const addColumnQuery = `ALTER TABLE "${tableName}" ADD COLUMN "${column.name}" ${column.type}`; if (newColumns.length > 0) {
this.db.prepare(addColumnQuery).run(); console.log(`[DB Sync] Adding ${newColumns.length} new column(s) to ${tableName}`);
for (const col of newColumns) {
const safeColName = this._validateAndQuoteIdentifier(col.name);
const addColumnQuery = `ALTER TABLE ${safeTableName} ADD COLUMN ${safeColName} ${col.type}`;
this.db.exec(addColumnQuery);
console.log(`[DB Sync] Added column ${col.name} to ${tableName}`);
} }
} }
if (tableSchema.constraints && tableSchema.constraints.length > 0) {
console.log(`[DB Sync] Note: Constraints for ${tableName} can only be set during table creation`);
}
} }
runQuery(query, params = []) { runQuery(query, params = []) {
@ -108,8 +206,8 @@ class SQLiteClient {
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`); console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
} }
initTables() { async initTables() {
this.synchronizeSchema(); await this.synchronizeSchema();
this.initDefaultData(); this.initDefaultData();
} }
@ -142,21 +240,6 @@ class SQLiteClient {
console.log('Default data initialized.'); console.log('Default data initialized.');
} }
markPermissionsAsCompleted() {
return this.query(
'INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, ?)',
['permissions_completed', 'true']
);
}
checkPermissionsCompleted() {
const result = this.query(
'SELECT value FROM system_settings WHERE key = ?',
['permissions_completed']
);
return result.length > 0 && result[0].value === 'true';
}
close() { close() {
if (this.db) { if (this.db) {
try { try {

View File

@ -0,0 +1,877 @@
const { EventEmitter } = require('events');
const { spawn, exec } = require('child_process');
const { promisify } = require('util');
const path = require('path');
const fs = require('fs');
const os = require('os');
const https = require('https');
const crypto = require('crypto');
const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
const execAsync = promisify(exec);
const fsPromises = fs.promises;
class WhisperService extends EventEmitter {
constructor() {
super();
this.serviceName = 'WhisperService';
// 경로 및 디렉토리
this.whisperPath = null;
this.modelsDir = null;
this.tempDir = null;
// 세션 관리 (세션 풀 내장)
this.sessionPool = [];
this.activeSessions = new Map();
this.maxSessions = 3;
// 설치 상태
this.installState = {
isInstalled: false,
isInitialized: false
};
// 사용 가능한 모델
this.availableModels = {
'whisper-tiny': {
name: 'Tiny',
size: '39M',
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin'
},
'whisper-base': {
name: 'Base',
size: '74M',
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin'
},
'whisper-small': {
name: 'Small',
size: '244M',
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin'
},
'whisper-medium': {
name: 'Medium',
size: '769M',
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin'
}
};
}
// Base class methods integration
getPlatform() {
return process.platform;
}
async checkCommand(command) {
try {
const platform = this.getPlatform();
const checkCmd = platform === 'win32' ? 'where' : 'which';
const { stdout } = await execAsync(`${checkCmd} ${command}`);
return stdout.trim();
} catch (error) {
return null;
}
}
async waitForService(checkFn, maxAttempts = 30, delayMs = 1000) {
for (let i = 0; i < maxAttempts; i++) {
if (await checkFn()) {
console.log(`[${this.serviceName}] Service is ready`);
return true;
}
await new Promise(resolve => setTimeout(resolve, delayMs));
}
throw new Error(`${this.serviceName} service failed to start within timeout`);
}
async downloadFile(url, destination, options = {}) {
const {
onProgress = null,
headers = { 'User-Agent': 'Glass-App' },
timeout = 300000,
modelId = null
} = options;
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
let downloadedSize = 0;
let totalSize = 0;
const request = https.get(url, { headers }, (response) => {
if ([301, 302, 307, 308].includes(response.statusCode)) {
file.close();
fs.unlink(destination, () => {});
if (!response.headers.location) {
reject(new Error('Redirect without location header'));
return;
}
console.log(`[${this.serviceName}] Following redirect from ${url} to ${response.headers.location}`);
this.downloadFile(response.headers.location, destination, options)
.then(resolve)
.catch(reject);
return;
}
if (response.statusCode !== 200) {
file.close();
fs.unlink(destination, () => {});
reject(new Error(`Download failed: ${response.statusCode} ${response.statusMessage}`));
return;
}
totalSize = parseInt(response.headers['content-length'], 10) || 0;
response.on('data', (chunk) => {
downloadedSize += chunk.length;
if (totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100);
if (onProgress) {
onProgress(progress, downloadedSize, totalSize);
}
}
});
response.pipe(file);
file.on('finish', () => {
file.close(() => {
resolve({ success: true, size: downloadedSize });
});
});
});
request.on('timeout', () => {
request.destroy();
file.close();
fs.unlink(destination, () => {});
reject(new Error('Download timeout'));
});
request.on('error', (err) => {
file.close();
fs.unlink(destination, () => {});
this.emit('download-error', { url, error: err, modelId });
reject(err);
});
request.setTimeout(timeout);
file.on('error', (err) => {
fs.unlink(destination, () => {});
reject(err);
});
});
}
async downloadWithRetry(url, destination, options = {}) {
const {
maxRetries = 3,
retryDelay = 1000,
expectedChecksum = null,
modelId = null,
...downloadOptions
} = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.downloadFile(url, destination, {
...downloadOptions,
modelId
});
if (expectedChecksum) {
const isValid = await this.verifyChecksum(destination, expectedChecksum);
if (!isValid) {
fs.unlinkSync(destination);
throw new Error('Checksum verification failed');
}
console.log(`[${this.serviceName}] Checksum verified successfully`);
}
return result;
} catch (error) {
if (attempt === maxRetries) {
throw error;
}
console.log(`Download attempt ${attempt} failed, retrying in ${retryDelay}ms...`);
await new Promise(resolve => setTimeout(resolve, retryDelay * attempt));
}
}
}
async verifyChecksum(filePath, expectedChecksum) {
return new Promise((resolve, reject) => {
const hash = crypto.createHash('sha256');
const stream = fs.createReadStream(filePath);
stream.on('data', (data) => hash.update(data));
stream.on('end', () => {
const fileChecksum = hash.digest('hex');
console.log(`[${this.serviceName}] File checksum: ${fileChecksum}`);
console.log(`[${this.serviceName}] Expected checksum: ${expectedChecksum}`);
resolve(fileChecksum === expectedChecksum);
});
stream.on('error', reject);
});
}
async autoInstall(onProgress) {
const platform = this.getPlatform();
console.log(`[${this.serviceName}] Starting auto-installation for ${platform}`);
try {
switch(platform) {
case 'darwin':
return await this.installMacOS(onProgress);
case 'win32':
return await this.installWindows(onProgress);
case 'linux':
return await this.installLinux();
default:
throw new Error(`Unsupported platform: ${platform}`);
}
} catch (error) {
console.error(`[${this.serviceName}] Auto-installation failed:`, error);
throw error;
}
}
async shutdown(force = false) {
console.log(`[${this.serviceName}] Starting ${force ? 'forced' : 'graceful'} shutdown...`);
const isRunning = await this.isServiceRunning();
if (!isRunning) {
console.log(`[${this.serviceName}] Service not running, nothing to shutdown`);
return true;
}
const platform = this.getPlatform();
try {
switch(platform) {
case 'darwin':
return await this.shutdownMacOS(force);
case 'win32':
return await this.shutdownWindows(force);
case 'linux':
return await this.shutdownLinux(force);
default:
console.warn(`[${this.serviceName}] Unsupported platform for shutdown: ${platform}`);
return false;
}
} catch (error) {
console.error(`[${this.serviceName}] Error during shutdown:`, error);
return false;
}
}
async initialize() {
if (this.installState.isInitialized) return;
try {
const homeDir = os.homedir();
const whisperDir = path.join(homeDir, '.glass', 'whisper');
this.modelsDir = path.join(whisperDir, 'models');
this.tempDir = path.join(whisperDir, 'temp');
// Windows에서는 .exe 확장자 필요
const platform = this.getPlatform();
const whisperExecutable = platform === 'win32' ? 'whisper-whisper.exe' : 'whisper';
this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable);
await this.ensureDirectories();
await this.ensureWhisperBinary();
this.installState.isInitialized = true;
console.log('[WhisperService] Initialized successfully');
} catch (error) {
console.error('[WhisperService] Initialization failed:', error);
// Emit error event - LocalAIManager가 처리
this.emit('error', {
errorType: 'initialization-failed',
error: error.message
});
throw error;
}
}
async ensureDirectories() {
await fsPromises.mkdir(this.modelsDir, { recursive: true });
await fsPromises.mkdir(this.tempDir, { recursive: true });
await fsPromises.mkdir(path.dirname(this.whisperPath), { recursive: true });
}
// local stt session
async getSession(config) {
// check available session
const availableSession = this.sessionPool.find(s => !s.inUse);
if (availableSession) {
availableSession.inUse = true;
await availableSession.reconfigure(config);
return availableSession;
}
// create new session
if (this.activeSessions.size >= this.maxSessions) {
throw new Error('Maximum session limit reached');
}
const session = new WhisperSession(config, this);
await session.initialize();
this.activeSessions.set(session.id, session);
return session;
}
async releaseSession(sessionId) {
const session = this.activeSessions.get(sessionId);
if (session) {
await session.cleanup();
session.inUse = false;
// add to session pool
if (this.sessionPool.length < 2) {
this.sessionPool.push(session);
} else {
// remove session
await session.destroy();
this.activeSessions.delete(sessionId);
}
}
}
//cleanup
async cleanup() {
// cleanup all sessions
for (const session of this.activeSessions.values()) {
await session.destroy();
}
this.activeSessions.clear();
this.sessionPool = [];
}
async ensureWhisperBinary() {
const whisperCliPath = await this.checkCommand('whisper-cli');
if (whisperCliPath) {
this.whisperPath = whisperCliPath;
console.log(`[WhisperService] Found whisper-cli at: ${this.whisperPath}`);
return;
}
const whisperPath = await this.checkCommand('whisper');
if (whisperPath) {
this.whisperPath = whisperPath;
console.log(`[WhisperService] Found whisper at: ${this.whisperPath}`);
return;
}
try {
await fsPromises.access(this.whisperPath, fs.constants.X_OK);
console.log('[WhisperService] Custom whisper binary found');
return;
} catch (error) {
// Continue to installation
}
const platform = this.getPlatform();
if (platform === 'darwin') {
console.log('[WhisperService] Whisper not found, trying Homebrew installation...');
try {
await this.installViaHomebrew();
// verify installation
const verified = await this.verifyInstallation();
if (!verified.success) {
throw new Error(verified.error);
}
return;
} catch (error) {
console.log('[WhisperService] Homebrew installation failed:', error.message);
}
}
await this.autoInstall();
// verify installation
const verified = await this.verifyInstallation();
if (!verified.success) {
throw new Error(`Whisper installation verification failed: ${verified.error}`);
}
}
async installViaHomebrew() {
const brewPath = await this.checkCommand('brew');
if (!brewPath) {
throw new Error('Homebrew not found. Please install Homebrew first.');
}
console.log('[WhisperService] Installing whisper-cpp via Homebrew...');
await spawnAsync('brew', ['install', 'whisper-cpp']);
const whisperCliPath = await this.checkCommand('whisper-cli');
if (whisperCliPath) {
this.whisperPath = whisperCliPath;
console.log(`[WhisperService] Whisper-cli installed via Homebrew at: ${this.whisperPath}`);
} else {
const whisperPath = await this.checkCommand('whisper');
if (whisperPath) {
this.whisperPath = whisperPath;
console.log(`[WhisperService] Whisper installed via Homebrew at: ${this.whisperPath}`);
}
}
}
async ensureModelAvailable(modelId) {
if (!this.installState.isInitialized) {
console.log('[WhisperService] Service not initialized, initializing now...');
await this.initialize();
}
const modelInfo = this.availableModels[modelId];
if (!modelInfo) {
throw new Error(`Unknown model: ${modelId}. Available models: ${Object.keys(this.availableModels).join(', ')}`);
}
const modelPath = await this.getModelPath(modelId);
try {
await fsPromises.access(modelPath, fs.constants.R_OK);
console.log(`[WhisperService] Model ${modelId} already available at: ${modelPath}`);
} catch (error) {
console.log(`[WhisperService] Model ${modelId} not found, downloading...`);
await this.downloadModel(modelId);
}
}
async downloadModel(modelId) {
const modelInfo = this.availableModels[modelId];
const modelPath = await this.getModelPath(modelId);
const checksumInfo = DOWNLOAD_CHECKSUMS.whisper.models[modelId];
// Emit progress event - LocalAIManager가 처리
this.emit('install-progress', {
model: modelId,
progress: 0
});
await this.downloadWithRetry(modelInfo.url, modelPath, {
expectedChecksum: checksumInfo?.sha256,
modelId, // pass modelId to LocalAIServiceBase for event handling
onProgress: (progress) => {
// Emit progress event - LocalAIManager가 처리
this.emit('install-progress', {
model: modelId,
progress
});
}
});
console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
this.emit('model-download-complete', { modelId });
}
async handleDownloadModel(modelId) {
try {
console.log(`[WhisperService] Handling download for model: ${modelId}`);
if (!this.installState.isInitialized) {
await this.initialize();
}
await this.ensureModelAvailable(modelId);
return { success: true };
} catch (error) {
console.error(`[WhisperService] Failed to handle download for model ${modelId}:`, error);
return { success: false, error: error.message };
}
}
async handleGetInstalledModels() {
try {
if (!this.installState.isInitialized) {
await this.initialize();
}
const models = await this.getInstalledModels();
return { success: true, models };
} catch (error) {
console.error('[WhisperService] Failed to get installed models:', error);
return { success: false, error: error.message };
}
}
async getModelPath(modelId) {
if (!this.installState.isInitialized || !this.modelsDir) {
throw new Error('WhisperService is not initialized. Call initialize() first.');
}
return path.join(this.modelsDir, `${modelId}.bin`);
}
async getWhisperPath() {
return this.whisperPath;
}
async saveAudioToTemp(audioBuffer, sessionId = '') {
const timestamp = Date.now();
const random = Math.random().toString(36).substr(2, 6);
const sessionPrefix = sessionId ? `${sessionId}_` : '';
const tempFile = path.join(this.tempDir, `audio_${sessionPrefix}${timestamp}_${random}.wav`);
const wavHeader = this.createWavHeader(audioBuffer.length);
const wavBuffer = Buffer.concat([wavHeader, audioBuffer]);
await fsPromises.writeFile(tempFile, wavBuffer);
return tempFile;
}
createWavHeader(dataSize) {
const header = Buffer.alloc(44);
const sampleRate = 16000;
const numChannels = 1;
const bitsPerSample = 16;
header.write('RIFF', 0);
header.writeUInt32LE(36 + dataSize, 4);
header.write('WAVE', 8);
header.write('fmt ', 12);
header.writeUInt32LE(16, 16);
header.writeUInt16LE(1, 20);
header.writeUInt16LE(numChannels, 22);
header.writeUInt32LE(sampleRate, 24);
header.writeUInt32LE(sampleRate * numChannels * bitsPerSample / 8, 28);
header.writeUInt16LE(numChannels * bitsPerSample / 8, 32);
header.writeUInt16LE(bitsPerSample, 34);
header.write('data', 36);
header.writeUInt32LE(dataSize, 40);
return header;
}
async cleanupTempFile(filePath) {
if (!filePath || typeof filePath !== 'string') {
console.warn('[WhisperService] Invalid file path for cleanup:', filePath);
return;
}
const filesToCleanup = [
filePath,
filePath.replace('.wav', '.txt'),
filePath.replace('.wav', '.json')
];
for (const file of filesToCleanup) {
try {
// Check if file exists before attempting to delete
await fsPromises.access(file, fs.constants.F_OK);
await fsPromises.unlink(file);
console.log(`[WhisperService] Cleaned up: ${file}`);
} catch (error) {
// File doesn't exist or already deleted - this is normal
if (error.code !== 'ENOENT') {
console.warn(`[WhisperService] Failed to cleanup ${file}:`, error.message);
}
}
}
}
async getInstalledModels() {
if (!this.installState.isInitialized) {
console.log('[WhisperService] Service not initialized for getInstalledModels, initializing now...');
await this.initialize();
}
const models = [];
for (const [modelId, modelInfo] of Object.entries(this.availableModels)) {
try {
const modelPath = await this.getModelPath(modelId);
await fsPromises.access(modelPath, fs.constants.R_OK);
models.push({
id: modelId,
name: modelInfo.name,
size: modelInfo.size,
installed: true
});
} catch (error) {
models.push({
id: modelId,
name: modelInfo.name,
size: modelInfo.size,
installed: false
});
}
}
return models;
}
async isServiceRunning() {
return this.installState.isInitialized;
}
async startService() {
if (!this.installState.isInitialized) {
await this.initialize();
}
return true;
}
async stopService() {
return true;
}
async isInstalled() {
try {
const whisperPath = await this.checkCommand('whisper-cli') || await this.checkCommand('whisper');
return !!whisperPath;
} catch (error) {
return false;
}
}
async installMacOS() {
throw new Error('Binary installation not available for macOS. Please install Homebrew and run: brew install whisper-cpp');
}
async installWindows() {
console.log('[WhisperService] Installing Whisper on Windows...');
const version = 'v1.7.6';
const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-bin-x64.zip`;
const tempFile = path.join(this.tempDir, 'whisper-binary.zip');
try {
console.log('[WhisperService] Step 1: Downloading Whisper binary...');
await this.downloadWithRetry(binaryUrl, tempFile);
console.log('[WhisperService] Step 2: Extracting archive...');
const extractDir = path.join(this.tempDir, 'extracted');
// 임시 압축 해제 디렉토리 생성
await fsPromises.mkdir(extractDir, { recursive: true });
// PowerShell 명령에서 경로를 올바르게 인용
const expandCommand = `Expand-Archive -Path "${tempFile}" -DestinationPath "${extractDir}" -Force`;
await spawnAsync('powershell', ['-command', expandCommand]);
console.log('[WhisperService] Step 3: Finding and moving whisper executable...');
// 압축 해제된 디렉토리에서 whisper.exe 파일 찾기
const whisperExecutables = await this.findWhisperExecutables(extractDir);
if (whisperExecutables.length === 0) {
throw new Error('whisper.exe not found in extracted files');
}
// 첫 번째로 찾은 whisper.exe를 목표 위치로 복사
const sourceExecutable = whisperExecutables[0];
const targetDir = path.dirname(this.whisperPath);
await fsPromises.mkdir(targetDir, { recursive: true });
await fsPromises.copyFile(sourceExecutable, this.whisperPath);
console.log('[WhisperService] Step 4: Verifying installation...');
// 설치 검증
await fsPromises.access(this.whisperPath, fs.constants.F_OK);
// whisper.exe 실행 테스트
try {
await spawnAsync(this.whisperPath, ['--help']);
console.log('[WhisperService] Whisper executable verified successfully');
} catch (testError) {
console.warn('[WhisperService] Whisper executable test failed, but file exists:', testError.message);
}
console.log('[WhisperService] Step 5: Cleanup...');
// 임시 파일 정리
await fsPromises.unlink(tempFile).catch(() => {});
await this.removeDirectory(extractDir).catch(() => {});
console.log('[WhisperService] Whisper installed successfully on Windows');
return true;
} catch (error) {
console.error('[WhisperService] Windows installation failed:', error);
// 실패 시 임시 파일 정리
await fsPromises.unlink(tempFile).catch(() => {});
await this.removeDirectory(path.join(this.tempDir, 'extracted')).catch(() => {});
throw new Error(`Failed to install Whisper on Windows: ${error.message}`);
}
}
// 압축 해제된 디렉토리에서 whisper.exe 파일들을 재귀적으로 찾기
async findWhisperExecutables(dir) {
const executables = [];
try {
const items = await fsPromises.readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
const subExecutables = await this.findWhisperExecutables(fullPath);
executables.push(...subExecutables);
} else if (item.isFile() && (item.name === 'whisper-whisper.exe' || item.name === 'whisper.exe' || item.name === 'main.exe')) {
executables.push(fullPath);
}
}
} catch (error) {
console.warn('[WhisperService] Error reading directory:', dir, error.message);
}
return executables;
}
// 디렉토리 재귀적 삭제
async removeDirectory(dir) {
try {
const items = await fsPromises.readdir(dir, { withFileTypes: true });
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
await this.removeDirectory(fullPath);
} else {
await fsPromises.unlink(fullPath);
}
}
await fsPromises.rmdir(dir);
} catch (error) {
console.warn('[WhisperService] Error removing directory:', dir, error.message);
}
}
async installLinux() {
console.log('[WhisperService] Installing Whisper on Linux...');
const version = 'v1.7.6';
const binaryUrl = `https://github.com/ggml-org/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-linux-x64.tar.gz`;
const tempFile = path.join(this.tempDir, 'whisper-binary.tar.gz');
try {
await this.downloadWithRetry(binaryUrl, tempFile);
const extractDir = path.dirname(this.whisperPath);
await spawnAsync('tar', ['-xzf', tempFile, '-C', extractDir, '--strip-components=1']);
await spawnAsync('chmod', ['+x', this.whisperPath]);
await fsPromises.unlink(tempFile);
console.log('[WhisperService] Whisper installed successfully on Linux');
return true;
} catch (error) {
console.error('[WhisperService] Linux installation failed:', error);
throw new Error(`Failed to install Whisper on Linux: ${error.message}`);
}
}
async shutdownMacOS(force) {
return true;
}
async shutdownWindows(force) {
return true;
}
async shutdownLinux(force) {
return true;
}
}
// WhisperSession class
class WhisperSession {
constructor(config, service) {
this.id = `session_${Date.now()}_${Math.random()}`;
this.config = config;
this.service = service;
this.process = null;
this.inUse = true;
this.audioBuffer = Buffer.alloc(0);
}
async initialize() {
await this.service.ensureModelAvailable(this.config.model);
this.startProcessingLoop();
}
async reconfigure(config) {
this.config = config;
await this.service.ensureModelAvailable(this.config.model);
}
startProcessingLoop() {
// TODO: 실제 처리 루프 구현
}
async cleanup() {
// 임시 파일 정리
await this.cleanupTempFiles();
}
async cleanupTempFiles() {
// TODO: 임시 파일 정리 구현
}
async destroy() {
if (this.process) {
this.process.kill();
}
// 임시 파일 정리
await this.cleanupTempFiles();
}
}
// verify installation
WhisperService.prototype.verifyInstallation = async function() {
try {
console.log('[WhisperService] Verifying installation...');
// 1. check binary
if (!this.whisperPath) {
return { success: false, error: 'Whisper binary path not set' };
}
try {
await fsPromises.access(this.whisperPath, fs.constants.X_OK);
} catch (error) {
return { success: false, error: 'Whisper binary not executable' };
}
// 2. check version
try {
const { stdout } = await spawnAsync(this.whisperPath, ['--help']);
if (!stdout.includes('whisper')) {
return { success: false, error: 'Invalid whisper binary' };
}
} catch (error) {
return { success: false, error: 'Whisper binary not responding' };
}
// 3. check directories
try {
await fsPromises.access(this.modelsDir, fs.constants.W_OK);
await fsPromises.access(this.tempDir, fs.constants.W_OK);
} catch (error) {
return { success: false, error: 'Required directories not accessible' };
}
console.log('[WhisperService] Installation verified successfully');
return { success: true };
} catch (error) {
console.error('[WhisperService] Verification failed:', error);
return { success: false, error: error.message };
}
};
// Export singleton instance
const whisperService = new WhisperService();
module.exports = whisperService;

View File

@ -0,0 +1,39 @@
const { spawn } = require('child_process');
function spawnAsync(command, args = [], options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(command, args, options);
let stdout = '';
let stderr = '';
if (child.stdout) {
child.stdout.on('data', (data) => {
stdout += data.toString();
});
}
if (child.stderr) {
child.stderr.on('data', (data) => {
stderr += data.toString();
});
}
child.on('error', (error) => {
reject(error);
});
child.on('close', (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
const error = new Error(`Command failed with code ${code}: ${stderr || stdout}`);
error.code = code;
error.stdout = stdout;
error.stderr = stderr;
reject(error);
}
});
});
}
module.exports = { spawnAsync };

View File

@ -1,9 +1,10 @@
const { BrowserWindow, app } = require('electron'); const { BrowserWindow } = require('electron');
const SttService = require('./stt/sttService'); const SttService = require('./stt/sttService');
const SummaryService = require('./summary/summaryService'); const SummaryService = require('./summary/summaryService');
const authService = require('../../common/services/authService'); const authService = require('../common/services/authService');
const sessionRepository = require('../../common/repositories/session'); const sessionRepository = require('../common/repositories/session');
const sttRepository = require('./stt/repositories'); const sttRepository = require('./stt/repositories');
const internalBridge = require('../../bridge/internalBridge');
class ListenService { class ListenService {
constructor() { constructor() {
@ -11,8 +12,9 @@ class ListenService {
this.summaryService = new SummaryService(); this.summaryService = new SummaryService();
this.currentSessionId = null; this.currentSessionId = null;
this.isInitializingSession = false; this.isInitializingSession = false;
this.setupServiceCallbacks(); this.setupServiceCallbacks();
console.log('[ListenService] Service instance created.');
} }
setupServiceCallbacks() { setupServiceCallbacks() {
@ -38,11 +40,60 @@ class ListenService {
} }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { const { windowPool } = require('../../window/windowManager');
if (!win.isDestroyed()) { const listenWindow = windowPool?.get('listen');
win.webContents.send(channel, data);
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
initialize() {
this.setupIpcHandlers();
console.log('[ListenService] Initialized and ready.');
}
async handleListenRequest(listenButtonText) {
const { windowPool } = require('../../window/windowManager');
const listenWindow = windowPool.get('listen');
const header = windowPool.get('header');
try {
switch (listenButtonText) {
case 'Listen':
console.log('[ListenService] changeSession to "Listen"');
internalBridge.emit('window:requestVisibility', { name: 'listen', visible: true });
await this.initializeSession();
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send('session-state-changed', { isActive: true });
}
break;
case 'Stop':
console.log('[ListenService] changeSession to "Stop"');
await this.closeSession();
if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send('session-state-changed', { isActive: false });
}
break;
case 'Done':
console.log('[ListenService] changeSession to "Done"');
internalBridge.emit('window:requestVisibility', { name: 'listen', visible: false });
listenWindow.webContents.send('session-state-changed', { isActive: false });
break;
default:
throw new Error(`[ListenService] unknown listenButtonText: ${listenButtonText}`);
} }
});
header.webContents.send('listen:changeSessionResult', { success: true });
} catch (error) {
console.error('[ListenService] error in handleListenRequest:', error);
header.webContents.send('listen:changeSessionResult', { success: false });
throw error;
}
} }
async handleTranscriptionComplete(speaker, text) { async handleTranscriptionComplete(speaker, text) {
@ -77,12 +128,15 @@ class ListenService {
async initializeNewSession() { async initializeNewSession() {
try { try {
const uid = authService.getCurrentUserId(); // The UID is no longer passed to the repository method directly.
if (!uid) { // The adapter layer handles UID injection. We just ensure a user is available.
throw new Error("Cannot initialize session: user not logged in."); const user = authService.getCurrentUser();
if (!user) {
// This case should ideally not happen as authService initializes a default user.
throw new Error("Cannot initialize session: auth service not ready.");
} }
this.currentSessionId = await sessionRepository.getOrCreateActive(uid, 'listen'); this.currentSessionId = await sessionRepository.getOrCreateActive('listen');
console.log(`[DB] New listen session ensured: ${this.currentSessionId}`); console.log(`[DB] New listen session ensured: ${this.currentSessionId}`);
// Set session ID for summary service // Set session ID for summary service
@ -141,7 +195,6 @@ class ListenService {
console.log('✅ Listen service initialized successfully.'); console.log('✅ Listen service initialized successfully.');
this.sendToRenderer('session-state-changed', { isActive: true });
this.sendToRenderer('update-status', 'Connected. Ready to listen.'); this.sendToRenderer('update-status', 'Connected. Ready to listen.');
return true; return true;
@ -152,11 +205,12 @@ class ListenService {
} finally { } finally {
this.isInitializingSession = false; this.isInitializingSession = false;
this.sendToRenderer('session-initializing', false); this.sendToRenderer('session-initializing', false);
this.sendToRenderer('change-listen-capture-state', { status: "start" });
} }
} }
async sendAudioContent(data, mimeType) { async sendMicAudioContent(data, mimeType) {
return await this.sttService.sendAudioContent(data, mimeType); return await this.sttService.sendMicAudioContent(data, mimeType);
} }
async startMacOSAudioCapture() { async startMacOSAudioCapture() {
@ -176,9 +230,12 @@ class ListenService {
async closeSession() { async closeSession() {
try { try {
this.sendToRenderer('change-listen-capture-state', { status: "stop" });
// Close STT sessions // Close STT sessions
await this.sttService.closeSessions(); await this.sttService.closeSessions();
await this.stopMacOSAudioCapture();
// End database session // End database session
if (this.currentSessionId) { if (this.currentSessionId) {
await sessionRepository.end(this.currentSessionId); await sessionRepository.end(this.currentSessionId);
@ -189,9 +246,6 @@ class ListenService {
this.currentSessionId = null; this.currentSessionId = null;
this.summaryService.resetConversationHistory(); this.summaryService.resetConversationHistory();
this.sendToRenderer('session-state-changed', { isActive: false });
this.sendToRenderer('session-did-close');
console.log('Listen service session closed.'); console.log('Listen service session closed.');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
@ -213,88 +267,58 @@ class ListenService {
return this.summaryService.getConversationHistory(); return this.summaryService.getConversationHistory();
} }
setupIpcHandlers() { _createHandler(asyncFn, successMessage, errorMessage) {
const { ipcMain } = require('electron'); return async (...args) => {
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 { try {
await this.sendAudioContent(data, mimeType); const result = await asyncFn.apply(this, args);
return { success: true }; if (successMessage) console.log(successMessage);
// `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로,
// 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다.
// 다른 함수들은 이미 success 객체를 반환합니다.
return result && typeof result.success !== 'undefined' ? result : { success: true };
} catch (e) { } catch (e) {
console.error('Error sending user audio:', e); console.error(errorMessage, e);
return { success: false, error: e.message }; return { success: false, error: e.message };
} }
}); };
}
ipcMain.handle('send-system-audio-content', async (event, { data, mimeType }) => { // `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다.
try { handleSendMicAudioContent = this._createHandler(
await this.sttService.sendSystemAudioContent(data, mimeType); this.sendMicAudioContent,
null,
// Send system audio data back to renderer for AEC reference (like macOS does) 'Error sending user audio:'
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 () => { handleStartMacosAudio = this._createHandler(
async () => {
if (process.platform !== 'darwin') { if (process.platform !== 'darwin') {
return { success: false, error: 'macOS audio capture only available on macOS' }; return { success: false, error: 'macOS audio capture only available on macOS' };
} }
if (this.sttService.isMacOSAudioRunning?.()) { if (this.sttService.isMacOSAudioRunning?.()) {
return { success: false, error: 'already_running' }; return { success: false, error: 'already_running' };
} }
await this.startMacOSAudioCapture();
return { success: true, error: null };
},
'macOS audio capture started.',
'Error starting macOS audio capture:'
);
handleStopMacosAudio = this._createHandler(
this.stopMacOSAudioCapture,
'macOS audio capture stopped.',
'Error stopping macOS audio capture:'
);
try { handleUpdateGoogleSearchSetting = this._createHandler(
const success = await this.startMacOSAudioCapture(); async (enabled) => {
return { success, error: null }; console.log('Google Search setting updated to:', enabled);
} catch (error) { },
console.error('Error starting macOS audio capture:', error); null,
return { success: false, error: error.message }; 'Error updating Google Search setting:'
} );
});
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; const listenService = new ListenService();
module.exports = listenService;

View File

@ -1,138 +0,0 @@
// 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,36 @@
const { collection, addDoc, query, getDocs, orderBy, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
const transcriptConverter = createEncryptedConverter(['text']);
function transcriptsCol(sessionId) {
if (!sessionId) throw new Error("Session ID is required to access transcripts.");
const db = getFirestoreInstance();
return collection(db, `sessions/${sessionId}/transcripts`).withConverter(transcriptConverter);
}
async function addTranscript({ uid, sessionId, speaker, text }) {
const now = Timestamp.now();
const newTranscript = {
uid, // To identify the author/source of the transcript
session_id: sessionId,
start_at: now,
speaker,
text,
created_at: now,
};
const docRef = await addDoc(transcriptsCol(sessionId), newTranscript);
return { id: docRef.id };
}
async function getAllTranscriptsBySessionId(sessionId) {
const q = query(transcriptsCol(sessionId), orderBy('start_at', 'asc'));
const querySnapshot = await getDocs(q);
return querySnapshot.docs.map(doc => doc.data());
}
module.exports = {
addTranscript,
getAllTranscriptsBySessionId,
};

View File

@ -1,5 +1,23 @@
const sttRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService');
module.exports = { function getBaseRepository() {
...sttRepository, const user = authService.getCurrentUser();
}; if (user && user.isLoggedIn) {
return firebaseRepository;
}
return sqliteRepository;
}
const sttRepositoryAdapter = {
addTranscript: ({ sessionId, speaker, text }) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().addTranscript({ uid, sessionId, speaker, text });
},
getAllTranscriptsBySessionId: (sessionId) => {
return getBaseRepository().getAllTranscriptsBySessionId(sessionId);
}
};
module.exports = sttRepositoryAdapter;

View File

@ -1,6 +1,7 @@
const sqliteClient = require('../../../../common/services/sqliteClient'); const sqliteClient = require('../../../common/services/sqliteClient');
function addTranscript({ sessionId, speaker, text }) { function addTranscript({ uid, sessionId, speaker, text }) {
// uid is ignored in the SQLite implementation
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();
const transcriptId = require('crypto').randomUUID(); const transcriptId = require('crypto').randomUUID();
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);

View File

@ -1,7 +1,7 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { spawn } = require('child_process'); const { spawn } = require('child_process');
const { createSTT } = require('../../../common/ai/factory'); const { createSTT } = require('../../common/ai/factory');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager'); const modelStateService = require('../../common/services/modelStateService');
const COMPLETION_DEBOUNCE_MS = 2000; const COMPLETION_DEBOUNCE_MS = 2000;
@ -12,11 +12,6 @@ class SttService {
this.myCurrentUtterance = ''; this.myCurrentUtterance = '';
this.theirCurrentUtterance = ''; this.theirCurrentUtterance = '';
this.myLastPartialText = '';
this.theirLastPartialText = '';
this.myInactivityTimer = null;
this.theirInactivityTimer = null;
// Turn-completion debouncing // Turn-completion debouncing
this.myCompletionBuffer = ''; this.myCompletionBuffer = '';
this.theirCompletionBuffer = ''; this.theirCompletionBuffer = '';
@ -38,46 +33,31 @@ class SttService {
this.onStatusUpdate = onStatusUpdate; this.onStatusUpdate = onStatusUpdate;
} }
// async getApiKey() {
// const storedKey = await getStoredApiKey();
// if (storedKey) {
// console.log('[SttService] Using stored API key');
// return storedKey;
// }
// const envKey = process.env.OPENAI_API_KEY;
// if (envKey) {
// console.log('[SttService] Using environment API key');
// return envKey;
// }
// console.error('[SttService] No API key found in storage or environment');
// return null;
// }
// async getAiProvider() {
// try {
// const { ipcRenderer } = require('electron');
// const provider = await ipcRenderer.invoke('get-ai-provider');
// return provider || 'openai';
// } catch (error) {
// return getStoredProvider ? getStoredProvider() : 'openai';
// }
// }
sendToRenderer(channel, data) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { // Listen 관련 이벤트는 Listen 윈도우에만 전송 (Ask 윈도우 충돌 방지)
if (!win.isDestroyed()) { const { windowPool } = require('../../../window/windowManager');
win.webContents.send(channel, data); const listenWindow = windowPool?.get('listen');
}
}); if (listenWindow && !listenWindow.isDestroyed()) {
listenWindow.webContents.send(channel, data);
}
}
async handleSendSystemAudioContent(data, mimeType) {
try {
await this.sendSystemAudioContent(data, mimeType);
this.sendToRenderer('system-audio-data', { data });
return { success: true };
} catch (error) {
console.error('Error sending system audio:', error);
return { success: false, error: error.message };
}
} }
flushMyCompletion() { flushMyCompletion() {
if (!this.modelInfo || !this.myCompletionBuffer.trim()) return; const finalText = (this.myCompletionBuffer + this.myCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return;
const finalText = this.myCompletionBuffer.trim();
// Notify completion callback // Notify completion callback
if (this.onTranscriptionComplete) { if (this.onTranscriptionComplete) {
this.onTranscriptionComplete('Me', finalText); this.onTranscriptionComplete('Me', finalText);
@ -102,9 +82,8 @@ class SttService {
} }
flushTheirCompletion() { flushTheirCompletion() {
if (!this.modelInfo || !this.theirCompletionBuffer.trim()) return; const finalText = (this.theirCompletionBuffer + this.theirCurrentUtterance).trim();
if (!this.modelInfo || !finalText) return;
const finalText = this.theirCompletionBuffer.trim();
// Notify completion callback // Notify completion callback
if (this.onTranscriptionComplete) { if (this.onTranscriptionComplete) {
@ -130,65 +109,145 @@ class SttService {
} }
debounceMyCompletion(text) { debounceMyCompletion(text) {
// 상대방이 말하고 있던 경우, 화자가 변경되었으므로 즉시 상대방의 말풍선을 완성합니다. if (this.modelInfo?.provider === 'gemini') {
if (this.theirCompletionTimer) { this.myCompletionBuffer += text;
clearTimeout(this.theirCompletionTimer); } else {
this.flushTheirCompletion(); this.myCompletionBuffer += (this.myCompletionBuffer ? ' ' : '') + text;
} }
this.myCompletionBuffer += (this.myCompletionBuffer ? ' ' : '') + text;
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer); if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = setTimeout(() => this.flushMyCompletion(), COMPLETION_DEBOUNCE_MS); this.myCompletionTimer = setTimeout(() => this.flushMyCompletion(), COMPLETION_DEBOUNCE_MS);
} }
debounceTheirCompletion(text) { debounceTheirCompletion(text) {
// 내가 말하고 있던 경우, 화자가 변경되었으므로 즉시 내 말풍선을 완성합니다. if (this.modelInfo?.provider === 'gemini') {
if (this.myCompletionTimer) { this.theirCompletionBuffer += text;
clearTimeout(this.myCompletionTimer); } else {
this.flushMyCompletion(); this.theirCompletionBuffer += (this.theirCompletionBuffer ? ' ' : '') + text;
} }
this.theirCompletionBuffer += (this.theirCompletionBuffer ? ' ' : '') + text;
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer); if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = setTimeout(() => this.flushTheirCompletion(), COMPLETION_DEBOUNCE_MS); this.theirCompletionTimer = setTimeout(() => this.flushTheirCompletion(), COMPLETION_DEBOUNCE_MS);
} }
async initializeSttSessions(language = 'en') { async initializeSttSessions(language = 'en') {
const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en'; const effectiveLanguage = process.env.OPENAI_TRANSCRIBE_LANG || language || 'en';
// const API_KEY = await this.getApiKey();
// if (!API_KEY) {
// throw new Error('No API key available');
// }
// const provider = await this.getAiProvider();
const modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); const modelInfo = await modelStateService.getCurrentModelInfo('stt');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
this.modelInfo = modelInfo; this.modelInfo = modelInfo;
console.log(`[SttService] Initializing STT for ${modelInfo.provider} using model ${modelInfo.model}`); console.log(`[SttService] Initializing STT for ${modelInfo.provider} using model ${modelInfo.model}`);
// const isGemini = modelInfo.provider === 'gemini';
// console.log(`[SttService] Initializing STT for provider: ${modelInfo.provider}`);
const handleMyMessage = message => { const handleMyMessage = message => {
if (!this.modelInfo) { if (!this.modelInfo) {
console.log('[SttService] Ignoring message - session already closed'); console.log('[SttService] Ignoring message - session already closed');
return; return;
} }
// console.log('[SttService] handleMyMessage', message);
if (this.modelInfo.provider === 'gemini') { if (this.modelInfo.provider === 'whisper') {
const text = message.serverContent?.inputTranscription?.text || ''; // Whisper STT emits 'transcription' events with different structure
if (text && text.trim()) { if (message.text && message.text.trim()) {
const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim(); const finalText = message.text.trim();
if (finalUtteranceText && finalUtteranceText !== '.') {
this.debounceMyCompletion(finalUtteranceText); // Filter out Whisper noise transcriptions
const noisePatterns = [
'[BLANK_AUDIO]',
'[INAUDIBLE]',
'[MUSIC]',
'[SOUND]',
'[NOISE]',
'(BLANK_AUDIO)',
'(INAUDIBLE)',
'(MUSIC)',
'(SOUND)',
'(NOISE)'
];
const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern
);
if (!isNoise && finalText.length > 2) {
this.debounceMyCompletion(finalText);
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: finalText,
isPartial: false,
isFinal: true,
timestamp: Date.now(),
});
} else {
console.log(`[Whisper-Me] Filtered noise: "${finalText}"`);
} }
} }
return;
} else 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(),
});
// Deepgram
} else if (this.modelInfo.provider === 'deepgram') {
const text = message.channel?.alternatives?.[0]?.transcript;
if (!text || text.trim().length === 0) return;
const isFinal = message.is_final;
console.log(`[SttService-Me-Deepgram] Received: isFinal=${isFinal}, text="${text}"`);
if (isFinal) {
// 최종 결과가 도착하면, 현재 진행중인 부분 발화는 비우고
// 최종 텍스트로 debounce를 실행합니다.
this.myCurrentUtterance = '';
this.debounceMyCompletion(text);
} else {
// 부분 결과(interim)인 경우, 화면에 실시간으로 업데이트합니다.
if (this.myCompletionTimer) clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null;
this.myCurrentUtterance = text;
const continuousText = (this.myCompletionBuffer + ' ' + this.myCurrentUtterance).trim();
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else { } else {
const type = message.type; const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -229,14 +288,104 @@ class SttService {
return; return;
} }
if (this.modelInfo.provider === 'gemini') { if (this.modelInfo.provider === 'whisper') {
const text = message.serverContent?.inputTranscription?.text || ''; // Whisper STT emits 'transcription' events with different structure
if (text && text.trim()) { if (message.text && message.text.trim()) {
const finalUtteranceText = text.trim().replace(/<noise>/g, '').trim(); const finalText = message.text.trim();
if (finalUtteranceText && finalUtteranceText !== '.') {
this.debounceTheirCompletion(finalUtteranceText); // Filter out Whisper noise transcriptions
const noisePatterns = [
'[BLANK_AUDIO]',
'[INAUDIBLE]',
'[MUSIC]',
'[SOUND]',
'[NOISE]',
'(BLANK_AUDIO)',
'(INAUDIBLE)',
'(MUSIC)',
'(SOUND)',
'(NOISE)'
];
const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern
);
// Only process if it's not noise, not a false positive, and has meaningful content
if (!isNoise && finalText.length > 2) {
this.debounceTheirCompletion(finalText);
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: finalText,
isPartial: false,
isFinal: true,
timestamp: Date.now(),
});
} else {
console.log(`[Whisper-Them] Filtered noise: "${finalText}"`);
} }
} }
return;
} else 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(),
});
// Deepgram
} else if (this.modelInfo.provider === 'deepgram') {
const text = message.channel?.alternatives?.[0]?.transcript;
if (!text || text.trim().length === 0) return;
const isFinal = message.is_final;
if (isFinal) {
this.theirCurrentUtterance = '';
this.debounceTheirCompletion(text);
} else {
if (this.theirCompletionTimer) clearTimeout(this.theirCompletionTimer);
this.theirCompletionTimer = null;
this.theirCurrentUtterance = text;
const continuousText = (this.theirCompletionBuffer + ' ' + this.theirCurrentUtterance).trim();
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: continuousText,
isPartial: true,
isFinal: false,
timestamp: Date.now(),
});
}
} else { } else {
const type = message.type; const type = message.type;
const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || ''; const text = message.transcript || message.delta || (message.alternatives && message.alternatives[0]?.transcript) || '';
@ -285,11 +434,6 @@ class SttService {
onclose: event => console.log('Their STT session closed:', event.reason), 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 = { const sttOptions = {
apiKey: this.modelInfo.apiKey, apiKey: this.modelInfo.apiKey,
@ -298,16 +442,20 @@ class SttService {
portkeyVirtualKey: this.modelInfo.provider === 'openai-glass' ? this.modelInfo.apiKey : undefined, portkeyVirtualKey: this.modelInfo.provider === 'openai-glass' ? this.modelInfo.apiKey : undefined,
}; };
// Add sessionType for Whisper to distinguish between My and Their sessions
const myOptions = { ...sttOptions, callbacks: mySttConfig.callbacks, sessionType: 'my' };
const theirOptions = { ...sttOptions, callbacks: theirSttConfig.callbacks, sessionType: 'their' };
[this.mySttSession, this.theirSttSession] = await Promise.all([ [this.mySttSession, this.theirSttSession] = await Promise.all([
createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: mySttConfig.callbacks }), createSTT(this.modelInfo.provider, myOptions),
createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: theirSttConfig.callbacks }), createSTT(this.modelInfo.provider, theirOptions),
]); ]);
console.log('✅ Both STT sessions initialized successfully.'); console.log('✅ Both STT sessions initialized successfully.');
return true; return true;
} }
async sendAudioContent(data, mimeType) { async sendMicAudioContent(data, mimeType) {
// const provider = await this.getAiProvider(); // const provider = await this.getAiProvider();
// const isGemini = provider === 'gemini'; // const isGemini = provider === 'gemini';
@ -318,16 +466,20 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = await modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
} }
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: data; payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(data, 'base64');
} else {
payload = data;
}
await this.mySttSession.sendRealtimeInput(payload); await this.mySttSession.sendRealtimeInput(payload);
} }
@ -339,16 +491,21 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = await modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
} }
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: data; payload = { audio: { data, mimeType: mimeType || 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(data, 'base64');
} else {
payload = data;
}
await this.theirSttSession.sendRealtimeInput(payload); await this.theirSttSession.sendRealtimeInput(payload);
} }
@ -390,8 +547,8 @@ class SttService {
const { app } = require('electron'); const { app } = require('electron');
const path = require('path'); const path = require('path');
const systemAudioPath = app.isPackaged const systemAudioPath = app.isPackaged
? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'assets', 'SystemAudioDump') ? path.join(process.resourcesPath, 'app.asar.unpacked', 'src', 'ui', 'assets', 'SystemAudioDump')
: path.join(app.getAppPath(), 'src', 'assets', 'SystemAudioDump'); : path.join(app.getAppPath(), 'src', 'ui', 'assets', 'SystemAudioDump');
console.log('SystemAudioDump path:', systemAudioPath); console.log('SystemAudioDump path:', systemAudioPath);
@ -420,7 +577,7 @@ class SttService {
let modelInfo = this.modelInfo; let modelInfo = this.modelInfo;
if (!modelInfo) { if (!modelInfo) {
console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...'); console.warn('[SttService] modelInfo not found, fetching on-the-fly as a fallback...');
modelInfo = await getCurrentModelInfo(null, { type: 'stt' }); modelInfo = await modelStateService.getCurrentModelInfo('stt');
} }
if (!modelInfo) { if (!modelInfo) {
throw new Error('STT model info could not be retrieved.'); throw new Error('STT model info could not be retrieved.');
@ -440,9 +597,15 @@ class SttService {
if (this.theirSttSession) { if (this.theirSttSession) {
try { try {
const payload = modelInfo.provider === 'gemini' let payload;
? { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } } if (modelInfo.provider === 'gemini') {
: base64Data; payload = { audio: { data: base64Data, mimeType: 'audio/pcm;rate=24000' } };
} else if (modelInfo.provider === 'deepgram') {
payload = Buffer.from(base64Data, 'base64');
} else {
payload = base64Data;
}
await this.theirSttSession.sendRealtimeInput(payload); await this.theirSttSession.sendRealtimeInput(payload);
} catch (err) { } catch (err) {
console.error('Error sending system audio:', err.message); console.error('Error sending system audio:', err.message);
@ -496,14 +659,6 @@ class SttService {
this.stopMacOSAudioCapture(); this.stopMacOSAudioCapture();
// Clear timers // Clear timers
if (this.myInactivityTimer) {
clearTimeout(this.myInactivityTimer);
this.myInactivityTimer = null;
}
if (this.theirInactivityTimer) {
clearTimeout(this.theirInactivityTimer);
this.theirInactivityTimer = null;
}
if (this.myCompletionTimer) { if (this.myCompletionTimer) {
clearTimeout(this.myCompletionTimer); clearTimeout(this.myCompletionTimer);
this.myCompletionTimer = null; this.myCompletionTimer = null;
@ -529,8 +684,6 @@ class SttService {
// Reset state // Reset state
this.myCurrentUtterance = ''; this.myCurrentUtterance = '';
this.theirCurrentUtterance = ''; this.theirCurrentUtterance = '';
this.myLastPartialText = '';
this.theirLastPartialText = '';
this.myCompletionBuffer = ''; this.myCompletionBuffer = '';
this.theirCompletionBuffer = ''; this.theirCompletionBuffer = '';
this.modelInfo = null; this.modelInfo = null;

View File

@ -0,0 +1,48 @@
const { collection, doc, setDoc, getDoc, Timestamp } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../../common/repositories/firestoreConverter');
const encryptionService = require('../../../common/services/encryptionService');
const fieldsToEncrypt = ['tldr', 'text', 'bullet_json', 'action_json'];
const summaryConverter = createEncryptedConverter(fieldsToEncrypt);
function summaryDocRef(sessionId) {
if (!sessionId) throw new Error("Session ID is required to access summary.");
const db = getFirestoreInstance();
// Reverting to the original structure with 'data' as the document ID.
const docPath = `sessions/${sessionId}/summary/data`;
return doc(db, docPath).withConverter(summaryConverter);
}
async function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) {
const now = Timestamp.now();
const summaryData = {
uid, // To know who generated the summary
session_id: sessionId,
generated_at: now,
model,
text,
tldr,
bullet_json,
action_json,
updated_at: now,
};
// The converter attached to summaryDocRef will handle encryption via its `toFirestore` method.
// Manual encryption was removed to fix the double-encryption bug.
const docRef = summaryDocRef(sessionId);
await setDoc(docRef, summaryData, { merge: true });
return { changes: 1 };
}
async function getSummaryBySessionId(sessionId) {
const docRef = summaryDocRef(sessionId);
const docSnap = await getDoc(docRef);
return docSnap.exists() ? docSnap.data() : null;
}
module.exports = {
saveSummary,
getSummaryBySessionId,
};

View File

@ -1,5 +1,23 @@
const summaryRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService');
module.exports = { function getBaseRepository() {
...summaryRepository, const user = authService.getCurrentUser();
}; if (user && user.isLoggedIn) {
return firebaseRepository;
}
return sqliteRepository;
}
const summaryRepositoryAdapter = {
saveSummary: ({ sessionId, tldr, text, bullet_json, action_json, model }) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model });
},
getSummaryBySessionId: (sessionId) => {
return getBaseRepository().getSummaryBySessionId(sessionId);
}
};
module.exports = summaryRepositoryAdapter;

View File

@ -1,6 +1,7 @@
const sqliteClient = require('../../../../common/services/sqliteClient'); const sqliteClient = require('../../../common/services/sqliteClient');
function saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) { function saveSummary({ uid, sessionId, tldr, text, bullet_json, action_json, model = 'unknown' }) {
// uid is ignored in the SQLite implementation
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const db = sqliteClient.getDb(); const db = sqliteClient.getDb();

View File

@ -1,10 +1,9 @@
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { getSystemPrompt } = require('../../../common/prompts/promptBuilder.js'); const { getSystemPrompt } = require('../../common/prompts/promptBuilder.js');
const { createLLM } = require('../../../common/ai/factory'); const { createLLM } = require('../../common/ai/factory');
const authService = require('../../../common/services/authService'); const sessionRepository = require('../../common/repositories/session');
const sessionRepository = require('../../../common/repositories/session');
const summaryRepository = require('./repositories'); const summaryRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, getCurrentModelInfo } = require('../../../electron/windowManager'); const modelStateService = require('../../common/services/modelStateService');
class SummaryService { class SummaryService {
constructor() { constructor() {
@ -27,29 +26,13 @@ class SummaryService {
this.currentSessionId = 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) { sendToRenderer(channel, data) {
BrowserWindow.getAllWindows().forEach(win => { const { windowPool } = require('../../../window/windowManager');
if (!win.isDestroyed()) { const listenWindow = windowPool?.get('listen');
win.webContents.send(channel, data);
} if (listenWindow && !listenWindow.isDestroyed()) {
}); listenWindow.webContents.send(channel, data);
}
} }
addConversationTurn(speaker, text) { addConversationTurn(speaker, text) {
@ -115,7 +98,7 @@ Please build upon this context while analyzing the new conversation segments.
await sessionRepository.touch(this.currentSessionId); await sessionRepository.touch(this.currentSessionId);
} }
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); const modelInfo = await modelStateService.getCurrentModelInfo('llm');
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key is not configured.'); throw new Error('AI model or API key is not configured.');
} }
@ -321,25 +304,20 @@ Keep all points concise and build upon previous analysis if provided.`,
*/ */
async triggerAnalysisIfNeeded() { async triggerAnalysisIfNeeded() {
if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) { if (this.conversationHistory.length >= 5 && this.conversationHistory.length % 5 === 0) {
console.log(`🚀 Triggering analysis (non-blocking) - ${this.conversationHistory.length} conversation texts accumulated`); console.log(`Triggering analysis - ${this.conversationHistory.length} conversation texts accumulated`);
this.makeOutlineAndRequests(this.conversationHistory) const data = await this.makeOutlineAndRequests(this.conversationHistory);
.then(data => { if (data) {
if (data) { console.log('Sending structured data to renderer');
console.log('📤 Sending structured data to renderer'); this.sendToRenderer('summary-update', data);
this.sendToRenderer('update-structured-data', data);
// Notify callback
// Notify callback if (this.onAnalysisComplete) {
if (this.onAnalysisComplete) { this.onAnalysisComplete(data);
this.onAnalysisComplete(data); }
} } else {
} else { console.log('No analysis data returned');
console.log('❌ No analysis data returned from non-blocking call'); }
}
})
.catch(error => {
console.error('❌ Error in non-blocking analysis:', error);
});
} }
} }

View File

@ -0,0 +1,148 @@
const { collection, doc, addDoc, getDoc, getDocs, updateDoc, deleteDoc, query, where, orderBy } = require('firebase/firestore');
const { getFirestoreInstance } = require('../../common/services/firebaseClient');
const { createEncryptedConverter } = require('../../common/repositories/firestoreConverter');
const encryptionService = require('../../common/services/encryptionService');
const userPresetConverter = createEncryptedConverter(['prompt', 'title']);
const defaultPresetConverter = {
toFirestore: (data) => data,
fromFirestore: (snapshot, options) => {
const data = snapshot.data(options);
return { ...data, id: snapshot.id };
}
};
function userPresetsCol() {
const db = getFirestoreInstance();
return collection(db, 'prompt_presets').withConverter(userPresetConverter);
}
function defaultPresetsCol() {
const db = getFirestoreInstance();
return collection(db, 'defaults/v1/prompt_presets').withConverter(defaultPresetConverter);
}
async function getPresets(uid) {
const userPresetsQuery = query(userPresetsCol(), where('uid', '==', uid));
const defaultPresetsQuery = query(defaultPresetsCol());
const [userSnapshot, defaultSnapshot] = await Promise.all([
getDocs(userPresetsQuery),
getDocs(defaultPresetsQuery)
]);
const presets = [
...defaultSnapshot.docs.map(d => d.data()),
...userSnapshot.docs.map(d => d.data())
];
return presets.sort((a, b) => {
if (a.is_default && !b.is_default) return -1;
if (!a.is_default && b.is_default) return 1;
return a.title.localeCompare(b.title);
});
}
async function getPresetTemplates() {
const q = query(defaultPresetsCol(), orderBy('title', 'asc'));
const snapshot = await getDocs(q);
return snapshot.docs.map(doc => doc.data());
}
async function createPreset({ uid, title, prompt }) {
const now = Math.floor(Date.now() / 1000);
const newPreset = {
uid: uid,
title,
prompt,
is_default: 0,
created_at: now,
};
const docRef = await addDoc(userPresetsCol(), newPreset);
return { id: docRef.id };
}
async function updatePreset(id, { title, prompt }, uid) {
const docRef = doc(userPresetsCol(), id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) {
throw new Error("Preset not found or permission denied to update.");
}
const updates = {};
if (title !== undefined) {
updates.title = encryptionService.encrypt(title);
}
if (prompt !== undefined) {
updates.prompt = encryptionService.encrypt(prompt);
}
updates.updated_at = Math.floor(Date.now() / 1000);
await updateDoc(docRef, updates);
return { changes: 1 };
}
async function deletePreset(id, uid) {
const docRef = doc(userPresetsCol(), id);
const docSnap = await getDoc(docRef);
if (!docSnap.exists() || docSnap.data().uid !== uid || docSnap.data().is_default) {
throw new Error("Preset not found or permission denied to delete.");
}
await deleteDoc(docRef);
return { changes: 1 };
}
async function getAutoUpdate(uid) {
// Assume users are stored in a "users" collection, and auto_update_enabled is a field
const userDocRef = doc(getFirestoreInstance(), 'users', uid);
try {
const userSnap = await getDoc(userDocRef);
if (userSnap.exists()) {
const data = userSnap.data();
if (typeof data.auto_update_enabled !== 'undefined') {
console.log('Firebase: Auto update setting found:', data.auto_update_enabled);
return !!data.auto_update_enabled;
} else {
// Field does not exist, just return default
return true;
}
} else {
// User doc does not exist, just return default
return true;
}
} catch (error) {
console.error('Firebase: Error getting auto_update_enabled setting:', error);
return true; // fallback to enabled
}
}
async function setAutoUpdate(uid, isEnabled) {
const userDocRef = doc(getFirestoreInstance(), 'users', uid);
try {
const userSnap = await getDoc(userDocRef);
if (userSnap.exists()) {
await updateDoc(userDocRef, { auto_update_enabled: !!isEnabled });
}
// If user doc does not exist, do nothing (no creation)
return { success: true };
} catch (error) {
console.error('Firebase: Error setting auto-update:', error);
return { success: false, error: error.message };
}
}
module.exports = {
getPresets,
getPresetTemplates,
createPreset,
updatePreset,
deletePreset,
getAutoUpdate,
setAutoUpdate,
};

View File

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

View File

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

View File

@ -1,9 +1,12 @@
const { ipcMain, BrowserWindow } = require('electron'); const { ipcMain, BrowserWindow } = require('electron');
const Store = require('electron-store'); const Store = require('electron-store');
const authService = require('../../common/services/authService'); const authService = require('../common/services/authService');
const userRepository = require('../../common/repositories/user');
const settingsRepository = require('./repositories'); const settingsRepository = require('./repositories');
const { getStoredApiKey, getStoredProvider, windowPool } = require('../../electron/windowManager'); const { getStoredApiKey, getStoredProvider, windowPool } = require('../../window/windowManager');
// New imports for common services
const modelStateService = require('../common/services/modelStateService');
const localAIManager = require('../common/services/localAIManager');
const store = new Store({ const store = new Store({
name: 'pickle-glass-settings', name: 'pickle-glass-settings',
@ -20,6 +23,52 @@ const NOTIFICATION_CONFIG = {
RETRY_BASE_DELAY: 1000, // exponential backoff base (ms) RETRY_BASE_DELAY: 1000, // exponential backoff base (ms)
}; };
// New facade functions for model state management
async function getModelSettings() {
try {
const [config, storedKeys, selectedModels, availableLlm, availableStt] = await Promise.all([
modelStateService.getProviderConfig(),
modelStateService.getAllApiKeys(),
modelStateService.getSelectedModels(),
modelStateService.getAvailableModels('llm'),
modelStateService.getAvailableModels('stt')
]);
return { success: true, data: { config, storedKeys, availableLlm, availableStt, selectedModels } };
} catch (error) {
console.error('[SettingsService] Error getting model settings:', error);
return { success: false, error: error.message };
}
}
async function clearApiKey(provider) {
const success = await modelStateService.handleRemoveApiKey(provider);
return { success };
}
async function setSelectedModel(type, modelId) {
const success = await modelStateService.handleSetSelectedModel(type, modelId);
return { success };
}
// LocalAI facade functions
async function getOllamaStatus() {
return localAIManager.getServiceStatus('ollama');
}
async function ensureOllamaReady() {
const status = await localAIManager.getServiceStatus('ollama');
if (!status.installed || !status.running) {
await localAIManager.startService('ollama');
}
return { success: true };
}
async function shutdownOllama() {
return localAIManager.stopService('ollama');
}
// window targeting system // window targeting system
class WindowNotificationManager { class WindowNotificationManager {
constructor() { constructor() {
@ -208,13 +257,8 @@ async function saveSettings(settings) {
async function getPresets() { async function getPresets() {
try { try {
const uid = authService.getCurrentUserId(); // The adapter now handles which presets to return based on login state.
if (!uid) { const presets = await settingsRepository.getPresets();
// Logged out users only see default presets
return await settingsRepository.getPresetTemplates();
}
const presets = await settingsRepository.getPresets(uid);
return presets; return presets;
} catch (error) { } catch (error) {
console.error('[SettingsService] Error getting presets:', error); console.error('[SettingsService] Error getting presets:', error);
@ -234,12 +278,8 @@ async function getPresetTemplates() {
async function createPreset(title, prompt) { async function createPreset(title, prompt) {
try { try {
const uid = authService.getCurrentUserId(); // The adapter injects the UID.
if (!uid) { const result = await settingsRepository.createPreset({ title, prompt });
throw new Error("User not logged in, cannot create preset.");
}
const result = await settingsRepository.createPreset({ uid, title, prompt });
windowNotificationManager.notifyRelevantWindows('presets-updated', { windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'created', action: 'created',
@ -256,12 +296,8 @@ async function createPreset(title, prompt) {
async function updatePreset(id, title, prompt) { async function updatePreset(id, title, prompt) {
try { try {
const uid = authService.getCurrentUserId(); // The adapter injects the UID.
if (!uid) { await settingsRepository.updatePreset(id, { title, prompt });
throw new Error("User not logged in, cannot update preset.");
}
await settingsRepository.updatePreset(id, { title, prompt }, uid);
windowNotificationManager.notifyRelevantWindows('presets-updated', { windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'updated', action: 'updated',
@ -278,12 +314,8 @@ async function updatePreset(id, title, prompt) {
async function deletePreset(id) { async function deletePreset(id) {
try { try {
const uid = authService.getCurrentUserId(); // The adapter injects the UID.
if (!uid) { await settingsRepository.deletePreset(id);
throw new Error("User not logged in, cannot delete preset.");
}
await settingsRepository.deletePreset(id, uid);
windowNotificationManager.notifyRelevantWindows('presets-updated', { windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'deleted', action: 'deleted',
@ -299,27 +331,13 @@ async function deletePreset(id) {
async function saveApiKey(apiKey, provider = 'openai') { async function saveApiKey(apiKey, provider = 'openai') {
try { try {
const uid = authService.getCurrentUserId(); // Use ModelStateService as the single source of truth for API key management
if (!uid) { const modelStateService = global.modelStateService;
// For non-logged-in users, save to local storage if (!modelStateService) {
const { app } = require('electron'); throw new Error('ModelStateService not initialized');
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 modelStateService.setApiKey(provider, apiKey);
await userRepository.saveApiKey(apiKey, uid, provider);
// Notify windows // Notify windows
BrowserWindow.getAllWindows().forEach(win => { BrowserWindow.getAllWindows().forEach(win => {
@ -337,17 +355,16 @@ async function saveApiKey(apiKey, provider = 'openai') {
async function removeApiKey() { async function removeApiKey() {
try { try {
const uid = authService.getCurrentUserId(); // Use ModelStateService as the single source of truth for API key management
if (!uid) { const modelStateService = global.modelStateService;
// For non-logged-in users, remove from local storage if (!modelStateService) {
const { app } = require('electron'); throw new Error('ModelStateService not initialized');
const Store = require('electron-store'); }
const store = new Store();
store.delete('apiKey'); // Remove all API keys for all providers
store.delete('provider'); const providers = ['openai', 'anthropic', 'gemini', 'ollama', 'whisper'];
} else { for (const provider of providers) {
// For logged-in users, remove from database await modelStateService.removeApiKey(provider);
await userRepository.saveApiKey(null, uid, null);
} }
// Notify windows // Notify windows
@ -357,6 +374,7 @@ async function removeApiKey() {
} }
}); });
console.log('[SettingsService] API key removed for all providers');
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[SettingsService] Error removing API key:', error); console.error('[SettingsService] Error removing API key:', error);
@ -383,52 +401,29 @@ async function updateContentProtection(enabled) {
} }
} }
async function getAutoUpdateSetting() {
try {
return settingsRepository.getAutoUpdate();
} catch (error) {
console.error('[SettingsService] Error getting auto update setting:', error);
return true; // Fallback to enabled
}
}
async function setAutoUpdateSetting(isEnabled) {
try {
await settingsRepository.setAutoUpdate(isEnabled);
return { success: true };
} catch (error) {
console.error('[SettingsService] Error setting auto update setting:', error);
return { success: false, error: error.message };
}
}
function initialize() { function initialize() {
// cleanup // cleanup
windowNotificationManager.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.'); console.log('[SettingsService] Initialized and ready.');
} }
@ -459,4 +454,14 @@ module.exports = {
saveApiKey, saveApiKey,
removeApiKey, removeApiKey,
updateContentProtection, updateContentProtection,
getAutoUpdateSetting,
setAutoUpdateSetting,
// Model settings facade
getModelSettings,
clearApiKey,
setSelectedModel,
// Ollama facade
getOllamaStatus,
ensureOllamaReady,
shutdownOllama
}; };

View File

@ -0,0 +1 @@
module.exports = require('./sqlite.repository');

View File

@ -0,0 +1,48 @@
const sqliteClient = require('../../common/services/sqliteClient');
const crypto = require('crypto');
function getAllKeybinds() {
const db = sqliteClient.getDb();
const query = 'SELECT * FROM shortcuts';
try {
return db.prepare(query).all();
} catch (error) {
console.error(`[DB] Failed to get keybinds:`, error);
return [];
}
}
function upsertKeybinds(keybinds) {
if (!keybinds || keybinds.length === 0) return;
const db = sqliteClient.getDb();
const upsert = db.transaction((items) => {
const query = `
INSERT INTO shortcuts (action, accelerator, created_at)
VALUES (@action, @accelerator, @created_at)
ON CONFLICT(action) DO UPDATE SET
accelerator = excluded.accelerator;
`;
const insert = db.prepare(query);
for (const item of items) {
insert.run({
action: item.action,
accelerator: item.accelerator,
created_at: Math.floor(Date.now() / 1000)
});
}
});
try {
upsert(keybinds);
} catch (error) {
console.error('[DB] Failed to upsert keybinds:', error);
throw error;
}
}
module.exports = {
getAllKeybinds,
upsertKeybinds
};

View File

@ -0,0 +1,288 @@
const { globalShortcut, screen } = require('electron');
const shortcutsRepository = require('./repositories');
const internalBridge = require('../../bridge/internalBridge');
const askService = require('../ask/askService');
class ShortcutsService {
constructor() {
this.lastVisibleWindows = new Set(['header']);
this.mouseEventsIgnored = false;
this.windowPool = null;
this.allWindowVisibility = true;
}
initialize(windowPool) {
this.windowPool = windowPool;
internalBridge.on('reregister-shortcuts', () => {
console.log('[ShortcutsService] Reregistering shortcuts due to header state change.');
this.registerShortcuts();
});
console.log('[ShortcutsService] Initialized with dependencies and event listener.');
}
async openShortcutSettingsWindow () {
const keybinds = await this.loadKeybinds();
const shortcutWin = this.windowPool.get('shortcut-settings');
shortcutWin.webContents.send('shortcut:loadShortcuts', keybinds);
globalShortcut.unregisterAll();
internalBridge.emit('window:requestVisibility', { name: 'shortcut-settings', visible: true });
console.log('[ShortcutsService] Shortcut settings window opened.');
return { success: true };
}
async closeShortcutSettingsWindow () {
await this.registerShortcuts();
internalBridge.emit('window:requestVisibility', { name: 'shortcut-settings', visible: false });
console.log('[ShortcutsService] Shortcut settings window closed.');
return { success: true };
}
async handleSaveShortcuts(newKeybinds) {
try {
await this.saveKeybinds(newKeybinds);
await this.closeShortcutSettingsWindow();
return { success: true };
} catch (error) {
console.error("Failed to save shortcuts:", error);
await this.closeShortcutSettingsWindow();
return { success: false, error: error.message };
}
}
async handleRestoreDefaults() {
const defaults = this.getDefaultKeybinds();
return defaults;
}
getDefaultKeybinds() {
const isMac = process.platform === 'darwin';
return {
moveUp: isMac ? 'Cmd+Up' : 'Ctrl+Up',
moveDown: isMac ? 'Cmd+Down' : 'Ctrl+Down',
moveLeft: isMac ? 'Cmd+Left' : 'Ctrl+Left',
moveRight: isMac ? 'Cmd+Right' : 'Ctrl+Right',
toggleVisibility: isMac ? 'Cmd+\\' : 'Ctrl+\\',
toggleClickThrough: isMac ? 'Cmd+M' : 'Ctrl+M',
nextStep: isMac ? 'Cmd+Enter' : 'Ctrl+Enter',
manualScreenshot: isMac ? 'Cmd+Shift+S' : 'Ctrl+Shift+S',
previousResponse: isMac ? 'Cmd+[' : 'Ctrl+[',
nextResponse: isMac ? 'Cmd+]' : 'Ctrl+]',
scrollUp: isMac ? 'Cmd+Shift+Up' : 'Ctrl+Shift+Up',
scrollDown: isMac ? 'Cmd+Shift+Down' : 'Ctrl+Shift+Down',
};
}
async loadKeybinds() {
let keybindsArray = await shortcutsRepository.getAllKeybinds();
if (!keybindsArray || keybindsArray.length === 0) {
console.log(`[Shortcuts] No keybinds found. Loading defaults.`);
const defaults = this.getDefaultKeybinds();
await this.saveKeybinds(defaults);
return defaults;
}
const keybinds = {};
keybindsArray.forEach(k => {
keybinds[k.action] = k.accelerator;
});
const defaults = this.getDefaultKeybinds();
let needsUpdate = false;
for (const action in defaults) {
if (!keybinds[action]) {
keybinds[action] = defaults[action];
needsUpdate = true;
}
}
if (needsUpdate) {
console.log('[Shortcuts] Updating missing keybinds with defaults.');
await this.saveKeybinds(keybinds);
}
return keybinds;
}
async saveKeybinds(newKeybinds) {
const keybindsToSave = [];
for (const action in newKeybinds) {
if (Object.prototype.hasOwnProperty.call(newKeybinds, action)) {
keybindsToSave.push({
action: action,
accelerator: newKeybinds[action],
});
}
}
await shortcutsRepository.upsertKeybinds(keybindsToSave);
console.log(`[Shortcuts] Saved keybinds.`);
}
async toggleAllWindowsVisibility() {
const targetVisibility = !this.allWindowVisibility;
internalBridge.emit('window:requestToggleAllWindowsVisibility', {
targetVisibility: targetVisibility
});
if (this.allWindowVisibility) {
await this.registerShortcuts(true);
} else {
await this.registerShortcuts();
}
this.allWindowVisibility = !this.allWindowVisibility;
}
async registerShortcuts(registerOnlyToggleVisibility = false) {
if (!this.windowPool) {
console.error('[Shortcuts] Service not initialized. Cannot register shortcuts.');
return;
}
const keybinds = await this.loadKeybinds();
globalShortcut.unregisterAll();
const header = this.windowPool.get('header');
const mainWindow = header;
const sendToRenderer = (channel, ...args) => {
this.windowPool.forEach(win => {
if (win && !win.isDestroyed()) {
try {
win.webContents.send(channel, ...args);
} catch (e) {
// Ignore errors for destroyed windows
}
}
});
};
sendToRenderer('shortcuts-updated', keybinds);
if (registerOnlyToggleVisibility) {
if (keybinds.toggleVisibility) {
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
}
console.log('[Shortcuts] registerOnlyToggleVisibility, only toggleVisibility shortcut is registered.');
return;
}
// --- Hardcoded shortcuts ---
const isMac = process.platform === 'darwin';
const modifier = isMac ? 'Cmd' : 'Ctrl';
// Monitor switching
const displays = screen.getAllDisplays();
if (displays.length > 1) {
displays.forEach((display, index) => {
const key = `${modifier}+Shift+${index + 1}`;
globalShortcut.register(key, () => internalBridge.emit('window:moveToDisplay', { displayId: display.id }));
});
}
// Edge snapping
const edgeDirections = [
{ key: `${modifier}+Shift+Left`, direction: 'left' },
{ key: `${modifier}+Shift+Right`, direction: 'right' },
];
edgeDirections.forEach(({ key, direction }) => {
globalShortcut.register(key, () => {
if (header && header.isVisible()) internalBridge.emit('window:moveToEdge', { direction });
});
});
// --- User-configurable shortcuts ---
if (header?.currentHeaderState === 'apikey') {
if (keybinds.toggleVisibility) {
globalShortcut.register(keybinds.toggleVisibility, () => this.toggleAllWindowsVisibility());
}
console.log('[Shortcuts] ApiKeyHeader is active, only toggleVisibility shortcut is registered.');
return;
}
for (const action in keybinds) {
const accelerator = keybinds[action];
if (!accelerator) continue;
let callback;
switch(action) {
case 'toggleVisibility':
callback = () => this.toggleAllWindowsVisibility();
break;
case 'nextStep':
callback = () => askService.toggleAskButton(true);
break;
case 'scrollUp':
callback = () => {
const askWindow = this.windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
askWindow.webContents.send('scroll-response-up');
}
};
break;
case 'scrollDown':
callback = () => {
const askWindow = this.windowPool.get('ask');
if (askWindow && !askWindow.isDestroyed() && askWindow.isVisible()) {
askWindow.webContents.send('scroll-response-down');
}
};
break;
case 'moveUp':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'up' }); };
break;
case 'moveDown':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'down' }); };
break;
case 'moveLeft':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'left' }); };
break;
case 'moveRight':
callback = () => { if (header && header.isVisible()) internalBridge.emit('window:moveStep', { direction: 'right' }); };
break;
case 'toggleClickThrough':
callback = () => {
this.mouseEventsIgnored = !this.mouseEventsIgnored;
if(mainWindow && !mainWindow.isDestroyed()){
mainWindow.setIgnoreMouseEvents(this.mouseEventsIgnored, { forward: true });
mainWindow.webContents.send('click-through-toggled', this.mouseEventsIgnored);
}
};
break;
case 'manualScreenshot':
callback = () => {
if(mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.executeJavaScript('window.captureManualScreenshot && window.captureManualScreenshot();');
}
};
break;
case 'previousResponse':
callback = () => sendToRenderer('navigate-previous-response');
break;
case 'nextResponse':
callback = () => sendToRenderer('navigate-next-response');
break;
}
if (callback) {
try {
globalShortcut.register(accelerator, callback);
} catch(e) {
console.error(`[Shortcuts] Failed to register shortcut for "${action}" (${accelerator}):`, e.message);
}
}
}
console.log('[Shortcuts] All shortcuts have been registered.');
}
unregisterAll() {
globalShortcut.unregisterAll();
console.log('[Shortcuts] All shortcuts have been unregistered.');
}
}
const shortcutsService = new ShortcutsService();
module.exports = shortcutsService;

View File

@ -12,11 +12,11 @@ if (require('electron-squirrel-startup')) {
} }
const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron'); const { app, BrowserWindow, shell, ipcMain, dialog, desktopCapturer, session } = require('electron');
const { createWindows } = require('./electron/windowManager.js'); const { createWindows } = require('./window/windowManager.js');
const ListenService = require('./features/listen/listenService'); const listenService = require('./features/listen/listenService');
const { initializeFirebase } = require('./common/services/firebaseClient'); const { initializeFirebase } = require('./features/common/services/firebaseClient');
const databaseInitializer = require('./common/services/databaseInitializer'); const databaseInitializer = require('./features/common/services/databaseInitializer');
const authService = require('./common/services/authService'); const authService = require('./features/common/services/authService');
const path = require('node:path'); const path = require('node:path');
const express = require('express'); const express = require('express');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
@ -24,21 +24,24 @@ const { autoUpdater } = require('electron-updater');
const { EventEmitter } = require('events'); const { EventEmitter } = require('events');
const askService = require('./features/ask/askService'); const askService = require('./features/ask/askService');
const settingsService = require('./features/settings/settingsService'); const settingsService = require('./features/settings/settingsService');
const sessionRepository = require('./common/repositories/session'); const sessionRepository = require('./features/common/repositories/session');
const ModelStateService = require('./common/services/modelStateService'); const modelStateService = require('./features/common/services/modelStateService');
const featureBridge = require('./bridge/featureBridge');
const windowBridge = require('./bridge/windowBridge');
// Global variables
const eventBridge = new EventEmitter(); const eventBridge = new EventEmitter();
let WEB_PORT = 3000; let WEB_PORT = 3000;
let isShuttingDown = false; // Flag to prevent infinite shutdown loop
const listenService = new ListenService();
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
global.listenService = listenService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService; global.modelStateService = modelStateService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
// Import and initialize OllamaService
const ollamaService = require('./features/common/services/ollamaService');
const ollamaModelRepository = require('./features/common/repositories/ollamaModel');
// Native deep link handling - cross-platform compatible // Native deep link handling - cross-platform compatible
let pendingDeepLinkUrl = null; let pendingDeepLinkUrl = null;
@ -116,7 +119,7 @@ function setupProtocolHandling() {
} }
function focusMainWindow() { function focusMainWindow() {
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
if (windowPool) { if (windowPool) {
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header && !header.isDestroyed()) { if (header && !header.isDestroyed()) {
@ -186,19 +189,31 @@ app.whenReady().then(async () => {
await databaseInitializer.initialize(); await databaseInitializer.initialize();
console.log('>>> [index.js] Database initialized successfully'); console.log('>>> [index.js] Database initialized successfully');
// Clean up zombie sessions from previous runs first // Clean up zombie sessions from previous runs first - MOVED TO authService
sessionRepository.endAllActiveSessions(); // sessionRepository.endAllActiveSessions();
authService.initialize(); await authService.initialize();
//////// after_modelStateService //////// //////// after_modelStateService ////////
modelStateService.initialize(); await modelStateService.initialize();
//////// after_modelStateService //////// //////// after_modelStateService ////////
listenService.setupIpcHandlers(); featureBridge.initialize(); // 추가: featureBridge 초기화
askService.initialize(); windowBridge.initialize();
settingsService.initialize(); setupWebDataHandlers();
setupGeneralIpcHandlers();
// Initialize Ollama models in database
await ollamaModelRepository.initializeDefaultModels();
// Auto warm-up selected Ollama model in background (non-blocking)
setTimeout(async () => {
try {
console.log('[index.js] Starting background Ollama model warm-up...');
await ollamaService.autoWarmUpSelectedModel();
} catch (error) {
console.log('[index.js] Background warm-up failed (non-critical):', error.message);
}
}, 2000); // Wait 2 seconds after app start
// Start web server and create windows ONLY after all initializations are successful // Start web server and create windows ONLY after all initializations are successful
WEB_PORT = await startWebStack(); WEB_PORT = await startWebStack();
@ -215,6 +230,7 @@ app.whenReady().then(async () => {
); );
} }
// initAutoUpdater should be called after auth is initialized
initAutoUpdater(); initAutoUpdater();
// Process any pending deep link after everything is initialized // Process any pending deep link after everything is initialized
@ -225,18 +241,71 @@ app.whenReady().then(async () => {
} }
}); });
app.on('window-all-closed', () => { app.on('before-quit', async (event) => {
listenService.stopMacOSAudioCapture(); // Prevent infinite loop by checking if shutdown is already in progress
if (process.platform !== 'darwin') { if (isShuttingDown) {
app.quit(); console.log('[Shutdown] 🔄 Shutdown already in progress, allowing quit...');
return;
}
console.log('[Shutdown] App is about to quit. Starting graceful shutdown...');
// Set shutdown flag to prevent infinite loop
isShuttingDown = true;
// Prevent immediate quit to allow graceful shutdown
event.preventDefault();
try {
// 1. Stop audio capture first (immediate)
await listenService.closeSession();
console.log('[Shutdown] Audio capture stopped');
// 2. End all active sessions (database operations) - with error handling
try {
await sessionRepository.endAllActiveSessions();
console.log('[Shutdown] Active sessions ended');
} catch (dbError) {
console.warn('[Shutdown] Could not end active sessions (database may be closed):', dbError.message);
}
// 3. Shutdown Ollama service (potentially time-consuming)
console.log('[Shutdown] shutting down Ollama service...');
const ollamaShutdownSuccess = await Promise.race([
ollamaService.shutdown(false), // Graceful shutdown
new Promise(resolve => setTimeout(() => resolve(false), 8000)) // 8s timeout
]);
if (ollamaShutdownSuccess) {
console.log('[Shutdown] Ollama service shut down gracefully');
} else {
console.log('[Shutdown] Ollama shutdown timeout, forcing...');
// Force shutdown if graceful failed
try {
await ollamaService.shutdown(true);
} catch (forceShutdownError) {
console.warn('[Shutdown] Force shutdown also failed:', forceShutdownError.message);
}
}
// 4. Close database connections (final cleanup)
try {
databaseInitializer.close();
console.log('[Shutdown] Database connections closed');
} catch (closeError) {
console.warn('[Shutdown] Error closing database:', closeError.message);
}
console.log('[Shutdown] Graceful shutdown completed successfully');
} catch (error) {
console.error('[Shutdown] Error during graceful shutdown:', error);
// Continue with shutdown even if there were errors
} finally {
// Actually quit the app now
console.log('[Shutdown] Exiting application...');
app.exit(0); // Use app.exit() instead of app.quit() to force quit
} }
});
app.on('before-quit', async () => {
console.log('[Shutdown] App is about to quit.');
listenService.stopMacOSAudioCapture();
await sessionRepository.endAllActiveSessions();
databaseInitializer.close();
}); });
app.on('activate', () => { app.on('activate', () => {
@ -245,146 +314,120 @@ app.on('activate', () => {
} }
}); });
function setupGeneralIpcHandlers() {
const userRepository = require('./common/repositories/user');
const presetRepository = require('./common/repositories/preset');
ipcMain.handle('save-api-key', (event, apiKey) => {
try {
userRepository.saveApiKey(apiKey, authService.getCurrentUserId());
BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('api-key-updated');
});
return { success: true };
} catch (error) {
console.error('IPC: Failed to save API key:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-user-presets', () => {
return presetRepository.getPresets(authService.getCurrentUserId());
});
ipcMain.handle('get-preset-templates', () => {
return presetRepository.getPresetTemplates();
});
ipcMain.handle('start-firebase-auth', async () => {
try {
const authUrl = `http://localhost:${WEB_PORT}/login?mode=electron`;
console.log(`[Auth] Opening Firebase auth URL in browser: ${authUrl}`);
await shell.openExternal(authUrl);
return { success: true };
} catch (error) {
console.error('[Auth] Failed to open Firebase auth URL:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('get-web-url', () => {
return process.env.pickleglass_WEB_URL || 'http://localhost:3000';
});
ipcMain.handle('get-current-user', () => {
return authService.getCurrentUser();
});
// --- Web UI Data Handlers (New) ---
setupWebDataHandlers();
}
function setupWebDataHandlers() { function setupWebDataHandlers() {
const sessionRepository = require('./common/repositories/session'); const sessionRepository = require('./features/common/repositories/session');
const sttRepository = require('./features/listen/stt/repositories'); const sttRepository = require('./features/listen/stt/repositories');
const summaryRepository = require('./features/listen/summary/repositories'); const summaryRepository = require('./features/listen/summary/repositories');
const askRepository = require('./features/ask/repositories'); const askRepository = require('./features/ask/repositories');
const userRepository = require('./common/repositories/user'); const userRepository = require('./features/common/repositories/user');
const presetRepository = require('./common/repositories/preset'); const presetRepository = require('./features/common/repositories/preset');
const handleRequest = (channel, responseChannel, payload) => { const handleRequest = async (channel, responseChannel, payload) => {
let result; let result;
const currentUserId = authService.getCurrentUserId(); // const currentUserId = authService.getCurrentUserId(); // No longer needed here
try { try {
switch (channel) { switch (channel) {
// SESSION // SESSION
case 'get-sessions': case 'get-sessions':
result = sessionRepository.getAllByUserId(currentUserId); // Adapter injects UID
result = await sessionRepository.getAllByUserId();
break; break;
case 'get-session-details': case 'get-session-details':
const session = sessionRepository.getById(payload); const session = await sessionRepository.getById(payload);
if (!session) { if (!session) {
result = null; result = null;
break; break;
} }
const transcripts = sttRepository.getAllTranscriptsBySessionId(payload); const [transcripts, ai_messages, summary] = await Promise.all([
const ai_messages = askRepository.getAllAiMessagesBySessionId(payload); sttRepository.getAllTranscriptsBySessionId(payload),
const summary = summaryRepository.getSummaryBySessionId(payload); askRepository.getAllAiMessagesBySessionId(payload),
summaryRepository.getSummaryBySessionId(payload)
]);
result = { session, transcripts, ai_messages, summary }; result = { session, transcripts, ai_messages, summary };
break; break;
case 'delete-session': case 'delete-session':
result = sessionRepository.deleteWithRelatedData(payload); result = await sessionRepository.deleteWithRelatedData(payload);
break; break;
case 'create-session': case 'create-session':
const id = sessionRepository.create(currentUserId, 'ask'); // Adapter injects UID
if (payload.title) { const id = await sessionRepository.create('ask');
sessionRepository.updateTitle(id, payload.title); if (payload && payload.title) {
await sessionRepository.updateTitle(id, payload.title);
} }
result = { id }; result = { id };
break; break;
// USER // USER
case 'get-user-profile': case 'get-user-profile':
result = userRepository.getById(currentUserId); // Adapter injects UID
result = await userRepository.getById();
break; break;
case 'update-user-profile': case 'update-user-profile':
result = userRepository.update({ uid: currentUserId, ...payload }); // Adapter injects UID
result = await userRepository.update(payload);
break; break;
case 'find-or-create-user': case 'find-or-create-user':
result = userRepository.findOrCreate(payload); result = await userRepository.findOrCreate(payload);
break; break;
case 'save-api-key': case 'save-api-key':
result = userRepository.saveApiKey(payload, currentUserId); // Use ModelStateService as the single source of truth for API key management
result = await modelStateService.setApiKey(payload.provider, payload.apiKey);
break; break;
case 'check-api-key-status': case 'check-api-key-status':
const user = userRepository.getById(currentUserId); // Use ModelStateService to check API key status
result = { hasApiKey: !!user?.api_key && user.api_key.length > 0 }; const hasApiKey = await modelStateService.hasValidApiKey();
result = { hasApiKey };
break; break;
case 'delete-account': case 'delete-account':
result = userRepository.deleteById(currentUserId); // Adapter injects UID
result = await userRepository.deleteById();
break; break;
// PRESET // PRESET
case 'get-presets': case 'get-presets':
result = presetRepository.getPresets(currentUserId); // Adapter injects UID
result = await presetRepository.getPresets();
break; break;
case 'create-preset': case 'create-preset':
result = presetRepository.create({ ...payload, uid: currentUserId }); // Adapter injects UID
result = await presetRepository.create(payload);
settingsService.notifyPresetUpdate('created', result.id, payload.title); settingsService.notifyPresetUpdate('created', result.id, payload.title);
break; break;
case 'update-preset': case 'update-preset':
result = presetRepository.update(payload.id, payload.data, currentUserId); // Adapter injects UID
result = await presetRepository.update(payload.id, payload.data);
settingsService.notifyPresetUpdate('updated', payload.id, payload.data.title); settingsService.notifyPresetUpdate('updated', payload.id, payload.data.title);
break; break;
case 'delete-preset': case 'delete-preset':
result = presetRepository.delete(payload, currentUserId); // Adapter injects UID
result = await presetRepository.delete(payload);
settingsService.notifyPresetUpdate('deleted', payload); settingsService.notifyPresetUpdate('deleted', payload);
break; break;
// BATCH // BATCH
case 'get-batch-data': case 'get-batch-data':
const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions']; const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions'];
const batchResult = {}; const promises = {};
if (includes.includes('profile')) { if (includes.includes('profile')) {
batchResult.profile = userRepository.getById(currentUserId); // Adapter injects UID
promises.profile = userRepository.getById();
} }
if (includes.includes('presets')) { if (includes.includes('presets')) {
batchResult.presets = presetRepository.getPresets(currentUserId); // Adapter injects UID
promises.presets = presetRepository.getPresets();
} }
if (includes.includes('sessions')) { if (includes.includes('sessions')) {
batchResult.sessions = sessionRepository.getAllByUserId(currentUserId); // Adapter injects UID
promises.sessions = sessionRepository.getAllByUserId();
} }
const batchResult = {};
const promiseResults = await Promise.all(Object.values(promises));
Object.keys(promises).forEach((key, index) => {
batchResult[key] = promiseResults[index];
});
result = batchResult; result = batchResult;
break; break;
@ -435,7 +478,7 @@ async function handleCustomUrl(url) {
handlePersonalizeFromUrl(params); handlePersonalizeFromUrl(params);
break; break;
default: default:
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
if (header.isMinimized()) header.restore(); if (header.isMinimized()) header.restore();
@ -453,7 +496,7 @@ async function handleCustomUrl(url) {
} }
async function handleFirebaseAuthCallback(params) { async function handleFirebaseAuthCallback(params) {
const userRepository = require('./common/repositories/user'); const userRepository = require('./features/common/repositories/user');
const { token: idToken } = params; const { token: idToken } = params;
if (!idToken) { if (!idToken) {
@ -497,7 +540,7 @@ async function handleFirebaseAuthCallback(params) {
console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...'); console.log('[Auth] Main process sign-in initiated. Waiting for onAuthStateChanged...');
// 3. Focus the app window // 3. Focus the app window
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
if (header.isMinimized()) header.restore(); if (header.isMinimized()) header.restore();
@ -510,7 +553,7 @@ async function handleFirebaseAuthCallback(params) {
console.error('[Auth] Error during custom token exchange or sign-in:', error); console.error('[Auth] Error during custom token exchange or sign-in:', error);
// The UI will not change, and the user can try again. // The UI will not change, and the user can try again.
// Optionally, send a generic error event to the renderer. // Optionally, send a generic error event to the renderer.
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
header.webContents.send('auth-failed', { message: error.message }); header.webContents.send('auth-failed', { message: error.message });
@ -521,7 +564,7 @@ async function handleFirebaseAuthCallback(params) {
function handlePersonalizeFromUrl(params) { function handlePersonalizeFromUrl(params) {
console.log('[Custom URL] Personalize params:', params); console.log('[Custom URL] Personalize params:', params);
const { windowPool } = require('./electron/windowManager'); const { windowPool } = require('./window/windowManager.js');
const header = windowPool.get('header'); const header = windowPool.get('header');
if (header) { if (header) {
@ -643,70 +686,43 @@ async function startWebStack() {
console.log(`✅ API server started on http://localhost:${apiPort}`); console.log(`✅ API server started on http://localhost:${apiPort}`);
console.log(`🚀 All services ready:`); console.log(`🚀 All services ready:
console.log(` Frontend: http://localhost:${frontendPort}`); Frontend: http://localhost:${frontendPort}
console.log(` API: http://localhost:${apiPort}`); API: http://localhost:${apiPort}`);
return frontendPort; return frontendPort;
} }
// Auto-update initialization // Auto-update initialization
function initAutoUpdater() { async function initAutoUpdater() {
if (process.env.NODE_ENV === 'development') {
console.log('Development environment, skipping auto-updater.');
return;
}
try { try {
// Skip auto-updater in development mode await autoUpdater.checkForUpdates();
if (!app.isPackaged) { autoUpdater.on('update-available', () => {
console.log('[AutoUpdater] Skipped in development (app is not packaged)'); console.log('Update available!');
return; autoUpdater.downloadUpdate();
}
autoUpdater.setFeedURL({
provider: 'github',
owner: 'pickle-com',
repo: 'glass',
}); });
autoUpdater.on('update-downloaded', (event, releaseNotes, releaseName, date, url) => {
// Immediately check for updates & notify console.log('Update downloaded:', releaseNotes, releaseName, date, url);
autoUpdater.checkForUpdatesAndNotify() dialog.showMessageBox({
.catch(err => {
console.error('[AutoUpdater] Error checking for updates:', err);
});
autoUpdater.on('checking-for-update', () => {
console.log('[AutoUpdater] Checking for updates…');
});
autoUpdater.on('update-available', (info) => {
console.log('[AutoUpdater] Update available:', info.version);
});
autoUpdater.on('update-not-available', () => {
console.log('[AutoUpdater] Application is up-to-date');
});
autoUpdater.on('error', (err) => {
console.error('[AutoUpdater] Error while updating:', err);
});
autoUpdater.on('update-downloaded', (info) => {
console.log(`[AutoUpdater] Update downloaded: ${info.version}`);
const dialogOpts = {
type: 'info', type: 'info',
buttons: ['Install now', 'Install on next launch'], title: 'Application Update',
title: 'Update Available', message: `A new version of PickleGlass (${releaseName}) has been downloaded. It will be installed the next time you launch the application.`,
message: 'A new version of Glass is ready to be installed.', buttons: ['Restart', 'Later']
defaultId: 0, }).then(response => {
cancelId: 1 if (response.response === 0) {
};
dialog.showMessageBox(dialogOpts).then((returnValue) => {
// returnValue.response 0 is for 'Install Now'
if (returnValue.response === 0) {
autoUpdater.quitAndInstall(); autoUpdater.quitAndInstall();
} }
}); });
}); });
} catch (e) { autoUpdater.on('error', (err) => {
console.error('[AutoUpdater] Failed to initialise:', e); console.error('Error in auto-updater:', err);
});
} catch (err) {
console.error('Error initializing auto-updater:', err);
} }
} }

View File

@ -1,2 +1,306 @@
// See the Electron documentation for details on how to use preload scripts: // src/preload.js
// https://www.electronjs.org/docs/latest/tutorial/process-model#preload-scripts const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
// Platform information for renderer processes
platform: {
isLinux: process.platform === 'linux',
isMacOS: process.platform === 'darwin',
isWindows: process.platform === 'win32',
platform: process.platform
},
// Common utilities used across multiple components
common: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
// User state listener (used by multiple components)
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
},
// UI Component specific namespaces
// src/ui/app/ApiKeyHeader.js
apiKeyHeader: {
// Model & Provider Management
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
// LocalAI 통합 API
getLocalAIStatus: (service) => ipcRenderer.invoke('localai:get-status', service),
installLocalAI: (service, options) => ipcRenderer.invoke('localai:install', { service, options }),
startLocalAIService: (service) => ipcRenderer.invoke('localai:start-service', service),
stopLocalAIService: (service) => ipcRenderer.invoke('localai:stop-service', service),
installLocalAIModel: (service, modelId, options) => ipcRenderer.invoke('localai:install-model', { service, modelId, options }),
getInstalledModels: (service) => ipcRenderer.invoke('localai:get-installed-models', service),
// Legacy support (호환성 위해 유지)
getOllamaStatus: () => ipcRenderer.invoke('localai:get-status', 'ollama'),
getModelSuggestions: () => ipcRenderer.invoke('ollama:get-model-suggestions'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
installOllama: () => ipcRenderer.invoke('localai:install', { service: 'ollama' }),
startOllamaService: () => ipcRenderer.invoke('localai:start-service', 'ollama'),
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
areProvidersConfigured: () => ipcRenderer.invoke('model:are-providers-configured'),
// Window Management
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
// Listeners
// LocalAI 통합 이벤트 리스너
onLocalAIProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
removeOnLocalAIProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
onLocalAIComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
removeOnLocalAIComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback),
onLocalAIError: (callback) => ipcRenderer.on('localai:error-notification', callback),
removeOnLocalAIError: (callback) => ipcRenderer.removeListener('localai:error-notification', callback),
onLocalAIModelReady: (callback) => ipcRenderer.on('localai:model-ready', callback),
removeOnLocalAIModelReady: (callback) => ipcRenderer.removeListener('localai:model-ready', callback),
// Remove all listeners (for cleanup)
removeAllListeners: () => {
// LocalAI 통합 이벤트
ipcRenderer.removeAllListeners('localai:install-progress');
ipcRenderer.removeAllListeners('localai:installation-complete');
ipcRenderer.removeAllListeners('localai:error-notification');
ipcRenderer.removeAllListeners('localai:model-ready');
ipcRenderer.removeAllListeners('localai:service-status-changed');
}
},
// src/ui/app/HeaderController.js
headerController: {
// State Management
sendHeaderStateChanged: (state) => ipcRenderer.send('header-state-changed', state),
reInitializeModelState: () => ipcRenderer.invoke('model:re-initialize-state'),
// Window Management
resizeHeaderWindow: (dimensions) => ipcRenderer.invoke('resize-header-window', dimensions),
// Permissions
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
checkPermissionsCompleted: () => ipcRenderer.invoke('check-permissions-completed'),
// Listeners
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onAuthFailed: (callback) => ipcRenderer.on('auth-failed', callback),
removeOnAuthFailed: (callback) => ipcRenderer.removeListener('auth-failed', callback),
onForceShowApiKeyHeader: (callback) => ipcRenderer.on('force-show-apikey-header', callback),
removeOnForceShowApiKeyHeader: (callback) => ipcRenderer.removeListener('force-show-apikey-header', callback),
},
// src/ui/app/MainHeader.js
mainHeader: {
// Window Management
getHeaderPosition: () => ipcRenderer.invoke('get-header-position'),
moveHeaderTo: (x, y) => ipcRenderer.invoke('move-header-to', x, y),
sendHeaderAnimationFinished: (state) => ipcRenderer.send('header-animation-finished', state),
// Settings Window Management
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
showSettingsWindow: () => ipcRenderer.send('show-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// Generic invoke (for dynamic channel names)
// invoke: (channel, ...args) => ipcRenderer.invoke(channel, ...args),
sendListenButtonClick: (listenButtonText) => ipcRenderer.invoke('listen:changeSession', listenButtonText),
sendAskButtonClick: () => ipcRenderer.invoke('ask:toggleAskButton'),
sendToggleAllWindowsVisibility: () => ipcRenderer.invoke('shortcut:toggleAllWindowsVisibility'),
// Listeners
onListenChangeSessionResult: (callback) => ipcRenderer.on('listen:changeSessionResult', callback),
removeOnListenChangeSessionResult: (callback) => ipcRenderer.removeListener('listen:changeSessionResult', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback)
},
// src/ui/app/PermissionHeader.js
permissionHeader: {
// Permission Management
checkSystemPermissions: () => ipcRenderer.invoke('check-system-permissions'),
requestMicrophonePermission: () => ipcRenderer.invoke('request-microphone-permission'),
openSystemPreferences: (preference) => ipcRenderer.invoke('open-system-preferences', preference),
markKeychainCompleted: () => ipcRenderer.invoke('mark-keychain-completed'),
checkKeychainCompleted: (uid) => ipcRenderer.invoke('check-keychain-completed', uid),
initializeEncryptionKey: () => ipcRenderer.invoke('initialize-encryption-key') // New for keychain
},
// src/ui/app/PickleGlassApp.js
pickleGlassApp: {
// Listeners
onClickThroughToggled: (callback) => ipcRenderer.on('click-through-toggled', callback),
removeOnClickThroughToggled: (callback) => ipcRenderer.removeListener('click-through-toggled', callback),
removeAllClickThroughListeners: () => ipcRenderer.removeAllListeners('click-through-toggled')
},
// src/ui/ask/AskView.js
askView: {
// Window Management
closeAskWindow: () => ipcRenderer.invoke('ask:closeAskWindow'),
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
// Message Handling
sendMessage: (text) => ipcRenderer.invoke('ask:sendQuestionFromAsk', text),
// Listeners
onAskStateUpdate: (callback) => ipcRenderer.on('ask:stateUpdate', callback),
removeOnAskStateUpdate: (callback) => ipcRenderer.removeListener('ask:stateUpdate', callback),
onAskStreamError: (callback) => ipcRenderer.on('ask-response-stream-error', callback),
removeOnAskStreamError: (callback) => ipcRenderer.removeListener('ask-response-stream-error', callback),
// Listeners
onShowTextInput: (callback) => ipcRenderer.on('ask:showTextInput', callback),
removeOnShowTextInput: (callback) => ipcRenderer.removeListener('ask:showTextInput', callback),
onScrollResponseUp: (callback) => ipcRenderer.on('aks:scrollResponseUp', callback),
removeOnScrollResponseUp: (callback) => ipcRenderer.removeListener('aks:scrollResponseUp', callback),
onScrollResponseDown: (callback) => ipcRenderer.on('aks:scrollResponseDown', callback),
removeOnScrollResponseDown: (callback) => ipcRenderer.removeListener('aks:scrollResponseDown', callback)
},
// src/ui/listen/ListenView.js
listenView: {
// Window Management
adjustWindowHeight: (winName, height) => ipcRenderer.invoke('adjust-window-height', { winName, height }),
// Listeners
onSessionStateChanged: (callback) => ipcRenderer.on('session-state-changed', callback),
removeOnSessionStateChanged: (callback) => ipcRenderer.removeListener('session-state-changed', callback)
},
// src/ui/listen/stt/SttView.js
sttView: {
// Listeners
onSttUpdate: (callback) => ipcRenderer.on('stt-update', callback),
removeOnSttUpdate: (callback) => ipcRenderer.removeListener('stt-update', callback)
},
// src/ui/listen/summary/SummaryView.js
summaryView: {
// Message Handling
sendQuestionFromSummary: (text) => ipcRenderer.invoke('ask:sendQuestionFromSummary', text),
// Listeners
onSummaryUpdate: (callback) => ipcRenderer.on('summary-update', callback),
removeOnSummaryUpdate: (callback) => ipcRenderer.removeListener('summary-update', callback),
removeAllSummaryUpdateListeners: () => ipcRenderer.removeAllListeners('summary-update')
},
// src/ui/settings/SettingsView.js
settingsView: {
// User & Auth
getCurrentUser: () => ipcRenderer.invoke('get-current-user'),
openPersonalizePage: () => ipcRenderer.invoke('open-personalize-page'),
firebaseLogout: () => ipcRenderer.invoke('firebase-logout'),
startFirebaseAuth: () => ipcRenderer.invoke('start-firebase-auth'),
// Model & Provider Management
getModelSettings: () => ipcRenderer.invoke('settings:get-model-settings'), // Facade call
getProviderConfig: () => ipcRenderer.invoke('model:get-provider-config'),
getAllKeys: () => ipcRenderer.invoke('model:get-all-keys'),
getAvailableModels: (type) => ipcRenderer.invoke('model:get-available-models', type),
getSelectedModels: () => ipcRenderer.invoke('model:get-selected-models'),
validateKey: (data) => ipcRenderer.invoke('model:validate-key', data),
saveApiKey: (key) => ipcRenderer.invoke('model:save-api-key', key),
removeApiKey: (provider) => ipcRenderer.invoke('model:remove-api-key', provider),
setSelectedModel: (data) => ipcRenderer.invoke('model:set-selected-model', data),
// Ollama Management
getOllamaStatus: () => ipcRenderer.invoke('ollama:get-status'),
ensureOllamaReady: () => ipcRenderer.invoke('ollama:ensure-ready'),
shutdownOllama: (graceful) => ipcRenderer.invoke('ollama:shutdown', graceful),
// Whisper Management
getWhisperInstalledModels: () => ipcRenderer.invoke('whisper:get-installed-models'),
downloadWhisperModel: (modelId) => ipcRenderer.invoke('whisper:download-model', modelId),
// Settings Management
getPresets: () => ipcRenderer.invoke('settings:getPresets'),
getAutoUpdate: () => ipcRenderer.invoke('settings:get-auto-update'),
setAutoUpdate: (isEnabled) => ipcRenderer.invoke('settings:set-auto-update', isEnabled),
getContentProtectionStatus: () => ipcRenderer.invoke('get-content-protection-status'),
toggleContentProtection: () => ipcRenderer.invoke('toggle-content-protection'),
getCurrentShortcuts: () => ipcRenderer.invoke('settings:getCurrentShortcuts'),
openShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:openShortcutSettingsWindow'),
// Window Management
moveWindowStep: (direction) => ipcRenderer.invoke('move-window-step', direction),
cancelHideSettingsWindow: () => ipcRenderer.send('cancel-hide-settings-window'),
hideSettingsWindow: () => ipcRenderer.send('hide-settings-window'),
// App Control
quitApplication: () => ipcRenderer.invoke('quit-application'),
// Progress Tracking
pullOllamaModel: (modelName) => ipcRenderer.invoke('ollama:pull-model', modelName),
// Listeners
onUserStateChanged: (callback) => ipcRenderer.on('user-state-changed', callback),
removeOnUserStateChanged: (callback) => ipcRenderer.removeListener('user-state-changed', callback),
onSettingsUpdated: (callback) => ipcRenderer.on('settings-updated', callback),
removeOnSettingsUpdated: (callback) => ipcRenderer.removeListener('settings-updated', callback),
onPresetsUpdated: (callback) => ipcRenderer.on('presets-updated', callback),
removeOnPresetsUpdated: (callback) => ipcRenderer.removeListener('presets-updated', callback),
onShortcutsUpdated: (callback) => ipcRenderer.on('shortcuts-updated', callback),
removeOnShortcutsUpdated: (callback) => ipcRenderer.removeListener('shortcuts-updated', callback),
// 통합 LocalAI 이벤트 사용
onLocalAIInstallProgress: (callback) => ipcRenderer.on('localai:install-progress', callback),
removeOnLocalAIInstallProgress: (callback) => ipcRenderer.removeListener('localai:install-progress', callback),
onLocalAIInstallationComplete: (callback) => ipcRenderer.on('localai:installation-complete', callback),
removeOnLocalAIInstallationComplete: (callback) => ipcRenderer.removeListener('localai:installation-complete', callback)
},
// src/ui/settings/ShortCutSettingsView.js
shortcutSettingsView: {
// Shortcut Management
saveShortcuts: (shortcuts) => ipcRenderer.invoke('shortcut:saveShortcuts', shortcuts),
getDefaultShortcuts: () => ipcRenderer.invoke('shortcut:getDefaultShortcuts'),
closeShortcutSettingsWindow: () => ipcRenderer.invoke('shortcut:closeShortcutSettingsWindow'),
// Listeners
onLoadShortcuts: (callback) => ipcRenderer.on('shortcut:loadShortcuts', callback),
removeOnLoadShortcuts: (callback) => ipcRenderer.removeListener('shortcut:loadShortcuts', callback)
},
// src/ui/app/content.html inline scripts
content: {
// Listeners
onSettingsWindowHideAnimation: (callback) => ipcRenderer.on('settings-window-hide-animation', callback),
removeOnSettingsWindowHideAnimation: (callback) => ipcRenderer.removeListener('settings-window-hide-animation', callback),
},
// src/ui/listen/audioCore/listenCapture.js
listenCapture: {
// Audio Management
sendMicAudioContent: (data) => ipcRenderer.invoke('listen:sendMicAudio', data),
sendSystemAudioContent: (data) => ipcRenderer.invoke('listen:sendSystemAudio', data),
startMacosSystemAudio: () => ipcRenderer.invoke('listen:startMacosSystemAudio'),
stopMacosSystemAudio: () => ipcRenderer.invoke('listen:stopMacosSystemAudio'),
// Session Management
isSessionActive: () => ipcRenderer.invoke('listen:isSessionActive'),
// Listeners
onSystemAudioData: (callback) => ipcRenderer.on('system-audio-data', callback),
removeOnSystemAudioData: (callback) => ipcRenderer.removeListener('system-audio-data', callback)
},
// src/ui/listen/audioCore/renderer.js
renderer: {
// Listeners
onChangeListenCaptureState: (callback) => ipcRenderer.on('change-listen-capture-state', callback),
removeOnChangeListenCaptureState: (callback) => ipcRenderer.removeListener('change-listen-capture-state', callback)
}
});

2090
src/ui/app/ApiKeyHeader.js Normal file

File diff suppressed because it is too large Load Diff

Some files were not shown because too many files have changed in this diff Show More