Compare commits
151 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7455907835 | ||
|
|
60a8c30157 | ||
|
|
dad74875a0 | ||
|
|
bba38ac56f | ||
|
|
ecae4050bb | ||
|
|
f755fdb9e3 | ||
|
|
a27ab05fa8 | ||
|
|
8592d1c4ed | ||
|
|
ab23c10006 | ||
|
|
fc16532cd9 | ||
|
|
7f98acb5e3 | ||
|
|
698473007a | ||
|
|
9359b32c01 | ||
|
|
6ece74737b | ||
|
|
c0cf74273a | ||
|
|
4d93df09e2 | ||
|
|
94ae002d83 | ||
|
|
a2f57cbfa9 | ||
|
|
e244ce1d4d | ||
|
|
f764ad5362 | ||
|
|
bcc8a59882 | ||
|
|
c464098951 | ||
|
|
2a3c7db200 | ||
|
|
aa14a1d0b6 | ||
|
|
fbe5c22aa4 | ||
|
|
a509e87b22 | ||
|
|
290ee0ed29 | ||
|
|
2bfcadecb4 | ||
|
|
8da13dcb27 | ||
|
|
7d33ea9ca8 | ||
|
|
9f29fa5873 | ||
|
|
9e0c74eed4 | ||
|
|
fc81c05dbe | ||
|
|
9d913f052c | ||
|
|
68f9042a69 | ||
|
|
ffcc1cb9a3 | ||
|
|
2ce82a7edc | ||
|
|
50ffaa0894 | ||
|
|
5698791d46 | ||
|
|
a10cc77dfb | ||
|
|
d1c81ce012 | ||
|
|
6ec45e138f | ||
|
|
77443beeaa | ||
|
|
9b0ad9607b | ||
|
|
bcefa75154 | ||
|
|
e86c2db464 | ||
|
|
dfb430aadd | ||
|
|
3031d0d288 | ||
|
|
2a1edb6ed8 | ||
|
|
0e9df73caa | ||
|
|
88cacb4bc3 | ||
|
|
5e5fb4d0e9 | ||
|
|
fde34ef3de | ||
|
|
682e6391c8 | ||
|
|
e2c286fc81 | ||
|
|
c0d2331712 | ||
|
|
1ab3ebe88d | ||
|
|
aa9d49136d | ||
|
|
ab00ac7d9a | ||
|
|
980083a211 | ||
|
|
a975ba94f9 | ||
|
|
0ff9f4b74e | ||
|
|
186f0a9839 | ||
|
|
594f9e8d19 | ||
|
|
c497234230 | ||
|
|
ae24d49fe5 | ||
|
|
a4e61c73e5 | ||
|
|
c285c7e477 | ||
|
|
dd8d217a97 | ||
|
|
338eef7112 | ||
|
|
bf344268e7 | ||
|
|
e87c73058a | ||
|
|
ee28516a4e | ||
|
|
1b48151f08 | ||
|
|
8d857e091a | ||
|
|
1f9250fef4 | ||
|
|
181bd2bcc4 | ||
|
|
d161102bc0 | ||
|
|
fae6962297 | ||
|
|
a1acce1a3f | ||
|
|
5ea5e86621 | ||
|
|
9977387fbc | ||
|
|
55961c956a | ||
|
|
f6540ef3ec | ||
|
|
194507cb50 | ||
|
|
159dbca683 | ||
|
|
e267026a22 | ||
|
|
2ba95ece92 | ||
|
|
2bb5fcfae7 | ||
|
|
18cb1e0efd | ||
|
|
9caa3dc062 | ||
|
|
4b8b097ce2 | ||
|
|
843694daac | ||
|
|
e9b5f8ee35 | ||
|
|
28ddb2006a | ||
|
|
5cc0d2b83a | ||
|
|
733ea7a0f6 | ||
|
|
02cd22b1f1 | ||
|
|
8aab4feb22 | ||
|
|
8da1ed5791 | ||
|
|
013a033051 | ||
|
|
e5d301cc9c | ||
|
|
2e259fcb8f | ||
|
|
e17668caba | ||
|
|
da6602ef66 | ||
|
|
3f116b43bf | ||
|
|
ba3e8a153c | ||
|
|
bcfdac1569 | ||
|
|
b3d9892b07 | ||
|
|
8abb6b49aa | ||
|
|
3ea5dd75b6 | ||
|
|
649c3b5c31 | ||
|
|
1755705339 | ||
|
|
8db58dcb1e | ||
|
|
ccf19d5d1e | ||
|
|
6a0d8d35c4 | ||
|
|
00db563fd3 | ||
|
|
96062e9b8d | ||
|
|
516b1ae61e | ||
|
|
2f7fa36f45 | ||
|
|
d2951f9fef | ||
|
|
3f9a2ba3eb | ||
|
|
22f53918e0 | ||
|
|
795ff440ea | ||
|
|
4dcf19f6a5 | ||
|
|
82796737ef | ||
|
|
d610ecb6f5 | ||
|
|
17b10b1ad0 | ||
|
|
0f17a06c3a | ||
|
|
4c02969e93 | ||
|
|
a089a5f98b | ||
|
|
d2ca68b177 | ||
|
|
995d4d014e | ||
|
|
427be2a293 | ||
|
|
86e903ec4b | ||
|
|
6bbca03719 | ||
|
|
1f2eda1ee1 | ||
|
|
076474480c | ||
|
|
db05524ff5 | ||
|
|
d0c99c2972 | ||
|
|
045095334e | ||
|
|
a3f76662e1 | ||
|
|
12a07b8607 | ||
|
|
a6ee8c4058 | ||
|
|
1c1da37f4a | ||
|
|
aa9252880b | ||
|
|
549a3cdeb9 | ||
|
|
80a3c01656 | ||
|
|
401e83de6f | ||
|
|
64111891eb | ||
|
|
a18e93583f |
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal file
31
.github/ISSUE_TEMPLATE/bug_report.md
vendored
Normal 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.
|
||||
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal file
20
.github/ISSUE_TEMPLATE/feature_request.md
vendored
Normal 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
28
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal 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
62
.github/workflows/assign-on-comment.yml
vendored
Normal 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'
|
||||
});
|
||||
45
.github/workflows/build.yml
vendored
Normal file
45
.github/workflows/build.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
||||
name: Build & Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ "main" ] # Runs on every push to main branch
|
||||
|
||||
jobs:
|
||||
build:
|
||||
# Currently runs on macOS only, can add windows-latest later
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: 🚚 Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: ⚙️ Setup Node.js environment
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20.x' # Node.js version compatible with project
|
||||
cache: 'npm' # npm dependency caching for speed improvement
|
||||
|
||||
- name: 📦 Install root dependencies
|
||||
run: npm install
|
||||
|
||||
- name: 🌐 Install and build web (Renderer) part
|
||||
# Move to pickleglass_web directory and run commands
|
||||
working-directory: ./pickleglass_web
|
||||
run: |
|
||||
npm install
|
||||
npm run build
|
||||
|
||||
- name: 🖥️ Build Electron app
|
||||
# Run Electron build script from root directory
|
||||
run: npm run build
|
||||
|
||||
- name: 🚨 Send failure notification to Slack
|
||||
if: failure()
|
||||
uses: rtCamp/action-slack-notify@v2
|
||||
env:
|
||||
SLACK_CHANNEL: general
|
||||
SLACK_TITLE: "🚨 Build Failed"
|
||||
SLACK_MESSAGE: "😭 Build failed for `${{ github.repository }}` repo on main branch."
|
||||
SLACK_COLOR: 'danger'
|
||||
SLACK_WEBHOOK: ${{ secrets.SLACK_WEBHOOK_URL }}
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -102,7 +102,6 @@ pickleglass_web/venv/
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-error.log
|
||||
package-lock.json
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
|
||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
[submodule "aec"]
|
||||
path = aec
|
||||
url = https://github.com/samtiz/aec.git
|
||||
1
.npmrc
1
.npmrc
@ -1,3 +1,2 @@
|
||||
better-sqlite3:ignore-scripts=true
|
||||
electron-deeplink:ignore-scripts=true
|
||||
sharp:ignore-scripts=true
|
||||
@ -1,2 +1,2 @@
|
||||
src/assets
|
||||
src/ui/assets
|
||||
node_modules
|
||||
|
||||
93
CONTRIBUTING.md
Normal file
93
CONTRIBUTING.md
Normal file
@ -0,0 +1,93 @@
|
||||
# Contributing to Glass
|
||||
|
||||
Thank you for considering contributing to **Glass by Pickle**! Contributions make the open-source community vibrant, innovative, and collaborative. We appreciate every contribution you make—big or small.
|
||||
|
||||
This document guides you through the entire contribution process, from finding an issue to getting your pull request merged.
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Contribution Workflow
|
||||
|
||||
To ensure a smooth and effective workflow, all contributions must go through the following process. Please follow these steps carefully.
|
||||
|
||||
### 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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
Ensure the following are installed:
|
||||
- [Node.js v20.x.x](https://nodejs.org/en/download)
|
||||
- [Python](https://www.python.org/downloads/)
|
||||
- (Windows users) [Build Tools for Visual Studio](https://visualstudio.microsoft.com/downloads/)
|
||||
|
||||
Ensure you're using Node.js version 20.x.x to avoid build errors with native dependencies.
|
||||
|
||||
```bash
|
||||
# Check your Node.js version
|
||||
node --version
|
||||
|
||||
# If you need to install Node.js 20.x.x, we recommend using nvm:
|
||||
# curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.0/install.sh | bash
|
||||
# nvm install 20
|
||||
# nvm use 20
|
||||
```
|
||||
|
||||
## Setup and Build
|
||||
|
||||
```bash
|
||||
npm run setup
|
||||
```
|
||||
Please ensure that you can make a full production build before pushing code.
|
||||
|
||||
|
||||
|
||||
## Linting
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
If you get errors, be sure to fix them before committing.
|
||||
41
README.md
41
README.md
@ -62,11 +62,14 @@ npm run setup
|
||||
|
||||
<img width="100%" alt="booking-screen" src="./public/assets/01.gif">
|
||||
|
||||
### Use your own OpenAI API key, or sign up to use ours (free)
|
||||
### Use your own API key, or sign up to use ours (free)
|
||||
|
||||
<img width="100%" alt="booking-screen" src="./public/assets/02.gif">
|
||||
|
||||
You can visit [here](https://platform.openai.com/api-keys) to get your OpenAI API Key.
|
||||
**Currently Supporting:**
|
||||
- OpenAI API: Get OpenAI API Key [here](https://platform.openai.com/api-keys)
|
||||
- Gemini API: Get Gemini API Key [here](https://aistudio.google.com/apikey)
|
||||
- Local LLM Ollama & Whisper
|
||||
|
||||
### Liquid Glass Design (coming soon)
|
||||
|
||||
@ -88,21 +91,39 @@ You can visit [here](https://platform.openai.com/api-keys) to get your OpenAI AP
|
||||
|
||||
`Ctrl/Cmd + Arrows` : move main window position
|
||||
|
||||
## Repo Activity
|
||||
|
||||

|
||||
|
||||
## Contributing
|
||||
|
||||
We love contributions! Feel free to open issues for bugs or feature requests.
|
||||
We love contributions! Feel free to open issues for bugs or feature requests. For detailed guide, please see our [contributing guide](/CONTRIBUTING.md).
|
||||
> Currently, we're working on a full code refactor and modularization. Once that's completed, we'll jump into addressing the major issues.
|
||||
|
||||
## 🛠 Current Issues & Improvements
|
||||
### Contributors
|
||||
|
||||
<a href="https://github.com/pickle-com/glass/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=pickle-com/glass" />
|
||||
</a>
|
||||
|
||||
### Help Wanted Issues
|
||||
|
||||
We have a list of [help wanted](https://github.com/pickle-com/glass/issues?q=is%3Aissue%20state%3Aopen%20label%3A%22%F0%9F%99%8B%E2%80%8D%E2%99%82%EF%B8%8Fhelp%20wanted%22) that contain small features and bugs which have a relatively limited scope. This is a great place to get started, gain experience, and get familiar with our contribution process.
|
||||
|
||||
|
||||
### 🛠 Current Issues & Improvements
|
||||
|
||||
| Status | Issue | Description |
|
||||
|--------|--------------------------------|---------------------------------------------------|
|
||||
| 🚧 WIP | AEC Improvement | Transcription is not working occasionally |
|
||||
| 🚧 WIP | Code Refactoring | Refactoring the entire codebase for better maintainability. |
|
||||
| 🚧 WIP | Firebase Data Storage Issue | Session & ask should be saved in firebase for signup users |
|
||||
| 🚧 WIP | Login Issue | Currently breaking when switching between local and sign-in mode |
|
||||
| 🚧 WIP | Liquid Glass | Liquid Glass UI for MacOS 26 |
|
||||
| 🚧 WIP | Permission Issue | Mic & system audio & display capture permission sometimes not working|
|
||||
|
||||
### Changelog
|
||||
|
||||
- Jul 5: Now support Gemini, Intel Mac supported
|
||||
- Jul 6: Full code refactoring has done.
|
||||
- Jul 7: Now support Claude, LLM/STT model selection
|
||||
- Jul 8: Now support Windows(beta), Improved AEC by Rust(to seperate mic/system audio), shortcut editing(beta)
|
||||
- Jul 8: Now support Local LLM & STT, Firebase Data Storage
|
||||
|
||||
|
||||
## About Pickle
|
||||
@ -110,4 +131,4 @@ We love contributions! Feel free to open issues for bugs or feature requests.
|
||||
**Our mission is to build a living digital clone for everyone.** Glass is part of Step 1—a trusted pipeline that transforms your daily data into a scalable clone. Visit [pickle.com](https://pickle.com) to learn more.
|
||||
|
||||
## Star History
|
||||
[](https://www.star-history.com/#pickle-com/glass&Date)
|
||||
[](https://www.star-history.com/#pickle-com/glass&Date)
|
||||
|
||||
1
aec
Submodule
1
aec
Submodule
@ -0,0 +1 @@
|
||||
Subproject commit 9e11f4f95707714464194bdfc9db0222ec5c6163
|
||||
4
build.js
4
build.js
@ -14,8 +14,8 @@ const baseConfig = {
|
||||
};
|
||||
|
||||
const entryPoints = [
|
||||
{ in: 'src/app/HeaderController.js', out: 'public/build/header' },
|
||||
{ in: 'src/app/PickleGlassApp.js', out: 'public/build/content' },
|
||||
{ in: 'src/ui/app/HeaderController.js', out: 'public/build/header' },
|
||||
{ in: 'src/ui/app/PickleGlassApp.js', out: 'public/build/content' },
|
||||
];
|
||||
|
||||
async function build() {
|
||||
|
||||
126
docs/DESIGN_PATTERNS.md
Normal file
126
docs/DESIGN_PATTERNS.md
Normal 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
19
docs/refactor-plan.md
Normal 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.
|
||||
@ -13,6 +13,12 @@ publish:
|
||||
repo: glass
|
||||
releaseType: draft
|
||||
|
||||
# Protocols configuration for deep linking
|
||||
protocols:
|
||||
name: PickleGlass Protocol
|
||||
schemes:
|
||||
- pickleglass
|
||||
|
||||
# List of files to be included in the app package
|
||||
files:
|
||||
- src/**/*
|
||||
@ -27,14 +33,41 @@ extraResources:
|
||||
to: out
|
||||
|
||||
asarUnpack:
|
||||
- "src/assets/SystemAudioDump"
|
||||
- "src/ui/assets/SystemAudioDump"
|
||||
- "**/node_modules/sharp/**/*"
|
||||
- "**/node_modules/@img/**/*"
|
||||
|
||||
# Windows configuration
|
||||
win:
|
||||
icon: src/ui/assets/logo.ico
|
||||
target:
|
||||
- target: nsis
|
||||
arch: x64
|
||||
- target: portable
|
||||
arch: x64
|
||||
requestedExecutionLevel: asInvoker
|
||||
signAndEditExecutable: true
|
||||
cscLink: build\certs\glass-dev.pfx
|
||||
cscKeyPassword: "${env.CSC_KEY_PASSWORD}"
|
||||
signtoolOptions:
|
||||
certificateSubjectName: "Glass Dev Code Signing"
|
||||
|
||||
# NSIS installer configuration for Windows
|
||||
nsis:
|
||||
oneClick: false
|
||||
perMachine: false
|
||||
allowToChangeInstallationDirectory: true
|
||||
deleteAppDataOnUninstall: true
|
||||
createDesktopShortcut: always
|
||||
createStartMenuShortcut: true
|
||||
shortcutName: Glass
|
||||
|
||||
# macOS specific configuration
|
||||
mac:
|
||||
# The application category type
|
||||
category: public.app-category.utilities
|
||||
# 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)
|
||||
minimumSystemVersion: '11.0'
|
||||
hardenedRuntime: true
|
||||
|
||||
@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
7725
package-lock.json
generated
Normal file
7725
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@ -1,19 +1,22 @@
|
||||
{
|
||||
"name": "pickle-glass",
|
||||
"productName": "Glass",
|
||||
"version": "0.1.2",
|
||||
"version": "0.2.4",
|
||||
"description": "Cl*ely for Free",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"setup": "npm install && cd pickleglass_web && npm install && npm run build && cd .. && npm start",
|
||||
"start": "npm run build:renderer && electron-forge start",
|
||||
"package": "npm run build:renderer && electron-forge package",
|
||||
"start": "npm run build:renderer && electron .",
|
||||
"package": "npm run build:all && electron-builder --dir",
|
||||
"make": "npm run build:renderer && electron-forge make",
|
||||
"build": "npm run build:renderer && electron-builder --config electron-builder.yml --publish never",
|
||||
"publish": "npm run build:renderer && electron-builder --config electron-builder.yml --publish always",
|
||||
"build": "npm run build:all && electron-builder --config electron-builder.yml --publish never",
|
||||
"build:win": "npm run build:all && electron-builder --win --x64 --publish never",
|
||||
"publish": "npm run build:all && electron-builder --config electron-builder.yml --publish always",
|
||||
"lint": "eslint --ext .ts,.tsx,.js .",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
"build:renderer": "node build.js",
|
||||
"build:web": "cd pickleglass_web && npm run build && cd ..",
|
||||
"build:all": "npm run build:renderer && npm run build:web",
|
||||
"watch:renderer": "node build.js --watch"
|
||||
},
|
||||
"keywords": [
|
||||
@ -29,13 +32,14 @@
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.56.0",
|
||||
"@deepgram/sdk": "^4.9.1",
|
||||
"@google/genai": "^1.8.0",
|
||||
"@google/generative-ai": "^0.24.1",
|
||||
"axios": "^1.10.0",
|
||||
"better-sqlite3": "^9.4.3",
|
||||
"better-sqlite3": "^9.6.0",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^17.0.0",
|
||||
"electron-deeplink": "^1.0.10",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"electron-store": "^8.2.0",
|
||||
"electron-updater": "^6.6.2",
|
||||
@ -43,33 +47,26 @@
|
||||
"firebase": "^11.10.0",
|
||||
"firebase-admin": "^13.4.0",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"keytar": "^7.9.0",
|
||||
"node-fetch": "^2.7.0",
|
||||
"openai": "^4.70.0",
|
||||
"portkey-ai": "^1.10.1",
|
||||
"react-hot-toast": "^2.5.2",
|
||||
"sharp": "^0.34.2",
|
||||
"sqlite3": "^5.1.7",
|
||||
"validator": "^13.11.0",
|
||||
"wait-on": "^8.0.3",
|
||||
"ws": "^8.18.0"
|
||||
},
|
||||
"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/notarize": "^2.5.0",
|
||||
"electron": "^30.5.1",
|
||||
"electron-builder": "^26.0.12",
|
||||
"electron-reloader": "^1.2.3",
|
||||
"esbuild": "^0.25.5"
|
||||
"esbuild": "^0.25.5",
|
||||
"prettier": "^3.6.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@img/sharp-darwin-x64": "^0.34.2",
|
||||
"@img/sharp-libvips-darwin-x64": "^1.1.0"
|
||||
"electron-liquid-glass": "^1.0.1"
|
||||
}
|
||||
}
|
||||
|
||||
@ -43,9 +43,10 @@ export default function LoginPage() {
|
||||
|
||||
window.location.href = deepLinkUrl
|
||||
|
||||
setTimeout(() => {
|
||||
alert('Login completed. Please return to Pickle Glass app.')
|
||||
}, 1000)
|
||||
// Maybe we don't need this
|
||||
// setTimeout(() => {
|
||||
// alert('Login completed. Please return to Pickle Glass app.')
|
||||
// }, 1000)
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Deep link processing failed:', error)
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
const path = require('path');
|
||||
const databaseInitializer = require('../../src/common/services/databaseInitializer');
|
||||
const Database = require('better-sqlite3');
|
||||
|
||||
const dbPath = databaseInitializer.getDatabasePath();
|
||||
const db = new Database(dbPath);
|
||||
|
||||
db.pragma('journal_mode = WAL');
|
||||
|
||||
// The schema is now managed by the main Electron process on startup.
|
||||
// This file can assume the schema is correct and up-to-date.
|
||||
|
||||
const defaultPresets = [
|
||||
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],
|
||||
['meetings', 'Meetings', 'You are a meeting assistant. Your goal is to help the user capture key information during meetings and follow up effectively.\n\nYou help capture meeting notes, track action items, identify key decisions, and summarize important points discussed during meetings.', 1],
|
||||
['sales', 'Sales', 'You are a real-time AI sales assistant, and your goal is to help the user close deals during sales interactions.\n\nYou provide real-time sales support, suggest responses to objections, help identify customer needs, and recommend strategies to advance deals.', 1],
|
||||
['recruiting', 'Recruiting', 'You are a recruiting assistant. Your goal is to help the user interview candidates and evaluate talent effectively.\n\nYou help evaluate candidates, suggest interview questions, analyze responses, and provide insights about candidate fit for positions.', 1],
|
||||
['customer-support', 'Customer Support', 'You are a customer support assistant. Your goal is to help resolve customer issues efficiently and thoroughly.\n\nYou help diagnose customer problems, suggest solutions, provide step-by-step troubleshooting guidance, and ensure customer satisfaction.', 1],
|
||||
];
|
||||
|
||||
const stmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO prompt_presets (id, uid, title, prompt, is_default, created_at)
|
||||
VALUES (@id, 'default_user', @title, @prompt, @is_default, strftime('%s','now'));
|
||||
`);
|
||||
db.transaction(() => defaultPresets.forEach(([id, title, prompt, is_default]) => stmt.run({ id, title, prompt, is_default })))();
|
||||
|
||||
const defaultUserStmt = db.prepare(`
|
||||
INSERT OR IGNORE INTO users (uid, display_name, email, created_at)
|
||||
VALUES ('default_user', 'Default User', 'contact@pickle.com', strftime('%s','now'));
|
||||
`);
|
||||
defaultUserStmt.run();
|
||||
|
||||
module.exports = db;
|
||||
@ -1,9 +1,9 @@
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const db = require('./db');
|
||||
// const db = require('./db'); // No longer needed
|
||||
const { identifyUser } = require('./middleware/auth');
|
||||
|
||||
function createApp() {
|
||||
function createApp(eventBridge) {
|
||||
const app = express();
|
||||
|
||||
const webUrl = process.env.pickleglass_WEB_URL || 'http://localhost:3000';
|
||||
@ -20,6 +20,11 @@ function createApp() {
|
||||
res.json({ message: "pickleglass API is running" });
|
||||
});
|
||||
|
||||
app.use((req, res, next) => {
|
||||
req.bridge = eventBridge;
|
||||
next();
|
||||
});
|
||||
|
||||
app.use('/api', identifyUser);
|
||||
|
||||
app.use('/api/auth', require('./routes/auth'));
|
||||
|
||||
35
pickleglass_web/backend_node/ipcBridge.js
Normal file
35
pickleglass_web/backend_node/ipcBridge.js
Normal file
@ -0,0 +1,35 @@
|
||||
const crypto = require('crypto');
|
||||
|
||||
function ipcRequest(req, channel, payload) {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Immediately check bridge status and fail if it's not available.
|
||||
if (!req.bridge || typeof req.bridge.emit !== 'function') {
|
||||
reject(new Error('IPC bridge is not available'));
|
||||
return;
|
||||
}
|
||||
|
||||
const responseChannel = `${channel}-${crypto.randomUUID()}`;
|
||||
|
||||
req.bridge.once(responseChannel, (response) => {
|
||||
if (!response) {
|
||||
reject(new Error(`No response received from ${channel}`));
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.success) {
|
||||
resolve(response.data);
|
||||
} else {
|
||||
reject(new Error(response.error || `IPC request to ${channel} failed`));
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
req.bridge.emit('web-data-request', channel, responseChannel, payload);
|
||||
} catch (error) {
|
||||
req.bridge.removeAllListeners(responseChannel);
|
||||
reject(new Error(`Failed to emit IPC request: ${error.message}`));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { ipcRequest };
|
||||
@ -1,13 +0,0 @@
|
||||
const jwt = require('jsonwebtoken');
|
||||
const SECRET = process.env.JWT_SECRET_KEY || 'change-me';
|
||||
const EXPIRE = 60 * 24; // minutes
|
||||
|
||||
exports.sign = (sub, extra = {}) => jwt.sign({ sub, ...extra }, SECRET, { algorithm: 'HS256', expiresIn: `${EXPIRE}m` });
|
||||
|
||||
exports.verify = token => {
|
||||
try {
|
||||
return jwt.verify(token, SECRET).sub;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@ -1,5 +1,3 @@
|
||||
const { verify } = require('../jwt');
|
||||
|
||||
function identifyUser(req, res, next) {
|
||||
const userId = req.get('X-User-ID');
|
||||
|
||||
|
||||
@ -1,19 +1,24 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const { ipcRequest } = require('../ipcBridge');
|
||||
|
||||
router.get('/status', (req, res) => {
|
||||
const user = db.prepare('SELECT uid, display_name FROM users WHERE uid = ?').get('default_user');
|
||||
if (!user) {
|
||||
return res.status(500).json({ error: 'Default user not initialized' });
|
||||
}
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.uid,
|
||||
name: user.display_name
|
||||
router.get('/status', async (req, res) => {
|
||||
try {
|
||||
const user = await ipcRequest(req, 'get-user-profile');
|
||||
if (!user) {
|
||||
return res.status(500).json({ error: 'Default user not initialized' });
|
||||
}
|
||||
});
|
||||
res.json({
|
||||
authenticated: true,
|
||||
user: {
|
||||
id: user.uid,
|
||||
name: user.display_name
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to get auth status via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to retrieve auth status' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@ -1,121 +1,54 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const crypto = require('crypto');
|
||||
const validator = require('validator');
|
||||
const { ipcRequest } = require('../ipcBridge');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const sessions = db.prepare(
|
||||
"SELECT id, uid, title, session_type, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC"
|
||||
).all(req.uid);
|
||||
const sessions = await ipcRequest(req, 'get-sessions');
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
console.error('Failed to get sessions:', error);
|
||||
console.error('Failed to get sessions via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to retrieve sessions' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title } = req.body;
|
||||
const sessionId = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO sessions (id, uid, title, started_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
).run(sessionId, req.uid, title || 'New Conversation', now, now);
|
||||
|
||||
res.status(201).json({ id: sessionId, message: 'Session created successfully' });
|
||||
const result = await ipcRequest(req, 'create-session', req.body);
|
||||
res.status(201).json({ ...result, message: 'Session created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create session:', error);
|
||||
console.error('Failed to create session via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to create session' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:session_id', (req, res) => {
|
||||
const { session_id } = req.params;
|
||||
router.get('/:session_id', async (req, res) => {
|
||||
try {
|
||||
const session = db.prepare("SELECT * FROM sessions WHERE id = ?").get(session_id);
|
||||
if (!session) {
|
||||
const details = await ipcRequest(req, 'get-session-details', req.params.session_id);
|
||||
if (!details) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
const transcripts = db.prepare("SELECT * FROM transcripts WHERE session_id = ? ORDER BY start_at ASC").all(session_id);
|
||||
const ai_messages = db.prepare("SELECT * FROM ai_messages WHERE session_id = ? ORDER BY sent_at ASC").all(session_id);
|
||||
const summary = db.prepare("SELECT * FROM summaries WHERE session_id = ?").get(session_id);
|
||||
|
||||
res.json({
|
||||
session,
|
||||
transcripts,
|
||||
ai_messages,
|
||||
summary: summary || null
|
||||
});
|
||||
res.json(details);
|
||||
} catch (error) {
|
||||
console.error(`Failed to get session ${session_id}:`, error);
|
||||
console.error(`Failed to get session details via IPC for ${req.params.session_id}:`, error);
|
||||
res.status(500).json({ error: 'Failed to retrieve session details' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:session_id', (req, res) => {
|
||||
const { session_id } = req.params;
|
||||
|
||||
const session = db.prepare("SELECT id FROM sessions WHERE id = ?").get(session_id);
|
||||
if (!session) {
|
||||
return res.status(404).json({ error: 'Session not found' });
|
||||
}
|
||||
|
||||
router.delete('/:session_id', async (req, res) => {
|
||||
try {
|
||||
db.transaction(() => {
|
||||
db.prepare("DELETE FROM transcripts WHERE session_id = ?").run(session_id);
|
||||
db.prepare("DELETE FROM ai_messages WHERE session_id = ?").run(session_id);
|
||||
db.prepare("DELETE FROM summaries WHERE session_id = ?").run(session_id);
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(session_id);
|
||||
})();
|
||||
await ipcRequest(req, 'delete-session', req.params.session_id);
|
||||
res.status(200).json({ message: 'Session deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete session ${session_id}:`, error);
|
||||
console.error(`Failed to delete session via IPC for ${req.params.session_id}:`, error);
|
||||
res.status(500).json({ error: 'Failed to delete session' });
|
||||
}
|
||||
});
|
||||
|
||||
// The search functionality will be more complex to move to IPC.
|
||||
// For now, we can disable it or leave it as is, knowing it's a future task.
|
||||
router.get('/search', (req, res) => {
|
||||
const { q } = req.query;
|
||||
if (!q || !validator.isLength(q, { min: 3 })) {
|
||||
return res.status(400).json({ error: 'Query parameter "q" is required' });
|
||||
}
|
||||
// Sanitize and validate input
|
||||
const sanitizedQuery = validator.escape(q.trim()); // Escapes HTML and special chars
|
||||
if (sanitizedQuery.length === 0 || sanitizedQuery.length > 255) {
|
||||
return res.status(400).json({ error: 'Query parameter "q" must be between 3 and 255 characters' });
|
||||
}
|
||||
try {
|
||||
const searchQuery = `%${sanitizedQuery}%`;
|
||||
const sessionIds = db.prepare(`
|
||||
SELECT DISTINCT session_id FROM (
|
||||
SELECT session_id FROM transcripts WHERE text LIKE ?
|
||||
UNION
|
||||
SELECT session_id FROM ai_messages WHERE content LIKE ?
|
||||
UNION
|
||||
SELECT session_id FROM summaries WHERE text LIKE ? OR tldr LIKE ?
|
||||
)
|
||||
`).all(searchQuery, searchQuery, searchQuery, searchQuery).map(row => row.session_id);
|
||||
|
||||
if (sessionIds.length === 0) {
|
||||
return res.json([]);
|
||||
}
|
||||
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
const sessions = db.prepare(
|
||||
`SELECT id, uid, title, started_at, ended_at, sync_state, updated_at FROM sessions WHERE id IN (${placeholders}) ORDER BY started_at DESC`
|
||||
).all(sessionIds);
|
||||
|
||||
res.json(sessions);
|
||||
} catch (error) {
|
||||
console.error('Search failed:', error);
|
||||
res.status(500).json({ error: 'Failed to perform search' });
|
||||
}
|
||||
res.status(501).json({ error: 'Search not implemented for IPC bridge yet.' });
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@ -1,85 +1,43 @@
|
||||
const express = require('express');
|
||||
const crypto = require('crypto');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const { ipcRequest } = require('../ipcBridge');
|
||||
|
||||
router.get('/', (req, res) => {
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const presets = db.prepare(
|
||||
`SELECT * FROM prompt_presets
|
||||
WHERE uid = ? OR is_default = 1
|
||||
ORDER BY is_default DESC, title ASC`
|
||||
).all(req.uid);
|
||||
const presets = await ipcRequest(req, 'get-presets');
|
||||
res.json(presets);
|
||||
} catch (error) {
|
||||
console.error('Failed to get presets:', error);
|
||||
console.error('Failed to get presets via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to retrieve presets' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', (req, res) => {
|
||||
const { title, prompt } = req.body;
|
||||
if (!title || !prompt) {
|
||||
return res.status(400).json({ error: 'Title and prompt are required' });
|
||||
}
|
||||
|
||||
const presetId = crypto.randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
db.prepare(
|
||||
`INSERT INTO prompt_presets (id, uid, title, prompt, is_default, created_at, sync_state)
|
||||
VALUES (?, ?, ?, ?, 0, ?, 'dirty')`
|
||||
).run(presetId, req.uid, title, prompt, now);
|
||||
|
||||
res.status(201).json({ id: presetId, message: 'Preset created successfully' });
|
||||
const result = await ipcRequest(req, 'create-preset', req.body);
|
||||
res.status(201).json({ ...result, message: 'Preset created successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to create preset:', error);
|
||||
console.error('Failed to create preset via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to create preset' });
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { title, prompt } = req.body;
|
||||
if (!title || !prompt) {
|
||||
return res.status(400).json({ error: 'Title and prompt are required' });
|
||||
}
|
||||
|
||||
router.put('/:id', async (req, res) => {
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`UPDATE prompt_presets
|
||||
SET title = ?, prompt = ?, sync_state = 'dirty'
|
||||
WHERE id = ? AND uid = ? AND is_default = 0`
|
||||
).run(title, prompt, id, req.uid);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Preset not found or you don't have permission to edit it." });
|
||||
}
|
||||
|
||||
await ipcRequest(req, 'update-preset', { id: req.params.id, data: req.body });
|
||||
res.json({ message: 'Preset updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to update preset:', error);
|
||||
console.error('Failed to update preset via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to update preset' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', (req, res) => {
|
||||
const { id } = req.params;
|
||||
|
||||
router.delete('/:id', async (req, res) => {
|
||||
try {
|
||||
const result = db.prepare(
|
||||
`DELETE FROM prompt_presets
|
||||
WHERE id = ? AND uid = ? AND is_default = 0`
|
||||
).run(id, req.uid);
|
||||
|
||||
if (result.changes === 0) {
|
||||
return res.status(404).json({ error: "Preset not found or you don't have permission to delete it." });
|
||||
}
|
||||
|
||||
await ipcRequest(req, 'delete-preset', req.params.id);
|
||||
res.json({ message: 'Preset deleted successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to delete preset:', error);
|
||||
console.error('Failed to delete preset via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to delete preset' });
|
||||
}
|
||||
});
|
||||
|
||||
@ -1,144 +1,88 @@
|
||||
const express = require('express');
|
||||
const db = require('../db');
|
||||
const router = express.Router();
|
||||
const { ipcRequest } = require('../ipcBridge');
|
||||
|
||||
router.put('/profile', (req, res) => {
|
||||
const { displayName } = req.body;
|
||||
if (!displayName) return res.status(400).json({ error: 'displayName is required' });
|
||||
|
||||
router.put('/profile', async (req, res) => {
|
||||
try {
|
||||
db.prepare("UPDATE users SET display_name = ? WHERE uid = ?").run(displayName, req.uid);
|
||||
await ipcRequest(req, 'update-user-profile', req.body);
|
||||
res.json({ message: 'Profile updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to update profile:', error);
|
||||
console.error('Failed to update profile via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to update profile' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/profile', (req, res) => {
|
||||
router.get('/profile', async (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT uid, display_name, email FROM users WHERE uid = ?').get(req.uid);
|
||||
const user = await ipcRequest(req, 'get-user-profile');
|
||||
if (!user) return res.status(404).json({ error: 'User not found' });
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
console.error('Failed to get profile:', error);
|
||||
console.error('Failed to get profile via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to get profile' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/find-or-create', (req, res) => {
|
||||
const { uid, displayName, email } = req.body;
|
||||
if (!uid || !displayName || !email) {
|
||||
return res.status(400).json({ error: 'uid, displayName, and email are required' });
|
||||
}
|
||||
|
||||
router.post('/find-or-create', async (req, res) => {
|
||||
try {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
db.prepare(
|
||||
`INSERT INTO users (uid, display_name, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(uid) DO NOTHING`
|
||||
).run(uid, displayName, email, now);
|
||||
console.log('[API] find-or-create request received:', req.body);
|
||||
|
||||
const user = db.prepare('SELECT * FROM users WHERE uid = ?').get(uid);
|
||||
if (!req.body || !req.body.uid) {
|
||||
return res.status(400).json({ error: 'User data with uid is required' });
|
||||
}
|
||||
|
||||
const user = await ipcRequest(req, 'find-or-create-user', req.body);
|
||||
console.log('[API] find-or-create response:', user);
|
||||
res.status(200).json(user);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to find or create user:', error);
|
||||
res.status(500).json({ error: 'Failed to find or create user' });
|
||||
console.error('Failed to find or create user via IPC:', error);
|
||||
console.error('Request body:', req.body);
|
||||
res.status(500).json({
|
||||
error: 'Failed to find or create user',
|
||||
details: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api-key', (req, res) => {
|
||||
const { apiKey } = req.body;
|
||||
if (typeof apiKey !== 'string') {
|
||||
return res.status(400).json({ error: 'API key must be a string' });
|
||||
}
|
||||
|
||||
router.post('/api-key', async (req, res) => {
|
||||
try {
|
||||
db.prepare("UPDATE users SET api_key = ? WHERE uid = ?").run(apiKey, req.uid);
|
||||
res.json({ message: 'API key saved successfully' });
|
||||
const { apiKey, provider = 'openai' } = req.body;
|
||||
await ipcRequest(req, 'save-api-key', { apiKey, provider });
|
||||
res.json({ message: 'API key saved successfully' });
|
||||
} catch (error) {
|
||||
console.error('Failed to save API key:', error);
|
||||
console.error('Failed to save API key via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to save API key' });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api-key-status', (req, res) => {
|
||||
router.get('/api-key-status', async (req, res) => {
|
||||
try {
|
||||
const row = db.prepare('SELECT api_key FROM users WHERE uid = ?').get(req.uid);
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
res.json({ hasApiKey: !!row.api_key && row.api_key.length > 0 });
|
||||
const status = await ipcRequest(req, 'check-api-key-status');
|
||||
res.json(status);
|
||||
} catch (error) {
|
||||
console.error('Failed to get API key status:', error);
|
||||
console.error('Failed to get API key status via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to get API key status' });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/profile', (req, res) => {
|
||||
router.delete('/profile', async (req, res) => {
|
||||
try {
|
||||
const user = db.prepare('SELECT uid FROM users WHERE uid = ?').get(req.uid);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found' });
|
||||
}
|
||||
|
||||
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(user.uid);
|
||||
const sessionIds = userSessions.map(s => s.id);
|
||||
|
||||
db.transaction(() => {
|
||||
if (sessionIds.length > 0) {
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM transcripts WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM ai_messages WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM summaries WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM sessions WHERE uid = ?`).run(user.uid);
|
||||
}
|
||||
db.prepare('DELETE FROM prompt_presets WHERE uid = ?').run(user.uid);
|
||||
db.prepare('DELETE FROM users WHERE uid = ?').run(user.uid);
|
||||
})();
|
||||
|
||||
await ipcRequest(req, 'delete-account');
|
||||
res.status(200).json({ message: 'User account and all data deleted successfully.' });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to delete user account:', error);
|
||||
console.error('Failed to delete user account via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to delete user account' });
|
||||
}
|
||||
});
|
||||
|
||||
async function getUserBatchData(req, res) {
|
||||
const { include = 'profile,presets,sessions' } = req.query;
|
||||
|
||||
router.get('/batch', async (req, res) => {
|
||||
try {
|
||||
const includes = include.split(',').map(item => item.trim());
|
||||
const result = {};
|
||||
|
||||
if (includes.includes('profile')) {
|
||||
const user = db.prepare('SELECT uid, display_name, email FROM users WHERE uid = ?').get(req.uid);
|
||||
result.profile = user || null;
|
||||
}
|
||||
|
||||
if (includes.includes('presets')) {
|
||||
const presets = db.prepare('SELECT * FROM prompt_presets WHERE uid = ? OR is_default = 1').all(req.uid);
|
||||
result.presets = presets || [];
|
||||
}
|
||||
|
||||
if (includes.includes('sessions')) {
|
||||
const recent_sessions = db.prepare(
|
||||
"SELECT id, title, started_at, updated_at FROM sessions WHERE uid = ? ORDER BY updated_at DESC LIMIT 10"
|
||||
).all(req.uid);
|
||||
result.sessions = recent_sessions || [];
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to get batch data:', error);
|
||||
const result = await ipcRequest(req, 'get-batch-data', req.query.include);
|
||||
res.json(result);
|
||||
} catch(error) {
|
||||
console.error('Failed to get batch data via IPC:', error);
|
||||
res.status(500).json({ error: 'Failed to get batch data' });
|
||||
}
|
||||
}
|
||||
|
||||
router.get('/batch', getUserBatchData);
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
6976
pickleglass_web/package-lock.json
generated
Normal file
6976
pickleglass_web/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -87,7 +87,11 @@ export interface SessionDetails {
|
||||
|
||||
|
||||
const isFirebaseMode = (): boolean => {
|
||||
return firebaseAuth.currentUser !== null;
|
||||
// The web frontend can no longer directly access Firebase state,
|
||||
// so we assume communication always goes through the backend API.
|
||||
// In the future, we can create an endpoint like /api/auth/status
|
||||
// in the backend to retrieve the authentication state.
|
||||
return false;
|
||||
};
|
||||
|
||||
const timestampToUnix = (timestamp: Timestamp): number => {
|
||||
@ -185,41 +189,13 @@ const loadRuntimeConfig = async (): Promise<string | null> => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const getApiUrlFromElectron = (): string | null => {
|
||||
if (typeof window !== 'undefined') {
|
||||
try {
|
||||
const { ipcRenderer } = window.require?.('electron') || {};
|
||||
if (ipcRenderer) {
|
||||
try {
|
||||
const apiUrl = ipcRenderer.sendSync('get-api-url-sync');
|
||||
if (apiUrl) {
|
||||
console.log('✅ API URL from Electron IPC:', apiUrl);
|
||||
return apiUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('⚠️ Electron IPC failed:', error);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('ℹ️ Not in Electron environment');
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
let apiUrlInitialized = false;
|
||||
let initializationPromise: Promise<void> | null = null;
|
||||
|
||||
const initializeApiUrl = async () => {
|
||||
if (apiUrlInitialized) return;
|
||||
|
||||
const electronUrl = getApiUrlFromElectron();
|
||||
if (electronUrl) {
|
||||
API_ORIGIN = electronUrl;
|
||||
apiUrlInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// Electron IPC 관련 코드를 모두 제거하고 runtime-config.json 또는 fallback에만 의존합니다.
|
||||
const runtimeUrl = await loadRuntimeConfig();
|
||||
if (runtimeUrl) {
|
||||
API_ORIGIN = runtimeUrl;
|
||||
|
||||
@ -36,21 +36,12 @@ export const useAuth = () => {
|
||||
|
||||
setUser(profile);
|
||||
setUserInfo(profile);
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.send('set-current-user', profile.uid);
|
||||
}
|
||||
|
||||
} else {
|
||||
console.log('🏠 Local mode activated');
|
||||
setMode('local');
|
||||
|
||||
setUser(defaultLocalUser);
|
||||
setUserInfo(defaultLocalUser);
|
||||
|
||||
if (window.ipcRenderer) {
|
||||
window.ipcRenderer.send('set-current-user', defaultLocalUser.uid);
|
||||
}
|
||||
}
|
||||
setIsLoading(false);
|
||||
});
|
||||
|
||||
0
preload.js
Normal file
0
preload.js
Normal file
@ -1,598 +0,0 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class ApiKeyHeader extends LitElement {
|
||||
static properties = {
|
||||
apiKey: { type: String },
|
||||
isLoading: { type: Boolean },
|
||||
errorMessage: { type: String },
|
||||
selectedProvider: { type: String },
|
||||
};
|
||||
|
||||
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: 285px;
|
||||
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;
|
||||
}
|
||||
|
||||
.provider-select {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding: 0 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 6px;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: url('data:image/svg+xml;charset=US-ASCII,%3Csvg%20width%3D%2714%27%20height%3D%278%27%20viewBox%3D%270%200%2014%208%27%20xmlns%3D%27http%3A//www.w3.org/2000/svg%27%3E%3Cpath%20d%3D%27M1%201l6%206%206-6%27%20stroke%3D%27%23ffffff%27%20stroke-width%3D%271.5%27%20fill%3D%27none%27%20fill-rule%3D%27evenodd%27/%3E%3C/svg%3E');
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 10px center;
|
||||
background-size: 12px;
|
||||
padding-right: 30px;
|
||||
}
|
||||
|
||||
.provider-select: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);
|
||||
}
|
||||
|
||||
.provider-select option {
|
||||
background: #1a1a1a;
|
||||
color: white;
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.provider-label {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
`;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dragState = null;
|
||||
this.wasJustDragged = false;
|
||||
this.apiKey = '';
|
||||
this.isLoading = false;
|
||||
this.errorMessage = '';
|
||||
this.validatedApiKey = null;
|
||||
this.selectedProvider = 'openai';
|
||||
|
||||
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 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();
|
||||
}
|
||||
}
|
||||
|
||||
async handleSubmit() {
|
||||
if (this.wasJustDragged || this.isLoading || !this.apiKey.trim()) {
|
||||
console.log('Submit blocked:', {
|
||||
wasJustDragged: this.wasJustDragged,
|
||||
isLoading: this.isLoading,
|
||||
hasApiKey: !!this.apiKey.trim(),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Starting API key validation...');
|
||||
this.isLoading = true;
|
||||
this.errorMessage = '';
|
||||
this.requestUpdate();
|
||||
|
||||
const apiKey = this.apiKey.trim();
|
||||
let isValid = false;
|
||||
try {
|
||||
const isValid = await this.validateApiKey(this.apiKey.trim(), this.selectedProvider);
|
||||
|
||||
if (isValid) {
|
||||
console.log('API key valid - starting slide out animation');
|
||||
this.startSlideOutAnimation();
|
||||
this.validatedApiKey = this.apiKey.trim();
|
||||
this.validatedProvider = this.selectedProvider;
|
||||
} else {
|
||||
this.errorMessage = 'Invalid API key - please check and try again';
|
||||
console.log('API key validation failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API key validation error:', error);
|
||||
this.errorMessage = 'Validation error - please try again';
|
||||
} finally {
|
||||
this.isLoading = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
async validateApiKey(apiKey, provider = 'openai') {
|
||||
if (!apiKey || apiKey.length < 15) return false;
|
||||
|
||||
if (provider === 'openai') {
|
||||
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
|
||||
|
||||
try {
|
||||
console.log('Validating OpenAI API key...');
|
||||
|
||||
const response = await fetch('https://api.openai.com/v1/models', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
const hasGPTModels = data.data && data.data.some(m => m.id.startsWith('gpt-'));
|
||||
if (hasGPTModels) {
|
||||
console.log('OpenAI API key validation successful');
|
||||
return true;
|
||||
} else {
|
||||
console.log('API key valid but no GPT models available');
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.log('API key validation failed:', response.status, errorData.error?.message || 'Unknown error');
|
||||
return false;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('API key validation network error:', error);
|
||||
return apiKey.length >= 20; // Fallback for network issues
|
||||
}
|
||||
} else if (provider === 'gemini') {
|
||||
// Gemini API keys typically start with 'AIza'
|
||||
if (!apiKey.match(/^[A-Za-z0-9_-]+$/)) return false;
|
||||
|
||||
try {
|
||||
console.log('Validating Gemini API key...');
|
||||
|
||||
// Test the API key with a simple models list request
|
||||
const response = await fetch(`https://generativelanguage.googleapis.com/v1beta/models?key=${apiKey}`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
if (data.models && data.models.length > 0) {
|
||||
console.log('Gemini API key validation successful');
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
console.log('Gemini API key validation failed');
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Gemini API key validation network error:', error);
|
||||
return apiKey.length >= 20; // Fallback
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
startSlideOutAnimation() {
|
||||
this.classList.add('sliding-out');
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
|
||||
handleAnimationEnd(e) {
|
||||
if (e.target !== this) return;
|
||||
|
||||
if (this.classList.contains('sliding-out')) {
|
||||
this.classList.remove('sliding-out');
|
||||
this.classList.add('hidden');
|
||||
|
||||
if (this.validatedApiKey) {
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('api-key-validated', {
|
||||
apiKey: this.validatedApiKey,
|
||||
provider: this.validatedProvider || 'openai'
|
||||
});
|
||||
}
|
||||
this.validatedApiKey = null;
|
||||
this.validatedProvider = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.addEventListener('animationend', this.handleAnimationEnd);
|
||||
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener('animationend', this.handleAnimationEnd);
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
const isButtonDisabled = this.isLoading || !this.apiKey || !this.apiKey.trim();
|
||||
console.log('Rendering with provider:', this.selectedProvider);
|
||||
|
||||
return html`
|
||||
<div class="container" @mousedown=${this.handleMouseDown}>
|
||||
<button class="close-button" @click=${this.handleClose} title="Close application">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="title">Choose how to power your AI</h1>
|
||||
|
||||
<div class="form-content">
|
||||
<div class="error-message">${this.errorMessage}</div>
|
||||
<div class="provider-label">Select AI Provider:</div>
|
||||
<select
|
||||
class="provider-select"
|
||||
.value=${this.selectedProvider || 'openai'}
|
||||
@change=${this.handleProviderChange}
|
||||
?disabled=${this.isLoading}
|
||||
tabindex="0"
|
||||
>
|
||||
<option value="openai" ?selected=${this.selectedProvider === 'openai'}>OpenAI</option>
|
||||
<option value="gemini" ?selected=${this.selectedProvider === 'gemini'}>Google Gemini</option>
|
||||
</select>
|
||||
<input
|
||||
type="password"
|
||||
class="api-input"
|
||||
placeholder=${this.selectedProvider === 'openai' ? "Enter your OpenAI API key" : "Enter your Gemini API key"}
|
||||
.value=${this.apiKey || ''}
|
||||
@input=${this.handleInput}
|
||||
@keypress=${this.handleKeyPress}
|
||||
@paste=${this.handlePaste}
|
||||
@focus=${() => (this.errorMessage = '')}
|
||||
?disabled=${this.isLoading}
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
tabindex="0"
|
||||
/>
|
||||
|
||||
<button class="action-button" @click=${this.handleSubmit} ?disabled=${isButtonDisabled} tabindex="0">
|
||||
${this.isLoading ? 'Validating...' : 'Confirm'}
|
||||
</button>
|
||||
|
||||
<div class="or-text">or</div>
|
||||
|
||||
<button class="action-button" @click=${this.handleUsePicklesKey}>Use Pickle's API Key</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('apikey-header', ApiKeyHeader);
|
||||
@ -1,386 +0,0 @@
|
||||
import { initializeApp } from 'firebase/app';
|
||||
import { getAuth, onAuthStateChanged, GoogleAuthProvider, signInWithCredential, signInWithCustomToken, signOut } from 'firebase/auth';
|
||||
|
||||
import './AppHeader.js';
|
||||
import './ApiKeyHeader.js';
|
||||
import './PermissionSetup.js';
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g',
|
||||
authDomain: 'pickle-3651a.firebaseapp.com',
|
||||
projectId: 'pickle-3651a',
|
||||
storageBucket: 'pickle-3651a.firebasestorage.app',
|
||||
messagingSenderId: '904706892885',
|
||||
appId: '1:904706892885:web:0e42b3dda796674ead20dc',
|
||||
measurementId: 'G-SQ0WM6S28T',
|
||||
};
|
||||
|
||||
const firebaseApp = initializeApp(firebaseConfig);
|
||||
const auth = getAuth(firebaseApp);
|
||||
|
||||
class HeaderTransitionManager {
|
||||
constructor() {
|
||||
|
||||
this.headerContainer = document.getElementById('header-container');
|
||||
this.currentHeaderType = null; // 'apikey' | 'app' | 'permission'
|
||||
this.apiKeyHeader = null;
|
||||
this.appHeader = null;
|
||||
this.permissionSetup = null;
|
||||
|
||||
/**
|
||||
* only one header window is allowed
|
||||
* @param {'apikey'|'app'|'permission'} type
|
||||
*/
|
||||
this.ensureHeader = (type) => {
|
||||
if (this.currentHeaderType === type) return;
|
||||
|
||||
this.headerContainer.innerHTML = '';
|
||||
|
||||
this.apiKeyHeader = null;
|
||||
this.appHeader = null;
|
||||
this.permissionSetup = null;
|
||||
|
||||
// Create new header element
|
||||
if (type === 'apikey') {
|
||||
this.apiKeyHeader = document.createElement('apikey-header');
|
||||
this.headerContainer.appendChild(this.apiKeyHeader);
|
||||
} else if (type === 'permission') {
|
||||
this.permissionSetup = document.createElement('permission-setup');
|
||||
this.permissionSetup.continueCallback = () => this.transitionToAppHeader();
|
||||
this.headerContainer.appendChild(this.permissionSetup);
|
||||
} else {
|
||||
this.appHeader = document.createElement('app-header');
|
||||
this.headerContainer.appendChild(this.appHeader);
|
||||
this.appHeader.startSlideInAnimation?.();
|
||||
}
|
||||
|
||||
this.currentHeaderType = type;
|
||||
this.notifyHeaderState(type === 'permission' ? 'apikey' : type); // Keep permission state as apikey for compatibility
|
||||
};
|
||||
|
||||
console.log('[HeaderController] Manager initialized');
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer
|
||||
.invoke('get-current-api-key')
|
||||
.then(storedKey => {
|
||||
this.hasApiKey = !!storedKey;
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
ipcRenderer.on('login-successful', async (event, payload) => {
|
||||
const { customToken, token, error } = payload || {};
|
||||
try {
|
||||
if (customToken) {
|
||||
console.log('[HeaderController] Received custom token, signing in with custom token...');
|
||||
await signInWithCustomToken(auth, customToken);
|
||||
return;
|
||||
}
|
||||
|
||||
if (token) {
|
||||
console.log('[HeaderController] Received ID token, attempting Google credential sign-in...');
|
||||
const credential = GoogleAuthProvider.credential(token);
|
||||
await signInWithCredential(auth, credential);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
console.warn('[HeaderController] Login payload indicates verification failure. Showing permission setup.');
|
||||
// Show permission setup after login error
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Sign-in failed', error);
|
||||
// Show permission setup after sign-in failure
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
ipcRenderer.on('request-firebase-logout', async () => {
|
||||
console.log('[HeaderController] Received request to sign out.');
|
||||
try {
|
||||
this.hasApiKey = false;
|
||||
await signOut(auth);
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Sign out failed', error);
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-validated', () => {
|
||||
this.hasApiKey = true;
|
||||
// Wait for animation to complete before transitioning
|
||||
setTimeout(() => {
|
||||
this.transitionToPermissionSetup();
|
||||
}, 350); // Give time for slide-out animation to complete
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-removed', () => {
|
||||
this.hasApiKey = false;
|
||||
this.transitionToApiKeyHeader();
|
||||
});
|
||||
|
||||
ipcRenderer.on('api-key-updated', () => {
|
||||
this.hasApiKey = true;
|
||||
if (!auth.currentUser) {
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
});
|
||||
|
||||
ipcRenderer.on('firebase-auth-success', async (event, firebaseUser) => {
|
||||
console.log('[HeaderController] Received firebase-auth-success:', firebaseUser.uid);
|
||||
try {
|
||||
if (firebaseUser.idToken) {
|
||||
const credential = GoogleAuthProvider.credential(firebaseUser.idToken);
|
||||
await signInWithCredential(auth, credential);
|
||||
console.log('[HeaderController] Firebase sign-in successful via ID token');
|
||||
} else {
|
||||
console.warn('[HeaderController] No ID token received from deeplink, showing permission setup');
|
||||
// Show permission setup after Firebase auth
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Firebase auth failed:', error);
|
||||
this.transitionToPermissionSetup();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this._bootstrap();
|
||||
|
||||
onAuthStateChanged(auth, async user => {
|
||||
console.log('[HeaderController] Auth state changed. User:', user ? user.email : 'null');
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
let userDataWithToken = null;
|
||||
if (user) {
|
||||
try {
|
||||
const idToken = await user.getIdToken();
|
||||
userDataWithToken = {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
photoURL: user.photoURL,
|
||||
idToken: idToken,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Failed to get ID token:', error);
|
||||
userDataWithToken = {
|
||||
uid: user.uid,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
photoURL: user.photoURL,
|
||||
idToken: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
ipcRenderer.invoke('firebase-auth-state-changed', userDataWithToken).catch(console.error);
|
||||
}
|
||||
|
||||
if (!this.isInitialized) {
|
||||
this.isInitialized = true;
|
||||
return; // Skip on initial load - bootstrap handles it
|
||||
}
|
||||
|
||||
// Only handle state changes after initial load
|
||||
if (user) {
|
||||
console.log('[HeaderController] User logged in, updating hasApiKey and checking permissions...');
|
||||
this.hasApiKey = true; // User login should provide API key
|
||||
// Delay permission check to ensure smooth login flow
|
||||
setTimeout(() => this.transitionToPermissionSetup(), 500);
|
||||
} else if (this.hasApiKey) {
|
||||
console.log('[HeaderController] No Firebase user but API key exists, checking if permission setup is needed...');
|
||||
setTimeout(() => this.transitionToPermissionSetup(), 500);
|
||||
} else {
|
||||
console.log('[HeaderController] No auth & no API key — showing ApiKeyHeader');
|
||||
this.transitionToApiKeyHeader();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
notifyHeaderState(stateOverride) {
|
||||
const state = stateOverride || this.currentHeaderType || 'apikey';
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.send('header-state-changed', state);
|
||||
}
|
||||
}
|
||||
|
||||
async _bootstrap() {
|
||||
let storedKey = null;
|
||||
if (window.require) {
|
||||
try {
|
||||
storedKey = await window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('get-current-api-key');
|
||||
} catch (_) {}
|
||||
}
|
||||
this.hasApiKey = !!storedKey;
|
||||
|
||||
const user = await new Promise(resolve => {
|
||||
const unsubscribe = onAuthStateChanged(auth, u => {
|
||||
unsubscribe();
|
||||
resolve(u);
|
||||
});
|
||||
});
|
||||
|
||||
// check flow order: API key -> Permissions -> App
|
||||
if (!user && !this.hasApiKey) {
|
||||
// No auth and no API key -> show API key input
|
||||
await this._resizeForApiKey();
|
||||
this.ensureHeader('apikey');
|
||||
} else {
|
||||
// Has API key or user -> check permissions first
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
// All permissions granted -> go to app
|
||||
await this._resizeForApp();
|
||||
this.ensureHeader('app');
|
||||
} else {
|
||||
// Permissions needed -> show permission setup
|
||||
await this._resizeForPermissionSetup();
|
||||
this.ensureHeader('permission');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async transitionToPermissionSetup() {
|
||||
// Prevent duplicate transitions
|
||||
if (this.currentHeaderType === 'permission') {
|
||||
console.log('[HeaderController] Already showing permission setup, skipping transition');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if permissions were previously completed
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
try {
|
||||
const permissionsCompleted = await ipcRenderer.invoke('check-permissions-completed');
|
||||
if (permissionsCompleted) {
|
||||
console.log('[HeaderController] Permissions were previously completed, checking current status...');
|
||||
|
||||
// Double check current permission status
|
||||
const permissionResult = await this.checkPermissions();
|
||||
if (permissionResult.success) {
|
||||
// Skip permission setup if already granted
|
||||
this.transitionToAppHeader();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[HeaderController] Permissions were revoked, showing setup again');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Error checking permissions completed status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
await this._resizeForPermissionSetup();
|
||||
this.ensureHeader('permission');
|
||||
}
|
||||
|
||||
async transitionToAppHeader(animate = true) {
|
||||
if (this.currentHeaderType === 'app') {
|
||||
return this._resizeForApp();
|
||||
}
|
||||
|
||||
const canAnimate =
|
||||
animate &&
|
||||
(this.apiKeyHeader || this.permissionSetup) &&
|
||||
this.currentHeaderType !== 'app';
|
||||
|
||||
if (canAnimate && this.apiKeyHeader?.startSlideOutAnimation) {
|
||||
const old = this.apiKeyHeader;
|
||||
const onEnd = () => {
|
||||
clearTimeout(fallback);
|
||||
this._resizeForApp().then(() => this.ensureHeader('app'));
|
||||
};
|
||||
old.addEventListener('animationend', onEnd, { once: true });
|
||||
old.startSlideOutAnimation();
|
||||
|
||||
const fallback = setTimeout(onEnd, 450);
|
||||
} else {
|
||||
this.ensureHeader('app');
|
||||
this._resizeForApp();
|
||||
}
|
||||
}
|
||||
|
||||
_resizeForApp() {
|
||||
if (!window.require) return;
|
||||
return window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('resize-header-window', { width: 353, height: 60 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForApiKey() {
|
||||
if (!window.require) return;
|
||||
return window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 300 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async _resizeForPermissionSetup() {
|
||||
if (!window.require) return;
|
||||
return window
|
||||
.require('electron')
|
||||
.ipcRenderer.invoke('resize-header-window', { width: 285, height: 220 })
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
async transitionToApiKeyHeader() {
|
||||
await this._resizeForApiKey();
|
||||
|
||||
if (this.currentHeaderType !== 'apikey') {
|
||||
this.ensureHeader('apikey');
|
||||
}
|
||||
|
||||
if (this.apiKeyHeader) this.apiKeyHeader.reset();
|
||||
}
|
||||
|
||||
async checkPermissions() {
|
||||
if (!window.require) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
// Check permission status
|
||||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[HeaderController] Current permissions:', permissions);
|
||||
|
||||
if (!permissions.needsSetup) {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// If permissions are not set up, return false
|
||||
let errorMessage = '';
|
||||
if (!permissions.microphone && !permissions.screen) {
|
||||
errorMessage = 'Microphone and screen recording access required';
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: errorMessage
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[HeaderController] Error checking permissions:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Failed to check permissions'
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
new HeaderTransitionManager();
|
||||
});
|
||||
@ -1,533 +0,0 @@
|
||||
import { LitElement, html, css } from '../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class PermissionSetup extends LitElement {
|
||||
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: 285px;
|
||||
height: 220px;
|
||||
padding: 18px 20px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
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;
|
||||
margin: 0;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
text-align: center;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.permission-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
min-height: 20px;
|
||||
}
|
||||
|
||||
.permission-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
font-size: 11px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.permission-item.granted {
|
||||
color: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.permission-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
color: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.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;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.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:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.continue-button {
|
||||
width: 100%;
|
||||
height: 34px;
|
||||
background: rgba(34, 197, 94, 0.8);
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.continue-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;
|
||||
}
|
||||
|
||||
.continue-button:hover:not(:disabled) {
|
||||
background: rgba(34, 197, 94, 0.9);
|
||||
}
|
||||
|
||||
.continue-button:disabled {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
microphoneGranted: { type: String },
|
||||
screenGranted: { type: String },
|
||||
isChecking: { type: String },
|
||||
continueCallback: { type: Function }
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.microphoneGranted = 'unknown';
|
||||
this.screenGranted = 'unknown';
|
||||
this.isChecking = false;
|
||||
this.continueCallback = null;
|
||||
|
||||
this.handleMouseMove = this.handleMouseMove.bind(this);
|
||||
this.handleMouseUp = this.handleMouseUp.bind(this);
|
||||
}
|
||||
|
||||
async connectedCallback() {
|
||||
super.connectedCallback();
|
||||
await this.checkPermissions();
|
||||
|
||||
// Set up periodic permission check
|
||||
this.permissionCheckInterval = setInterval(() => {
|
||||
this.checkPermissions();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.permissionCheckInterval) {
|
||||
clearInterval(this.permissionCheckInterval);
|
||||
}
|
||||
}
|
||||
|
||||
async handleMouseDown(e) {
|
||||
if (e.target.tagName === 'BUTTON') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
async checkPermissions() {
|
||||
if (!window.require || this.isChecking) return;
|
||||
|
||||
this.isChecking = true;
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[PermissionSetup] Permission check result:', permissions);
|
||||
|
||||
const prevMic = this.microphoneGranted;
|
||||
const prevScreen = this.screenGranted;
|
||||
|
||||
this.microphoneGranted = permissions.microphone;
|
||||
this.screenGranted = permissions.screen;
|
||||
|
||||
// if permissions changed == UI update
|
||||
if (prevMic !== this.microphoneGranted || prevScreen !== this.screenGranted) {
|
||||
console.log('[PermissionSetup] Permission status changed, updating UI');
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
// if all permissions granted == automatically continue
|
||||
if (this.microphoneGranted === 'granted' &&
|
||||
this.screenGranted === 'granted' &&
|
||||
this.continueCallback) {
|
||||
console.log('[PermissionSetup] All permissions granted, proceeding automatically');
|
||||
setTimeout(() => this.handleContinue(), 500);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error checking permissions:', error);
|
||||
} finally {
|
||||
this.isChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleMicrophoneClick() {
|
||||
if (!window.require || this.microphoneGranted === 'granted' || this.wasJustDragged) return;
|
||||
|
||||
console.log('[PermissionSetup] Requesting microphone permission...');
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const result = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[PermissionSetup] Microphone permission result:', result);
|
||||
|
||||
if (result.microphone === 'granted') {
|
||||
this.microphoneGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.microphone === 'not-determined' || result.microphone === 'denied' || result.microphone === 'unknown' || result.microphone === 'restricted') {
|
||||
const res = await ipcRenderer.invoke('request-microphone-permission');
|
||||
if (res.status === 'granted' || res.success === true) {
|
||||
this.microphoneGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Check permissions again after a delay
|
||||
// setTimeout(() => this.checkPermissions(), 1000);
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error requesting microphone permission:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleScreenClick() {
|
||||
if (!window.require || this.screenGranted === 'granted' || this.wasJustDragged) return;
|
||||
|
||||
console.log('[PermissionSetup] Checking screen recording permission...');
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
try {
|
||||
const permissions = await ipcRenderer.invoke('check-system-permissions');
|
||||
console.log('[PermissionSetup] Screen permission check result:', permissions);
|
||||
|
||||
if (permissions.screen === 'granted') {
|
||||
this.screenGranted = 'granted';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
if (permissions.screen === 'not-determined' || permissions.screen === 'denied' || permissions.screen === 'unknown' || permissions.screen === 'restricted') {
|
||||
console.log('[PermissionSetup] Opening screen recording preferences...');
|
||||
await ipcRenderer.invoke('open-system-preferences', 'screen-recording');
|
||||
}
|
||||
|
||||
// Check permissions again after a delay
|
||||
// (This may not execute if app restarts after permission grant)
|
||||
// setTimeout(() => this.checkPermissions(), 2000);
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error opening screen recording preferences:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async handleContinue() {
|
||||
if (this.continueCallback &&
|
||||
this.microphoneGranted === 'granted' &&
|
||||
this.screenGranted === 'granted' &&
|
||||
!this.wasJustDragged) {
|
||||
// Mark permissions as completed
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
try {
|
||||
await ipcRenderer.invoke('mark-permissions-completed');
|
||||
console.log('[PermissionSetup] Marked permissions as completed');
|
||||
} catch (error) {
|
||||
console.error('[PermissionSetup] Error marking permissions as completed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
this.continueCallback();
|
||||
}
|
||||
}
|
||||
|
||||
handleClose() {
|
||||
console.log('Close button clicked');
|
||||
if (window.require) {
|
||||
window.require('electron').ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const allGranted = this.microphoneGranted === 'granted' && this.screenGranted === 'granted';
|
||||
|
||||
return html`
|
||||
<div class="container" @mousedown=${this.handleMouseDown}>
|
||||
<button class="close-button" @click=${this.handleClose} title="Close application">
|
||||
<svg width="8" height="8" viewBox="0 0 10 10" fill="currentColor">
|
||||
<path d="M1 1L9 9M9 1L1 9" stroke="currentColor" stroke-width="1.2" />
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="title">Permission Setup Required</h1>
|
||||
|
||||
<div class="form-content">
|
||||
<div class="subtitle">Grant access to microphone and screen recording to continue</div>
|
||||
|
||||
<div class="permission-status">
|
||||
<div class="permission-item ${this.microphoneGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.microphoneGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Microphone ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M7 4a3 3 0 016 0v4a3 3 0 11-6 0V4zm4 10.93A7.001 7.001 0 0017 8a1 1 0 10-2 0A5 5 0 015 8a1 1 0 00-2 0 7.001 7.001 0 006 6.93V17H6a1 1 0 100 2h8a1 1 0 100-2h-3v-2.07z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Microphone</span>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<div class="permission-item ${this.screenGranted === 'granted' ? 'granted' : ''}">
|
||||
${this.screenGranted === 'granted' ? html`
|
||||
<svg class="check-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen ✓</span>
|
||||
` : html`
|
||||
<svg class="permission-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M3 5a2 2 0 012-2h10a2 2 0 012 2v8a2 2 0 01-2 2h-2.22l.123.489.804.804A1 1 0 0113 18H7a1 1 0 01-.707-1.707l.804-.804L7.22 15H5a2 2 0 01-2-2V5zm5.771 7H5V5h10v7H8.771z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Screen Recording</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
${this.microphoneGranted !== 'granted' ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleMicrophoneClick}
|
||||
>
|
||||
Grant Microphone Access
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${this.screenGranted !== 'granted' ? html`
|
||||
<button
|
||||
class="action-button"
|
||||
@click=${this.handleScreenClick}
|
||||
>
|
||||
Grant Screen Recording Access
|
||||
</button>
|
||||
` : ''}
|
||||
|
||||
${allGranted ? html`
|
||||
<button
|
||||
class="continue-button"
|
||||
@click=${this.handleContinue}
|
||||
>
|
||||
Continue to Pickle Glass
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('permission-setup', PermissionSetup);
|
||||
@ -1,295 +0,0 @@
|
||||
import { html, css, LitElement } from '../assets/lit-core-2.7.4.min.js';
|
||||
import { CustomizeView } from '../features/customize/CustomizeView.js';
|
||||
import { AssistantView } from '../features/listen/AssistantView.js';
|
||||
import { OnboardingView } from '../features/onboarding/OnboardingView.js';
|
||||
import { AskView } from '../features/ask/AskView.js';
|
||||
|
||||
import '../features/listen/renderer.js';
|
||||
|
||||
export class PickleGlassApp extends LitElement {
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
width: 100%;
|
||||
color: var(--text-color);
|
||||
background: transparent;
|
||||
border-radius: 7px;
|
||||
}
|
||||
|
||||
assistant-view {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
ask-view, customize-view, history-view, help-view, onboarding-view, setup-view {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
currentView: { type: String },
|
||||
statusText: { type: String },
|
||||
startTime: { type: Number },
|
||||
currentResponseIndex: { type: Number },
|
||||
isMainViewVisible: { type: Boolean },
|
||||
selectedProfile: { type: String },
|
||||
selectedLanguage: { type: String },
|
||||
selectedScreenshotInterval: { type: String },
|
||||
selectedImageQuality: { type: String },
|
||||
isClickThrough: { type: Boolean, state: true },
|
||||
layoutMode: { type: String },
|
||||
_viewInstances: { type: Object, state: true },
|
||||
_isClickThrough: { state: true },
|
||||
structuredData: { type: Object },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
this.currentView = urlParams.get('view') || 'listen';
|
||||
this.currentResponseIndex = -1;
|
||||
this.selectedProfile = localStorage.getItem('selectedProfile') || 'interview';
|
||||
|
||||
// Language format migration for legacy users
|
||||
let lang = localStorage.getItem('selectedLanguage') || 'en';
|
||||
if (lang.includes('-')) {
|
||||
const newLang = lang.split('-')[0];
|
||||
console.warn(`[Migration] Correcting language format from "${lang}" to "${newLang}".`);
|
||||
localStorage.setItem('selectedLanguage', newLang);
|
||||
lang = newLang;
|
||||
}
|
||||
this.selectedLanguage = lang;
|
||||
|
||||
this.selectedScreenshotInterval = localStorage.getItem('selectedScreenshotInterval') || '5';
|
||||
this.selectedImageQuality = localStorage.getItem('selectedImageQuality') || 'medium';
|
||||
this._isClickThrough = false;
|
||||
this.outlines = [];
|
||||
this.analysisRequests = [];
|
||||
|
||||
window.pickleGlass.setStructuredData = data => {
|
||||
this.updateStructuredData(data);
|
||||
};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
ipcRenderer.on('update-status', (_, status) => this.setStatus(status));
|
||||
ipcRenderer.on('click-through-toggled', (_, isEnabled) => {
|
||||
this._isClickThrough = isEnabled;
|
||||
});
|
||||
ipcRenderer.on('show-view', (_, view) => {
|
||||
this.currentView = view;
|
||||
this.isMainViewVisible = true;
|
||||
});
|
||||
ipcRenderer.on('start-listening-session', () => {
|
||||
console.log('Received start-listening-session command, calling handleListenClick.');
|
||||
this.handleListenClick();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.removeAllListeners('update-status');
|
||||
ipcRenderer.removeAllListeners('click-through-toggled');
|
||||
ipcRenderer.removeAllListeners('show-view');
|
||||
ipcRenderer.removeAllListeners('start-listening-session');
|
||||
}
|
||||
}
|
||||
|
||||
updated(changedProperties) {
|
||||
if (changedProperties.has('isMainViewVisible') || changedProperties.has('currentView')) {
|
||||
this.requestWindowResize();
|
||||
}
|
||||
|
||||
if (changedProperties.has('currentView') && window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.send('view-changed', this.currentView);
|
||||
|
||||
const viewContainer = this.shadowRoot?.querySelector('.view-container');
|
||||
if (viewContainer) {
|
||||
viewContainer.classList.add('entering');
|
||||
requestAnimationFrame(() => {
|
||||
viewContainer.classList.remove('entering');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only update localStorage when these specific properties change
|
||||
if (changedProperties.has('selectedProfile')) {
|
||||
localStorage.setItem('selectedProfile', this.selectedProfile);
|
||||
}
|
||||
if (changedProperties.has('selectedLanguage')) {
|
||||
localStorage.setItem('selectedLanguage', this.selectedLanguage);
|
||||
}
|
||||
if (changedProperties.has('selectedScreenshotInterval')) {
|
||||
localStorage.setItem('selectedScreenshotInterval', this.selectedScreenshotInterval);
|
||||
}
|
||||
if (changedProperties.has('selectedImageQuality')) {
|
||||
localStorage.setItem('selectedImageQuality', this.selectedImageQuality);
|
||||
}
|
||||
if (changedProperties.has('layoutMode')) {
|
||||
this.updateLayoutMode();
|
||||
}
|
||||
}
|
||||
|
||||
requestWindowResize() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
ipcRenderer.invoke('resize-window', {
|
||||
isMainViewVisible: this.isMainViewVisible,
|
||||
view: this.currentView,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setStatus(text) {
|
||||
this.statusText = text;
|
||||
}
|
||||
|
||||
async handleListenClick() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
const isActive = await ipcRenderer.invoke('is-session-active');
|
||||
if (isActive) {
|
||||
console.log('Session is already active. No action needed.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (window.pickleGlass) {
|
||||
await window.pickleGlass.initializeopenai(this.selectedProfile, this.selectedLanguage);
|
||||
window.pickleGlass.startCapture(this.selectedScreenshotInterval, this.selectedImageQuality);
|
||||
}
|
||||
|
||||
// 🔄 Clear previous summary/analysis when a new listening session begins
|
||||
this.structuredData = {
|
||||
summary: [],
|
||||
topic: { header: '', bullets: [] },
|
||||
actions: [],
|
||||
followUps: [],
|
||||
};
|
||||
|
||||
this.currentResponseIndex = -1;
|
||||
this.startTime = Date.now();
|
||||
this.currentView = 'listen';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
handleShowHideClick() {
|
||||
this.isMainViewVisible = !this.isMainViewVisible;
|
||||
}
|
||||
|
||||
handleCustomizeClick() {
|
||||
this.currentView = 'customize';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
handleHelpClick() {
|
||||
this.currentView = 'help';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
handleHistoryClick() {
|
||||
this.currentView = 'history';
|
||||
this.isMainViewVisible = true;
|
||||
}
|
||||
|
||||
async handleClose() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
await ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
handleBackClick() {
|
||||
this.currentView = 'listen';
|
||||
}
|
||||
|
||||
async handleSendText(message) {
|
||||
if (window.pickleGlass) {
|
||||
const result = await window.pickleGlass.sendTextMessage(message);
|
||||
|
||||
if (!result.success) {
|
||||
console.error('Failed to send message:', result.error);
|
||||
this.setStatus('Error sending message: ' + result.error);
|
||||
} else {
|
||||
this.setStatus('Message sent...');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// updateOutline(outline) {
|
||||
// console.log('📝 PickleGlassApp updateOutline:', outline);
|
||||
// this.outlines = [...outline];
|
||||
// this.requestUpdate();
|
||||
// }
|
||||
|
||||
// updateAnalysisRequests(requests) {
|
||||
// console.log('📝 PickleGlassApp updateAnalysisRequests:', requests);
|
||||
// this.analysisRequests = [...requests];
|
||||
// this.requestUpdate();
|
||||
// }
|
||||
|
||||
updateStructuredData(data) {
|
||||
console.log('📝 PickleGlassApp updateStructuredData:', data);
|
||||
this.structuredData = data;
|
||||
this.requestUpdate();
|
||||
|
||||
const assistantView = this.shadowRoot?.querySelector('assistant-view');
|
||||
if (assistantView) {
|
||||
assistantView.structuredData = data;
|
||||
console.log('✅ Structured data passed to AssistantView');
|
||||
}
|
||||
}
|
||||
|
||||
handleResponseIndexChanged(e) {
|
||||
this.currentResponseIndex = e.detail.index;
|
||||
}
|
||||
|
||||
handleOnboardingComplete() {
|
||||
this.currentView = 'main';
|
||||
}
|
||||
|
||||
render() {
|
||||
switch (this.currentView) {
|
||||
case 'listen':
|
||||
return html`<assistant-view
|
||||
.currentResponseIndex=${this.currentResponseIndex}
|
||||
.selectedProfile=${this.selectedProfile}
|
||||
.structuredData=${this.structuredData}
|
||||
.onSendText=${message => this.handleSendText(message)}
|
||||
@response-index-changed=${e => (this.currentResponseIndex = e.detail.index)}
|
||||
></assistant-view>`;
|
||||
case 'ask':
|
||||
return html`<ask-view></ask-view>`;
|
||||
case 'customize':
|
||||
return html`<customize-view
|
||||
.selectedProfile=${this.selectedProfile}
|
||||
.selectedLanguage=${this.selectedLanguage}
|
||||
.onProfileChange=${profile => (this.selectedProfile = profile)}
|
||||
.onLanguageChange=${lang => (this.selectedLanguage = lang)}
|
||||
></customize-view>`;
|
||||
case 'history':
|
||||
return html`<history-view></history-view>`;
|
||||
case 'help':
|
||||
return html`<help-view></help-view>`;
|
||||
case 'onboarding':
|
||||
return html`<onboarding-view></onboarding-view>`;
|
||||
case 'setup':
|
||||
return html`<setup-view></setup-view>`;
|
||||
default:
|
||||
return html`<div>Unknown view: ${this.currentView}</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('pickle-glass-app', PickleGlassApp);
|
||||
@ -1,316 +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.25s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.window-sliding-up {
|
||||
animation: slideUpToHeader 0.18s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
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.22s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
transform-origin: 85% 0%;
|
||||
will-change: transform, opacity;
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
|
||||
.settings-window-hide {
|
||||
animation: settingsCollapseToButton 0.18s cubic-bezier(0.55, 0.085, 0.68, 0.53) forwards;
|
||||
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');
|
||||
}, 250);
|
||||
});
|
||||
|
||||
ipcRenderer.on('settings-window-show-animation', () => {
|
||||
console.log('Starting settings window show animation');
|
||||
app.classList.remove('window-hidden', 'window-sliding-up', 'settings-window-hide');
|
||||
app.classList.add('settings-window-show');
|
||||
|
||||
if (animationTimeout) clearTimeout(animationTimeout);
|
||||
animationTimeout = setTimeout(() => {
|
||||
app.classList.remove('settings-window-show');
|
||||
}, 220);
|
||||
});
|
||||
|
||||
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');
|
||||
}, 180);
|
||||
});
|
||||
|
||||
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');
|
||||
}, 180);
|
||||
});
|
||||
|
||||
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>
|
||||
</body>
|
||||
</html>
|
||||
239
src/bridge/featureBridge.js
Normal file
239
src/bridge/featureBridge.js
Normal 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);
|
||||
},
|
||||
};
|
||||
11
src/bridge/internalBridge.js
Normal file
11
src/bridge/internalBridge.js
Normal 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에서 처리
|
||||
// });
|
||||
34
src/bridge/windowBridge.js
Normal file
34
src/bridge/windowBridge.js
Normal 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);
|
||||
}
|
||||
};
|
||||
@ -1,377 +0,0 @@
|
||||
const { createOpenAiGenerativeClient, getOpenAiGenerativeModel } = require('./openAiClient.js');
|
||||
const { createGeminiClient, getGeminiGenerativeModel, createGeminiChat } = require('./googleGeminiClient.js');
|
||||
|
||||
/**
|
||||
* Creates an AI client based on the provider
|
||||
* @param {string} apiKey - The API key
|
||||
* @param {string} provider - The provider ('openai' or 'gemini')
|
||||
* @returns {object} The AI client
|
||||
*/
|
||||
function createAIClient(apiKey, provider = 'openai') {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return createOpenAiGenerativeClient(apiKey);
|
||||
case 'gemini':
|
||||
return createGeminiClient(apiKey);
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a generative model based on the provider
|
||||
* @param {object} client - The AI client
|
||||
* @param {string} provider - The provider ('openai' or 'gemini')
|
||||
* @param {string} model - The model name (optional)
|
||||
* @returns {object} The model object
|
||||
*/
|
||||
function getGenerativeModel(client, provider = 'openai', model) {
|
||||
switch (provider) {
|
||||
case 'openai':
|
||||
return getOpenAiGenerativeModel(client, model || 'gpt-4.1');
|
||||
case 'gemini':
|
||||
return getGeminiGenerativeModel(client, model || 'gemini-2.5-flash');
|
||||
default:
|
||||
throw new Error(`Unsupported AI provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a chat completion request based on the provider
|
||||
* @param {object} params - Request parameters
|
||||
* @returns {Promise<object>} The completion response
|
||||
*/
|
||||
async function makeChatCompletion({ apiKey, provider = 'openai', messages, temperature = 0.7, maxTokens = 1024, model, stream = false }) {
|
||||
if (provider === 'openai') {
|
||||
const fetchUrl = 'https://api.openai.com/v1/chat/completions';
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4.1',
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
stream,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
if (stream) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
content: result.choices[0].message.content.trim(),
|
||||
raw: result
|
||||
};
|
||||
} else if (provider === 'gemini') {
|
||||
const client = createGeminiClient(apiKey);
|
||||
const genModel = getGeminiGenerativeModel(client, model || 'gemini-2.5-flash');
|
||||
|
||||
// Convert OpenAI format messages to Gemini format
|
||||
const parts = [];
|
||||
for (const message of messages) {
|
||||
if (message.role === 'system') {
|
||||
parts.push(message.content);
|
||||
} else if (message.role === 'user') {
|
||||
if (typeof message.content === 'string') {
|
||||
parts.push(message.content);
|
||||
} else if (Array.isArray(message.content)) {
|
||||
// Handle multimodal content
|
||||
for (const part of message.content) {
|
||||
if (part.type === 'text') {
|
||||
parts.push(part.text);
|
||||
} else if (part.type === 'image_url' && part.image_url?.url) {
|
||||
// Extract base64 data from data URL
|
||||
const base64Match = part.image_url.url.match(/^data:(.+);base64,(.+)$/);
|
||||
if (base64Match) {
|
||||
parts.push({
|
||||
inlineData: {
|
||||
mimeType: base64Match[1],
|
||||
data: base64Match[2]
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const result = await genModel.generateContent(parts);
|
||||
return {
|
||||
content: result.response.text(),
|
||||
raw: result
|
||||
};
|
||||
} else {
|
||||
throw new Error(`Unsupported AI provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a chat completion request with Portkey support
|
||||
* @param {object} params - Request parameters including Portkey options
|
||||
* @returns {Promise<object>} The completion response
|
||||
*/
|
||||
async function makeChatCompletionWithPortkey({
|
||||
apiKey,
|
||||
provider = 'openai',
|
||||
messages,
|
||||
temperature = 0.7,
|
||||
maxTokens = 1024,
|
||||
model,
|
||||
usePortkey = false,
|
||||
portkeyVirtualKey = null
|
||||
}) {
|
||||
if (!usePortkey) {
|
||||
return makeChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
|
||||
}
|
||||
|
||||
// Portkey is only supported for OpenAI currently
|
||||
if (provider !== 'openai') {
|
||||
console.warn('Portkey is only supported for OpenAI provider, falling back to direct API');
|
||||
return makeChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
|
||||
}
|
||||
|
||||
const fetchUrl = 'https://api.portkey.ai/v1/chat/completions';
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
|
||||
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4.1',
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Portkey API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
content: result.choices[0].message.content.trim(),
|
||||
raw: result
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a streaming chat completion request
|
||||
* @param {object} params - Request parameters
|
||||
* @returns {Promise<Response>} The streaming response
|
||||
*/
|
||||
async function makeStreamingChatCompletion({ apiKey, provider = 'openai', messages, temperature = 0.7, maxTokens = 1024, model }) {
|
||||
if (provider === 'openai') {
|
||||
const fetchUrl = 'https://api.openai.com/v1/chat/completions';
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4.1',
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
} else if (provider === 'gemini') {
|
||||
console.log('[AIProviderService] Starting Gemini streaming request');
|
||||
// Gemini streaming requires a different approach
|
||||
// We'll create a ReadableStream that mimics OpenAI's SSE format
|
||||
const geminiClient = createGeminiClient(apiKey);
|
||||
|
||||
// Extract system instruction if present
|
||||
let systemInstruction = '';
|
||||
const nonSystemMessages = [];
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === 'system') {
|
||||
systemInstruction = msg.content;
|
||||
} else {
|
||||
nonSystemMessages.push(msg);
|
||||
}
|
||||
}
|
||||
|
||||
const chat = createGeminiChat(geminiClient, model || 'gemini-2.0-flash-exp', {
|
||||
temperature,
|
||||
maxOutputTokens: maxTokens || 8192,
|
||||
systemInstruction: systemInstruction || undefined
|
||||
});
|
||||
|
||||
// Create a ReadableStream to handle Gemini's streaming
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
console.log('[AIProviderService] Processing messages for Gemini:', nonSystemMessages.length, 'messages (excluding system)');
|
||||
|
||||
// Get the last user message
|
||||
const lastMessage = nonSystemMessages[nonSystemMessages.length - 1];
|
||||
let lastUserMessage = lastMessage.content;
|
||||
|
||||
// Handle case where content might be an array (multimodal)
|
||||
if (Array.isArray(lastUserMessage)) {
|
||||
// Extract text content from array
|
||||
const textParts = lastUserMessage.filter(part =>
|
||||
typeof part === 'string' || (part && part.type === 'text')
|
||||
);
|
||||
lastUserMessage = textParts.map(part =>
|
||||
typeof part === 'string' ? part : part.text
|
||||
).join(' ');
|
||||
}
|
||||
|
||||
console.log('[AIProviderService] Sending message to Gemini:',
|
||||
typeof lastUserMessage === 'string' ? lastUserMessage.substring(0, 100) + '...' : 'multimodal content');
|
||||
|
||||
// Prepare the message content for Gemini
|
||||
let geminiContent = [];
|
||||
|
||||
// Handle multimodal content properly
|
||||
if (Array.isArray(lastMessage.content)) {
|
||||
for (const part of lastMessage.content) {
|
||||
if (typeof part === 'string') {
|
||||
geminiContent.push(part);
|
||||
} else if (part.type === 'text') {
|
||||
geminiContent.push(part.text);
|
||||
} else if (part.type === 'image_url' && part.image_url) {
|
||||
// Convert base64 image to Gemini format
|
||||
const base64Data = part.image_url.url.split(',')[1];
|
||||
geminiContent.push({
|
||||
inlineData: {
|
||||
mimeType: 'image/png',
|
||||
data: base64Data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
geminiContent = [lastUserMessage];
|
||||
}
|
||||
|
||||
console.log('[AIProviderService] Prepared Gemini content:',
|
||||
geminiContent.length, 'parts');
|
||||
|
||||
// Stream the response
|
||||
let chunkCount = 0;
|
||||
let totalContent = '';
|
||||
|
||||
for await (const chunk of chat.sendMessageStream(geminiContent)) {
|
||||
chunkCount++;
|
||||
const chunkText = chunk.text || '';
|
||||
totalContent += chunkText;
|
||||
|
||||
// Format as SSE data
|
||||
const data = JSON.stringify({
|
||||
choices: [{
|
||||
delta: {
|
||||
content: chunkText
|
||||
}
|
||||
}]
|
||||
});
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`));
|
||||
}
|
||||
|
||||
console.log(`[AIProviderService] Streamed ${chunkCount} chunks, total length: ${totalContent.length} chars`);
|
||||
|
||||
// Send the final done message
|
||||
controller.enqueue(new TextEncoder().encode('data: [DONE]\n\n'));
|
||||
controller.close();
|
||||
console.log('[AIProviderService] Gemini streaming completed successfully');
|
||||
} catch (error) {
|
||||
console.error('[AIProviderService] Gemini streaming error:', error);
|
||||
controller.error(error);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create a Response object with the stream
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
'Connection': 'keep-alive'
|
||||
}
|
||||
});
|
||||
} else {
|
||||
throw new Error(`Unsupported AI provider: ${provider}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a streaming chat completion request with Portkey support
|
||||
* @param {object} params - Request parameters
|
||||
* @returns {Promise<Response>} The streaming response
|
||||
*/
|
||||
async function makeStreamingChatCompletionWithPortkey({
|
||||
apiKey,
|
||||
provider = 'openai',
|
||||
messages,
|
||||
temperature = 0.7,
|
||||
maxTokens = 1024,
|
||||
model,
|
||||
usePortkey = false,
|
||||
portkeyVirtualKey = null
|
||||
}) {
|
||||
if (!usePortkey) {
|
||||
return makeStreamingChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
|
||||
}
|
||||
|
||||
// Portkey is only supported for OpenAI currently
|
||||
if (provider !== 'openai') {
|
||||
console.warn('Portkey is only supported for OpenAI provider, falling back to direct API');
|
||||
return makeStreamingChatCompletion({ apiKey, provider, messages, temperature, maxTokens, model });
|
||||
}
|
||||
|
||||
const fetchUrl = 'https://api.portkey.ai/v1/chat/completions';
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
|
||||
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model || 'gpt-4.1',
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Portkey API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createAIClient,
|
||||
getGenerativeModel,
|
||||
makeChatCompletion,
|
||||
makeChatCompletionWithPortkey,
|
||||
makeStreamingChatCompletion,
|
||||
makeStreamingChatCompletionWithPortkey
|
||||
};
|
||||
@ -1,239 +0,0 @@
|
||||
const axios = require('axios');
|
||||
const config = require('../config/config');
|
||||
|
||||
class APIClient {
|
||||
constructor() {
|
||||
this.baseURL = config.get('apiUrl');
|
||||
this.client = axios.create({
|
||||
baseURL: this.baseURL,
|
||||
timeout: config.get('apiTimeout'),
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
this.client.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
console.error('API request failed:', error.message);
|
||||
if (error.response) {
|
||||
console.error('response status:', error.response.status);
|
||||
console.error('response data:', error.response.data);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
try {
|
||||
const response = await this.client.get('/api/auth/status');
|
||||
console.log('[APIClient] checked default user status:', response.data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[APIClient] failed to initialize:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async checkConnection() {
|
||||
try {
|
||||
const response = await this.client.get('/');
|
||||
return response.status === 200;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async saveApiKey(apiKey) {
|
||||
try {
|
||||
const response = await this.client.post('/api/user/api-key', { apiKey });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to save api key:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkApiKey() {
|
||||
try {
|
||||
const response = await this.client.get('/api/user/api-key');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to check api key:', error);
|
||||
return { hasApiKey: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getUserBatchData(includes = ['profile', 'context', 'presets']) {
|
||||
try {
|
||||
const includeParam = includes.join(',');
|
||||
const response = await this.client.get(`/api/user/batch?include=${includeParam}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get user batch data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserContext() {
|
||||
try {
|
||||
const response = await this.client.get('/api/user/context');
|
||||
return response.data.context;
|
||||
} catch (error) {
|
||||
console.error('fail to get user context:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserProfile() {
|
||||
try {
|
||||
const response = await this.client.get('/api/user/profile');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get user profile:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getUserPresets() {
|
||||
try {
|
||||
const response = await this.client.get('/api/user/presets');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get user presets:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserContext(context) {
|
||||
try {
|
||||
const response = await this.client.post('/api/user/context', context);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to update user context:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async addActivity(activity) {
|
||||
try {
|
||||
const response = await this.client.post('/api/user/activities', activity);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to add activity:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getPresetTemplates() {
|
||||
try {
|
||||
const response = await this.client.get('/api/preset-templates');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get preset templates:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async updateUserProfile(profile) {
|
||||
try {
|
||||
const response = await this.client.post('/api/user/profile', profile);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to update user profile:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async searchUsers(name = '') {
|
||||
try {
|
||||
const response = await this.client.get('/api/users/search', {
|
||||
params: { name }
|
||||
});
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to search users:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getUserProfileById(userId) {
|
||||
try {
|
||||
const response = await this.client.get(`/api/users/${userId}/profile`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get user profile by id:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async saveConversationSession(sessionId, conversationHistory) {
|
||||
try {
|
||||
const payload = {
|
||||
sessionId,
|
||||
conversationHistory
|
||||
};
|
||||
const response = await this.client.post('/api/conversations', payload);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to save conversation session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getConversationSession(sessionId) {
|
||||
try {
|
||||
const response = await this.client.get(`/api/conversations/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get conversation session:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getAllConversationSessions() {
|
||||
try {
|
||||
const response = await this.client.get('/api/conversations');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get all conversation sessions:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async deleteConversationSession(sessionId) {
|
||||
try {
|
||||
const response = await this.client.delete(`/api/conversations/${sessionId}`);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to delete conversation session:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async getSyncStatus() {
|
||||
try {
|
||||
const response = await this.client.get('/api/sync/status');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get sync status:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async getFullUserData() {
|
||||
try {
|
||||
const response = await this.client.get('/api/user/full');
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('failed to get full user data:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const apiClient = new APIClient();
|
||||
|
||||
module.exports = apiClient;
|
||||
module.exports = apiClient;
|
||||
@ -1,158 +0,0 @@
|
||||
const config = require('../config/config');
|
||||
|
||||
class DataService {
|
||||
constructor() {
|
||||
this.cache = new Map();
|
||||
this.cacheTimeout = config.get('cacheTimeout');
|
||||
this.enableCaching = config.get('enableCaching');
|
||||
this.sqliteClient = null;
|
||||
this.currentUserId = 'default_user';
|
||||
this.isInitialized = false;
|
||||
|
||||
if (config.get('enableSQLiteStorage')) {
|
||||
try {
|
||||
this.sqliteClient = require('./sqliteClient');
|
||||
console.log('[DataService] SQLite storage enabled.');
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to load SQLite client:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async initialize() {
|
||||
if (this.isInitialized || !this.sqliteClient) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sqliteClient.connect();
|
||||
this.isInitialized = true;
|
||||
console.log('[DataService] Initialized successfully');
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to initialize:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
setCurrentUser(uid) {
|
||||
if (this.currentUserId !== uid) {
|
||||
console.log(`[DataService] Current user switched to: ${uid}`);
|
||||
this.currentUserId = uid;
|
||||
this.clearCache();
|
||||
}
|
||||
}
|
||||
|
||||
getCacheKey(operation, params = '') {
|
||||
return `${this.currentUserId}:${operation}:${params}`;
|
||||
}
|
||||
|
||||
getFromCache(key) {
|
||||
if (!this.enableCaching) return null;
|
||||
const cached = this.cache.get(key);
|
||||
if (cached && (Date.now() - cached.timestamp) < this.cacheTimeout) {
|
||||
return cached.data;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
setCache(key, data) {
|
||||
if (!this.enableCaching) return;
|
||||
this.cache.set(key, { data, timestamp: Date.now() });
|
||||
}
|
||||
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
async findOrCreateUser(firebaseUser) {
|
||||
if (!this.sqliteClient) {
|
||||
console.log('[DataService] SQLite client not available, skipping user creation');
|
||||
return firebaseUser;
|
||||
}
|
||||
|
||||
try {
|
||||
await this.initialize();
|
||||
const existingUser = await this.sqliteClient.getUser(firebaseUser.uid);
|
||||
|
||||
if (!existingUser) {
|
||||
console.log(`[DataService] Creating new user in local DB: ${firebaseUser.uid}`);
|
||||
await this.sqliteClient.findOrCreateUser({
|
||||
uid: firebaseUser.uid,
|
||||
display_name: firebaseUser.displayName || firebaseUser.display_name,
|
||||
email: firebaseUser.email
|
||||
});
|
||||
}
|
||||
|
||||
this.clearCache();
|
||||
return firebaseUser;
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to sync Firebase user to local DB:', error);
|
||||
return firebaseUser;
|
||||
}
|
||||
}
|
||||
|
||||
async saveApiKey(apiKey) {
|
||||
if (!this.sqliteClient) {
|
||||
throw new Error("SQLite client not available.");
|
||||
}
|
||||
try {
|
||||
await this.initialize();
|
||||
const result = await this.sqliteClient.saveApiKey(apiKey, this.currentUserId);
|
||||
this.clearCache();
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to save API key to SQLite:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async checkApiKey() {
|
||||
if (!this.sqliteClient) return { hasApiKey: false };
|
||||
try {
|
||||
await this.initialize();
|
||||
const user = await this.sqliteClient.getUser(this.currentUserId);
|
||||
return { hasApiKey: !!user?.api_key && user.api_key.length > 0 };
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to check API key from SQLite:', error);
|
||||
return { hasApiKey: false };
|
||||
}
|
||||
}
|
||||
|
||||
async getUserPresets() {
|
||||
const cacheKey = this.getCacheKey('presets');
|
||||
const cached = this.getFromCache(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!this.sqliteClient) return [];
|
||||
try {
|
||||
await this.initialize();
|
||||
const presets = await this.sqliteClient.getPresets(this.currentUserId);
|
||||
this.setCache(cacheKey, presets);
|
||||
return presets;
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to get presets from SQLite:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async getPresetTemplates() {
|
||||
const cacheKey = this.getCacheKey('preset_templates');
|
||||
const cached = this.getFromCache(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
if (!this.sqliteClient) return [];
|
||||
try {
|
||||
await this.initialize();
|
||||
const templates = await this.sqliteClient.getPresetTemplates();
|
||||
this.setCache(cacheKey, templates);
|
||||
return templates;
|
||||
} catch (error) {
|
||||
console.error('[DataService] Failed to get preset templates from SQLite:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dataService = new DataService();
|
||||
|
||||
module.exports = dataService;
|
||||
@ -1,171 +0,0 @@
|
||||
const { GoogleGenerativeAI } = require('@google/generative-ai');
|
||||
const { GoogleGenAI } = require('@google/genai');
|
||||
|
||||
/**
|
||||
* Creates and returns a Google Gemini client instance for generative AI.
|
||||
* @param {string} apiKey - The API key for authentication.
|
||||
* @returns {GoogleGenerativeAI} The initialized Gemini client.
|
||||
*/
|
||||
function createGeminiClient(apiKey) {
|
||||
return new GoogleGenerativeAI(apiKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a Gemini model for text/image generation.
|
||||
* @param {GoogleGenerativeAI} client - The Gemini client instance.
|
||||
* @param {string} [model='gemini-2.5-flash'] - The name for the text/vision model.
|
||||
* @returns {object} Model object with generateContent method
|
||||
*/
|
||||
function getGeminiGenerativeModel(client, model = 'gemini-2.5-flash') {
|
||||
const genAI = client;
|
||||
const geminiModel = genAI.getGenerativeModel({ model: model });
|
||||
|
||||
return {
|
||||
generateContent: async (parts) => {
|
||||
let systemPrompt = '';
|
||||
let userContent = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
if (systemPrompt === '' && part.includes('You are')) {
|
||||
systemPrompt = part;
|
||||
} else {
|
||||
userContent.push(part);
|
||||
}
|
||||
} else if (part.inlineData) {
|
||||
// Convert base64 image data to Gemini format
|
||||
userContent.push({
|
||||
inlineData: {
|
||||
mimeType: part.inlineData.mimeType,
|
||||
data: part.inlineData.data
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prepare content array
|
||||
const content = [];
|
||||
|
||||
// Add system instruction if present
|
||||
if (systemPrompt) {
|
||||
// For Gemini, we'll prepend system prompt to user content
|
||||
content.push(systemPrompt + '\n\n' + userContent[0]);
|
||||
content.push(...userContent.slice(1));
|
||||
} else {
|
||||
content.push(...userContent);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await geminiModel.generateContent(content);
|
||||
const response = await result.response;
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: () => response.text()
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Gemini API error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a Gemini chat session for multi-turn conversations.
|
||||
* @param {GoogleGenerativeAI} client - The Gemini client instance.
|
||||
* @param {string} [model='gemini-2.5-flash'] - The model to use.
|
||||
* @param {object} [config={}] - Configuration options.
|
||||
* @returns {object} Chat session object
|
||||
*/
|
||||
function createGeminiChat(client, model = 'gemini-2.5-flash', config = {}) {
|
||||
const genAI = client;
|
||||
const geminiModel = genAI.getGenerativeModel({
|
||||
model: model,
|
||||
systemInstruction: config.systemInstruction
|
||||
});
|
||||
|
||||
const chat = geminiModel.startChat({
|
||||
history: config.history || [],
|
||||
generationConfig: {
|
||||
temperature: config.temperature || 0.7,
|
||||
maxOutputTokens: config.maxOutputTokens || 8192,
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
sendMessage: async (message) => {
|
||||
const result = await chat.sendMessage(message);
|
||||
const response = await result.response;
|
||||
return {
|
||||
text: response.text()
|
||||
};
|
||||
},
|
||||
sendMessageStream: async function* (message) {
|
||||
const result = await chat.sendMessageStream(message);
|
||||
for await (const chunk of result.stream) {
|
||||
yield {
|
||||
text: chunk.text()
|
||||
};
|
||||
}
|
||||
},
|
||||
getHistory: () => chat.getHistory()
|
||||
};
|
||||
}
|
||||
|
||||
// async function connectToGeminiSession(apiKey, { language = 'en-US', callbacks = {} } = {}) {
|
||||
// const liveClient = new GoogleGenAI({
|
||||
// vertexai: false, // Vertex AI 사용 안함
|
||||
// apiKey,
|
||||
// });
|
||||
|
||||
// // 라이브 STT 세션 열기
|
||||
// const session = await liveClient.live.connect({
|
||||
// model: 'gemini-live-2.5-flash-preview',
|
||||
// callbacks,
|
||||
// config: {
|
||||
// inputAudioTranscription: {}, // 실시간 STT 필수
|
||||
// speechConfig: { languageCode: language },
|
||||
// },
|
||||
// });
|
||||
|
||||
// return {
|
||||
// sendRealtimeInput: async data => session.send({
|
||||
// audio: { data, mimeType: 'audio/pcm;rate=24000' }
|
||||
// }),
|
||||
// close: async () => session.close(),
|
||||
// };
|
||||
// }
|
||||
|
||||
async function connectToGeminiSession(apiKey, { language = 'en-US', callbacks = {} } = {}) {
|
||||
// ① 옛날 스타일 helper 재사용
|
||||
const liveClient = new GoogleGenAI({ vertexai: false, apiKey });
|
||||
|
||||
// ② 언어 코드 강제 BCP-47 변환
|
||||
const lang = language.includes('-') ? language : `${language}-US`;
|
||||
|
||||
const session = await liveClient.live.connect({
|
||||
model: 'gemini-live-2.5-flash-preview',
|
||||
callbacks,
|
||||
config: {
|
||||
inputAudioTranscription: {},
|
||||
speechConfig: { languageCode: lang },
|
||||
},
|
||||
});
|
||||
|
||||
// ③ SDK 0.5+ : sendRealtimeInput 가 정식 이름
|
||||
return {
|
||||
sendRealtimeInput: async payload => session.sendRealtimeInput(payload),
|
||||
close: async () => session.close(),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
|
||||
module.exports = {
|
||||
createGeminiClient,
|
||||
getGeminiGenerativeModel,
|
||||
createGeminiChat,
|
||||
connectToGeminiSession,
|
||||
};
|
||||
@ -1,177 +0,0 @@
|
||||
const OpenAI = require('openai');
|
||||
const WebSocket = require('ws');
|
||||
|
||||
/**
|
||||
* Creates and returns an OpenAI client instance for STT (Speech-to-Text).
|
||||
* @param {string} apiKey - The API key for authentication.
|
||||
* @returns {OpenAI} The initialized OpenAI client.
|
||||
*/
|
||||
function createOpenAiClient(apiKey) {
|
||||
return new OpenAI({
|
||||
apiKey: apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates and returns an OpenAI client instance for text/image generation.
|
||||
* @param {string} apiKey - The API key for authentication.
|
||||
* @returns {OpenAI} The initialized OpenAI client.
|
||||
*/
|
||||
function createOpenAiGenerativeClient(apiKey) {
|
||||
return new OpenAI({
|
||||
apiKey: apiKey,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Connects to an OpenAI Realtime WebSocket session for STT.
|
||||
* @param {string} key - Portkey vKey or OpenAI apiKey.
|
||||
* @param {object} config - The configuration object for the realtime session.
|
||||
* @param {'apiKey'|'vKey'} keyType - key type ('apiKey' | 'vKey').
|
||||
* @returns {Promise<object>} A promise that resolves to the session object with send and close methods.
|
||||
*/
|
||||
async function connectToOpenAiSession(key, config, keyType) {
|
||||
if (keyType !== 'apiKey' && keyType !== 'vKey') {
|
||||
throw new Error('keyType must be either "apiKey" or "vKey".');
|
||||
}
|
||||
|
||||
const wsUrl = keyType === 'apiKey'
|
||||
? 'wss://api.openai.com/v1/realtime?intent=transcription'
|
||||
: 'wss://api.portkey.ai/v1/realtime?intent=transcription';
|
||||
|
||||
const headers = keyType === 'apiKey'
|
||||
? {
|
||||
'Authorization': `Bearer ${key}`,
|
||||
'OpenAI-Beta' : 'realtime=v1',
|
||||
}
|
||||
: {
|
||||
'x-portkey-api-key' : 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
|
||||
'x-portkey-virtual-key': key,
|
||||
'OpenAI-Beta' : 'realtime=v1',
|
||||
};
|
||||
|
||||
const ws = new WebSocket(wsUrl, { headers });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket session opened.");
|
||||
|
||||
const sessionConfig = {
|
||||
type: 'transcription_session.update',
|
||||
session: {
|
||||
input_audio_format: 'pcm16',
|
||||
input_audio_transcription: {
|
||||
model: 'gpt-4o-mini-transcribe',
|
||||
prompt: config.prompt || '',
|
||||
language: config.language || 'en'
|
||||
},
|
||||
turn_detection: {
|
||||
type: 'server_vad',
|
||||
threshold: 0.5,
|
||||
prefix_padding_ms: 50,
|
||||
silence_duration_ms: 25,
|
||||
},
|
||||
input_audio_noise_reduction: {
|
||||
type: 'near_field'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(sessionConfig));
|
||||
|
||||
resolve({
|
||||
sendRealtimeInput: (audioData) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
type: 'input_audio_buffer.append',
|
||||
audio: audioData
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'session.close' }));
|
||||
ws.close(1000, 'Client initiated close.');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (config.callbacks && config.callbacks.onmessage) {
|
||||
config.callbacks.onmessage(message);
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error.message);
|
||||
if (config.callbacks && config.callbacks.onerror) {
|
||||
config.callbacks.onerror(error);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
|
||||
if (config.callbacks && config.callbacks.onclose) {
|
||||
config.callbacks.onclose(event);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a GPT model for text/image generation.
|
||||
* @param {OpenAI} client - The OpenAI client instance.
|
||||
* @param {string} [model='gpt-4.1'] - The name for the text/vision model.
|
||||
* @returns {object} Model object with generateContent method
|
||||
*/
|
||||
function getOpenAiGenerativeModel(client, model = 'gpt-4.1') {
|
||||
return {
|
||||
generateContent: async (parts) => {
|
||||
const messages = [];
|
||||
let systemPrompt = '';
|
||||
let userContent = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
if (systemPrompt === '' && part.includes('You are')) {
|
||||
systemPrompt = part;
|
||||
} else {
|
||||
userContent.push({ type: 'text', text: part });
|
||||
}
|
||||
} else if (part.inlineData) {
|
||||
userContent.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
|
||||
if (userContent.length > 0) messages.push({ role: 'user', content: userContent });
|
||||
|
||||
const response = await client.chat.completions.create({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048
|
||||
});
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: () => response.choices[0].message.content
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createOpenAiClient,
|
||||
connectToOpenAiSession,
|
||||
createOpenAiGenerativeClient,
|
||||
getOpenAiGenerativeModel,
|
||||
};
|
||||
@ -1,467 +0,0 @@
|
||||
const sqlite3 = require('sqlite3').verbose();
|
||||
const path = require('path');
|
||||
const LATEST_SCHEMA = require('../config/schema');
|
||||
|
||||
class SQLiteClient {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.dbPath = null;
|
||||
this.defaultUserId = 'default_user';
|
||||
}
|
||||
|
||||
connect(dbPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.db) {
|
||||
console.log('[SQLiteClient] Already connected.');
|
||||
return resolve();
|
||||
}
|
||||
|
||||
this.dbPath = dbPath;
|
||||
this.db = new sqlite3.Database(this.dbPath, (err) => {
|
||||
if (err) {
|
||||
console.error('[SQLiteClient] Could not connect to database', err);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('[SQLiteClient] Connected successfully to:', this.dbPath);
|
||||
|
||||
this.db.run('PRAGMA journal_mode = WAL;', (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async synchronizeSchema() {
|
||||
console.log('[DB Sync] Starting schema synchronization...');
|
||||
const tablesInDb = await this.getTablesFromDb();
|
||||
|
||||
for (const tableName of Object.keys(LATEST_SCHEMA)) {
|
||||
const tableSchema = LATEST_SCHEMA[tableName];
|
||||
|
||||
if (!tablesInDb.includes(tableName)) {
|
||||
// Table doesn't exist, create it
|
||||
await this.createTable(tableName, tableSchema);
|
||||
} else {
|
||||
// Table exists, check for missing columns
|
||||
await this.updateTable(tableName, tableSchema);
|
||||
}
|
||||
}
|
||||
console.log('[DB Sync] Schema synchronization finished.');
|
||||
}
|
||||
|
||||
async getTablesFromDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all("SELECT name FROM sqlite_master WHERE type='table'", (err, tables) => {
|
||||
if (err) return reject(err);
|
||||
resolve(tables.map(t => t.name));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createTable(tableName, tableSchema) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const columnDefs = tableSchema.columns.map(col => `"${col.name}" ${col.type}`).join(', ');
|
||||
const query = `CREATE TABLE IF NOT EXISTS "${tableName}" (${columnDefs})`;
|
||||
|
||||
console.log(`[DB Sync] Creating table: ${tableName}`);
|
||||
this.db.run(query, (err) => {
|
||||
if (err) return reject(err);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateTable(tableName, tableSchema) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(`PRAGMA table_info("${tableName}")`, async (err, existingColumns) => {
|
||||
if (err) return reject(err);
|
||||
|
||||
const existingColumnNames = existingColumns.map(c => c.name);
|
||||
const columnsToAdd = tableSchema.columns.filter(col => !existingColumnNames.includes(col.name));
|
||||
|
||||
if (columnsToAdd.length > 0) {
|
||||
console.log(`[DB Sync] Updating table: ${tableName}. Adding columns: ${columnsToAdd.map(c=>c.name).join(', ')}`);
|
||||
for (const column of columnsToAdd) {
|
||||
const addColumnQuery = `ALTER TABLE "${tableName}" ADD COLUMN "${column.name}" ${column.type}`;
|
||||
try {
|
||||
await this.runQuery(addColumnQuery);
|
||||
} catch (alterErr) {
|
||||
return reject(alterErr);
|
||||
}
|
||||
}
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async runQuery(query, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(query, params, function(err) {
|
||||
if (err) return reject(err);
|
||||
resolve(this);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async cleanupEmptySessions() {
|
||||
console.log('[DB Cleanup] Checking for empty sessions...');
|
||||
const query = `
|
||||
SELECT s.id FROM sessions s
|
||||
LEFT JOIN transcripts t ON s.id = t.session_id
|
||||
LEFT JOIN ai_messages a ON s.id = a.session_id
|
||||
LEFT JOIN summaries su ON s.id = su.session_id
|
||||
WHERE t.id IS NULL AND a.id IS NULL AND su.session_id IS NULL
|
||||
`;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.all(query, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('[DB Cleanup] Error finding empty sessions:', err);
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('[DB Cleanup] No empty sessions found.');
|
||||
return resolve();
|
||||
}
|
||||
|
||||
const idsToDelete = rows.map(r => r.id);
|
||||
const placeholders = idsToDelete.map(() => '?').join(',');
|
||||
const deleteQuery = `DELETE FROM sessions WHERE id IN (${placeholders})`;
|
||||
|
||||
console.log(`[DB Cleanup] Found ${idsToDelete.length} empty sessions. Deleting...`);
|
||||
this.db.run(deleteQuery, idsToDelete, function(deleteErr) {
|
||||
if (deleteErr) {
|
||||
console.error('[DB Cleanup] Error deleting empty sessions:', deleteErr);
|
||||
return reject(deleteErr);
|
||||
}
|
||||
console.log(`[DB Cleanup] Successfully deleted ${this.changes} empty sessions.`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async initTables() {
|
||||
await this.synchronizeSchema();
|
||||
await this.initDefaultData();
|
||||
}
|
||||
|
||||
async initDefaultData() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const initUserQuery = `
|
||||
INSERT OR IGNORE INTO users (uid, display_name, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
this.db.run(initUserQuery, [this.defaultUserId, 'Default User', 'contact@pickle.com', now], (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to initialize default user:', err);
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const defaultPresets = [
|
||||
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],
|
||||
['meetings', 'Meetings', 'You are a meeting assistant. Your goal is to help the user capture key information during meetings and follow up effectively.\n\nYou help capture meeting notes, track action items, identify key decisions, and summarize important points discussed during meetings.', 1],
|
||||
['sales', 'Sales', 'You are a real-time AI sales assistant, and your goal is to help the user close deals during sales interactions.\n\nYou provide real-time sales support, suggest responses to objections, help identify customer needs, and recommend strategies to advance deals.', 1],
|
||||
['recruiting', 'Recruiting', 'You are a recruiting assistant. Your goal is to help the user interview candidates and evaluate talent effectively.\n\nYou help evaluate candidates, suggest interview questions, analyze responses, and provide insights about candidate fit for positions.', 1],
|
||||
['customer-support', 'Customer Support', 'You are a customer support assistant. Your goal is to help resolve customer issues efficiently and thoroughly.\n\nYou help diagnose customer problems, suggest solutions, provide step-by-step troubleshooting guidance, and ensure customer satisfaction.', 1],
|
||||
];
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO prompt_presets (id, uid, title, prompt, is_default, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const preset of defaultPresets) {
|
||||
stmt.run(preset[0], this.defaultUserId, preset[1], preset[2], preset[3], now);
|
||||
}
|
||||
|
||||
stmt.finalize((err) => {
|
||||
if (err) {
|
||||
console.error('Failed to finalize preset statement:', err);
|
||||
return reject(err);
|
||||
}
|
||||
console.log('Default data initialized.');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async findOrCreateUser(user) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const { uid, display_name, email } = user;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
const query = `
|
||||
INSERT INTO users (uid, display_name, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
email=excluded.email
|
||||
`;
|
||||
|
||||
this.db.run(query, [uid, display_name, email, now], (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to find or create user in SQLite:', err);
|
||||
return reject(err);
|
||||
}
|
||||
this.getUser(uid).then(resolve).catch(reject);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getUser(uid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get('SELECT * FROM users WHERE uid = ?', [uid], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async saveApiKey(apiKey, uid = this.defaultUserId, provider = 'openai') {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.run(
|
||||
'UPDATE users SET api_key = ?, provider = ? WHERE uid = ?',
|
||||
[apiKey, provider, uid],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('SQLite: Failed to save API key:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`SQLite: API key saved for user ${uid} with provider ${provider}.`);
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async getPresets(uid) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = `
|
||||
SELECT * FROM prompt_presets
|
||||
WHERE uid = ? OR is_default = 1
|
||||
ORDER BY is_default DESC, title ASC
|
||||
`;
|
||||
this.db.all(query, [uid], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('SQLite: Failed to get presets:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getPresetTemplates() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const query = `
|
||||
SELECT * FROM prompt_presets
|
||||
WHERE is_default = 1
|
||||
ORDER BY title ASC
|
||||
`;
|
||||
this.db.all(query, [], (err, rows) => {
|
||||
if (err) {
|
||||
console.error('SQLite: Failed to get preset templates:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async getSession(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.db.get('SELECT * FROM sessions WHERE id = ?', [id], (err, row) => {
|
||||
if (err) reject(err);
|
||||
else resolve(row);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async updateSessionType(id, type) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = 'UPDATE sessions SET session_type = ?, updated_at = ? WHERE id = ?';
|
||||
this.db.run(query, [type, now, id], function(err) {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ changes: this.changes });
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async touchSession(id) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = 'UPDATE sessions SET updated_at = ? WHERE id = ?';
|
||||
this.db.run(query, [now, id], function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async createSession(uid, type = 'ask') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const sessionId = require('crypto').randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `INSERT INTO sessions (id, uid, title, session_type, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
this.db.run(query, [sessionId, uid, `Session @ ${new Date().toLocaleTimeString()}`, type, now, now], function(err) {
|
||||
if (err) {
|
||||
console.error('SQLite: Failed to create session:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
console.log(`SQLite: Created session ${sessionId} for user ${uid} (type: ${type})`);
|
||||
resolve(sessionId);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async endSession(sessionId) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE id = ?`;
|
||||
this.db.run(query, [now, now, sessionId], function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addTranscript({ sessionId, speaker, text }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
|
||||
const transcriptId = require('crypto').randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `INSERT INTO transcripts (id, session_id, start_at, speaker, text, created_at) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
this.db.run(query, [transcriptId, sessionId, now, speaker, text, now], function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: transcriptId });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async addAiMessage({ sessionId, role, content, model = 'gpt-4.1' }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
|
||||
const messageId = require('crypto').randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `INSERT INTO ai_messages (id, session_id, sent_at, role, content, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
||||
this.db.run(query, [messageId, sessionId, now, role, content, model, now], function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ id: messageId });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async saveSummary({ sessionId, tldr, text, bullet_json, action_json, model = 'gpt-4.1' }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
this.touchSession(sessionId).catch(err => console.error("Failed to touch session", err));
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `
|
||||
INSERT INTO summaries (session_id, generated_at, model, text, tldr, bullet_json, action_json, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_id) DO UPDATE SET
|
||||
generated_at=excluded.generated_at,
|
||||
model=excluded.model,
|
||||
text=excluded.text,
|
||||
tldr=excluded.tldr,
|
||||
bullet_json=excluded.bullet_json,
|
||||
action_json=excluded.action_json,
|
||||
updated_at=excluded.updated_at
|
||||
`;
|
||||
this.db.run(query, [sessionId, now, model, text, tldr, bullet_json, action_json, now], function(err) {
|
||||
if (err) reject(err);
|
||||
else resolve({ changes: this.changes });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async runMigrations() {
|
||||
return new Promise((resolve, reject) => {
|
||||
console.log('[DB Migration] Checking schema for `sessions` table...');
|
||||
this.db.all("PRAGMA table_info(sessions)", (err, columns) => {
|
||||
if (err) {
|
||||
console.error('[DB Migration] Error checking sessions table schema:', err);
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
const hasSessionTypeCol = columns.some(col => col.name === 'session_type');
|
||||
|
||||
if (!hasSessionTypeCol) {
|
||||
console.log('[DB Migration] `session_type` column missing. Altering table...');
|
||||
this.db.run("ALTER TABLE sessions ADD COLUMN session_type TEXT DEFAULT 'ask'", (alterErr) => {
|
||||
if (alterErr) {
|
||||
console.error('[DB Migration] Failed to add `session_type` column:', alterErr);
|
||||
return reject(alterErr);
|
||||
}
|
||||
console.log('[DB Migration] `sessions` table updated successfully.');
|
||||
resolve();
|
||||
});
|
||||
} else {
|
||||
console.log('[DB Migration] Schema is up to date.');
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
this.db.close((err) => {
|
||||
if (err) {
|
||||
console.error('SQLite connection close failed:', err);
|
||||
} else {
|
||||
console.log('SQLite connection closed.');
|
||||
}
|
||||
});
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
async query(sql, params = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!this.db) {
|
||||
return reject(new Error('Database not connected'));
|
||||
}
|
||||
|
||||
if (sql.toUpperCase().startsWith('SELECT')) {
|
||||
this.db.all(sql, params, (err, rows) => {
|
||||
if (err) {
|
||||
console.error('Query error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve(rows);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.db.run(sql, params, function(err) {
|
||||
if (err) {
|
||||
console.error('Query error:', err);
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({ changes: this.changes, lastID: this.lastID });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const sqliteClient = new SQLiteClient();
|
||||
module.exports = sqliteClient;
|
||||
File diff suppressed because it is too large
Load Diff
450
src/features/ask/askService.js
Normal file
450
src/features/ask/askService.js
Normal file
@ -0,0 +1,450 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
const { createStreamingLLM } = require('../common/ai/factory');
|
||||
// Lazy require helper to avoid circular dependency issues
|
||||
const getWindowManager = () => require('../../window/windowManager');
|
||||
const internalBridge = require('../../bridge/internalBridge');
|
||||
|
||||
const getWindowPool = () => {
|
||||
try {
|
||||
return getWindowManager().windowPool;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const sessionRepository = require('../common/repositories/session');
|
||||
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 {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize: {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
},
|
||||
});
|
||||
|
||||
if (sources.length === 0) {
|
||||
throw new Error('No screen sources available');
|
||||
}
|
||||
const source = sources[0];
|
||||
const buffer = source.thumbnail.toJPEG(70);
|
||||
const base64 = buffer.toString('base64');
|
||||
const size = source.thumbnail.getSize();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
base64,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to capture screenshot using desktopCapturer:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @class
|
||||
* @description
|
||||
*/
|
||||
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')
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const askService = new AskService();
|
||||
|
||||
module.exports = askService;
|
||||
38
src/features/ask/repositories/firebase.repository.js
Normal file
38
src/features/ask/repositories/firebase.repository.js
Normal 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,
|
||||
};
|
||||
25
src/features/ask/repositories/index.js
Normal file
25
src/features/ask/repositories/index.js
Normal file
@ -0,0 +1,25 @@
|
||||
const sqliteRepository = require('./sqlite.repository');
|
||||
const firebaseRepository = require('./firebase.repository');
|
||||
const authService = require('../../common/services/authService');
|
||||
|
||||
function getBaseRepository() {
|
||||
const user = authService.getCurrentUser();
|
||||
if (user && user.isLoggedIn) {
|
||||
return firebaseRepository;
|
||||
}
|
||||
return sqliteRepository;
|
||||
}
|
||||
|
||||
// The adapter layer that injects the UID
|
||||
const askRepositoryAdapter = {
|
||||
addAiMessage: ({ sessionId, role, content, model }) => {
|
||||
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;
|
||||
28
src/features/ask/repositories/sqlite.repository.js
Normal file
28
src/features/ask/repositories/sqlite.repository.js
Normal file
@ -0,0 +1,28 @@
|
||||
const sqliteClient = require('../../common/services/sqliteClient');
|
||||
|
||||
function addAiMessage({ uid, sessionId, role, content, model = 'unknown' }) {
|
||||
// uid is ignored in the SQLite implementation
|
||||
const db = sqliteClient.getDb();
|
||||
const messageId = require('crypto').randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `INSERT INTO ai_messages (id, session_id, sent_at, role, content, model, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
try {
|
||||
db.prepare(query).run(messageId, sessionId, now, role, content, model, now);
|
||||
return { id: messageId };
|
||||
} catch (err) {
|
||||
console.error('SQLite: Failed to add AI message:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllAiMessagesBySessionId(sessionId) {
|
||||
const db = sqliteClient.getDb();
|
||||
const query = "SELECT * FROM ai_messages WHERE session_id = ? ORDER BY sent_at ASC";
|
||||
return db.prepare(query).all(sessionId);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
addAiMessage,
|
||||
getAllAiMessagesBySessionId
|
||||
};
|
||||
185
src/features/common/ai/factory.js
Normal file
185
src/features/common/ai/factory.js
Normal file
@ -0,0 +1,185 @@
|
||||
// factory.js
|
||||
|
||||
/**
|
||||
* @typedef {object} ModelOption
|
||||
* @property {string} id
|
||||
* @property {string} name
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {object} Provider
|
||||
* @property {string} name
|
||||
* @property {() => any} handler
|
||||
* @property {ModelOption[]} llmModels
|
||||
* @property {ModelOption[]} sttModels
|
||||
*/
|
||||
|
||||
/**
|
||||
* @type {Object.<string, Provider>}
|
||||
*/
|
||||
const PROVIDERS = {
|
||||
'openai': {
|
||||
name: 'OpenAI',
|
||||
handler: () => require("./providers/openai"),
|
||||
llmModels: [
|
||||
{ id: 'gpt-4.1', name: 'GPT-4.1' },
|
||||
],
|
||||
sttModels: [
|
||||
{ id: 'gpt-4o-mini-transcribe', name: 'GPT-4o Mini Transcribe' }
|
||||
],
|
||||
},
|
||||
|
||||
'openai-glass': {
|
||||
name: 'OpenAI (Glass)',
|
||||
handler: () => require("./providers/openai"),
|
||||
llmModels: [
|
||||
{ id: 'gpt-4.1-glass', name: 'GPT-4.1 (glass)' },
|
||||
],
|
||||
sttModels: [
|
||||
{ id: 'gpt-4o-mini-transcribe-glass', name: 'GPT-4o Mini Transcribe (glass)' }
|
||||
],
|
||||
},
|
||||
'gemini': {
|
||||
name: 'Gemini',
|
||||
handler: () => require("./providers/gemini"),
|
||||
llmModels: [
|
||||
{ id: 'gemini-2.5-flash', name: 'Gemini 2.5 Flash' },
|
||||
],
|
||||
sttModels: [
|
||||
{ id: 'gemini-live-2.5-flash-preview', name: 'Gemini Live 2.5 Flash' }
|
||||
],
|
||||
},
|
||||
'anthropic': {
|
||||
name: 'Anthropic',
|
||||
handler: () => require("./providers/anthropic"),
|
||||
llmModels: [
|
||||
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
|
||||
],
|
||||
sttModels: [],
|
||||
},
|
||||
'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) {
|
||||
return (typeof model === 'string') ? model.replace(/-glass$/, '') : model;
|
||||
}
|
||||
|
||||
function createSTT(provider, opts) {
|
||||
if (provider === 'openai-glass') provider = 'openai';
|
||||
|
||||
const handler = PROVIDERS[provider]?.handler();
|
||||
if (!handler?.createSTT) {
|
||||
throw new Error(`STT not supported for provider: ${provider}`);
|
||||
}
|
||||
if (opts && opts.model) {
|
||||
opts = { ...opts, model: sanitizeModelId(opts.model) };
|
||||
}
|
||||
return handler.createSTT(opts);
|
||||
}
|
||||
|
||||
function createLLM(provider, opts) {
|
||||
if (provider === 'openai-glass') provider = 'openai';
|
||||
|
||||
const handler = PROVIDERS[provider]?.handler();
|
||||
if (!handler?.createLLM) {
|
||||
throw new Error(`LLM not supported for provider: ${provider}`);
|
||||
}
|
||||
if (opts && opts.model) {
|
||||
opts = { ...opts, model: sanitizeModelId(opts.model) };
|
||||
}
|
||||
return handler.createLLM(opts);
|
||||
}
|
||||
|
||||
function createStreamingLLM(provider, opts) {
|
||||
if (provider === 'openai-glass') provider = 'openai';
|
||||
|
||||
const handler = PROVIDERS[provider]?.handler();
|
||||
if (!handler?.createStreamingLLM) {
|
||||
throw new Error(`Streaming LLM not supported for provider: ${provider}`);
|
||||
}
|
||||
if (opts && opts.model) {
|
||||
opts = { ...opts, model: sanitizeModelId(opts.model) };
|
||||
}
|
||||
return handler.createStreamingLLM(opts);
|
||||
}
|
||||
|
||||
function 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() {
|
||||
const stt = [];
|
||||
const llm = [];
|
||||
for (const [id, provider] of Object.entries(PROVIDERS)) {
|
||||
if (provider.sttModels.length > 0) stt.push(id);
|
||||
if (provider.llmModels.length > 0) llm.push(id);
|
||||
}
|
||||
return { stt: [...new Set(stt)], llm: [...new Set(llm)] };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
PROVIDERS,
|
||||
createSTT,
|
||||
createLLM,
|
||||
createStreamingLLM,
|
||||
getProviderClass,
|
||||
getAvailableProviders,
|
||||
};
|
||||
327
src/features/common/ai/providers/anthropic.js
Normal file
327
src/features/common/ai/providers/anthropic.js
Normal file
@ -0,0 +1,327 @@
|
||||
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
|
||||
* Note: Anthropic doesn't have native real-time STT, so this is a placeholder
|
||||
* You might want to use a different STT service or implement a workaround
|
||||
* @param {object} opts - Configuration options
|
||||
* @param {string} opts.apiKey - Anthropic API key
|
||||
* @param {string} [opts.language='en'] - Language code
|
||||
* @param {object} [opts.callbacks] - Event callbacks
|
||||
* @returns {Promise<object>} STT session placeholder
|
||||
*/
|
||||
async function createSTT({ apiKey, language = "en", callbacks = {}, ...config }) {
|
||||
console.warn("[Anthropic] STT not natively supported. Consider using OpenAI or Gemini for STT.")
|
||||
|
||||
// Return a mock STT session that doesn't actually do anything
|
||||
// You might want to fallback to another provider for STT
|
||||
return {
|
||||
sendRealtimeInput: async (audioData) => {
|
||||
console.warn("[Anthropic] STT sendRealtimeInput called but not implemented")
|
||||
},
|
||||
close: async () => {
|
||||
console.log("[Anthropic] STT session closed")
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Anthropic LLM instance
|
||||
* @param {object} opts - Configuration options
|
||||
* @param {string} opts.apiKey - Anthropic API key
|
||||
* @param {string} [opts.model='claude-3-5-sonnet-20241022'] - Model name
|
||||
* @param {number} [opts.temperature=0.7] - Temperature
|
||||
* @param {number} [opts.maxTokens=4096] - Max tokens
|
||||
* @returns {object} LLM instance
|
||||
*/
|
||||
function createLLM({ apiKey, model = "claude-3-5-sonnet-20241022", temperature = 0.7, maxTokens = 4096, ...config }) {
|
||||
const client = new Anthropic({ apiKey })
|
||||
|
||||
return {
|
||||
generateContent: async (parts) => {
|
||||
const messages = []
|
||||
let systemPrompt = ""
|
||||
const userContent = []
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === "string") {
|
||||
if (systemPrompt === "" && part.includes("You are")) {
|
||||
systemPrompt = part
|
||||
} else {
|
||||
userContent.push({ type: "text", text: part })
|
||||
}
|
||||
} else if (part.inlineData) {
|
||||
userContent.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: part.inlineData.mimeType,
|
||||
data: part.inlineData.data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (userContent.length > 0) {
|
||||
messages.push({ role: "user", content: userContent })
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: model,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
system: systemPrompt || undefined,
|
||||
messages: messages,
|
||||
})
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: () => response.content[0].text,
|
||||
},
|
||||
raw: response,
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Anthropic API error:", error)
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
// For compatibility with chat-style interfaces
|
||||
chat: async (messages) => {
|
||||
let systemPrompt = ""
|
||||
const anthropicMessages = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
systemPrompt = msg.content
|
||||
} else {
|
||||
// Handle multimodal content
|
||||
let content
|
||||
if (Array.isArray(msg.content)) {
|
||||
content = []
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "string") {
|
||||
content.push({ type: "text", text: part })
|
||||
} else if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
} else if (part.type === "image_url" && part.image_url) {
|
||||
// Convert base64 image to Anthropic format
|
||||
const imageUrl = part.image_url.url
|
||||
const [mimeInfo, base64Data] = imageUrl.split(",")
|
||||
|
||||
// Extract the actual MIME type from the data URL
|
||||
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/jpeg"
|
||||
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mimeType,
|
||||
data: base64Data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
|
||||
anthropicMessages.push({
|
||||
role: msg.role === "user" ? "user" : "assistant",
|
||||
content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const response = await client.messages.create({
|
||||
model: model,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
system: systemPrompt || undefined,
|
||||
messages: anthropicMessages,
|
||||
})
|
||||
|
||||
return {
|
||||
content: response.content[0].text,
|
||||
raw: response,
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an Anthropic streaming LLM instance
|
||||
* @param {object} opts - Configuration options
|
||||
* @param {string} opts.apiKey - Anthropic API key
|
||||
* @param {string} [opts.model='claude-3-5-sonnet-20241022'] - Model name
|
||||
* @param {number} [opts.temperature=0.7] - Temperature
|
||||
* @param {number} [opts.maxTokens=4096] - Max tokens
|
||||
* @returns {object} Streaming LLM instance
|
||||
*/
|
||||
function createStreamingLLM({
|
||||
apiKey,
|
||||
model = "claude-3-5-sonnet-20241022",
|
||||
temperature = 0.7,
|
||||
maxTokens = 4096,
|
||||
...config
|
||||
}) {
|
||||
const client = new Anthropic({ apiKey })
|
||||
|
||||
return {
|
||||
streamChat: async (messages) => {
|
||||
console.log("[Anthropic Provider] Starting streaming request")
|
||||
|
||||
let systemPrompt = ""
|
||||
const anthropicMessages = []
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "system") {
|
||||
systemPrompt = msg.content
|
||||
} else {
|
||||
// Handle multimodal content
|
||||
let content
|
||||
if (Array.isArray(msg.content)) {
|
||||
content = []
|
||||
for (const part of msg.content) {
|
||||
if (typeof part === "string") {
|
||||
content.push({ type: "text", text: part })
|
||||
} else if (part.type === "text") {
|
||||
content.push({ type: "text", text: part.text })
|
||||
} else if (part.type === "image_url" && part.image_url) {
|
||||
// Convert base64 image to Anthropic format
|
||||
const imageUrl = part.image_url.url
|
||||
const [mimeInfo, base64Data] = imageUrl.split(",")
|
||||
|
||||
// Extract the actual MIME type from the data URL
|
||||
const mimeType = mimeInfo.match(/data:([^;]+)/)?.[1] || "image/jpeg"
|
||||
|
||||
console.log(`[Anthropic] Processing image with MIME type: ${mimeType}`)
|
||||
|
||||
content.push({
|
||||
type: "image",
|
||||
source: {
|
||||
type: "base64",
|
||||
media_type: mimeType,
|
||||
data: base64Data,
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
content = [{ type: "text", text: msg.content }]
|
||||
}
|
||||
|
||||
anthropicMessages.push({
|
||||
role: msg.role === "user" ? "user" : "assistant",
|
||||
content: content,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Create a ReadableStream to handle Anthropic's streaming
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
console.log("[Anthropic Provider] Processing messages:", anthropicMessages.length, "messages")
|
||||
|
||||
let chunkCount = 0
|
||||
let totalContent = ""
|
||||
|
||||
// Stream the response
|
||||
const stream = await client.messages.create({
|
||||
model: model,
|
||||
max_tokens: maxTokens,
|
||||
temperature: temperature,
|
||||
system: systemPrompt || undefined,
|
||||
messages: anthropicMessages,
|
||||
stream: true,
|
||||
})
|
||||
|
||||
for await (const chunk of stream) {
|
||||
if (chunk.type === "content_block_delta" && chunk.delta.type === "text_delta") {
|
||||
chunkCount++
|
||||
const chunkText = chunk.delta.text || ""
|
||||
totalContent += chunkText
|
||||
|
||||
// Format as SSE data
|
||||
const data = JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
delta: {
|
||||
content: chunkText,
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
controller.enqueue(new TextEncoder().encode(`data: ${data}\n\n`))
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Anthropic Provider] Streamed ${chunkCount} chunks, total length: ${totalContent.length} chars`,
|
||||
)
|
||||
|
||||
// Send the final done message
|
||||
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"))
|
||||
controller.close()
|
||||
console.log("[Anthropic Provider] Streaming completed successfully")
|
||||
} catch (error) {
|
||||
console.error("[Anthropic Provider] Streaming error:", error)
|
||||
controller.error(error)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Create a Response object with the stream
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache",
|
||||
Connection: "keep-alive",
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AnthropicProvider,
|
||||
createSTT,
|
||||
createLLM,
|
||||
createStreamingLLM
|
||||
};
|
||||
111
src/features/common/ai/providers/deepgram.js
Normal file
111
src/features/common/ai/providers/deepgram.js
Normal 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 (10 s)'));
|
||||
}, 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
|
||||
};
|
||||
328
src/features/common/ai/providers/gemini.js
Normal file
328
src/features/common/ai/providers/gemini.js
Normal 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
|
||||
};
|
||||
342
src/features/common/ai/providers/ollama.js
Normal file
342
src/features/common/ai/providers/ollama.js
Normal 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
|
||||
};
|
||||
294
src/features/common/ai/providers/openai.js
Normal file
294
src/features/common/ai/providers/openai.js
Normal file
@ -0,0 +1,294 @@
|
||||
const OpenAI = require('openai');
|
||||
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
|
||||
* @param {object} opts - Configuration options
|
||||
* @param {string} opts.apiKey - OpenAI API key
|
||||
* @param {string} [opts.language='en'] - Language code
|
||||
* @param {object} [opts.callbacks] - Event callbacks
|
||||
* @param {boolean} [opts.usePortkey=false] - Whether to use Portkey
|
||||
* @param {string} [opts.portkeyVirtualKey] - Portkey virtual key
|
||||
* @returns {Promise<object>} STT session
|
||||
*/
|
||||
async function createSTT({ apiKey, language = 'en', callbacks = {}, usePortkey = false, portkeyVirtualKey, ...config }) {
|
||||
const keyType = usePortkey ? 'vKey' : 'apiKey';
|
||||
const key = usePortkey ? (portkeyVirtualKey || apiKey) : apiKey;
|
||||
|
||||
const wsUrl = keyType === 'apiKey'
|
||||
? 'wss://api.openai.com/v1/realtime?intent=transcription'
|
||||
: 'wss://api.portkey.ai/v1/realtime?intent=transcription';
|
||||
|
||||
const headers = keyType === 'apiKey'
|
||||
? {
|
||||
'Authorization': `Bearer ${key}`,
|
||||
'OpenAI-Beta': 'realtime=v1',
|
||||
}
|
||||
: {
|
||||
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
|
||||
'x-portkey-virtual-key': key,
|
||||
'OpenAI-Beta': 'realtime=v1',
|
||||
};
|
||||
|
||||
const ws = new WebSocket(wsUrl, { headers });
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
ws.onopen = () => {
|
||||
console.log("WebSocket session opened.");
|
||||
|
||||
const sessionConfig = {
|
||||
type: 'transcription_session.update',
|
||||
session: {
|
||||
input_audio_format: 'pcm16',
|
||||
input_audio_transcription: {
|
||||
model: 'gpt-4o-mini-transcribe',
|
||||
prompt: config.prompt || '',
|
||||
language: language || 'en'
|
||||
},
|
||||
turn_detection: {
|
||||
type: 'server_vad',
|
||||
threshold: 0.5,
|
||||
prefix_padding_ms: 200,
|
||||
silence_duration_ms: 100,
|
||||
},
|
||||
input_audio_noise_reduction: {
|
||||
type: 'near_field'
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
ws.send(JSON.stringify(sessionConfig));
|
||||
|
||||
resolve({
|
||||
sendRealtimeInput: (audioData) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const message = {
|
||||
type: 'input_audio_buffer.append',
|
||||
audio: audioData
|
||||
};
|
||||
ws.send(JSON.stringify(message));
|
||||
}
|
||||
},
|
||||
close: () => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'session.close' }));
|
||||
ws.onmessage = ws.onerror = () => {}; // 핸들러 제거
|
||||
ws.close(1000, 'Client initiated close.');
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
// ── 종료·하트비트 패킷 필터링 ──────────────────────────────
|
||||
if (!event.data || event.data === 'null' || event.data === '[DONE]') return;
|
||||
|
||||
let msg;
|
||||
try { msg = JSON.parse(event.data); }
|
||||
catch { return; } // JSON 파싱 실패 무시
|
||||
|
||||
if (!msg || typeof msg !== 'object') return;
|
||||
|
||||
msg.provider = 'openai'; // ← 항상 명시
|
||||
callbacks.onmessage?.(msg);
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error:', error.message);
|
||||
if (callbacks && callbacks.onerror) {
|
||||
callbacks.onerror(error);
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
|
||||
ws.onclose = (event) => {
|
||||
console.log(`WebSocket closed: ${event.code} ${event.reason}`);
|
||||
if (callbacks && callbacks.onclose) {
|
||||
callbacks.onclose(event);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OpenAI LLM instance
|
||||
* @param {object} opts - Configuration options
|
||||
* @param {string} opts.apiKey - OpenAI API key
|
||||
* @param {string} [opts.model='gpt-4.1'] - Model name
|
||||
* @param {number} [opts.temperature=0.7] - Temperature
|
||||
* @param {number} [opts.maxTokens=2048] - Max tokens
|
||||
* @param {boolean} [opts.usePortkey=false] - Whether to use Portkey
|
||||
* @param {string} [opts.portkeyVirtualKey] - Portkey virtual key
|
||||
* @returns {object} LLM instance
|
||||
*/
|
||||
function createLLM({ apiKey, model = 'gpt-4.1', temperature = 0.7, maxTokens = 2048, usePortkey = false, portkeyVirtualKey, ...config }) {
|
||||
const client = new OpenAI({ apiKey });
|
||||
|
||||
const callApi = async (messages) => {
|
||||
if (!usePortkey) {
|
||||
const response = await client.chat.completions.create({
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: temperature,
|
||||
max_tokens: maxTokens
|
||||
});
|
||||
return {
|
||||
content: response.choices[0].message.content.trim(),
|
||||
raw: response
|
||||
};
|
||||
} else {
|
||||
const fetchUrl = 'https://api.portkey.ai/v1/chat/completions';
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
|
||||
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Portkey API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return {
|
||||
content: result.choices[0].message.content.trim(),
|
||||
raw: result
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
generateContent: async (parts) => {
|
||||
const messages = [];
|
||||
let systemPrompt = '';
|
||||
let userContent = [];
|
||||
|
||||
for (const part of parts) {
|
||||
if (typeof part === 'string') {
|
||||
if (systemPrompt === '' && part.includes('You are')) {
|
||||
systemPrompt = part;
|
||||
} else {
|
||||
userContent.push({ type: 'text', text: part });
|
||||
}
|
||||
} else if (part.inlineData) {
|
||||
userContent.push({
|
||||
type: 'image_url',
|
||||
image_url: { url: `data:${part.inlineData.mimeType};base64,${part.inlineData.data}` }
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (systemPrompt) messages.push({ role: 'system', content: systemPrompt });
|
||||
if (userContent.length > 0) messages.push({ role: 'user', content: userContent });
|
||||
|
||||
const result = await callApi(messages);
|
||||
|
||||
return {
|
||||
response: {
|
||||
text: () => result.content
|
||||
},
|
||||
raw: result.raw
|
||||
};
|
||||
},
|
||||
|
||||
// For compatibility with chat-style interfaces
|
||||
chat: async (messages) => {
|
||||
return await callApi(messages);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an OpenAI streaming LLM instance
|
||||
* @param {object} opts - Configuration options
|
||||
* @param {string} opts.apiKey - OpenAI API key
|
||||
* @param {string} [opts.model='gpt-4.1'] - Model name
|
||||
* @param {number} [opts.temperature=0.7] - Temperature
|
||||
* @param {number} [opts.maxTokens=2048] - Max tokens
|
||||
* @param {boolean} [opts.usePortkey=false] - Whether to use Portkey
|
||||
* @param {string} [opts.portkeyVirtualKey] - Portkey virtual key
|
||||
* @returns {object} Streaming LLM instance
|
||||
*/
|
||||
function createStreamingLLM({ apiKey, model = 'gpt-4.1', temperature = 0.7, maxTokens = 2048, usePortkey = false, portkeyVirtualKey, ...config }) {
|
||||
return {
|
||||
streamChat: async (messages) => {
|
||||
const fetchUrl = usePortkey
|
||||
? 'https://api.portkey.ai/v1/chat/completions'
|
||||
: 'https://api.openai.com/v1/chat/completions';
|
||||
|
||||
const headers = usePortkey
|
||||
? {
|
||||
'x-portkey-api-key': 'gRv2UGRMq6GGLJ8aVEB4e7adIewu',
|
||||
'x-portkey-virtual-key': portkeyVirtualKey || apiKey,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
const response = await fetch(fetchUrl, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
model: model,
|
||||
messages,
|
||||
temperature,
|
||||
max_tokens: maxTokens,
|
||||
stream: true,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
OpenAIProvider,
|
||||
createSTT,
|
||||
createLLM,
|
||||
createStreamingLLM
|
||||
};
|
||||
241
src/features/common/ai/providers/whisper.js
Normal file
241
src/features/common/ai/providers/whisper.js
Normal 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
|
||||
};
|
||||
54
src/features/common/config/checksums.js
Normal file
54
src/features/common/config/checksums.js
Normal 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 };
|
||||
@ -5,8 +5,8 @@ const LATEST_SCHEMA = {
|
||||
{ name: 'display_name', type: 'TEXT NOT NULL' },
|
||||
{ name: 'email', type: 'TEXT NOT NULL' },
|
||||
{ name: 'created_at', type: 'INTEGER' },
|
||||
{ name: 'api_key', type: 'TEXT' },
|
||||
{ name: 'provider', type: 'TEXT DEFAULT \'openai\'' }
|
||||
{ name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' },
|
||||
{ name: 'has_migrated_to_firebase', type: 'INTEGER DEFAULT 0' }
|
||||
]
|
||||
},
|
||||
sessions: {
|
||||
@ -71,6 +71,49 @@ const LATEST_SCHEMA = {
|
||||
{ name: 'created_at', type: 'INTEGER' },
|
||||
{ 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' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
60
src/features/common/repositories/firestoreConverter.js
Normal file
60
src/features/common/repositories/firestoreConverter.js
Normal 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,
|
||||
};
|
||||
20
src/features/common/repositories/ollamaModel/index.js
Normal file
20
src/features/common/repositories/ollamaModel/index.js
Normal 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)
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
11
src/features/common/repositories/permission/index.js
Normal file
11
src/features/common/repositories/permission/index.js
Normal file
@ -0,0 +1,11 @@
|
||||
const sqliteRepository = require('./sqlite.repository');
|
||||
|
||||
// This repository is not user-specific, so we always return sqlite.
|
||||
function getRepository() {
|
||||
return sqliteRepository;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
markKeychainCompleted: (...args) => getRepository().markKeychainCompleted(...args),
|
||||
checkKeychainCompleted: (...args) => getRepository().checkKeychainCompleted(...args),
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
107
src/features/common/repositories/preset/firebase.repository.js
Normal file
107
src/features/common/repositories/preset/firebase.repository.js
Normal 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,
|
||||
};
|
||||
39
src/features/common/repositories/preset/index.js
Normal file
39
src/features/common/repositories/preset/index.js
Normal 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;
|
||||
85
src/features/common/repositories/preset/sqlite.repository.js
Normal file
85
src/features/common/repositories/preset/sqlite.repository.js
Normal file
@ -0,0 +1,85 @@
|
||||
const sqliteClient = require('../../services/sqliteClient');
|
||||
|
||||
function getPresets(uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
const query = `
|
||||
SELECT * FROM prompt_presets
|
||||
WHERE uid = ? OR is_default = 1
|
||||
ORDER BY is_default DESC, title ASC
|
||||
`;
|
||||
|
||||
try {
|
||||
return db.prepare(query).all(uid);
|
||||
} catch (err) {
|
||||
console.error('SQLite: Failed to get presets:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getPresetTemplates() {
|
||||
const db = sqliteClient.getDb();
|
||||
const query = `
|
||||
SELECT * FROM prompt_presets
|
||||
WHERE is_default = 1
|
||||
ORDER BY title ASC
|
||||
`;
|
||||
|
||||
try {
|
||||
return db.prepare(query).all();
|
||||
} catch (err) {
|
||||
console.error('SQLite: Failed to get preset templates:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function create({ uid, title, prompt }) {
|
||||
const db = sqliteClient.getDb();
|
||||
const presetId = require('crypto').randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `INSERT INTO prompt_presets (id, uid, title, prompt, is_default, created_at, sync_state) VALUES (?, ?, ?, ?, 0, ?, 'dirty')`;
|
||||
|
||||
try {
|
||||
db.prepare(query).run(presetId, uid, title, prompt, now);
|
||||
return { id: presetId };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function update(id, { title, prompt }, uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
const query = `UPDATE prompt_presets SET title = ?, prompt = ?, sync_state = 'dirty' WHERE id = ? AND uid = ? AND is_default = 0`;
|
||||
|
||||
try {
|
||||
const result = db.prepare(query).run(title, prompt, id, uid);
|
||||
if (result.changes === 0) {
|
||||
throw new Error("Preset not found or permission denied.");
|
||||
}
|
||||
return { changes: result.changes };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function del(id, uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
const query = `DELETE FROM prompt_presets WHERE id = ? AND uid = ? AND is_default = 0`;
|
||||
|
||||
try {
|
||||
const result = db.prepare(query).run(id, uid);
|
||||
if (result.changes === 0) {
|
||||
throw new Error("Preset not found or permission denied.");
|
||||
}
|
||||
return { changes: result.changes };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getPresets,
|
||||
getPresetTemplates,
|
||||
create,
|
||||
update,
|
||||
delete: del
|
||||
};
|
||||
68
src/features/common/repositories/providerSettings/index.js
Normal file
68
src/features/common/repositories/providerSettings/index.js
Normal 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
|
||||
};
|
||||
@ -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
|
||||
};
|
||||
161
src/features/common/repositories/session/firebase.repository.js
Normal file
161
src/features/common/repositories/session/firebase.repository.js
Normal 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,
|
||||
};
|
||||
60
src/features/common/repositories/session/index.js
Normal file
60
src/features/common/repositories/session/index.js
Normal 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;
|
||||
138
src/features/common/repositories/session/sqlite.repository.js
Normal file
138
src/features/common/repositories/session/sqlite.repository.js
Normal file
@ -0,0 +1,138 @@
|
||||
const sqliteClient = require('../../services/sqliteClient');
|
||||
|
||||
function getById(id) {
|
||||
const db = sqliteClient.getDb();
|
||||
return db.prepare('SELECT * FROM sessions WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function create(uid, type = 'ask') {
|
||||
const db = sqliteClient.getDb();
|
||||
const sessionId = require('crypto').randomUUID();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `INSERT INTO sessions (id, uid, title, session_type, started_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`;
|
||||
|
||||
try {
|
||||
db.prepare(query).run(sessionId, uid, `Session @ ${new Date().toLocaleTimeString()}`, type, now, now);
|
||||
console.log(`SQLite: Created session ${sessionId} for user ${uid} (type: ${type})`);
|
||||
return sessionId;
|
||||
} catch (err) {
|
||||
console.error('SQLite: Failed to create session:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function getAllByUserId(uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
const query = "SELECT id, uid, title, session_type, started_at, ended_at, sync_state, updated_at FROM sessions WHERE uid = ? ORDER BY started_at DESC";
|
||||
return db.prepare(query).all(uid);
|
||||
}
|
||||
|
||||
function updateTitle(id, title) {
|
||||
const db = sqliteClient.getDb();
|
||||
const result = db.prepare('UPDATE sessions SET title = ? WHERE id = ?').run(title, id);
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function deleteWithRelatedData(id) {
|
||||
const db = sqliteClient.getDb();
|
||||
const transaction = db.transaction(() => {
|
||||
db.prepare("DELETE FROM transcripts WHERE session_id = ?").run(id);
|
||||
db.prepare("DELETE FROM ai_messages WHERE session_id = ?").run(id);
|
||||
db.prepare("DELETE FROM summaries WHERE session_id = ?").run(id);
|
||||
db.prepare("DELETE FROM sessions WHERE id = ?").run(id);
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function end(id) {
|
||||
const db = sqliteClient.getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = `UPDATE sessions SET ended_at = ?, updated_at = ? WHERE id = ?`;
|
||||
const result = db.prepare(query).run(now, now, id);
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function updateType(id, type) {
|
||||
const db = sqliteClient.getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = 'UPDATE sessions SET session_type = ?, updated_at = ? WHERE id = ?';
|
||||
const result = db.prepare(query).run(type, now, id);
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function touch(id) {
|
||||
const db = sqliteClient.getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const query = 'UPDATE sessions SET updated_at = ? WHERE id = ?';
|
||||
const result = db.prepare(query).run(now, id);
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function getOrCreateActive(uid, requestedType = 'ask') {
|
||||
const db = sqliteClient.getDb();
|
||||
|
||||
// 1. Look for ANY active session for the user (ended_at IS NULL).
|
||||
// Prefer 'listen' sessions over 'ask' sessions to ensure continuity.
|
||||
const findQuery = `
|
||||
SELECT id, session_type FROM sessions
|
||||
WHERE uid = ? AND ended_at IS NULL
|
||||
ORDER BY CASE session_type WHEN 'listen' THEN 1 WHEN 'ask' THEN 2 ELSE 3 END
|
||||
LIMIT 1
|
||||
`;
|
||||
|
||||
const activeSession = db.prepare(findQuery).get(uid);
|
||||
|
||||
if (activeSession) {
|
||||
// An active session exists.
|
||||
console.log(`[Repo] Found active session ${activeSession.id} of type ${activeSession.session_type}`);
|
||||
|
||||
// 2. Promotion Logic: If it's an 'ask' session and we need 'listen', promote it.
|
||||
if (activeSession.session_type === 'ask' && requestedType === 'listen') {
|
||||
updateType(activeSession.id, 'listen');
|
||||
console.log(`[Repo] Promoted session ${activeSession.id} to 'listen' type.`);
|
||||
}
|
||||
|
||||
// 3. Touch the session and return its ID.
|
||||
touch(activeSession.id);
|
||||
return activeSession.id;
|
||||
} else {
|
||||
// 4. No active session found, create a new one.
|
||||
console.log(`[Repo] No active session for user ${uid}. Creating new '${requestedType}' session.`);
|
||||
return create(uid, requestedType);
|
||||
}
|
||||
}
|
||||
|
||||
function endAllActiveSessions(uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
// 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 {
|
||||
const result = db.prepare(query).run(now, now, uid);
|
||||
console.log(`[Repo] Ended ${result.changes} active SQLite session(s) for user ${uid}.`);
|
||||
return { changes: result.changes };
|
||||
} catch (err) {
|
||||
console.error('SQLite: Failed to end all active sessions:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getById,
|
||||
create,
|
||||
getAllByUserId,
|
||||
updateTitle,
|
||||
deleteWithRelatedData,
|
||||
end,
|
||||
updateType,
|
||||
touch,
|
||||
getOrCreateActive,
|
||||
endAllActiveSessions,
|
||||
};
|
||||
86
src/features/common/repositories/user/firebase.repository.js
Normal file
86
src/features/common/repositories/user/firebase.repository.js
Normal 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,
|
||||
};
|
||||
51
src/features/common/repositories/user/index.js
Normal file
51
src/features/common/repositories/user/index.js
Normal 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
|
||||
};
|
||||
92
src/features/common/repositories/user/sqlite.repository.js
Normal file
92
src/features/common/repositories/user/sqlite.repository.js
Normal file
@ -0,0 +1,92 @@
|
||||
const sqliteClient = require('../../services/sqliteClient');
|
||||
|
||||
function findOrCreate(user) {
|
||||
const db = sqliteClient.getDb();
|
||||
|
||||
if (!user || !user.uid) {
|
||||
throw new Error('User object and uid are required');
|
||||
}
|
||||
|
||||
const { uid, displayName, email } = user;
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
|
||||
// Validate inputs
|
||||
const safeDisplayName = displayName || 'User';
|
||||
const safeEmail = email || 'no-email@example.com';
|
||||
|
||||
const query = `
|
||||
INSERT INTO users (uid, display_name, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(uid) DO UPDATE SET
|
||||
display_name=excluded.display_name,
|
||||
email=excluded.email
|
||||
`;
|
||||
|
||||
try {
|
||||
console.log('[SQLite] Creating/updating user:', { uid, displayName: safeDisplayName, email: safeEmail });
|
||||
db.prepare(query).run(uid, safeDisplayName, safeEmail, now);
|
||||
const result = getById(uid);
|
||||
console.log('[SQLite] User operation successful:', result);
|
||||
return result;
|
||||
} catch (err) {
|
||||
console.error('SQLite: Failed to find or create user:', err);
|
||||
console.error('SQLite: User data:', { uid, displayName: safeDisplayName, email: safeEmail });
|
||||
throw new Error(`Failed to create user in database: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function getById(uid) {
|
||||
const db = sqliteClient.getDb();
|
||||
return db.prepare('SELECT * FROM users WHERE uid = ?').get(uid);
|
||||
}
|
||||
|
||||
|
||||
|
||||
function update({ uid, displayName }) {
|
||||
const db = sqliteClient.getDb();
|
||||
const result = db.prepare('UPDATE users SET display_name = ? WHERE uid = ?').run(displayName, uid);
|
||||
return { changes: result.changes };
|
||||
}
|
||||
|
||||
function 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) {
|
||||
const db = sqliteClient.getDb();
|
||||
const userSessions = db.prepare('SELECT id FROM sessions WHERE uid = ?').all(uid);
|
||||
const sessionIds = userSessions.map(s => s.id);
|
||||
|
||||
const transaction = db.transaction(() => {
|
||||
if (sessionIds.length > 0) {
|
||||
const placeholders = sessionIds.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM transcripts WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM ai_messages WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM summaries WHERE session_id IN (${placeholders})`).run(...sessionIds);
|
||||
db.prepare(`DELETE FROM sessions WHERE uid = ?`).run(uid);
|
||||
}
|
||||
db.prepare('DELETE FROM prompt_presets WHERE uid = ? AND is_default = 0').run(uid);
|
||||
db.prepare('DELETE FROM users WHERE uid = ?').run(uid);
|
||||
});
|
||||
|
||||
try {
|
||||
transaction();
|
||||
return { success: true };
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
findOrCreate,
|
||||
getById,
|
||||
update,
|
||||
setMigrationComplete,
|
||||
deleteById
|
||||
};
|
||||
53
src/features/common/repositories/whisperModel/index.js
Normal file
53
src/features/common/repositories/whisperModel/index.js
Normal 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;
|
||||
211
src/features/common/services/authService.js
Normal file
211
src/features/common/services/authService.js
Normal 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;
|
||||
@ -10,10 +10,13 @@ class DatabaseInitializer {
|
||||
|
||||
// 최종적으로 사용될 DB 경로 (쓰기 가능한 위치)
|
||||
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.dataDir = userDataPath;
|
||||
|
||||
// 원본 DB 경로 (패키지 내 읽기 전용 위치)
|
||||
// The original DB path (read-only location in the package)
|
||||
this.sourceDbPath = app.isPackaged
|
||||
? path.join(process.resourcesPath, 'data', 'pickleglass.db')
|
||||
: path.join(app.getAppPath(), 'data', 'pickleglass.db');
|
||||
@ -52,7 +55,7 @@ class DatabaseInitializer {
|
||||
try {
|
||||
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.
|
||||
await sqliteClient.initTables();
|
||||
175
src/features/common/services/encryptionService.js
Normal file
175
src/features/common/services/encryptionService.js
Normal 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,
|
||||
};
|
||||
119
src/features/common/services/firebaseClient.js
Normal file
119
src/features/common/services/firebaseClient.js
Normal file
@ -0,0 +1,119 @@
|
||||
const { initializeApp } = require('firebase/app');
|
||||
const { initializeAuth } = require('firebase/auth');
|
||||
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*,
|
||||
* not instances. It then calls `new PersistenceClass()` internally.
|
||||
*
|
||||
* The helper below returns such a class, pre-configured with an `electron-store` instance that
|
||||
* will be shared across all constructed objects. This mirrors the pattern used by Firebase's own
|
||||
* `browserLocalPersistence` implementation as well as community solutions for NodeJS.
|
||||
*/
|
||||
function createElectronStorePersistence(storeName = 'firebase-auth-session') {
|
||||
// Create a single `electron-store` behind the scenes – all Persistence instances will use it.
|
||||
const sharedStore = new Store({ name: storeName });
|
||||
|
||||
return class ElectronStorePersistence {
|
||||
constructor() {
|
||||
this.store = sharedStore;
|
||||
this.type = 'LOCAL';
|
||||
}
|
||||
|
||||
/**
|
||||
* Firebase calls this to check whether the persistence is usable in the current context.
|
||||
*/
|
||||
_isAvailable() {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
async _set(key, value) {
|
||||
this.store.set(key, value);
|
||||
}
|
||||
|
||||
async _get(key) {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
async _remove(key) {
|
||||
this.store.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* These are used by Firebase to react to external storage events (e.g. multi-tab).
|
||||
* Electron apps are single-renderer per process, so we can safely provide no-op
|
||||
* implementations.
|
||||
*/
|
||||
_addListener(_key, _listener) {
|
||||
// no-op
|
||||
}
|
||||
|
||||
_removeListener(_key, _listener) {
|
||||
// no-op
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const firebaseConfig = {
|
||||
apiKey: 'AIzaSyAgtJrmsFWG1C7m9S55HyT1laICEzuUS2g',
|
||||
authDomain: 'pickle-3651a.firebaseapp.com',
|
||||
projectId: 'pickle-3651a',
|
||||
storageBucket: 'pickle-3651a.firebasestorage.app',
|
||||
messagingSenderId: '904706892885',
|
||||
appId: '1:904706892885:web:0e42b3dda796674ead20dc',
|
||||
measurementId: 'G-SQ0WM6S28T',
|
||||
};
|
||||
|
||||
let firebaseApp = null;
|
||||
let firebaseAuth = null;
|
||||
let firestoreInstance = null; // To hold the specific DB instance
|
||||
|
||||
function initializeFirebase() {
|
||||
if (firebaseApp) {
|
||||
console.log('[FirebaseClient] Firebase already initialized.');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
firebaseApp = initializeApp(firebaseConfig);
|
||||
|
||||
// Build a *class* persistence provider and hand it to Firebase.
|
||||
const ElectronStorePersistence = createElectronStorePersistence('firebase-auth-session');
|
||||
|
||||
firebaseAuth = initializeAuth(firebaseApp, {
|
||||
// `initializeAuth` accepts a single class or an array – we pass an array for future
|
||||
// extensibility and to match Firebase examples.
|
||||
persistence: [ElectronStorePersistence],
|
||||
});
|
||||
|
||||
// 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] Firestore instance is targeting the "pickle-glass" database.');
|
||||
} catch (error) {
|
||||
console.error('[FirebaseClient] Firebase initialization failed:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function getFirebaseAuth() {
|
||||
if (!firebaseAuth) {
|
||||
throw new Error("Firebase Auth has not been initialized. Call initializeFirebase() first.");
|
||||
}
|
||||
return firebaseAuth;
|
||||
}
|
||||
|
||||
function getFirestoreInstance() {
|
||||
if (!firestoreInstance) {
|
||||
throw new Error("Firestore has not been initialized. Call initializeFirebase() first.");
|
||||
}
|
||||
return firestoreInstance;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeFirebase,
|
||||
getFirebaseAuth,
|
||||
getFirestoreInstance,
|
||||
};
|
||||
639
src/features/common/services/localAIManager.js
Normal file
639
src/features/common/services/localAIManager.js
Normal 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;
|
||||
192
src/features/common/services/migrationService.js
Normal file
192
src/features/common/services/migrationService.js
Normal 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,
|
||||
};
|
||||
437
src/features/common/services/modelStateService.js
Normal file
437
src/features/common/services/modelStateService.js
Normal 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;
|
||||
1533
src/features/common/services/ollamaService.js
Normal file
1533
src/features/common/services/ollamaService.js
Normal file
File diff suppressed because it is too large
Load Diff
124
src/features/common/services/permissionService.js
Normal file
124
src/features/common/services/permissionService.js
Normal 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;
|
||||
275
src/features/common/services/sqliteClient.js
Normal file
275
src/features/common/services/sqliteClient.js
Normal file
@ -0,0 +1,275 @@
|
||||
const Database = require('better-sqlite3');
|
||||
const path = require('path');
|
||||
const LATEST_SCHEMA = require('../config/schema');
|
||||
|
||||
class SQLiteClient {
|
||||
constructor() {
|
||||
this.db = null;
|
||||
this.dbPath = null;
|
||||
this.defaultUserId = 'default_user';
|
||||
}
|
||||
|
||||
connect(dbPath) {
|
||||
if (this.db) {
|
||||
console.log('[SQLiteClient] Already connected.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
this.dbPath = dbPath;
|
||||
this.db = new Database(this.dbPath);
|
||||
this.db.pragma('journal_mode = WAL');
|
||||
console.log('[SQLiteClient] Connected successfully to:', this.dbPath);
|
||||
} catch (err) {
|
||||
console.error('[SQLiteClient] Could not connect to database', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
getDb() {
|
||||
if (!this.db) {
|
||||
throw new Error("Database not connected. Call connect() first.");
|
||||
}
|
||||
return this.db;
|
||||
}
|
||||
|
||||
_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...');
|
||||
|
||||
// Run special migration for provider_settings before the generic sync logic
|
||||
this._migrateProviderSettings();
|
||||
|
||||
const tablesInDb = this.getTablesFromDb();
|
||||
|
||||
for (const tableName of Object.keys(LATEST_SCHEMA)) {
|
||||
const tableSchema = LATEST_SCHEMA[tableName];
|
||||
|
||||
if (!tablesInDb.includes(tableName)) {
|
||||
// Table doesn't exist, create it
|
||||
this.createTable(tableName, tableSchema);
|
||||
} else {
|
||||
// Table exists, check for missing columns
|
||||
this.updateTable(tableName, tableSchema);
|
||||
}
|
||||
}
|
||||
console.log('[DB Sync] Schema synchronization finished.');
|
||||
}
|
||||
|
||||
getTablesFromDb() {
|
||||
const tables = this.db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all();
|
||||
return tables.map(t => t.name);
|
||||
}
|
||||
|
||||
createTable(tableName, tableSchema) {
|
||||
const safeTableName = this._validateAndQuoteIdentifier(tableName);
|
||||
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}`);
|
||||
this.db.exec(query);
|
||||
}
|
||||
|
||||
updateTable(tableName, tableSchema) {
|
||||
const safeTableName = this._validateAndQuoteIdentifier(tableName);
|
||||
|
||||
// Get current columns
|
||||
const currentColumns = this.db.prepare(`PRAGMA table_info(${safeTableName})`).all();
|
||||
const currentColumnNames = currentColumns.map(col => col.name);
|
||||
|
||||
// Check for new columns to add
|
||||
const newColumns = tableSchema.columns.filter(col => !currentColumnNames.includes(col.name));
|
||||
|
||||
if (newColumns.length > 0) {
|
||||
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 = []) {
|
||||
return this.db.prepare(query).run(params);
|
||||
}
|
||||
|
||||
cleanupEmptySessions() {
|
||||
console.log('[DB Cleanup] Checking for empty sessions...');
|
||||
const query = `
|
||||
SELECT s.id FROM sessions s
|
||||
LEFT JOIN transcripts t ON s.id = t.session_id
|
||||
LEFT JOIN ai_messages a ON s.id = a.session_id
|
||||
LEFT JOIN summaries su ON s.id = su.session_id
|
||||
WHERE t.id IS NULL AND a.id IS NULL AND su.session_id IS NULL
|
||||
`;
|
||||
|
||||
const rows = this.db.prepare(query).all();
|
||||
|
||||
if (rows.length === 0) {
|
||||
console.log('[DB Cleanup] No empty sessions found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const idsToDelete = rows.map(r => r.id);
|
||||
const placeholders = idsToDelete.map(() => '?').join(',');
|
||||
const deleteQuery = `DELETE FROM sessions WHERE id IN (${placeholders})`;
|
||||
|
||||
console.log(`[DB Cleanup] Found ${idsToDelete.length} empty sessions. Deleting...`);
|
||||
const result = this.db.prepare(deleteQuery).run(idsToDelete);
|
||||
console.log(`[DB Cleanup] Successfully deleted ${result.changes} empty sessions.`);
|
||||
}
|
||||
|
||||
async initTables() {
|
||||
await this.synchronizeSchema();
|
||||
this.initDefaultData();
|
||||
}
|
||||
|
||||
initDefaultData() {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const initUserQuery = `
|
||||
INSERT OR IGNORE INTO users (uid, display_name, email, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
`;
|
||||
|
||||
this.db.prepare(initUserQuery).run(this.defaultUserId, 'Default User', 'contact@pickle.com', now);
|
||||
|
||||
const defaultPresets = [
|
||||
['school', 'School', 'You are a school and lecture assistant. Your goal is to help the user, a student, understand academic material and answer questions.\n\nWhenever a question appears on the user\'s screen or is asked aloud, you provide a direct, step-by-step answer, showing all necessary reasoning or calculations.\n\nIf the user is watching a lecture or working through new material, you offer concise explanations of key concepts and clarify definitions as they come up.', 1],
|
||||
['meetings', 'Meetings', 'You are a meeting assistant. Your goal is to help the user capture key information during meetings and follow up effectively.\n\nYou help capture meeting notes, track action items, identify key decisions, and summarize important points discussed during meetings.', 1],
|
||||
['sales', 'Sales', 'You are a real-time AI sales assistant, and your goal is to help the user close deals during sales interactions.\n\nYou provide real-time sales support, suggest responses to objections, help identify customer needs, and recommend strategies to advance deals.', 1],
|
||||
['recruiting', 'Recruiting', 'You are a recruiting assistant. Your goal is to help the user interview candidates and evaluate talent effectively.\n\nYou help evaluate candidates, suggest interview questions, analyze responses, and provide insights about candidate fit for positions.', 1],
|
||||
['customer-support', 'Customer Support', 'You are a customer support assistant. Your goal is to help resolve customer issues efficiently and thoroughly.\n\nYou help diagnose customer problems, suggest solutions, provide step-by-step troubleshooting guidance, and ensure customer satisfaction.', 1],
|
||||
];
|
||||
|
||||
const stmt = this.db.prepare(`
|
||||
INSERT OR IGNORE INTO prompt_presets (id, uid, title, prompt, is_default, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
for (const preset of defaultPresets) {
|
||||
stmt.run(preset[0], this.defaultUserId, preset[1], preset[2], preset[3], now);
|
||||
}
|
||||
|
||||
console.log('Default data initialized.');
|
||||
}
|
||||
|
||||
close() {
|
||||
if (this.db) {
|
||||
try {
|
||||
this.db.close();
|
||||
console.log('SQLite connection closed.');
|
||||
} catch (err) {
|
||||
console.error('SQLite connection close failed:', err);
|
||||
}
|
||||
this.db = null;
|
||||
}
|
||||
}
|
||||
|
||||
query(sql, params = []) {
|
||||
if (!this.db) {
|
||||
throw new Error('Database not connected');
|
||||
}
|
||||
|
||||
try {
|
||||
if (sql.toUpperCase().startsWith('SELECT')) {
|
||||
return this.db.prepare(sql).all(params);
|
||||
} else {
|
||||
const result = this.db.prepare(sql).run(params);
|
||||
return { changes: result.changes, lastID: result.lastID };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Query error:', err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const sqliteClient = new SQLiteClient();
|
||||
module.exports = sqliteClient;
|
||||
877
src/features/common/services/whisperService.js
Normal file
877
src/features/common/services/whisperService.js
Normal 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;
|
||||
39
src/features/common/utils/spawnHelper.js
Normal file
39
src/features/common/utils/spawnHelper.js
Normal 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 };
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,179 +0,0 @@
|
||||
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export class SetupView extends LitElement {
|
||||
static styles = css`
|
||||
* {
|
||||
font-family: 'Helvetica Neue', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.welcome {
|
||||
color: white;
|
||||
font-size: 12px;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-weight: 500;
|
||||
margin-bottom: 8px;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.input-group input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
input {
|
||||
background: rgba(255, 255, 255, 0.20);
|
||||
border-radius: 8px;
|
||||
outline: 1px rgba(255, 255, 255, 0.50) solid;
|
||||
outline-offset: -1px;
|
||||
backdrop-filter: blur(0.50px);
|
||||
border: none;
|
||||
color: white;
|
||||
padding: 12px 16px;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
font-family: 'Helvetica Neue', sans-serif;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
|
||||
.start-button {
|
||||
background: var(--start-button-background);
|
||||
color: var(--start-button-color);
|
||||
border: 1px solid var(--start-button-border);
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.start-button:hover {
|
||||
background: var(--start-button-hover-background);
|
||||
border-color: var(--start-button-hover-border);
|
||||
}
|
||||
|
||||
.start-button.initializing {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.start-button.initializing:hover {
|
||||
background: var(--start-button-background);
|
||||
border-color: var(--start-button-border);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--description-color);
|
||||
font-size: 14px;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--link-color);
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
:host {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
onStart: { type: Function },
|
||||
onAPIKeyHelp: { type: Function },
|
||||
isInitializing: { type: Boolean },
|
||||
onLayoutModeChange: { type: Function },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.onStart = () => {};
|
||||
this.onAPIKeyHelp = () => {};
|
||||
this.isInitializing = false;
|
||||
this.onLayoutModeChange = () => {};
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
window.electron?.ipcRenderer?.on('session-initializing', (event, isInitializing) => {
|
||||
this.isInitializing = isInitializing;
|
||||
});
|
||||
|
||||
// Load and apply layout mode on startup
|
||||
this.loadLayoutMode();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.electron?.ipcRenderer?.removeAllListeners('session-initializing');
|
||||
}
|
||||
|
||||
handleInput(e) {
|
||||
localStorage.setItem('apiKey', e.target.value);
|
||||
}
|
||||
|
||||
handleStartClick() {
|
||||
if (this.isInitializing) {
|
||||
return;
|
||||
}
|
||||
this.onStart();
|
||||
}
|
||||
|
||||
handleAPIKeyHelpClick() {
|
||||
this.onAPIKeyHelp();
|
||||
}
|
||||
|
||||
handleResetOnboarding() {
|
||||
localStorage.removeItem('onboardingCompleted');
|
||||
// Refresh the page to trigger onboarding
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
loadLayoutMode() {
|
||||
const savedLayoutMode = localStorage.getItem('layoutMode');
|
||||
if (savedLayoutMode && savedLayoutMode !== 'normal') {
|
||||
// Notify parent component to apply the saved layout mode
|
||||
this.onLayoutModeChange(savedLayoutMode);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="welcome">Welcome</div>
|
||||
|
||||
<div class="input-group">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Enter your openai API Key"
|
||||
.value=${localStorage.getItem('apiKey') || ''}
|
||||
@input=${this.handleInput}
|
||||
/>
|
||||
<button @click=${this.handleStartClick} class="start-button ${this.isInitializing ? 'initializing' : ''}">Start Session</button>
|
||||
</div>
|
||||
<p class="description">
|
||||
dont have an api key?
|
||||
<span @click=${this.handleAPIKeyHelpClick} class="link">get one here</span>
|
||||
</p>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('setup-view', SetupView);
|
||||
@ -1,123 +0,0 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
function pcmToWav(pcmBuffer, outputPath, sampleRate = 24000, channels = 1, bitDepth = 16) {
|
||||
const byteRate = sampleRate * channels * (bitDepth / 8);
|
||||
const blockAlign = channels * (bitDepth / 8);
|
||||
const dataSize = pcmBuffer.length;
|
||||
|
||||
const header = Buffer.alloc(44);
|
||||
|
||||
header.write('RIFF', 0);
|
||||
header.writeUInt32LE(dataSize + 36, 4);
|
||||
header.write('WAVE', 8);
|
||||
|
||||
header.write('fmt ', 12);
|
||||
header.writeUInt32LE(16, 16);
|
||||
header.writeUInt16LE(1, 20);
|
||||
header.writeUInt16LE(channels, 22);
|
||||
header.writeUInt32LE(sampleRate, 24);
|
||||
header.writeUInt32LE(byteRate, 28);
|
||||
header.writeUInt16LE(blockAlign, 32);
|
||||
header.writeUInt16LE(bitDepth, 34);
|
||||
|
||||
header.write('data', 36);
|
||||
header.writeUInt32LE(dataSize, 40);
|
||||
|
||||
const wavBuffer = Buffer.concat([header, pcmBuffer]);
|
||||
|
||||
fs.writeFileSync(outputPath, wavBuffer);
|
||||
|
||||
return outputPath;
|
||||
}
|
||||
|
||||
function analyzeAudioBuffer(buffer, label = 'Audio') {
|
||||
const int16Array = new Int16Array(buffer.buffer, buffer.byteOffset, buffer.length / 2);
|
||||
|
||||
let minValue = 32767;
|
||||
let maxValue = -32768;
|
||||
let avgValue = 0;
|
||||
let rmsValue = 0;
|
||||
let silentSamples = 0;
|
||||
|
||||
for (let i = 0; i < int16Array.length; i++) {
|
||||
const sample = int16Array[i];
|
||||
minValue = Math.min(minValue, sample);
|
||||
maxValue = Math.max(maxValue, sample);
|
||||
avgValue += sample;
|
||||
rmsValue += sample * sample;
|
||||
|
||||
if (Math.abs(sample) < 100) {
|
||||
silentSamples++;
|
||||
}
|
||||
}
|
||||
|
||||
avgValue /= int16Array.length;
|
||||
rmsValue = Math.sqrt(rmsValue / int16Array.length);
|
||||
|
||||
const silencePercentage = (silentSamples / int16Array.length) * 100;
|
||||
|
||||
console.log(`${label} Analysis:`);
|
||||
console.log(` Samples: ${int16Array.length}`);
|
||||
console.log(` Min: ${minValue}, Max: ${maxValue}`);
|
||||
console.log(` Average: ${avgValue.toFixed(2)}`);
|
||||
console.log(` RMS: ${rmsValue.toFixed(2)}`);
|
||||
console.log(` Silence: ${silencePercentage.toFixed(1)}%`);
|
||||
console.log(` Dynamic Range: ${20 * Math.log10(maxValue / (rmsValue || 1))} dB`);
|
||||
|
||||
return {
|
||||
minValue,
|
||||
maxValue,
|
||||
avgValue,
|
||||
rmsValue,
|
||||
silencePercentage,
|
||||
sampleCount: int16Array.length,
|
||||
};
|
||||
}
|
||||
|
||||
function saveDebugAudio(buffer, type, timestamp = Date.now()) {
|
||||
const homeDir = require('os').homedir();
|
||||
const debugDir = path.join(homeDir, '.pickle-glass', 'debug');
|
||||
|
||||
if (!fs.existsSync(debugDir)) {
|
||||
fs.mkdirSync(debugDir, { recursive: true });
|
||||
}
|
||||
|
||||
const pcmPath = path.join(debugDir, `${type}_${timestamp}.pcm`);
|
||||
const wavPath = path.join(debugDir, `${type}_${timestamp}.wav`);
|
||||
const metaPath = path.join(debugDir, `${type}_${timestamp}.json`);
|
||||
|
||||
fs.writeFileSync(pcmPath, buffer);
|
||||
|
||||
pcmToWav(buffer, wavPath);
|
||||
|
||||
const analysis = analyzeAudioBuffer(buffer, type);
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
timestamp,
|
||||
type,
|
||||
bufferSize: buffer.length,
|
||||
analysis,
|
||||
format: {
|
||||
sampleRate: 24000,
|
||||
channels: 1,
|
||||
bitDepth: 16,
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
console.log(`Debug audio saved: ${wavPath}`);
|
||||
|
||||
return { pcmPath, wavPath, metaPath };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
pcmToWav,
|
||||
analyzeAudioBuffer,
|
||||
saveDebugAudio,
|
||||
};
|
||||
324
src/features/listen/listenService.js
Normal file
324
src/features/listen/listenService.js
Normal file
@ -0,0 +1,324 @@
|
||||
const { BrowserWindow } = require('electron');
|
||||
const SttService = require('./stt/sttService');
|
||||
const SummaryService = require('./summary/summaryService');
|
||||
const authService = require('../common/services/authService');
|
||||
const sessionRepository = require('../common/repositories/session');
|
||||
const sttRepository = require('./stt/repositories');
|
||||
const internalBridge = require('../../bridge/internalBridge');
|
||||
|
||||
class ListenService {
|
||||
constructor() {
|
||||
this.sttService = new SttService();
|
||||
this.summaryService = new SummaryService();
|
||||
this.currentSessionId = null;
|
||||
this.isInitializingSession = false;
|
||||
|
||||
this.setupServiceCallbacks();
|
||||
console.log('[ListenService] Service instance created.');
|
||||
}
|
||||
|
||||
setupServiceCallbacks() {
|
||||
// STT service callbacks
|
||||
this.sttService.setCallbacks({
|
||||
onTranscriptionComplete: (speaker, text) => {
|
||||
this.handleTranscriptionComplete(speaker, text);
|
||||
},
|
||||
onStatusUpdate: (status) => {
|
||||
this.sendToRenderer('update-status', status);
|
||||
}
|
||||
});
|
||||
|
||||
// Summary service callbacks
|
||||
this.summaryService.setCallbacks({
|
||||
onAnalysisComplete: (data) => {
|
||||
console.log('📊 Analysis completed:', data);
|
||||
},
|
||||
onStatusUpdate: (status) => {
|
||||
this.sendToRenderer('update-status', status);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
sendToRenderer(channel, data) {
|
||||
const { windowPool } = require('../../window/windowManager');
|
||||
const listenWindow = windowPool?.get('listen');
|
||||
|
||||
if (listenWindow && !listenWindow.isDestroyed()) {
|
||||
listenWindow.webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
initialize() {
|
||||
this.setupIpcHandlers();
|
||||
console.log('[ListenService] Initialized and ready.');
|
||||
}
|
||||
|
||||
async handleListenRequest(listenButtonText) {
|
||||
const { windowPool } = 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) {
|
||||
console.log(`[ListenService] Transcription complete: ${speaker} - ${text}`);
|
||||
|
||||
// Save to database
|
||||
await this.saveConversationTurn(speaker, text);
|
||||
|
||||
// Add to summary service for analysis
|
||||
this.summaryService.addConversationTurn(speaker, text);
|
||||
}
|
||||
|
||||
async saveConversationTurn(speaker, transcription) {
|
||||
if (!this.currentSessionId) {
|
||||
console.error('[DB] Cannot save turn, no active session ID.');
|
||||
return;
|
||||
}
|
||||
if (transcription.trim() === '') return;
|
||||
|
||||
try {
|
||||
await sessionRepository.touch(this.currentSessionId);
|
||||
await sttRepository.addTranscript({
|
||||
sessionId: this.currentSessionId,
|
||||
speaker: speaker,
|
||||
text: transcription.trim(),
|
||||
});
|
||||
console.log(`[DB] Saved transcript for session ${this.currentSessionId}: (${speaker})`);
|
||||
} catch (error) {
|
||||
console.error('Failed to save transcript to DB:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async initializeNewSession() {
|
||||
try {
|
||||
// The UID is no longer passed to the repository method directly.
|
||||
// The adapter layer handles UID injection. We just ensure a user is available.
|
||||
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('listen');
|
||||
console.log(`[DB] New listen session ensured: ${this.currentSessionId}`);
|
||||
|
||||
// Set session ID for summary service
|
||||
this.summaryService.setSessionId(this.currentSessionId);
|
||||
|
||||
// Reset conversation history
|
||||
this.summaryService.resetConversationHistory();
|
||||
|
||||
console.log('New conversation session started:', this.currentSessionId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize new session in DB:', error);
|
||||
this.currentSessionId = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async initializeSession(language = 'en') {
|
||||
if (this.isInitializingSession) {
|
||||
console.log('Session initialization already in progress.');
|
||||
return false;
|
||||
}
|
||||
|
||||
this.isInitializingSession = true;
|
||||
this.sendToRenderer('session-initializing', true);
|
||||
this.sendToRenderer('update-status', 'Initializing sessions...');
|
||||
|
||||
try {
|
||||
// Initialize database session
|
||||
const sessionInitialized = await this.initializeNewSession();
|
||||
if (!sessionInitialized) {
|
||||
throw new Error('Failed to initialize database session');
|
||||
}
|
||||
|
||||
/* ---------- STT Initialization Retry Logic ---------- */
|
||||
const MAX_RETRY = 10;
|
||||
const RETRY_DELAY_MS = 300; // 0.3 seconds
|
||||
|
||||
let sttReady = false;
|
||||
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||||
try {
|
||||
await this.sttService.initializeSttSessions(language);
|
||||
sttReady = true;
|
||||
break; // Exit on success
|
||||
} catch (err) {
|
||||
console.warn(
|
||||
`[ListenService] STT init attempt ${attempt} failed: ${err.message}`
|
||||
);
|
||||
if (attempt < MAX_RETRY) {
|
||||
await new Promise(r => setTimeout(r, RETRY_DELAY_MS));
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!sttReady) throw new Error('STT init failed after retries');
|
||||
/* ------------------------------------------- */
|
||||
|
||||
console.log('✅ Listen service initialized successfully.');
|
||||
|
||||
this.sendToRenderer('update-status', 'Connected. Ready to listen.');
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize listen service:', error);
|
||||
this.sendToRenderer('update-status', 'Initialization failed.');
|
||||
return false;
|
||||
} finally {
|
||||
this.isInitializingSession = false;
|
||||
this.sendToRenderer('session-initializing', false);
|
||||
this.sendToRenderer('change-listen-capture-state', { status: "start" });
|
||||
}
|
||||
}
|
||||
|
||||
async sendMicAudioContent(data, mimeType) {
|
||||
return await this.sttService.sendMicAudioContent(data, mimeType);
|
||||
}
|
||||
|
||||
async startMacOSAudioCapture() {
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('macOS audio capture only available on macOS');
|
||||
}
|
||||
return await this.sttService.startMacOSAudioCapture();
|
||||
}
|
||||
|
||||
async stopMacOSAudioCapture() {
|
||||
this.sttService.stopMacOSAudioCapture();
|
||||
}
|
||||
|
||||
isSessionActive() {
|
||||
return this.sttService.isSessionActive();
|
||||
}
|
||||
|
||||
async closeSession() {
|
||||
try {
|
||||
this.sendToRenderer('change-listen-capture-state', { status: "stop" });
|
||||
// Close STT sessions
|
||||
await this.sttService.closeSessions();
|
||||
|
||||
await this.stopMacOSAudioCapture();
|
||||
|
||||
// End database session
|
||||
if (this.currentSessionId) {
|
||||
await sessionRepository.end(this.currentSessionId);
|
||||
console.log(`[DB] Session ${this.currentSessionId} ended.`);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
this.currentSessionId = null;
|
||||
this.summaryService.resetConversationHistory();
|
||||
|
||||
console.log('Listen service session closed.');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error closing listen service session:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentSessionData() {
|
||||
return {
|
||||
sessionId: this.currentSessionId,
|
||||
conversationHistory: this.summaryService.getConversationHistory(),
|
||||
totalTexts: this.summaryService.getConversationHistory().length,
|
||||
analysisData: this.summaryService.getCurrentAnalysisData(),
|
||||
};
|
||||
}
|
||||
|
||||
getConversationHistory() {
|
||||
return this.summaryService.getConversationHistory();
|
||||
}
|
||||
|
||||
_createHandler(asyncFn, successMessage, errorMessage) {
|
||||
return async (...args) => {
|
||||
try {
|
||||
const result = await asyncFn.apply(this, args);
|
||||
if (successMessage) console.log(successMessage);
|
||||
// `startMacOSAudioCapture`는 성공 시 { success, error } 객체를 반환하지 않으므로,
|
||||
// 핸들러가 일관된 응답을 보내도록 여기서 success 객체를 반환합니다.
|
||||
// 다른 함수들은 이미 success 객체를 반환합니다.
|
||||
return result && typeof result.success !== 'undefined' ? result : { success: true };
|
||||
} catch (e) {
|
||||
console.error(errorMessage, e);
|
||||
return { success: false, error: e.message };
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// `_createHandler`를 사용하여 핸들러들을 동적으로 생성합니다.
|
||||
handleSendMicAudioContent = this._createHandler(
|
||||
this.sendMicAudioContent,
|
||||
null,
|
||||
'Error sending user audio:'
|
||||
);
|
||||
|
||||
handleStartMacosAudio = this._createHandler(
|
||||
async () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
return { success: false, error: 'macOS audio capture only available on macOS' };
|
||||
}
|
||||
if (this.sttService.isMacOSAudioRunning?.()) {
|
||||
return { success: false, error: 'already_running' };
|
||||
}
|
||||
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:'
|
||||
);
|
||||
|
||||
handleUpdateGoogleSearchSetting = this._createHandler(
|
||||
async (enabled) => {
|
||||
console.log('Google Search setting updated to:', enabled);
|
||||
},
|
||||
null,
|
||||
'Error updating Google Search setting:'
|
||||
);
|
||||
}
|
||||
|
||||
const listenService = new ListenService();
|
||||
module.exports = listenService;
|
||||
File diff suppressed because it is too large
Load Diff
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
Loading…
x
Reference in New Issue
Block a user