Merge branch 'main' into feature/animation_refactoring

This commit is contained in:
sanio 2025-07-10 03:25:41 +09:00
commit 682e6391c8
58 changed files with 7082 additions and 2642 deletions

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

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

View File

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

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

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

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

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

View File

@ -2,52 +2,57 @@
Thank you for considering contributing to **Glass by Pickle**! Contributions make the open-source community vibrant, innovative, and collaborative. We appreciate every contribution you make—big or small. Thank you for considering contributing to **Glass by Pickle**! Contributions make the open-source community vibrant, innovative, and collaborative. We appreciate every contribution you make—big or small.
## 📌 Contribution Guidelines This document guides you through the entire contribution process, from finding an issue to getting your pull request merged.
### 👥 Avoid Work Duplication
Before creating an issue or submitting a pull request (PR), please check existing [Issues](https://github.com/pickle-com/glass/issues) and [Pull Requests](https://github.com/pickle-com/glass/pulls) to prevent duplicate efforts.
### ✅ Start with Approved Issues
- **Feature Requests**: Please wait for approval from core maintainers before starting work. Issues needing approval are marked with the `🚨 needs approval` label.
- **Bugs & Improvements**: You may begin immediately without explicit approval.
### 📝 Clearly Document Your Work
Provide enough context and detail to allow easy understanding. Issues and PRs should clearly communicate the problem or feature and stand alone without external references.
### 💡 Summarize Pull Requests
Include a brief summary at the top of your PR, describing the intent and scope of your changes.
### 🔗 Link Related Issues
Use GitHub keywords (`Closes #123`, `Fixes #456`) to auto-link and close issues upon PR merge.
### 🧪 Include Testing Information
Clearly state how your changes were tested.
> Example:
> "Tested locally on macOS 14, confirmed all features working as expected."
### 🧠 Future-Proof Your Descriptions
Document trade-offs, edge cases, and temporary workarounds clearly to help future maintainers understand your decisions.
--- ---
## 🔖 Issue Priorities ## 🚀 Contribution Workflow
| Issue Type | Priority | To ensure a smooth and effective workflow, all contributions must go through the following process. Please follow these steps carefully.
|----------------------------------------------------|---------------------|
| Minor enhancements & non-core feature requests | 🟢 Low Priority |
| UX improvements & minor bugs | 🟡 Medium Priority |
| Core functionalities & essential features | 🟠 High Priority |
| Critical bugs & breaking issues | 🔴 Urgent |
|
### 1. Find or Create an Issue
All work begins with an issue. This is the central place to discuss new ideas and track progress.
- Browse our existing [**Issues**](https://github.com/pickle-com/glass/issues) to find something you'd like to work on. We recommend looking for issues labeled `good first issue` if you're new!
- If you have a new idea or find a bug that hasn't been reported, please **create a new issue** using our templates.
### 2. Claim the Issue
To avoid duplicate work, you must claim an issue before you start coding.
- On the issue you want to work on, leave a comment with the command:
```
/assign
```
- Our GitHub bot will automatically assign the issue to you. Once your profile appears in the **`Assignees`** section on the right, you are ready to start development.
### 3. Fork & Create a Branch
Now it's time to set up your local environment.
1. **Fork** the repository to your own GitHub account.
2. **Clone** your forked repository to your local machine.
3. **Create a new branch** from `main`. A clear branch name is recommended.
- For new features: `feat/short-description` (e.g., `feat/user-login-ui`)
- For bug fixes: `fix/short-description` (e.g., `fix/header-rendering-bug`)
### 4. Develop
Write your code! As you work, please adhere to our quality standards.
- **Code Style & Quality**: Our project uses `Prettier` and `ESLint` to maintain a consistent code style.
- **Architecture & Design Patterns**: All new code must be consistent with the project's architecture. Please read our **[Design Patterns Guide](https://github.com/pickle-com/glass/blob/main/docs/DESIGN_PATTERNS.md)** before making significant changes.
### 5. Create a Pull Request (PR)
Once your work is ready, create a Pull Request to the `main` branch of the original repository.
- **Fill out the PR Template**: Our template will appear automatically. Please provide a clear summary of your changes.
- **Link the Issue**: In the PR description, include the line `Closes #XXX` (e.g., `Closes #123`) to link it to the issue you resolved. This is mandatory.
- **Code Review**: A maintainer will review your code, provide feedback, and merge it.
---
# Developing # Developing
@ -85,11 +90,4 @@ Please ensure that you can make a full production build before pushing code.
npm run lint npm run lint
``` ```
If you get errors, be sure to fix them before committing. If you get errors, be sure to fix them before committing.
## Making a Pull Request
- Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) when creating your PR. (This option isn't available if you're [contributing from a fork belonging to an organization](https://github.com/orgs/community/discussions/5634))
- If your PR refers to or fixes an issue, add `refs #XXX` or `fixes #XXX` to the PR description. Replace `XXX` with the respective issue number. See more about [linking a pull request to an issue](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue).
- Lastly, make sure to keep your branches updated (e.g., click the `Update branch` button on the GitHub PR page).

View File

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

126
docs/DESIGN_PATTERNS.md Normal file
View File

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

View File

@ -34,6 +34,8 @@ extraResources:
asarUnpack: asarUnpack:
- "src/assets/SystemAudioDump" - "src/assets/SystemAudioDump"
- "**/node_modules/sharp/**/*"
- "**/node_modules/@img/**/*"
# Windows configuration # Windows configuration
win: win:

2454
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@
"name": "pickle-glass", "name": "pickle-glass",
"productName": "Glass", "productName": "Glass",
"version": "0.2.3", "version": "0.2.4",
"description": "Cl*ely for Free", "description": "Cl*ely for Free",
"main": "src/index.js", "main": "src/index.js",
@ -48,6 +48,7 @@
"firebase": "^11.10.0", "firebase": "^11.10.0",
"firebase-admin": "^13.4.0", "firebase-admin": "^13.4.0",
"jsonwebtoken": "^9.0.2", "jsonwebtoken": "^9.0.2",
"keytar": "^7.9.0",
"node-fetch": "^2.7.0", "node-fetch": "^2.7.0",
"openai": "^4.70.0", "openai": "^4.70.0",
"react-hot-toast": "^2.5.2", "react-hot-toast": "^2.5.2",
@ -70,9 +71,10 @@
"electron": "^30.5.1", "electron": "^30.5.1",
"electron-builder": "^26.0.12", "electron-builder": "^26.0.12",
"electron-reloader": "^1.2.3", "electron-reloader": "^1.2.3",
"esbuild": "^0.25.5" "esbuild": "^0.25.5",
"prettier": "^3.6.2"
}, },
"optionalDependencies": { "optionalDependencies": {
"electron-liquid-glass": "^1.0.1" "electron-liquid-glass": "^1.0.1"
} }
} }

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -57,6 +57,34 @@ const PROVIDERS = {
], ],
sttModels: [], sttModels: [],
}, },
'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: () => {
// Only load in main process
if (typeof window === 'undefined') {
return require("./providers/whisper");
}
// Return dummy for renderer
return {
createSTT: () => { throw new Error('Whisper STT is only available in main process'); },
createLLM: () => { throw new Error('Whisper does not support LLM'); },
createStreamingLLM: () => { throw new Error('Whisper does not support LLM'); }
};
},
llmModels: [],
sttModels: [
{ id: 'whisper-tiny', name: 'Whisper Tiny (39M)' },
{ id: 'whisper-base', name: 'Whisper Base (74M)' },
{ id: 'whisper-small', name: 'Whisper Small (244M)' },
{ id: 'whisper-medium', name: 'Whisper Medium (769M)' },
],
},
}; };
function sanitizeModelId(model) { function sanitizeModelId(model) {

View File

@ -0,0 +1,242 @@
const http = require('http');
const fetch = require('node-fetch');
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') });
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);
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);
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 = {
createLLM,
createStreamingLLM
};

View File

@ -0,0 +1,231 @@
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 = 24000 * 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 {
constructor() {
this.whisperService = null;
}
async initialize() {
if (!this.whisperService) {
const { WhisperService } = require('../../services/whisperService');
this.whisperService = new WhisperService();
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() {
throw new Error('Whisper provider does not support streaming LLM functionality');
}
}
module.exports = new WhisperProvider();

View File

@ -0,0 +1,46 @@
const DOWNLOAD_CHECKSUMS = {
ollama: {
dmg: {
url: 'https://ollama.com/download/Ollama.dmg',
sha256: null // To be updated with actual checksum
},
exe: {
url: 'https://ollama.com/download/OllamaSetup.exe',
sha256: null // To be updated with actual checksum
}
},
whisper: {
models: {
'whisper-tiny': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.bin',
sha256: 'be07e048e1e599ad46341c8d2a135645097a538221678b7acdd1b1919c6e1b21'
},
'whisper-base': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin',
sha256: '60ed5bc3dd14eea856493d334349b405782ddcaf0028d4b5df4088345fba2efe'
},
'whisper-small': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.bin',
sha256: '1be3a9b2063867b937e64e2ec7483364a79917e157fa98c5d94b5c1fffea987b'
},
'whisper-medium': {
url: 'https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-medium.bin',
sha256: '6c14d5adee5f86394037b4e4e8b59f1673b6cee10e3cf0b11bbdbee79c156208'
}
},
binaries: {
'v1.7.6': {
windows: {
url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-win-x64.zip',
sha256: null // To be updated with actual checksum
},
linux: {
url: 'https://github.com/ggerganov/whisper.cpp/releases/download/v1.7.6/whisper-cpp-v1.7.6-linux-x64.tar.gz',
sha256: null // To be updated with actual checksum
}
}
}
}
};
module.exports = { DOWNLOAD_CHECKSUMS };

View File

@ -7,7 +7,8 @@ const LATEST_SCHEMA = {
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'api_key', type: 'TEXT' }, { name: 'api_key', type: 'TEXT' },
{ name: 'provider', type: 'TEXT DEFAULT \'openai\'' }, { name: 'provider', type: 'TEXT DEFAULT \'openai\'' },
{ name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' } { name: 'auto_update_enabled', type: 'INTEGER DEFAULT 1' },
{ name: 'has_migrated_to_firebase', type: 'INTEGER DEFAULT 0' }
] ]
}, },
sessions: { sessions: {
@ -72,6 +73,23 @@ const LATEST_SCHEMA = {
{ name: 'created_at', type: 'INTEGER' }, { name: 'created_at', type: 'INTEGER' },
{ name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' } { name: 'sync_state', type: 'TEXT DEFAULT \'clean\'' }
] ]
},
ollama_models: {
columns: [
{ name: 'name', type: 'TEXT PRIMARY KEY' },
{ name: 'size', type: 'TEXT NOT NULL' },
{ name: 'installed', type: 'INTEGER DEFAULT 0' },
{ name: 'installing', type: 'INTEGER DEFAULT 0' }
]
},
whisper_models: {
columns: [
{ name: 'id', type: 'TEXT PRIMARY KEY' },
{ name: 'name', type: 'TEXT NOT NULL' },
{ name: 'size', type: 'TEXT NOT NULL' },
{ name: 'installed', type: 'INTEGER DEFAULT 0' },
{ name: 'installing', type: 'INTEGER DEFAULT 0' }
]
} }
}; };

View File

@ -0,0 +1,54 @@
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) {
appObject[field] = encryptionService.decrypt(appObject[field]);
}
}
// Convert Firestore Timestamps back to Unix timestamps (seconds) for app-wide consistency
for (const key in appObject) {
if (appObject[key] instanceof Timestamp) {
appObject[key] = appObject[key].seconds;
}
}
return appObject;
}
};
}
module.exports = {
createEncryptedConverter,
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,26 +1,60 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository'); // Future implementation const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService');
function getRepository() { let authService = null;
// In the future, we can check the user's login status from authService
// const user = authService.getCurrentUser(); function setAuthService(service) {
// if (user.isLoggedIn) { authService = service;
// return firebaseRepository; }
// }
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; return sqliteRepository;
} }
// Directly export functions for ease of use, decided by the strategy // The adapter layer that injects the UID
module.exports = { const sessionRepositoryAdapter = {
getById: (...args) => getRepository().getById(...args), setAuthService, // Expose the setter
create: (...args) => getRepository().create(...args),
getAllByUserId: (...args) => getRepository().getAllByUserId(...args), getById: (id) => getBaseRepository().getById(id),
updateTitle: (...args) => getRepository().updateTitle(...args),
deleteWithRelatedData: (...args) => getRepository().deleteWithRelatedData(...args), create: (type = 'ask') => {
end: (...args) => getRepository().end(...args), const uid = authService.getCurrentUserId();
updateType: (...args) => getRepository().updateType(...args), return getBaseRepository().create(uid, type);
touch: (...args) => getRepository().touch(...args), },
getOrCreateActive: (...args) => getRepository().getOrCreateActive(...args),
endAllActiveSessions: (...args) => getRepository().endAllActiveSessions(...args), getAllByUserId: () => {
}; const uid = authService.getCurrentUserId();
return getBaseRepository().getAllByUserId(uid);
},
updateTitle: (id, title) => getBaseRepository().updateTitle(id, title),
deleteWithRelatedData: (id) => getBaseRepository().deleteWithRelatedData(id),
end: (id) => getBaseRepository().end(id),
updateType: (id, type) => getBaseRepository().updateType(id, type),
touch: (id) => getBaseRepository().touch(id),
getOrCreateActive: (requestedType = 'ask') => {
const uid = authService.getCurrentUserId();
return getBaseRepository().getOrCreateActive(uid, requestedType);
},
endAllActiveSessions: () => {
const uid = authService.getCurrentUserId();
return getBaseRepository().endAllActiveSessions(uid);
},
};
module.exports = sessionRepositoryAdapter;

View File

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

View File

@ -0,0 +1,91 @@
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(['api_key']);
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 saveApiKey(uid, apiKey, provider = 'openai') {
const docRef = doc(usersCol(), uid);
await setDoc(docRef, { api_key: apiKey, provider }, { merge: true });
return { changes: 1 };
}
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,
saveApiKey,
update,
deleteById,
};

View File

@ -1,19 +1,40 @@
const sqliteRepository = require('./sqlite.repository'); const sqliteRepository = require('./sqlite.repository');
// const firebaseRepository = require('./firebase.repository'); const firebaseRepository = require('./firebase.repository');
const authService = require('../../../common/services/authService'); const authService = require('../../services/authService');
function getRepository() { function getBaseRepository() {
// const user = authService.getCurrentUser(); const user = authService.getCurrentUser();
// if (user.isLoggedIn) { if (user && user.isLoggedIn) {
// return firebaseRepository; return firebaseRepository;
// } }
return sqliteRepository; return sqliteRepository;
} }
module.exports = { const userRepositoryAdapter = {
findOrCreate: (...args) => getRepository().findOrCreate(...args), findOrCreate: (user) => {
getById: (...args) => getRepository().getById(...args), // This function receives the full user object, which includes the uid. No need to inject.
saveApiKey: (...args) => getRepository().saveApiKey(...args), return getBaseRepository().findOrCreate(user);
update: (...args) => getRepository().update(...args), },
deleteById: (...args) => getRepository().deleteById(...args),
}; getById: () => {
const uid = authService.getCurrentUserId();
return getBaseRepository().getById(uid);
},
saveApiKey: (apiKey, provider) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().saveApiKey(uid, apiKey, provider);
},
update: (updateData) => {
const uid = authService.getCurrentUserId();
return getBaseRepository().update({ uid, ...updateData });
},
deleteById: () => {
const uid = authService.getCurrentUserId();
return getBaseRepository().deleteById(uid);
}
};
module.exports = userRepositoryAdapter;

View File

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

View File

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

View File

@ -1,8 +1,10 @@
const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth'); const { onAuthStateChanged, signInWithCustomToken, signOut } = require('firebase/auth');
const { BrowserWindow } = require('electron'); const { BrowserWindow } = require('electron');
const { getFirebaseAuth } = require('./firebaseClient'); const { getFirebaseAuth } = require('./firebaseClient');
const userRepository = require('../repositories/user');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const encryptionService = require('./encryptionService');
const migrationService = require('./migrationService');
const sessionRepository = require('../repositories/session');
async function getVirtualKeyByEmail(email, idToken) { async function getVirtualKeyByEmail(email, idToken) {
if (!idToken) { if (!idToken) {
@ -37,12 +39,22 @@ class AuthService {
this.currentUserMode = 'local'; // 'local' or 'firebase' this.currentUserMode = 'local'; // 'local' or 'firebase'
this.currentUser = null; this.currentUser = null;
this.isInitialized = false; this.isInitialized = false;
// Initialize immediately for the default local user on startup.
// This ensures the key is ready before any login/logout state change.
encryptionService.initializeKey(this.currentUserId);
this.initializationPromise = null; this.initializationPromise = null;
} }
initialize() { initialize() {
if (this.isInitialized) return this.initializationPromise; if (this.isInitialized) return this.initializationPromise;
// --- Break the circular dependency ---
// Inject this authService instance into the session repository so it can be used
// without a direct `require` cycle.
sessionRepository.setAuthService(this);
// --- End of dependency injection ---
this.initializationPromise = new Promise((resolve) => { this.initializationPromise = new Promise((resolve) => {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
onAuthStateChanged(auth, async (user) => { onAuthStateChanged(auth, async (user) => {
@ -55,6 +67,17 @@ class AuthService {
this.currentUserId = user.uid; this.currentUserId = user.uid;
this.currentUserMode = 'firebase'; 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 **
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);
// Start background task to fetch and save virtual key // Start background task to fetch and save virtual key
(async () => { (async () => {
try { try {
@ -83,6 +106,12 @@ class AuthService {
this.currentUser = null; this.currentUser = null;
this.currentUserId = 'default_user'; this.currentUserId = 'default_user';
this.currentUserMode = 'local'; this.currentUserMode = 'local';
// End active sessions for the local/default user as well.
await sessionRepository.endAllActiveSessions();
// ** Initialize encryption key for the default/local user **
await encryptionService.initializeKey(this.currentUserId);
} }
this.broadcastUserState(); this.broadcastUserState();
@ -112,6 +141,9 @@ class AuthService {
async signOut() { async signOut() {
const auth = getFirebaseAuth(); const auth = getFirebaseAuth();
try { try {
// End all active sessions for the current user BEFORE signing out.
await sessionRepository.endAllActiveSessions();
await signOut(auth); await signOut(auth);
console.log('[AuthService] User sign-out initiated successfully.'); console.log('[AuthService] User sign-out initiated successfully.');
// onAuthStateChanged will handle the state update and broadcast, // onAuthStateChanged will handle the state update and broadcast,

View File

@ -0,0 +1,89 @@
const crypto = require('crypto');
const { app } = require('electron');
const os = require('os');
class CryptoService {
constructor() {
this.algorithm = 'aes-256-gcm';
this.saltLength = 32;
this.tagLength = 16;
this.ivLength = 16;
this.iterations = 100000;
this.keyLength = 32;
this._derivedKey = null;
}
_getMachineId() {
const machineInfo = `${os.hostname()}-${os.platform()}-${os.arch()}`;
const appPath = app.getPath('userData');
return crypto.createHash('sha256').update(machineInfo + appPath).digest('hex');
}
_deriveKey() {
if (this._derivedKey) return this._derivedKey;
const machineId = this._getMachineId();
const salt = crypto.createHash('sha256').update('pickle-glass-salt').digest();
this._derivedKey = crypto.pbkdf2Sync(machineId, salt, this.iterations, this.keyLength, 'sha256');
return this._derivedKey;
}
encrypt(text) {
if (!text) return null;
try {
const iv = crypto.randomBytes(this.ivLength);
const salt = crypto.randomBytes(this.saltLength);
const key = this._deriveKey();
const cipher = crypto.createCipheriv(this.algorithm, key, iv);
const encrypted = Buffer.concat([
cipher.update(text, 'utf8'),
cipher.final()
]);
const tag = cipher.getAuthTag();
const combined = Buffer.concat([salt, iv, tag, encrypted]);
return combined.toString('base64');
} catch (error) {
console.error('[CryptoService] Encryption failed:', error.message);
throw new Error('Encryption failed');
}
}
decrypt(encryptedData) {
if (!encryptedData) return null;
try {
const combined = Buffer.from(encryptedData, 'base64');
const salt = combined.slice(0, this.saltLength);
const iv = combined.slice(this.saltLength, this.saltLength + this.ivLength);
const tag = combined.slice(this.saltLength + this.ivLength, this.saltLength + this.ivLength + this.tagLength);
const encrypted = combined.slice(this.saltLength + this.ivLength + this.tagLength);
const key = this._deriveKey();
const decipher = crypto.createDecipheriv(this.algorithm, key, iv);
decipher.setAuthTag(tag);
const decrypted = Buffer.concat([
decipher.update(encrypted),
decipher.final()
]);
return decrypted.toString('utf8');
} catch (error) {
console.error('[CryptoService] Decryption failed:', error.message);
throw new Error('Decryption failed');
}
}
clearCache() {
this._derivedKey = null;
}
}
module.exports = new CryptoService();

View File

@ -0,0 +1,140 @@
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 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.');
}
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}.`);
}
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');
}
}
if (!sessionKey) {
throw new Error('Failed to initialize encryption key.');
}
}
/**
* 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.
return encryptedText;
}
}
module.exports = {
initializeKey,
encrypt,
decrypt,
};

View File

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

View File

@ -0,0 +1,277 @@
const { exec } = require('child_process');
const { promisify } = require('util');
const { EventEmitter } = require('events');
const path = require('path');
const os = require('os');
const https = require('https');
const fs = require('fs');
const crypto = require('crypto');
const execAsync = promisify(exec);
class LocalAIServiceBase extends EventEmitter {
constructor(serviceName) {
super();
this.serviceName = serviceName;
this.baseUrl = null;
this.installationProgress = new Map();
}
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 isInstalled() {
throw new Error('isInstalled() must be implemented by subclass');
}
async isServiceRunning() {
throw new Error('isServiceRunning() must be implemented by subclass');
}
async startService() {
throw new Error('startService() must be implemented by subclass');
}
async stopService() {
throw new Error('stopService() must be implemented by subclass');
}
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`);
}
getInstallProgress(modelName) {
return this.installationProgress.get(modelName) || 0;
}
setInstallProgress(modelName, progress) {
this.installationProgress.set(modelName, progress);
this.emit('install-progress', { model: modelName, progress });
}
clearInstallProgress(modelName) {
this.installationProgress.delete(modelName);
}
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 installMacOS() {
throw new Error('installMacOS() must be implemented by subclass');
}
async installWindows() {
throw new Error('installWindows() must be implemented by subclass');
}
async installLinux() {
throw new Error('installLinux() must be implemented by subclass');
}
// parseProgress method removed - using proper REST API now
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 shutdownMacOS(force) {
throw new Error('shutdownMacOS() must be implemented by subclass');
}
async shutdownWindows(force) {
throw new Error('shutdownWindows() must be implemented by subclass');
}
async shutdownLinux(force) {
throw new Error('shutdownLinux() must be implemented by subclass');
}
async downloadFile(url, destination, options = {}) {
const {
onProgress = null,
headers = { 'User-Agent': 'Glass-App' },
timeout = 300000 // 5 minutes default
} = options;
return new Promise((resolve, reject) => {
const file = fs.createWriteStream(destination);
let downloadedSize = 0;
let totalSize = 0;
const request = https.get(url, { headers }, (response) => {
// Handle redirects (301, 302, 307, 308)
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 (onProgress && totalSize > 0) {
const progress = Math.round((downloadedSize / totalSize) * 100);
onProgress(progress, downloadedSize, totalSize);
}
});
response.pipe(file);
file.on('finish', () => {
file.close(() => {
this.emit('download-complete', { url, destination, size: downloadedSize });
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 });
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, ...downloadOptions } = options;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const result = await this.downloadFile(url, destination, downloadOptions);
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);
});
}
}
module.exports = LocalAIServiceBase;

View File

@ -0,0 +1,133 @@
export class LocalProgressTracker {
constructor(serviceName) {
this.serviceName = serviceName;
this.activeOperations = new Map(); // operationId -> { controller, onProgress }
this.ipcRenderer = window.require?.('electron')?.ipcRenderer;
if (!this.ipcRenderer) {
throw new Error(`${serviceName} requires Electron environment`);
}
this.globalProgressHandler = (event, data) => {
const operation = this.activeOperations.get(data.model || data.modelId);
if (operation && !operation.controller.signal.aborted) {
operation.onProgress(data.progress);
}
};
const progressEvents = {
'ollama': 'ollama:pull-progress',
'whisper': 'whisper:download-progress'
};
const eventName = progressEvents[serviceName.toLowerCase()] || `${serviceName}:progress`;
this.progressEvent = eventName;
this.ipcRenderer.on(eventName, this.globalProgressHandler);
}
async trackOperation(operationId, operationType, onProgress) {
if (this.activeOperations.has(operationId)) {
throw new Error(`${operationType} ${operationId} is already in progress`);
}
const controller = new AbortController();
const operation = { controller, onProgress };
this.activeOperations.set(operationId, operation);
try {
const ipcChannels = {
'ollama': { install: 'ollama:pull-model' },
'whisper': { download: 'whisper:download-model' }
};
const channel = ipcChannels[this.serviceName.toLowerCase()]?.[operationType] ||
`${this.serviceName}:${operationType}`;
const result = await this.ipcRenderer.invoke(channel, operationId);
if (!result.success) {
throw new Error(result.error || `${operationType} failed`);
}
return true;
} catch (error) {
if (!controller.signal.aborted) {
throw error;
}
return false;
} finally {
this.activeOperations.delete(operationId);
}
}
async installModel(modelName, onProgress) {
return this.trackOperation(modelName, 'install', onProgress);
}
async downloadModel(modelId, onProgress) {
return this.trackOperation(modelId, 'download', onProgress);
}
cancelOperation(operationId) {
const operation = this.activeOperations.get(operationId);
if (operation) {
operation.controller.abort();
this.activeOperations.delete(operationId);
}
}
cancelAllOperations() {
for (const [operationId, operation] of this.activeOperations) {
operation.controller.abort();
}
this.activeOperations.clear();
}
isOperationActive(operationId) {
return this.activeOperations.has(operationId);
}
getActiveOperations() {
return Array.from(this.activeOperations.keys());
}
destroy() {
this.cancelAllOperations();
if (this.ipcRenderer) {
this.ipcRenderer.removeListener(this.progressEvent, this.globalProgressHandler);
}
}
}
let trackers = new Map();
export function getLocalProgressTracker(serviceName) {
if (!trackers.has(serviceName)) {
trackers.set(serviceName, new LocalProgressTracker(serviceName));
}
return trackers.get(serviceName);
}
export function destroyLocalProgressTracker(serviceName) {
const tracker = trackers.get(serviceName);
if (tracker) {
tracker.destroy();
trackers.delete(serviceName);
}
}
export function destroyAllProgressTrackers() {
for (const [name, tracker] of trackers) {
tracker.destroy();
}
trackers.clear();
}
// Legacy compatibility exports
export function getOllamaProgressTracker() {
return getLocalProgressTracker('ollama');
}
export function destroyOllamaProgressTracker() {
destroyLocalProgressTracker('ollama');
}

View File

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

View File

@ -2,6 +2,7 @@ const Store = require('electron-store');
const fetch = require('node-fetch'); const fetch = require('node-fetch');
const { ipcMain, webContents } = require('electron'); const { ipcMain, webContents } = require('electron');
const { PROVIDERS } = require('../ai/factory'); const { PROVIDERS } = require('../ai/factory');
const cryptoService = require('./cryptoService');
class ModelStateService { class ModelStateService {
constructor(authService) { constructor(authService) {
@ -23,7 +24,7 @@ class ModelStateService {
const llmProvider = this.getProviderForModel('llm', llmModel) || 'None'; const llmProvider = this.getProviderForModel('llm', llmModel) || 'None';
const sttProvider = this.getProviderForModel('stt', sttModel) || 'None'; const sttProvider = this.getProviderForModel('stt', sttModel) || 'None';
console.log(`[ModelStateService] 🌟 Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`); console.log(`[ModelStateService] Current Selection -> LLM: ${llmModel || 'None'} (Provider: ${llmProvider}), STT: ${sttModel || 'None'} (Provider: ${sttProvider})`);
} }
_autoSelectAvailableModels() { _autoSelectAvailableModels() {
@ -36,7 +37,9 @@ class ModelStateService {
if (currentModelId) { if (currentModelId) {
const provider = this.getProviderForModel(type, currentModelId); const provider = this.getProviderForModel(type, currentModelId);
if (provider && this.getApiKey(provider)) { const apiKey = this.getApiKey(provider);
// For Ollama, 'local' is a valid API key
if (provider && (apiKey || (provider === 'ollama' && apiKey === 'local'))) {
isCurrentModelValid = true; isCurrentModelValid = true;
} }
} }
@ -45,8 +48,15 @@ class ModelStateService {
console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`); console.log(`[ModelStateService] No valid ${type.toUpperCase()} model selected. Finding an alternative...`);
const availableModels = this.getAvailableModels(type); const availableModels = this.getAvailableModels(type);
if (availableModels.length > 0) { if (availableModels.length > 0) {
this.state.selectedModels[type] = availableModels[0].id; // Prefer API providers over local providers for auto-selection
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${availableModels[0].id}`); const apiModel = availableModels.find(model => {
const provider = this.getProviderForModel(type, model.id);
return provider && provider !== 'ollama' && provider !== 'whisper';
});
const selectedModel = apiModel || availableModels[0];
this.state.selectedModels[type] = selectedModel.id;
console.log(`[ModelStateService] Auto-selected ${type.toUpperCase()} model: ${selectedModel.id} (preferred: ${apiModel ? 'API' : 'local'})`);
} else { } else {
this.state.selectedModels[type] = null; this.state.selectedModels[type] = null;
} }
@ -67,11 +77,20 @@ class ModelStateService {
}; };
this.state = this.store.get(`users.${userId}`, defaultState); this.state = this.store.get(`users.${userId}`, defaultState);
console.log(`[ModelStateService] State loaded for user: ${userId}`); console.log(`[ModelStateService] State loaded for user: ${userId}`);
for (const p of Object.keys(PROVIDERS)) { for (const p of Object.keys(PROVIDERS)) {
if (!(p in this.state.apiKeys)) { if (!(p in this.state.apiKeys)) {
this.state.apiKeys[p] = null;
} else if (this.state.apiKeys[p] && p !== 'ollama' && p !== 'whisper') {
try {
this.state.apiKeys[p] = cryptoService.decrypt(this.state.apiKeys[p]);
} catch (error) {
console.error(`[ModelStateService] Failed to decrypt API key for ${p}, resetting`);
this.state.apiKeys[p] = null; this.state.apiKeys[p] = null;
} }
} }
}
this._autoSelectAvailableModels(); this._autoSelectAvailableModels();
this._saveState(); this._saveState();
this._logCurrentSelection(); this._logCurrentSelection();
@ -80,7 +99,23 @@ class ModelStateService {
_saveState() { _saveState() {
const userId = this.authService.getCurrentUserId(); const userId = this.authService.getCurrentUserId();
this.store.set(`users.${userId}`, this.state); const stateToSave = {
...this.state,
apiKeys: { ...this.state.apiKeys }
};
for (const [provider, key] of Object.entries(stateToSave.apiKeys)) {
if (key && provider !== 'ollama' && provider !== 'whisper') {
try {
stateToSave.apiKeys[provider] = cryptoService.encrypt(key);
} catch (error) {
console.error(`[ModelStateService] Failed to encrypt API key for ${provider}`);
stateToSave.apiKeys[provider] = null;
}
}
}
this.store.set(`users.${userId}`, stateToSave);
console.log(`[ModelStateService] State saved for user: ${userId}`); console.log(`[ModelStateService] State saved for user: ${userId}`);
this._logCurrentSelection(); this._logCurrentSelection();
} }
@ -94,6 +129,26 @@ class ModelStateService {
const body = undefined; const body = undefined;
switch (provider) { switch (provider) {
case 'ollama':
// Ollama doesn't need API key validation
// Just check if the service is running
try {
const response = await fetch('http://localhost:11434/api/tags');
if (response.ok) {
console.log(`[ModelStateService] Ollama service is accessible.`);
this.setApiKey(provider, 'local'); // Use 'local' as a placeholder
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.' };
}
case 'whisper':
// Whisper is a local service, no API key validation needed
console.log(`[ModelStateService] Whisper is a local service.`);
this.setApiKey(provider, 'local'); // Use 'local' as a placeholder
return { success: true };
case 'openai': case 'openai':
validationUrl = 'https://api.openai.com/v1/models'; validationUrl = 'https://api.openai.com/v1/models';
headers = { 'Authorization': `Bearer ${key}` }; headers = { 'Authorization': `Bearer ${key}` };
@ -158,13 +213,21 @@ class ModelStateService {
const llmModels = PROVIDERS['openai-glass']?.llmModels; const llmModels = PROVIDERS['openai-glass']?.llmModels;
const sttModels = PROVIDERS['openai-glass']?.sttModels; const sttModels = PROVIDERS['openai-glass']?.sttModels;
if (!this.state.selectedModels.llm && llmModels?.length > 0) { // When logging in with Pickle, prioritize Pickle's models over existing selections
if (virtualKey && llmModels?.length > 0) {
this.state.selectedModels.llm = llmModels[0].id; this.state.selectedModels.llm = llmModels[0].id;
console.log(`[ModelStateService] Prioritized Pickle LLM model: ${llmModels[0].id}`);
} }
if (!this.state.selectedModels.stt && sttModels?.length > 0) { if (virtualKey && sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id; this.state.selectedModels.stt = sttModels[0].id;
console.log(`[ModelStateService] Prioritized Pickle STT model: ${sttModels[0].id}`);
} }
this._autoSelectAvailableModels();
// If logging out (virtualKey is null), run auto-selection to find alternatives
if (!virtualKey) {
this._autoSelectAvailableModels();
}
this._saveState(); this._saveState();
this._logCurrentSelection(); this._logCurrentSelection();
} }
@ -176,11 +239,19 @@ class ModelStateService {
const llmModels = PROVIDERS[provider]?.llmModels; const llmModels = PROVIDERS[provider]?.llmModels;
const sttModels = PROVIDERS[provider]?.sttModels; const sttModels = PROVIDERS[provider]?.sttModels;
if (!this.state.selectedModels.llm && llmModels?.length > 0) { // Prioritize newly set API key provider over existing selections
this.state.selectedModels.llm = llmModels[0].id; // Only for non-local providers or if no model is currently selected
if (llmModels?.length > 0) {
if (!this.state.selectedModels.llm || provider !== 'ollama') {
this.state.selectedModels.llm = llmModels[0].id;
console.log(`[ModelStateService] Selected LLM model from newly configured provider ${provider}: ${llmModels[0].id}`);
}
} }
if (!this.state.selectedModels.stt && sttModels?.length > 0) { if (sttModels?.length > 0) {
this.state.selectedModels.stt = sttModels[0].id; if (!this.state.selectedModels.stt || provider !== 'whisper') {
this.state.selectedModels.stt = sttModels[0].id;
console.log(`[ModelStateService] Selected STT model from newly configured provider ${provider}: ${sttModels[0].id}`);
}
} }
this._saveState(); this._saveState();
this._logCurrentSelection(); this._logCurrentSelection();
@ -223,6 +294,14 @@ class ModelStateService {
return providerId; return providerId;
} }
} }
// If no provider was found, assume it could be a custom Ollama model
// if Ollama provider is configured (has a key).
if (type === 'llm' && this.state.apiKeys['ollama']) {
console.log(`[ModelStateService] Model '${modelId}' not found in PROVIDERS list, assuming it's a custom Ollama model.`);
return 'ollama';
}
return null; return null;
} }
@ -239,10 +318,33 @@ class ModelStateService {
if (this.isLoggedInWithFirebase()) return true; if (this.isLoggedInWithFirebase()) return true;
// LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인 // LLM과 STT 모델을 제공하는 Provider 중 하나라도 API 키가 설정되었는지 확인
const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => key && PROVIDERS[provider]?.llmModels.length > 0); const hasLlmKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => key && PROVIDERS[provider]?.sttModels.length > 0); if (provider === 'ollama') {
// Ollama uses dynamic models, so just check if configured (has 'local' key)
return key === 'local';
}
if (provider === 'whisper') {
// Whisper doesn't support LLM
return false;
}
return key && PROVIDERS[provider]?.llmModels.length > 0;
});
return hasLlmKey && hasSttKey; const hasSttKey = Object.entries(this.state.apiKeys).some(([provider, key]) => {
if (provider === 'whisper') {
// Whisper has static model list and supports STT
return key === 'local' && PROVIDERS[provider]?.sttModels.length > 0;
}
if (provider === 'ollama') {
// Ollama doesn't support STT yet
return false;
}
return key && PROVIDERS[provider]?.sttModels.length > 0;
});
const result = hasLlmKey && hasSttKey;
console.log(`[ModelStateService] areProvidersConfigured: LLM=${hasLlmKey}, STT=${hasSttKey}, result=${result}`);
return result;
} }
@ -265,13 +367,58 @@ class ModelStateService {
setSelectedModel(type, modelId) { setSelectedModel(type, modelId) {
const provider = this.getProviderForModel(type, modelId); const provider = this.getProviderForModel(type, modelId);
if (provider && this.state.apiKeys[provider]) { if (provider && this.state.apiKeys[provider]) {
const previousModel = this.state.selectedModels[type];
this.state.selectedModels[type] = modelId; this.state.selectedModels[type] = modelId;
this._saveState(); this._saveState();
// Auto warm-up for Ollama LLM models when changed
if (type === 'llm' && provider === 'ollama' && modelId !== previousModel) {
this._autoWarmUpOllamaModel(modelId, previousModel);
}
return true; return true;
} }
return false; return false;
} }
/**
* Auto warm-up Ollama model when LLM selection changes
* @private
* @param {string} newModelId - The newly selected model
* @param {string} previousModelId - The previously selected model
*/
async _autoWarmUpOllamaModel(newModelId, previousModelId) {
try {
console.log(`[ModelStateService] 🔥 LLM model changed: ${previousModelId || 'None'}${newModelId}, triggering warm-up`);
// Get Ollama service if available
const ollamaService = require('./ollamaService');
if (!ollamaService) {
console.log('[ModelStateService] OllamaService not available for auto warm-up');
return;
}
// Delay warm-up slightly to allow UI to update first
setTimeout(async () => {
try {
console.log(`[ModelStateService] Starting background warm-up for: ${newModelId}`);
const success = await ollamaService.warmUpModel(newModelId);
if (success) {
console.log(`[ModelStateService] ✅ Successfully warmed up model: ${newModelId}`);
} else {
console.log(`[ModelStateService] ⚠️ Failed to warm up model: ${newModelId}`);
}
} catch (error) {
console.log(`[ModelStateService] 🚫 Error during auto warm-up for ${newModelId}:`, error.message);
}
}, 500); // 500ms delay
} catch (error) {
console.error('[ModelStateService] Error in auto warm-up setup:', error);
}
}
/** /**
* *
* @param {('llm' | 'stt')} type * @param {('llm' | 'stt')} type

View File

@ -0,0 +1,809 @@
const { spawn } = require('child_process');
const { promisify } = require('util');
const fetch = require('node-fetch');
const path = require('path');
const fs = require('fs').promises;
const { app } = require('electron');
const LocalAIServiceBase = require('./localAIServiceBase');
const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
class OllamaService extends LocalAIServiceBase {
constructor() {
super('OllamaService');
this.baseUrl = 'http://localhost:11434';
this.warmingModels = new Map();
this.warmedModels = new Set();
this.lastWarmUpAttempt = new Map();
// Request management system
this.activeRequests = new Map();
this.requestTimeouts = new Map();
this.healthStatus = {
lastHealthCheck: 0,
consecutive_failures: 0,
is_circuit_open: false
};
// Configuration
this.requestTimeout = 8000; // 8s for health checks
this.warmupTimeout = 15000; // 15s for model warmup
this.healthCheckInterval = 60000; // 1min between health checks
this.circuitBreakerThreshold = 3;
this.circuitBreakerCooldown = 30000; // 30s
// Supported models are determined dynamically from installed models
this.supportedModels = {};
// Start health monitoring
this._startHealthMonitoring();
}
getOllamaCliPath() {
if (this.getPlatform() === 'darwin') {
return '/Applications/Ollama.app/Contents/Resources/ollama';
}
return 'ollama';
}
/**
* Professional request management with AbortController-based cancellation
*/
async _makeRequest(url, options = {}, operationType = 'default') {
const requestId = `${operationType}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// Circuit breaker check
if (this._isCircuitOpen()) {
throw new Error('Service temporarily unavailable (circuit breaker open)');
}
// Request deduplication for health checks
if (operationType === 'health' && this.activeRequests.has('health')) {
console.log('[OllamaService] Health check already in progress, returning existing promise');
return this.activeRequests.get('health');
}
const controller = new AbortController();
const timeout = options.timeout || this.requestTimeout;
// Set up timeout mechanism
const timeoutId = setTimeout(() => {
controller.abort();
this.activeRequests.delete(requestId);
this._recordFailure();
}, timeout);
this.requestTimeouts.set(requestId, timeoutId);
const requestPromise = this._executeRequest(url, {
...options,
signal: controller.signal
}, requestId);
// Store active request for deduplication and cleanup
this.activeRequests.set(operationType === 'health' ? 'health' : requestId, requestPromise);
try {
const result = await requestPromise;
this._recordSuccess();
return result;
} catch (error) {
this._recordFailure();
if (error.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw error;
} finally {
clearTimeout(timeoutId);
this.requestTimeouts.delete(requestId);
this.activeRequests.delete(operationType === 'health' ? 'health' : requestId);
}
}
async _executeRequest(url, options, requestId) {
try {
console.log(`[OllamaService] Executing request ${requestId} to ${url}`);
const response = await fetch(url, options);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return response;
} catch (error) {
console.error(`[OllamaService] Request ${requestId} failed:`, error.message);
throw error;
}
}
_isCircuitOpen() {
if (!this.healthStatus.is_circuit_open) return false;
// Check if cooldown period has passed
const now = Date.now();
if (now - this.healthStatus.lastHealthCheck > this.circuitBreakerCooldown) {
console.log('[OllamaService] Circuit breaker cooldown expired, attempting recovery');
this.healthStatus.is_circuit_open = false;
this.healthStatus.consecutive_failures = 0;
return false;
}
return true;
}
_recordSuccess() {
this.healthStatus.consecutive_failures = 0;
this.healthStatus.is_circuit_open = false;
this.healthStatus.lastHealthCheck = Date.now();
}
_recordFailure() {
this.healthStatus.consecutive_failures++;
this.healthStatus.lastHealthCheck = Date.now();
if (this.healthStatus.consecutive_failures >= this.circuitBreakerThreshold) {
console.warn(`[OllamaService] Circuit breaker opened after ${this.healthStatus.consecutive_failures} failures`);
this.healthStatus.is_circuit_open = true;
}
}
_startHealthMonitoring() {
// Passive health monitoring - only when requests are made
console.log('[OllamaService] Health monitoring system initialized');
}
/**
* Cleanup all active requests and resources
*/
_cleanup() {
console.log(`[OllamaService] Cleaning up ${this.activeRequests.size} active requests`);
// Cancel all active requests
for (const [requestId, promise] of this.activeRequests) {
if (this.requestTimeouts.has(requestId)) {
clearTimeout(this.requestTimeouts.get(requestId));
this.requestTimeouts.delete(requestId);
}
}
this.activeRequests.clear();
this.requestTimeouts.clear();
}
async isInstalled() {
try {
const platform = this.getPlatform();
if (platform === 'darwin') {
try {
await fs.access('/Applications/Ollama.app');
return true;
} catch {
const ollamaPath = await this.checkCommand(this.getOllamaCliPath());
return !!ollamaPath;
}
} else {
const ollamaPath = await this.checkCommand(this.getOllamaCliPath());
return !!ollamaPath;
}
} catch (error) {
console.log('[OllamaService] Ollama not found:', error.message);
return false;
}
}
async isServiceRunning() {
try {
const response = await this._makeRequest(`${this.baseUrl}/api/tags`, {
method: 'GET',
timeout: this.requestTimeout
}, 'health');
return response.ok;
} catch (error) {
console.log(`[OllamaService] Service health check failed: ${error.message}`);
return false;
}
}
async startService() {
const platform = this.getPlatform();
try {
if (platform === 'darwin') {
try {
await spawnAsync('open', ['-a', 'Ollama']);
await this.waitForService(() => this.isServiceRunning());
return true;
} catch {
spawn(this.getOllamaCliPath(), ['serve'], {
detached: true,
stdio: 'ignore'
}).unref();
await this.waitForService(() => this.isServiceRunning());
return true;
}
} else {
spawn(this.getOllamaCliPath(), ['serve'], {
detached: true,
stdio: 'ignore',
shell: platform === 'win32'
}).unref();
await this.waitForService(() => this.isServiceRunning());
return true;
}
} catch (error) {
console.error('[OllamaService] Failed to start service:', error);
throw error;
}
}
async stopService() {
return await this.shutdown();
}
async getInstalledModels() {
try {
const response = await this._makeRequest(`${this.baseUrl}/api/tags`, {
method: 'GET',
timeout: this.requestTimeout
}, 'models');
const data = await response.json();
return data.models || [];
} catch (error) {
console.error('[OllamaService] Failed to get installed models:', error.message);
return [];
}
}
async getInstalledModelsList() {
try {
const { stdout } = await spawnAsync(this.getOllamaCliPath(), ['list']);
const lines = stdout.split('\n').filter(line => line.trim());
// Skip header line (NAME, ID, SIZE, MODIFIED)
const modelLines = lines.slice(1);
const models = [];
for (const line of modelLines) {
if (!line.trim()) continue;
// Parse line: "model:tag model_id size modified_time"
const parts = line.split(/\s+/);
if (parts.length >= 3) {
models.push({
name: parts[0],
id: parts[1],
size: parts[2] + (parts[3] === 'GB' || parts[3] === 'MB' ? ' ' + parts[3] : ''),
status: 'installed'
});
}
}
return models;
} catch (error) {
console.log('[OllamaService] Failed to get installed models via CLI, falling back to API');
// Fallback to API if CLI fails
const apiModels = await this.getInstalledModels();
return apiModels.map(model => ({
name: model.name,
id: model.digest || 'unknown',
size: model.size || 'Unknown',
status: 'installed'
}));
}
}
async getModelSuggestions() {
try {
// Get actually installed models
const installedModels = await this.getInstalledModelsList();
// Get user input history from storage (we'll implement this in the frontend)
// For now, just return installed models
return installedModels;
} catch (error) {
console.error('[OllamaService] Failed to get model suggestions:', error);
return [];
}
}
async isModelInstalled(modelName) {
const models = await this.getInstalledModels();
return models.some(model => model.name === modelName);
}
async pullModel(modelName) {
if (!modelName?.trim()) {
throw new Error(`Invalid model name: ${modelName}`);
}
console.log(`[OllamaService] Starting to pull model: ${modelName} via API`);
try {
const response = await fetch(`${this.baseUrl}/api/pull`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
stream: true
})
});
if (!response.ok) {
throw new Error(`Pull API failed: ${response.status} ${response.statusText}`);
}
// Handle Node.js streaming response
return new Promise((resolve, reject) => {
let buffer = '';
response.body.on('data', (chunk) => {
buffer += chunk.toString();
const lines = buffer.split('\n');
// Keep incomplete line in buffer
buffer = lines.pop() || '';
// Process complete lines
for (const line of lines) {
if (!line.trim()) continue;
try {
const data = JSON.parse(line);
const progress = this._parseOllamaPullProgress(data, modelName);
if (progress !== null) {
this.setInstallProgress(modelName, progress);
this.emit('pull-progress', {
model: modelName,
progress,
status: data.status || 'downloading'
});
console.log(`[OllamaService] API Progress: ${progress}% for ${modelName} (${data.status || 'downloading'})`);
}
// Handle completion
if (data.status === 'success') {
console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
this.emit('pull-complete', { model: modelName });
this.clearInstallProgress(modelName);
resolve();
return;
}
} catch (parseError) {
console.warn('[OllamaService] Failed to parse response line:', line);
}
}
});
response.body.on('end', () => {
// Process any remaining data in buffer
if (buffer.trim()) {
try {
const data = JSON.parse(buffer);
if (data.status === 'success') {
console.log(`[OllamaService] Successfully pulled model: ${modelName}`);
this.emit('pull-complete', { model: modelName });
}
} catch (parseError) {
console.warn('[OllamaService] Failed to parse final buffer:', buffer);
}
}
this.clearInstallProgress(modelName);
resolve();
});
response.body.on('error', (error) => {
console.error(`[OllamaService] Stream error for ${modelName}:`, error);
this.clearInstallProgress(modelName);
reject(error);
});
});
} catch (error) {
this.clearInstallProgress(modelName);
console.error(`[OllamaService] Pull model failed:`, error);
throw error;
}
}
_parseOllamaPullProgress(data, modelName) {
// Handle Ollama API response format
if (data.status === 'success') {
return 100;
}
// Handle downloading progress
if (data.total && data.completed !== undefined) {
const progress = Math.round((data.completed / data.total) * 100);
return Math.min(progress, 99); // Don't show 100% until success
}
// Handle status-based progress
const statusProgress = {
'pulling manifest': 5,
'downloading': 10,
'verifying sha256 digest': 90,
'writing manifest': 95,
'removing any unused layers': 98
};
if (data.status && statusProgress[data.status] !== undefined) {
return statusProgress[data.status];
}
return null;
}
async installMacOS(onProgress) {
console.log('[OllamaService] Installing Ollama on macOS using DMG...');
try {
const dmgUrl = 'https://ollama.com/download/Ollama.dmg';
const tempDir = app.getPath('temp');
const dmgPath = path.join(tempDir, 'Ollama.dmg');
const mountPoint = path.join(tempDir, 'OllamaMount');
console.log('[OllamaService] Step 1: Downloading Ollama DMG...');
onProgress?.({ stage: 'downloading', message: 'Downloading Ollama installer...', progress: 0 });
const checksumInfo = DOWNLOAD_CHECKSUMS.ollama.dmg;
await this.downloadWithRetry(dmgUrl, dmgPath, {
expectedChecksum: checksumInfo?.sha256,
onProgress: (progress) => {
onProgress?.({ stage: 'downloading', message: `Downloading... ${progress}%`, progress });
}
});
console.log('[OllamaService] Step 2: Mounting DMG...');
onProgress?.({ stage: 'mounting', message: 'Mounting disk image...', progress: 0 });
await fs.mkdir(mountPoint, { recursive: true });
await spawnAsync('hdiutil', ['attach', dmgPath, '-mountpoint', mountPoint]);
onProgress?.({ stage: 'mounting', message: 'Disk image mounted.', progress: 100 });
console.log('[OllamaService] Step 3: Installing Ollama.app...');
onProgress?.({ stage: 'installing', message: 'Installing Ollama application...', progress: 0 });
await spawnAsync('cp', ['-R', `${mountPoint}/Ollama.app`, '/Applications/']);
onProgress?.({ stage: 'installing', message: 'Application installed.', progress: 100 });
console.log('[OllamaService] Step 4: Setting up CLI path...');
onProgress?.({ stage: 'linking', message: 'Creating command-line shortcut...', progress: 0 });
try {
const script = `do shell script "mkdir -p /usr/local/bin && ln -sf '${this.getOllamaCliPath()}' '/usr/local/bin/ollama'" with administrator privileges`;
await spawnAsync('osascript', ['-e', script]);
onProgress?.({ stage: 'linking', message: 'Shortcut created.', progress: 100 });
} catch (linkError) {
console.error('[OllamaService] CLI symlink creation failed:', linkError.message);
onProgress?.({ stage: 'linking', message: 'Shortcut creation failed (permissions?).', progress: 100 });
// Not throwing an error, as the app might still work
}
console.log('[OllamaService] Step 5: Cleanup...');
onProgress?.({ stage: 'cleanup', message: 'Cleaning up installation files...', progress: 0 });
await spawnAsync('hdiutil', ['detach', mountPoint]);
await fs.unlink(dmgPath).catch(() => {});
await fs.rmdir(mountPoint).catch(() => {});
onProgress?.({ stage: 'cleanup', message: 'Cleanup complete.', progress: 100 });
console.log('[OllamaService] Ollama installed successfully on macOS');
await new Promise(resolve => setTimeout(resolve, 2000));
return true;
} catch (error) {
console.error('[OllamaService] macOS installation failed:', error);
throw new Error(`Failed to install Ollama on macOS: ${error.message}`);
}
}
async installWindows(onProgress) {
console.log('[OllamaService] Installing Ollama on Windows...');
try {
const exeUrl = 'https://ollama.com/download/OllamaSetup.exe';
const tempDir = app.getPath('temp');
const exePath = path.join(tempDir, 'OllamaSetup.exe');
console.log('[OllamaService] Step 1: Downloading Ollama installer...');
onProgress?.({ stage: 'downloading', message: 'Downloading Ollama installer...', progress: 0 });
const checksumInfo = DOWNLOAD_CHECKSUMS.ollama.exe;
await this.downloadWithRetry(exeUrl, exePath, {
expectedChecksum: checksumInfo?.sha256,
onProgress: (progress) => {
onProgress?.({ stage: 'downloading', message: `Downloading... ${progress}%`, progress });
}
});
console.log('[OllamaService] Step 2: Running silent installation...');
onProgress?.({ stage: 'installing', message: 'Installing Ollama...', progress: 0 });
await spawnAsync(exePath, ['/VERYSILENT', '/NORESTART']);
onProgress?.({ stage: 'installing', message: 'Installation complete.', progress: 100 });
console.log('[OllamaService] Step 3: Cleanup...');
onProgress?.({ stage: 'cleanup', message: 'Cleaning up installation files...', progress: 0 });
await fs.unlink(exePath).catch(() => {});
onProgress?.({ stage: 'cleanup', message: 'Cleanup complete.', progress: 100 });
console.log('[OllamaService] Ollama installed successfully on Windows');
await new Promise(resolve => setTimeout(resolve, 3000));
return true;
} catch (error) {
console.error('[OllamaService] Windows installation failed:', error);
throw new Error(`Failed to install Ollama on Windows: ${error.message}`);
}
}
async installLinux() {
console.log('[OllamaService] Installing Ollama on Linux...');
console.log('[OllamaService] Automatic installation on Linux is not supported for security reasons.');
console.log('[OllamaService] Please install Ollama manually:');
console.log('[OllamaService] 1. Visit https://ollama.com/download/linux');
console.log('[OllamaService] 2. Follow the official installation instructions');
console.log('[OllamaService] 3. Or use your package manager if available');
throw new Error('Manual installation required on Linux. Please visit https://ollama.com/download/linux');
}
async warmUpModel(modelName, forceRefresh = false) {
if (!modelName?.trim()) {
console.warn(`[OllamaService] Invalid model name for warm-up`);
return false;
}
// Check if already warmed (and not forcing refresh)
if (!forceRefresh && this.warmedModels.has(modelName)) {
console.log(`[OllamaService] Model ${modelName} already warmed up, skipping`);
return true;
}
// Check if currently warming - return existing Promise
if (this.warmingModels.has(modelName)) {
console.log(`[OllamaService] Model ${modelName} is already warming up, joining existing operation`);
return await this.warmingModels.get(modelName);
}
// Check rate limiting (prevent too frequent attempts)
const lastAttempt = this.lastWarmUpAttempt.get(modelName);
const now = Date.now();
if (lastAttempt && (now - lastAttempt) < 5000) { // 5 second cooldown
console.log(`[OllamaService] Rate limiting warm-up for ${modelName}, try again in ${5 - Math.floor((now - lastAttempt) / 1000)}s`);
return false;
}
// Create and store the warming Promise
const warmingPromise = this._performWarmUp(modelName);
this.warmingModels.set(modelName, warmingPromise);
this.lastWarmUpAttempt.set(modelName, now);
try {
const result = await warmingPromise;
if (result) {
this.warmedModels.add(modelName);
console.log(`[OllamaService] Model ${modelName} successfully warmed up`);
}
return result;
} finally {
// Always clean up the warming Promise
this.warmingModels.delete(modelName);
}
}
async _performWarmUp(modelName) {
console.log(`[OllamaService] Starting warm-up for model: ${modelName}`);
try {
const response = await this._makeRequest(`${this.baseUrl}/api/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: modelName,
messages: [
{ role: 'user', content: 'Hi' }
],
stream: false,
options: {
num_predict: 1, // Minimal response
temperature: 0
}
}),
timeout: this.warmupTimeout
}, `warmup_${modelName}`);
return true;
} catch (error) {
console.error(`[OllamaService] Failed to warm up model ${modelName}:`, error.message);
return false;
}
}
async autoWarmUpSelectedModel() {
try {
// Get selected model from ModelStateService
const modelStateService = global.modelStateService;
if (!modelStateService) {
console.log('[OllamaService] ModelStateService not available for auto warm-up');
return false;
}
const selectedModels = modelStateService.getSelectedModels();
const llmModelId = selectedModels.llm;
// Check if it's an Ollama model
const provider = modelStateService.getProviderForModel('llm', llmModelId);
if (provider !== 'ollama') {
console.log('[OllamaService] Selected LLM is not Ollama, skipping warm-up');
return false;
}
// Check if Ollama service is running
const isRunning = await this.isServiceRunning();
if (!isRunning) {
console.log('[OllamaService] Ollama service not running, clearing warm-up cache');
this._clearWarmUpCache();
return false;
}
// Check if model is installed
const isInstalled = await this.isModelInstalled(llmModelId);
if (!isInstalled) {
console.log(`[OllamaService] Model ${llmModelId} not installed, skipping warm-up`);
return false;
}
console.log(`[OllamaService] Auto-warming up selected model: ${llmModelId}`);
return await this.warmUpModel(llmModelId);
} catch (error) {
console.error('[OllamaService] Auto warm-up failed:', error);
return false;
}
}
_clearWarmUpCache() {
this.warmedModels.clear();
this.warmingModels.clear();
this.lastWarmUpAttempt.clear();
console.log('[OllamaService] Warm-up cache cleared');
}
getWarmUpStatus() {
return {
warmedModels: Array.from(this.warmedModels),
warmingModels: Array.from(this.warmingModels.keys()),
lastAttempts: Object.fromEntries(this.lastWarmUpAttempt)
};
}
async shutdown(force = false) {
console.log(`[OllamaService] Shutdown initiated (force: ${force})`);
if (!force && this.warmingModels.size > 0) {
const warmingList = Array.from(this.warmingModels.keys());
console.log(`[OllamaService] Waiting for ${warmingList.length} models to finish warming: ${warmingList.join(', ')}`);
const warmingPromises = Array.from(this.warmingModels.values());
try {
// Use Promise.allSettled instead of race with setTimeout
const results = await Promise.allSettled(warmingPromises);
const completed = results.filter(r => r.status === 'fulfilled').length;
console.log(`[OllamaService] ${completed}/${results.length} warming operations completed`);
} catch (error) {
console.log('[OllamaService] Error waiting for warm-up completion, proceeding with shutdown');
}
}
// Clean up all resources
this._cleanup();
this._clearWarmUpCache();
return super.shutdown(force);
}
async shutdownMacOS(force) {
try {
// Try to quit Ollama.app gracefully
await spawnAsync('osascript', ['-e', 'tell application "Ollama" to quit']);
console.log('[OllamaService] Ollama.app quit successfully');
// Wait a moment for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 2000));
// Check if still running
const stillRunning = await this.isServiceRunning();
if (stillRunning) {
console.log('[OllamaService] Ollama still running, forcing shutdown');
// Force kill if necessary
await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]);
}
return true;
} catch (error) {
console.log('[OllamaService] Graceful quit failed, trying force kill');
try {
await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]);
return true;
} catch (killError) {
console.error('[OllamaService] Failed to force kill Ollama:', killError);
return false;
}
}
}
async shutdownWindows(force) {
try {
// Try to stop the service gracefully
await spawnAsync('taskkill', ['/IM', 'ollama.exe', '/T']);
console.log('[OllamaService] Ollama process terminated on Windows');
return true;
} catch (error) {
console.log('[OllamaService] Standard termination failed, trying force kill');
try {
await spawnAsync('taskkill', ['/IM', 'ollama.exe', '/F', '/T']);
return true;
} catch (killError) {
console.error('[OllamaService] Failed to force kill Ollama on Windows:', killError);
return false;
}
}
}
async shutdownLinux(force) {
try {
await spawnAsync('pkill', ['-f', this.getOllamaCliPath()]);
console.log('[OllamaService] Ollama process terminated on Linux');
return true;
} catch (error) {
if (force) {
await spawnAsync('pkill', ['-9', '-f', this.getOllamaCliPath()]).catch(() => {});
}
console.error('[OllamaService] Failed to shutdown Ollama on Linux:', error);
return false;
}
}
async getAllModelsWithStatus() {
// Get all installed models directly from Ollama
const installedModels = await this.getInstalledModels();
const models = [];
for (const model of installedModels) {
models.push({
name: model.name,
displayName: model.name, // Use model name as display name
size: model.size || 'Unknown',
description: `Ollama model: ${model.name}`,
installed: true,
installing: this.installationProgress.has(model.name),
progress: this.getInstallProgress(model.name)
});
}
// Also add any models currently being installed
for (const [modelName, progress] of this.installationProgress) {
if (!models.find(m => m.name === modelName)) {
models.push({
name: modelName,
displayName: modelName,
size: 'Unknown',
description: `Ollama model: ${modelName}`,
installed: false,
installing: true,
progress: progress
});
}
}
return models;
}
}
// Export singleton instance
const ollamaService = new OllamaService();
module.exports = ollamaService;

View File

@ -0,0 +1,451 @@
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
const os = require('os');
const LocalAIServiceBase = require('./localAIServiceBase');
const { spawnAsync } = require('../utils/spawnHelper');
const { DOWNLOAD_CHECKSUMS } = require('../config/checksums');
const fsPromises = fs.promises;
class WhisperService extends LocalAIServiceBase {
constructor() {
super('WhisperService');
this.isInitialized = false;
this.whisperPath = null;
this.modelsDir = null;
this.tempDir = null;
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'
}
};
}
async initialize() {
if (this.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.exe' : 'whisper';
this.whisperPath = path.join(whisperDir, 'bin', whisperExecutable);
await this.ensureDirectories();
await this.ensureWhisperBinary();
this.isInitialized = true;
console.log('[WhisperService] Initialized successfully');
} catch (error) {
console.error('[WhisperService] Initialization failed:', error);
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 });
}
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();
return;
} catch (error) {
console.log('[WhisperService] Homebrew installation failed:', error.message);
}
}
await this.autoInstall();
}
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.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];
this.emit('downloadProgress', { modelId, progress: 0 });
await this.downloadWithRetry(modelInfo.url, modelPath, {
expectedChecksum: checksumInfo?.sha256,
onProgress: (progress) => {
this.emit('downloadProgress', { modelId, progress });
}
});
console.log(`[WhisperService] Model ${modelId} downloaded successfully`);
}
async getModelPath(modelId) {
if (!this.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 = 24000;
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.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.isInitialized;
}
async startService() {
if (!this.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/ggerganov/whisper.cpp/releases/download/${version}/whisper-cpp-${version}-win-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.exe' || item.name === 'main.exe')) {
// main.exe도 포함 (일부 빌드에서 whisper 실행파일이 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/ggerganov/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;
}
}
module.exports = { WhisperService };

View File

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

View File

@ -6,7 +6,17 @@ const fs = require('node:fs');
const os = require('os'); const os = require('os');
const util = require('util'); const util = require('util');
const execFile = util.promisify(require('child_process').execFile); const execFile = util.promisify(require('child_process').execFile);
const sharp = require('sharp');
// Try to load sharp, but don't fail if it's not available
let sharp;
try {
sharp = require('sharp');
console.log('[WindowManager] Sharp module loaded successfully');
} catch (error) {
console.warn('[WindowManager] Sharp module not available:', error.message);
console.warn('[WindowManager] Screenshot functionality will work with reduced image processing capabilities');
sharp = null;
}
const authService = require('../common/services/authService'); const authService = require('../common/services/authService');
const systemSettingsRepository = require('../common/repositories/systemSettings'); const systemSettingsRepository = require('../common/repositories/systemSettings');
const userRepository = require('../common/repositories/user'); const userRepository = require('../common/repositories/user');
@ -1448,25 +1458,45 @@ async function captureScreenshot(options = {}) {
const imageBuffer = await fs.promises.readFile(tempPath); const imageBuffer = await fs.promises.readFile(tempPath);
await fs.promises.unlink(tempPath); await fs.promises.unlink(tempPath);
const resizedBuffer = await sharp(imageBuffer) if (sharp) {
// .resize({ height: 1080 }) try {
.resize({ height: 384 }) // Try using sharp for optimal image processing
.jpeg({ quality: 80 }) const resizedBuffer = await sharp(imageBuffer)
.toBuffer(); // .resize({ height: 1080 })
.resize({ height: 384 })
.jpeg({ quality: 80 })
.toBuffer();
const base64 = resizedBuffer.toString('base64'); const base64 = resizedBuffer.toString('base64');
const metadata = await sharp(resizedBuffer).metadata(); 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('[WindowManager] Using fallback image processing (no resize/compression)');
const base64 = imageBuffer.toString('base64');
lastScreenshot = { lastScreenshot = {
base64, base64,
width: metadata.width, width: null, // We don't have metadata without sharp
height: metadata.height, height: null,
timestamp: Date.now(), timestamp: Date.now(),
}; };
return { success: true, base64, width: metadata.width, height: metadata.height }; return { success: true, base64, width: null, height: null };
} catch (error) { } catch (error) {
console.error('Failed to capture and resize screenshot:', error); console.error('Failed to capture screenshot:', error);
return { success: false, error: error.message }; return { success: false, error: error.message };
} }
} }

View File

@ -28,9 +28,18 @@ async function sendMessage(userPrompt) {
askWindow.webContents.send('hide-text-input'); askWindow.webContents.send('hide-text-input');
} }
let sessionId;
try { try {
console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`); console.log(`[AskService] 🤖 Processing message: ${userPrompt.substring(0, 50)}...`);
// --- Save user's message immediately ---
// This ensures the user message is always timestamped before the assistant's response.
sessionId = await sessionRepository.getOrCreateActive('ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
console.log(`[AskService] DB: Saved user prompt to session ${sessionId}`);
// --- End of user message saving ---
const modelInfo = await getCurrentModelInfo(null, { type: 'llm' }); const modelInfo = await getCurrentModelInfo(null, { type: 'llm' });
if (!modelInfo || !modelInfo.apiKey) { if (!modelInfo || !modelInfo.apiKey) {
throw new Error('AI model or API key not configured.'); throw new Error('AI model or API key not configured.');
@ -99,16 +108,13 @@ async function sendMessage(userPrompt) {
if (data === '[DONE]') { if (data === '[DONE]') {
askWin.webContents.send('ask-response-stream-end'); askWin.webContents.send('ask-response-stream-end');
// Save to DB // Save assistant's message to DB
try { try {
const uid = authService.getCurrentUserId(); // sessionId is already available from when we saved the user prompt
if (!uid) throw new Error("User not logged in, cannot save message.");
const sessionId = await sessionRepository.getOrCreateActive(uid, 'ask');
await askRepository.addAiMessage({ sessionId, role: 'user', content: userPrompt.trim() });
await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse }); await askRepository.addAiMessage({ sessionId, role: 'assistant', content: fullResponse });
console.log(`[AskService] DB: Saved ask/answer pair to session ${sessionId}`); console.log(`[AskService] DB: Saved assistant response to session ${sessionId}`);
} catch(dbError) { } catch(dbError) {
console.error("[AskService] DB: Failed to save ask/answer pair:", dbError); console.error("[AskService] DB: Failed to save assistant response:", dbError);
} }
return { success: true, response: fullResponse }; return { success: true, response: fullResponse };

View File

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

View File

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

View File

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

View File

@ -77,12 +77,15 @@ class ListenService {
async initializeNewSession() { async initializeNewSession() {
try { try {
const uid = authService.getCurrentUserId(); // The UID is no longer passed to the repository method directly.
if (!uid) { // The adapter layer handles UID injection. We just ensure a user is available.
throw new Error("Cannot initialize session: user not logged in."); const user = authService.getCurrentUser();
if (!user) {
// This case should ideally not happen as authService initializes a default user.
throw new Error("Cannot initialize session: auth service not ready.");
} }
this.currentSessionId = await sessionRepository.getOrCreateActive(uid, 'listen'); this.currentSessionId = await sessionRepository.getOrCreateActive('listen');
console.log(`[DB] New listen session ensured: ${this.currentSessionId}`); console.log(`[DB] New listen session ensured: ${this.currentSessionId}`);
// Set session ID for summary service // Set session ID for summary service

View File

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

View File

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

View File

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

View File

@ -133,7 +133,50 @@ class SttService {
return; return;
} }
if (this.modelInfo.provider === 'gemini') { if (this.modelInfo.provider === 'whisper') {
// Whisper STT emits 'transcription' events with different structure
if (message.text && message.text.trim()) {
const finalText = message.text.trim();
// Filter out Whisper noise transcriptions
const noisePatterns = [
'[BLANK_AUDIO]',
'[INAUDIBLE]',
'[MUSIC]',
'[SOUND]',
'[NOISE]',
'(BLANK_AUDIO)',
'(INAUDIBLE)',
'(MUSIC)',
'(SOUND)',
'(NOISE)'
];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern
);
if (!isNoise && finalText.length > 2) {
this.debounceMyCompletion(finalText);
this.sendToRenderer('stt-update', {
speaker: 'Me',
text: finalText,
isPartial: false,
isFinal: true,
timestamp: Date.now(),
});
} else {
console.log(`[Whisper-Me] Filtered noise: "${finalText}"`);
}
}
return;
} else if (this.modelInfo.provider === 'gemini') {
if (!message.serverContent?.modelTurn) { if (!message.serverContent?.modelTurn) {
console.log('[Gemini STT - Me]', JSON.stringify(message, null, 2)); console.log('[Gemini STT - Me]', JSON.stringify(message, null, 2));
} }
@ -203,7 +246,50 @@ class SttService {
return; return;
} }
if (this.modelInfo.provider === 'gemini') { if (this.modelInfo.provider === 'whisper') {
// Whisper STT emits 'transcription' events with different structure
if (message.text && message.text.trim()) {
const finalText = message.text.trim();
// Filter out Whisper noise transcriptions
const noisePatterns = [
'[BLANK_AUDIO]',
'[INAUDIBLE]',
'[MUSIC]',
'[SOUND]',
'[NOISE]',
'(BLANK_AUDIO)',
'(INAUDIBLE)',
'(MUSIC)',
'(SOUND)',
'(NOISE)'
];
const normalizedText = finalText.toLowerCase().trim();
const isNoise = noisePatterns.some(pattern =>
finalText.includes(pattern) || finalText === pattern
);
// Only process if it's not noise, not a false positive, and has meaningful content
if (!isNoise && finalText.length > 2) {
this.debounceTheirCompletion(finalText);
this.sendToRenderer('stt-update', {
speaker: 'Them',
text: finalText,
isPartial: false,
isFinal: true,
timestamp: Date.now(),
});
} else {
console.log(`[Whisper-Them] Filtered noise: "${finalText}"`);
}
}
return;
} else if (this.modelInfo.provider === 'gemini') {
if (!message.serverContent?.modelTurn) { if (!message.serverContent?.modelTurn) {
console.log('[Gemini STT - Them]', JSON.stringify(message, null, 2)); console.log('[Gemini STT - Them]', JSON.stringify(message, null, 2));
} }
@ -294,9 +380,13 @@ class SttService {
portkeyVirtualKey: this.modelInfo.provider === 'openai-glass' ? this.modelInfo.apiKey : undefined, portkeyVirtualKey: this.modelInfo.provider === 'openai-glass' ? this.modelInfo.apiKey : undefined,
}; };
// Add sessionType for Whisper to distinguish between My and Their sessions
const myOptions = { ...sttOptions, callbacks: mySttConfig.callbacks, sessionType: 'my' };
const theirOptions = { ...sttOptions, callbacks: theirSttConfig.callbacks, sessionType: 'their' };
[this.mySttSession, this.theirSttSession] = await Promise.all([ [this.mySttSession, this.theirSttSession] = await Promise.all([
createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: mySttConfig.callbacks }), createSTT(this.modelInfo.provider, myOptions),
createSTT(this.modelInfo.provider, { ...sttOptions, callbacks: theirSttConfig.callbacks }), createSTT(this.modelInfo.provider, theirOptions),
]); ]);
console.log('✅ Both STT sessions initialized successfully.'); console.log('✅ Both STT sessions initialized successfully.');

View File

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

View File

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

View File

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

View File

@ -1,4 +1,5 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js'; import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
import { getOllamaProgressTracker } from '../../common/services/localProgressTracker.js';
export class SettingsView extends LitElement { export class SettingsView extends LitElement {
static styles = css` static styles = css`
@ -403,9 +404,77 @@ export class SettingsView extends LitElement {
overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 4px; overflow-y: auto; background: rgba(0,0,0,0.3); border-radius: 4px;
padding: 4px; margin-top: 4px; padding: 4px; margin-top: 4px;
} }
.model-item { padding: 5px 8px; font-size: 11px; border-radius: 3px; cursor: pointer; transition: background-color 0.15s; } .model-item {
padding: 5px 8px;
font-size: 11px;
border-radius: 3px;
cursor: pointer;
transition: background-color 0.15s;
display: flex;
justify-content: space-between;
align-items: center;
}
.model-item:hover { background-color: rgba(255,255,255,0.1); } .model-item:hover { background-color: rgba(255,255,255,0.1); }
.model-item.selected { background-color: rgba(0, 122, 255, 0.4); font-weight: 500; .model-item.selected { background-color: rgba(0, 122, 255, 0.4); font-weight: 500; }
.model-status {
font-size: 9px;
color: rgba(255,255,255,0.6);
margin-left: 8px;
}
.model-status.installed { color: rgba(0, 255, 0, 0.8); }
.model-status.not-installed { color: rgba(255, 200, 0, 0.8); }
.install-progress {
flex: 1;
height: 4px;
background: rgba(255,255,255,0.1);
border-radius: 2px;
margin-left: 8px;
overflow: hidden;
}
.install-progress-bar {
height: 100%;
background: rgba(0, 122, 255, 0.8);
transition: width 0.3s ease;
}
/* Dropdown styles */
select.model-dropdown {
background: rgba(0,0,0,0.2);
color: white;
cursor: pointer;
}
select.model-dropdown option {
background: #1a1a1a;
color: white;
}
select.model-dropdown option:disabled {
color: rgba(255,255,255,0.4);
}
/* ────────────────[ GLASS BYPASS ]─────────────── */
:host-context(body.has-glass) {
animation: none !important;
transition: none !important;
transform: none !important;
will-change: auto !important;
}
:host-context(body.has-glass) * {
background: transparent !important;
filter: none !important;
backdrop-filter: none !important;
box-shadow: none !important;
outline: none !important;
border: none !important;
border-radius: 0 !important;
transition: none !important;
animation: none !important;
}
:host-context(body.has-glass) .settings-container::before {
display: none !important;
} }
`; `;
@ -430,6 +499,12 @@ export class SettingsView extends LitElement {
showPresets: { type: Boolean, state: true }, showPresets: { type: Boolean, state: true },
autoUpdateEnabled: { type: Boolean, state: true }, autoUpdateEnabled: { type: Boolean, state: true },
autoUpdateLoading: { type: Boolean, state: true }, autoUpdateLoading: { type: Boolean, state: true },
// Ollama related properties
ollamaStatus: { type: Object, state: true },
ollamaModels: { type: Array, state: true },
installingModels: { type: Object, state: true },
// Whisper related properties
whisperModels: { type: Array, state: true },
}; };
//////// after_modelStateService //////// //////// after_modelStateService ////////
@ -438,7 +513,7 @@ export class SettingsView extends LitElement {
//////// after_modelStateService //////// //////// after_modelStateService ////////
this.shortcuts = {}; this.shortcuts = {};
this.firebaseUser = null; this.firebaseUser = null;
this.apiKeys = { openai: '', gemini: '', anthropic: '' }; this.apiKeys = { openai: '', gemini: '', anthropic: '', whisper: '' };
this.providerConfig = {}; this.providerConfig = {};
this.isLoading = true; this.isLoading = true;
this.isContentProtectionOn = true; this.isContentProtectionOn = true;
@ -452,6 +527,14 @@ export class SettingsView extends LitElement {
this.presets = []; this.presets = [];
this.selectedPreset = null; this.selectedPreset = null;
this.showPresets = false; this.showPresets = false;
// Ollama related
this.ollamaStatus = { installed: false, running: false };
this.ollamaModels = [];
this.installingModels = {}; // { modelName: progress }
this.progressTracker = getOllamaProgressTracker();
// Whisper related
this.whisperModels = [];
this.whisperProgressTracker = null; // Will be initialized when needed
this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this) this.handleUsePicklesKey = this.handleUsePicklesKey.bind(this)
this.autoUpdateEnabled = true; this.autoUpdateEnabled = true;
this.autoUpdateLoading = true; this.autoUpdateLoading = true;
@ -501,7 +584,7 @@ export class SettingsView extends LitElement {
this.isLoading = true; this.isLoading = true;
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
try { try {
const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection, shortcuts] = await Promise.all([ const [userState, config, storedKeys, availableLlm, availableStt, selectedModels, presets, contentProtection, shortcuts, ollamaStatus, whisperModelsResult] = await Promise.all([
ipcRenderer.invoke('get-current-user'), ipcRenderer.invoke('get-current-user'),
ipcRenderer.invoke('model:get-provider-config'), // Provider 설정 로드 ipcRenderer.invoke('model:get-provider-config'), // Provider 설정 로드
ipcRenderer.invoke('model:get-all-keys'), ipcRenderer.invoke('model:get-all-keys'),
@ -510,7 +593,9 @@ export class SettingsView extends LitElement {
ipcRenderer.invoke('model:get-selected-models'), ipcRenderer.invoke('model:get-selected-models'),
ipcRenderer.invoke('settings:getPresets'), ipcRenderer.invoke('settings:getPresets'),
ipcRenderer.invoke('get-content-protection-status'), ipcRenderer.invoke('get-content-protection-status'),
ipcRenderer.invoke('get-current-shortcuts') ipcRenderer.invoke('get-current-shortcuts'),
ipcRenderer.invoke('ollama:get-status'),
ipcRenderer.invoke('whisper:get-installed-models')
]); ]);
if (userState && userState.isLoggedIn) this.firebaseUser = userState; if (userState && userState.isLoggedIn) this.firebaseUser = userState;
@ -527,6 +612,23 @@ export class SettingsView extends LitElement {
const firstUserPreset = this.presets.find(p => p.is_default === 0); const firstUserPreset = this.presets.find(p => p.is_default === 0);
if (firstUserPreset) this.selectedPreset = firstUserPreset; if (firstUserPreset) this.selectedPreset = firstUserPreset;
} }
// Ollama status
if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || [];
}
// Whisper status
if (whisperModelsResult?.success) {
const installedWhisperModels = whisperModelsResult.models;
if (this.providerConfig.whisper) {
this.providerConfig.whisper.sttModels.forEach(m => {
const installedInfo = installedWhisperModels.find(i => i.id === m.id);
if (installedInfo) {
m.installed = installedInfo.installed;
}
});
}
}
} catch (error) { } catch (error) {
console.error('Error loading initial settings data:', error); console.error('Error loading initial settings data:', error);
} finally { } finally {
@ -538,8 +640,52 @@ export class SettingsView extends LitElement {
const input = this.shadowRoot.querySelector(`#key-input-${provider}`); const input = this.shadowRoot.querySelector(`#key-input-${provider}`);
if (!input) return; if (!input) return;
const key = input.value; const key = input.value;
// For Ollama, we need to ensure it's ready first
if (provider === 'ollama') {
this.saving = true;
const { ipcRenderer } = window.require('electron');
// First ensure Ollama is installed and running
const ensureResult = await ipcRenderer.invoke('ollama:ensure-ready');
if (!ensureResult.success) {
alert(`Failed to setup Ollama: ${ensureResult.error}`);
this.saving = false;
return;
}
// Now validate (which will check if service is running)
const result = await ipcRenderer.invoke('model:validate-key', { provider, key: 'local' });
if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: 'local' };
await this.refreshModelData();
await this.refreshOllamaStatus();
} else {
alert(`Failed to connect to Ollama: ${result.error}`);
}
this.saving = false;
return;
}
// For Whisper, just enable it
if (provider === 'whisper') {
this.saving = true;
const { ipcRenderer } = window.require('electron');
const result = await ipcRenderer.invoke('model:validate-key', { provider, key: 'local' });
if (result.success) {
this.apiKeys = { ...this.apiKeys, [provider]: 'local' };
await this.refreshModelData();
} else {
alert(`Failed to enable Whisper: ${result.error}`);
}
this.saving = false;
return;
}
// For other providers, use the normal flow
this.saving = true; this.saving = true;
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
const result = await ipcRenderer.invoke('model:validate-key', { provider, key }); const result = await ipcRenderer.invoke('model:validate-key', { provider, key });
@ -564,15 +710,17 @@ export class SettingsView extends LitElement {
async refreshModelData() { async refreshModelData() {
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
const [availableLlm, availableStt, selected] = await Promise.all([ const [availableLlm, availableStt, selected, storedKeys] = await Promise.all([
ipcRenderer.invoke('model:get-available-models', { type: 'llm' }), ipcRenderer.invoke('model:get-available-models', { type: 'llm' }),
ipcRenderer.invoke('model:get-available-models', { type: 'stt' }), ipcRenderer.invoke('model:get-available-models', { type: 'stt' }),
ipcRenderer.invoke('model:get-selected-models') ipcRenderer.invoke('model:get-selected-models'),
ipcRenderer.invoke('model:get-all-keys')
]); ]);
this.availableLlmModels = availableLlm; this.availableLlmModels = availableLlm;
this.availableSttModels = availableStt; this.availableSttModels = availableStt;
this.selectedLlm = selected.llm; this.selectedLlm = selected.llm;
this.selectedStt = selected.stt; this.selectedStt = selected.stt;
this.apiKeys = storedKeys;
this.requestUpdate(); this.requestUpdate();
} }
@ -594,6 +742,28 @@ export class SettingsView extends LitElement {
} }
async selectModel(type, modelId) { async selectModel(type, modelId) {
// Check if this is an Ollama model that needs to be installed
const provider = this.getProviderForModel(type, modelId);
if (provider === 'ollama') {
const ollamaModel = this.ollamaModels.find(m => m.name === modelId);
if (ollamaModel && !ollamaModel.installed && !ollamaModel.installing) {
// Need to install the model first
await this.installOllamaModel(modelId);
return;
}
}
// Check if this is a Whisper model that needs to be downloaded
if (provider === 'whisper' && type === 'stt') {
const isInstalling = this.installingModels[modelId] !== undefined;
const whisperModelInfo = this.providerConfig.whisper.sttModels.find(m => m.id === modelId);
if (whisperModelInfo && !whisperModelInfo.installed && !isInstalling) {
await this.downloadWhisperModel(modelId);
return;
}
}
this.saving = true; this.saving = true;
const { ipcRenderer } = window.require('electron'); const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('model:set-selected-model', { type, modelId }); await ipcRenderer.invoke('model:set-selected-model', { type, modelId });
@ -604,6 +774,102 @@ export class SettingsView extends LitElement {
this.saving = false; this.saving = false;
this.requestUpdate(); this.requestUpdate();
} }
async refreshOllamaStatus() {
const { ipcRenderer } = window.require('electron');
const ollamaStatus = await ipcRenderer.invoke('ollama:get-status');
if (ollamaStatus?.success) {
this.ollamaStatus = { installed: ollamaStatus.installed, running: ollamaStatus.running };
this.ollamaModels = ollamaStatus.models || [];
}
}
async installOllamaModel(modelName) {
// Mark as installing
this.installingModels = { ...this.installingModels, [modelName]: 0 };
this.requestUpdate();
try {
// Use the clean progress tracker - no manual event management needed
const success = await this.progressTracker.installModel(modelName, (progress) => {
this.installingModels = { ...this.installingModels, [modelName]: progress };
this.requestUpdate();
});
if (success) {
// Refresh status after installation
await this.refreshOllamaStatus();
await this.refreshModelData();
// Auto-select the model after installation
await this.selectModel('llm', modelName);
} else {
alert(`Installation of ${modelName} was cancelled`);
}
} catch (error) {
console.error(`[SettingsView] Error installing model ${modelName}:`, error);
alert(`Error installing ${modelName}: ${error.message}`);
} finally {
// Automatic cleanup - no manual event listener management
delete this.installingModels[modelName];
this.requestUpdate();
}
}
async downloadWhisperModel(modelId) {
// Mark as installing
this.installingModels = { ...this.installingModels, [modelId]: 0 };
this.requestUpdate();
try {
const { ipcRenderer } = window.require('electron');
// Set up progress listener
const progressHandler = (event, { modelId: id, progress }) => {
if (id === modelId) {
this.installingModels = { ...this.installingModels, [modelId]: progress };
this.requestUpdate();
}
};
ipcRenderer.on('whisper:download-progress', progressHandler);
// Start download
const result = await ipcRenderer.invoke('whisper:download-model', modelId);
if (result.success) {
// Auto-select the model after download
await this.selectModel('stt', modelId);
} else {
alert(`Failed to download Whisper model: ${result.error}`);
}
// Cleanup
ipcRenderer.removeListener('whisper:download-progress', progressHandler);
} catch (error) {
console.error(`[SettingsView] Error downloading Whisper model ${modelId}:`, error);
alert(`Error downloading ${modelId}: ${error.message}`);
} finally {
delete this.installingModels[modelId];
this.requestUpdate();
}
}
getProviderForModel(type, modelId) {
for (const [providerId, config] of Object.entries(this.providerConfig)) {
const models = type === 'llm' ? config.llmModels : config.sttModels;
if (models?.some(m => m.id === modelId)) {
return providerId;
}
}
return null;
}
async handleWhisperModelSelect(modelId) {
if (!modelId) return;
// Select the model (will trigger download if needed)
await this.selectModel('stt', modelId);
}
handleUsePicklesKey(e) { handleUsePicklesKey(e) {
e.preventDefault() e.preventDefault()
@ -637,6 +903,14 @@ export class SettingsView extends LitElement {
this.cleanupEventListeners(); this.cleanupEventListeners();
this.cleanupIpcListeners(); this.cleanupIpcListeners();
this.cleanupWindowResize(); this.cleanupWindowResize();
// Cancel any ongoing Ollama installations when component is destroyed
const installingModels = Object.keys(this.installingModels);
if (installingModels.length > 0) {
installingModels.forEach(modelName => {
this.progressTracker.cancelInstallation(modelName);
});
}
} }
setupEventListeners() { setupEventListeners() {
@ -892,6 +1166,36 @@ export class SettingsView extends LitElement {
} }
} }
async handleOllamaShutdown() {
console.log('[SettingsView] Shutting down Ollama service...');
if (!window.require) return;
const { ipcRenderer } = window.require('electron');
try {
// Show loading state
this.ollamaStatus = { ...this.ollamaStatus, running: false };
this.requestUpdate();
const result = await ipcRenderer.invoke('ollama:shutdown', false); // Graceful shutdown
if (result.success) {
console.log('[SettingsView] Ollama shut down successfully');
// Refresh status to reflect the change
await this.refreshOllamaStatus();
} else {
console.error('[SettingsView] Failed to shutdown Ollama:', result.error);
// Restore previous state on error
await this.refreshOllamaStatus();
}
} catch (error) {
console.error('[SettingsView] Error during Ollama shutdown:', error);
// Restore previous state on error
await this.refreshOllamaStatus();
}
}
//////// before_modelStateService //////// //////// before_modelStateService ////////
// render() { // render() {
@ -1044,20 +1348,124 @@ export class SettingsView extends LitElement {
<div class="api-key-section"> <div class="api-key-section">
${Object.entries(this.providerConfig) ${Object.entries(this.providerConfig)
.filter(([id, config]) => !id.includes('-glass')) .filter(([id, config]) => !id.includes('-glass'))
.map(([id, config]) => html` .map(([id, config]) => {
if (id === 'ollama') {
// Special UI for Ollama
return html`
<div class="provider-key-group">
<label>${config.name} (Local)</label>
${this.ollamaStatus.installed && this.ollamaStatus.running ? html`
<div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8);">
Ollama is running
</div>
<button class="settings-button full-width danger" @click=${this.handleOllamaShutdown}>
Stop Ollama Service
</button>
` : this.ollamaStatus.installed ? html`
<div style="padding: 8px; background: rgba(255,200,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(255,200,0,0.8);">
Ollama installed but not running
</div>
<button class="settings-button full-width" @click=${() => this.handleSaveKey(id)}>
Start Ollama
</button>
` : html`
<div style="padding: 8px; background: rgba(255,100,100,0.1); border-radius: 4px; font-size: 11px; color: rgba(255,100,100,0.8);">
Ollama not installed
</div>
<button class="settings-button full-width" @click=${() => this.handleSaveKey(id)}>
Install & Setup Ollama
</button>
`}
</div>
`;
}
if (id === 'whisper') {
// Special UI for Whisper with model selection
const whisperModels = config.sttModels || [];
const selectedWhisperModel = this.selectedStt && this.getProviderForModel('stt', this.selectedStt) === 'whisper'
? this.selectedStt
: null;
return html`
<div class="provider-key-group">
<label>${config.name} (Local STT)</label>
${this.apiKeys[id] === 'local' ? html`
<div style="padding: 8px; background: rgba(0,255,0,0.1); border-radius: 4px; font-size: 11px; color: rgba(0,255,0,0.8); margin-bottom: 8px;">
Whisper is enabled
</div>
<!-- Whisper Model Selection Dropdown -->
<label style="font-size: 10px; margin-top: 8px;">Select Model:</label>
<select
class="model-dropdown"
style="width: 100%; padding: 6px; background: rgba(0,0,0,0.2); border: 1px solid rgba(255,255,255,0.2); color: white; border-radius: 4px; font-size: 11px; margin-bottom: 8px;"
@change=${(e) => this.handleWhisperModelSelect(e.target.value)}
.value=${selectedWhisperModel || ''}
>
<option value="">Choose a model...</option>
${whisperModels.map(model => {
const isInstalling = this.installingModels[model.id] !== undefined;
const progress = this.installingModels[model.id] || 0;
let statusText = '';
if (isInstalling) {
statusText = ` (Downloading ${progress}%)`;
} else if (model.installed) {
statusText = ' (Installed)';
}
return html`
<option value="${model.id}" ?disabled=${isInstalling}>
${model.name}${statusText}
</option>
`;
})}
</select>
${Object.entries(this.installingModels).map(([modelId, progress]) => {
if (modelId.startsWith('whisper-') && progress !== undefined) {
return html`
<div style="margin: 8px 0;">
<div style="font-size: 10px; color: rgba(255,255,255,0.7); margin-bottom: 4px;">
Downloading ${modelId}...
</div>
<div class="install-progress" style="height: 4px; background: rgba(255,255,255,0.1); border-radius: 2px; overflow: hidden;">
<div class="install-progress-bar" style="height: 100%; background: rgba(0, 122, 255, 0.8); width: ${progress}%; transition: width 0.3s ease;"></div>
</div>
</div>
`;
}
return null;
})}
<button class="settings-button full-width danger" @click=${() => this.handleClearKey(id)}>
Disable Whisper
</button>
` : html`
<button class="settings-button full-width" @click=${() => this.handleSaveKey(id)}>
Enable Whisper STT
</button>
`}
</div>
`;
}
// Regular providers
return html`
<div class="provider-key-group"> <div class="provider-key-group">
<label for="key-input-${id}">${config.name} API Key</label> <label for="key-input-${id}">${config.name} API Key</label>
<input type="password" id="key-input-${id}" <input type="password" id="key-input-${id}"
placeholder=${loggedIn ? "Using Pickle's Key" : `Enter ${config.name} API Key`} placeholder=${loggedIn ? "Using Pickle's Key" : `Enter ${config.name} API Key`}
.value=${this.apiKeys[id] || ''} .value=${this.apiKeys[id] || ''}
> >
<div class="key-buttons"> <div class="key-buttons">
<button class="settings-button" @click=${() => this.handleSaveKey(id)} >Save</button> <button class="settings-button" @click=${() => this.handleSaveKey(id)} >Save</button>
<button class="settings-button danger" @click=${() => this.handleClearKey(id)} }>Clear</button> <button class="settings-button danger" @click=${() => this.handleClearKey(id)} }>Clear</button>
</div> </div>
</div> </div>
`)} `;
})}
</div> </div>
`; `;
@ -1076,11 +1484,30 @@ export class SettingsView extends LitElement {
</button> </button>
${this.isLlmListVisible ? html` ${this.isLlmListVisible ? html`
<div class="model-list"> <div class="model-list">
${this.availableLlmModels.map(model => html` ${this.availableLlmModels.map(model => {
<div class="model-item ${this.selectedLlm === model.id ? 'selected' : ''}" @click=${() => this.selectModel('llm', model.id)}> const isOllama = this.getProviderForModel('llm', model.id) === 'ollama';
${model.name} const ollamaModel = isOllama ? this.ollamaModels.find(m => m.name === model.id) : null;
const isInstalling = this.installingModels[model.id] !== undefined;
const installProgress = this.installingModels[model.id] || 0;
return html`
<div class="model-item ${this.selectedLlm === model.id ? 'selected' : ''}"
@click=${() => this.selectModel('llm', model.id)}>
<span>${model.name}</span>
${isOllama ? html`
${isInstalling ? html`
<div class="install-progress">
<div class="install-progress-bar" style="width: ${installProgress}%"></div>
</div> </div>
`)} ` : ollamaModel?.installed ? html`
<span class="model-status installed"> Installed</span>
` : html`
<span class="model-status not-installed">Click to install</span>
`}
` : ''}
</div>
`;
})}
</div> </div>
` : ''} ` : ''}
</div> </div>
@ -1091,11 +1518,23 @@ export class SettingsView extends LitElement {
</button> </button>
${this.isSttListVisible ? html` ${this.isSttListVisible ? html`
<div class="model-list"> <div class="model-list">
${this.availableSttModels.map(model => html` ${this.availableSttModels.map(model => {
<div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}" @click=${() => this.selectModel('stt', model.id)}> const isWhisper = this.getProviderForModel('stt', model.id) === 'whisper';
${model.name} const isInstalling = this.installingModels[model.id] !== undefined;
</div> const installProgress = this.installingModels[model.id] || 0;
`)}
return html`
<div class="model-item ${this.selectedStt === model.id ? 'selected' : ''}"
@click=${() => this.selectModel('stt', model.id)}>
<span>${model.name}</span>
${isWhisper && isInstalling ? html`
<div class="install-progress">
<div class="install-progress-bar" style="width: ${installProgress}%"></div>
</div>
` : ''}
</div>
`;
})}
</div> </div>
` : ''} ` : ''}
</div> </div>

View File

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

View File

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

View File

@ -208,13 +208,8 @@ async function saveSettings(settings) {
async function getPresets() { async function getPresets() {
try { try {
const uid = authService.getCurrentUserId(); // The adapter now handles which presets to return based on login state.
if (!uid) { const presets = await settingsRepository.getPresets();
// Logged out users only see default presets
return await settingsRepository.getPresetTemplates();
}
const presets = await settingsRepository.getPresets(uid);
return presets; return presets;
} catch (error) { } catch (error) {
console.error('[SettingsService] Error getting presets:', error); console.error('[SettingsService] Error getting presets:', error);
@ -234,12 +229,8 @@ async function getPresetTemplates() {
async function createPreset(title, prompt) { async function createPreset(title, prompt) {
try { try {
const uid = authService.getCurrentUserId(); // The adapter injects the UID.
if (!uid) { const result = await settingsRepository.createPreset({ title, prompt });
throw new Error("User not logged in, cannot create preset.");
}
const result = await settingsRepository.createPreset({ uid, title, prompt });
windowNotificationManager.notifyRelevantWindows('presets-updated', { windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'created', action: 'created',
@ -256,12 +247,8 @@ async function createPreset(title, prompt) {
async function updatePreset(id, title, prompt) { async function updatePreset(id, title, prompt) {
try { try {
const uid = authService.getCurrentUserId(); // The adapter injects the UID.
if (!uid) { await settingsRepository.updatePreset(id, { title, prompt });
throw new Error("User not logged in, cannot update preset.");
}
await settingsRepository.updatePreset(id, { title, prompt }, uid);
windowNotificationManager.notifyRelevantWindows('presets-updated', { windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'updated', action: 'updated',
@ -278,12 +265,8 @@ async function updatePreset(id, title, prompt) {
async function deletePreset(id) { async function deletePreset(id) {
try { try {
const uid = authService.getCurrentUserId(); // The adapter injects the UID.
if (!uid) { await settingsRepository.deletePreset(id);
throw new Error("User not logged in, cannot delete preset.");
}
await settingsRepository.deletePreset(id, uid);
windowNotificationManager.notifyRelevantWindows('presets-updated', { windowNotificationManager.notifyRelevantWindows('presets-updated', {
action: 'deleted', action: 'deleted',
@ -299,10 +282,9 @@ async function deletePreset(id) {
async function saveApiKey(apiKey, provider = 'openai') { async function saveApiKey(apiKey, provider = 'openai') {
try { try {
const uid = authService.getCurrentUserId(); const user = authService.getCurrentUser();
if (!uid) { if (!user.isLoggedIn) {
// For non-logged-in users, save to local storage // For non-logged-in users, save to local storage
const { app } = require('electron');
const Store = require('electron-store'); const Store = require('electron-store');
const store = new Store(); const store = new Store();
store.set('apiKey', apiKey); store.set('apiKey', apiKey);
@ -318,8 +300,8 @@ async function saveApiKey(apiKey, provider = 'openai') {
return { success: true }; return { success: true };
} }
// For logged-in users, save to database // For logged-in users, use the repository adapter which injects the UID.
await userRepository.saveApiKey(apiKey, uid, provider); await userRepository.saveApiKey(apiKey, provider);
// Notify windows // Notify windows
BrowserWindow.getAllWindows().forEach(win => { BrowserWindow.getAllWindows().forEach(win => {
@ -337,17 +319,16 @@ async function saveApiKey(apiKey, provider = 'openai') {
async function removeApiKey() { async function removeApiKey() {
try { try {
const uid = authService.getCurrentUserId(); const user = authService.getCurrentUser();
if (!uid) { if (!user.isLoggedIn) {
// For non-logged-in users, remove from local storage // For non-logged-in users, remove from local storage
const { app } = require('electron');
const Store = require('electron-store'); const Store = require('electron-store');
const store = new Store(); const store = new Store();
store.delete('apiKey'); store.delete('apiKey');
store.delete('provider'); store.delete('provider');
} else { } else {
// For logged-in users, remove from database // For logged-in users, use the repository adapter.
await userRepository.saveApiKey(null, uid, null); await userRepository.saveApiKey(null, null);
} }
// Notify windows // Notify windows
@ -385,10 +366,7 @@ async function updateContentProtection(enabled) {
async function getAutoUpdateSetting() { async function getAutoUpdateSetting() {
try { try {
const uid = authService.getCurrentUserId(); return settingsRepository.getAutoUpdate();
// This can be awaited if the repository returns a promise.
// Assuming it's synchronous for now based on original structure.
return settingsRepository.getAutoUpdate(uid);
} catch (error) { } catch (error) {
console.error('[SettingsService] Error getting auto update setting:', error); console.error('[SettingsService] Error getting auto update setting:', error);
return true; // Fallback to enabled return true; // Fallback to enabled
@ -397,8 +375,7 @@ async function getAutoUpdateSetting() {
async function setAutoUpdateSetting(isEnabled) { async function setAutoUpdateSetting(isEnabled) {
try { try {
const uid = authService.getCurrentUserId(); await settingsRepository.setAutoUpdate(isEnabled);
await settingsRepository.setAutoUpdate(uid, isEnabled);
return { success: true }; return { success: true };
} catch (error) { } catch (error) {
console.error('[SettingsService] Error setting auto update setting:', error); console.error('[SettingsService] Error setting auto update setting:', error);

View File

@ -28,8 +28,10 @@ const sessionRepository = require('./common/repositories/session');
const ModelStateService = require('./common/services/modelStateService'); const ModelStateService = require('./common/services/modelStateService');
const sqliteClient = require('./common/services/sqliteClient'); const sqliteClient = require('./common/services/sqliteClient');
// Global variables
const eventBridge = new EventEmitter(); const eventBridge = new EventEmitter();
let WEB_PORT = 3000; let WEB_PORT = 3000;
let isShuttingDown = false; // Flag to prevent infinite shutdown loop
const listenService = new ListenService(); const listenService = new ListenService();
// Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance // Make listenService globally accessible so other modules (e.g., windowManager, askService) can reuse the same instance
@ -40,6 +42,10 @@ const modelStateService = new ModelStateService(authService);
global.modelStateService = modelStateService; global.modelStateService = modelStateService;
//////// after_modelStateService //////// //////// after_modelStateService ////////
// Import and initialize OllamaService
const ollamaService = require('./common/services/ollamaService');
const ollamaModelRepository = require('./common/repositories/ollamaModel');
// Native deep link handling - cross-platform compatible // Native deep link handling - cross-platform compatible
let pendingDeepLinkUrl = null; let pendingDeepLinkUrl = null;
@ -187,8 +193,8 @@ app.whenReady().then(async () => {
await databaseInitializer.initialize(); await databaseInitializer.initialize();
console.log('>>> [index.js] Database initialized successfully'); console.log('>>> [index.js] Database initialized successfully');
// Clean up zombie sessions from previous runs first // Clean up zombie sessions from previous runs first - MOVED TO authService
sessionRepository.endAllActiveSessions(); // sessionRepository.endAllActiveSessions();
await authService.initialize(); await authService.initialize();
@ -200,6 +206,21 @@ app.whenReady().then(async () => {
askService.initialize(); askService.initialize();
settingsService.initialize(); settingsService.initialize();
setupGeneralIpcHandlers(); setupGeneralIpcHandlers();
setupOllamaIpcHandlers();
setupWhisperIpcHandlers();
// Initialize Ollama models in database
await ollamaModelRepository.initializeDefaultModels();
// Auto warm-up selected Ollama model in background (non-blocking)
setTimeout(async () => {
try {
console.log('[index.js] Starting background Ollama model warm-up...');
await ollamaService.autoWarmUpSelectedModel();
} catch (error) {
console.log('[index.js] Background warm-up failed (non-critical):', error.message);
}
}, 2000); // Wait 2 seconds after app start
// Start web server and create windows ONLY after all initializations are successful // Start web server and create windows ONLY after all initializations are successful
WEB_PORT = await startWebStack(); WEB_PORT = await startWebStack();
@ -234,11 +255,71 @@ app.on('window-all-closed', () => {
} }
}); });
app.on('before-quit', async () => { app.on('before-quit', async (event) => {
console.log('[Shutdown] App is about to quit.'); // Prevent infinite loop by checking if shutdown is already in progress
listenService.stopMacOSAudioCapture(); if (isShuttingDown) {
await sessionRepository.endAllActiveSessions(); console.log('[Shutdown] 🔄 Shutdown already in progress, allowing quit...');
databaseInitializer.close(); return;
}
console.log('[Shutdown] App is about to quit. Starting graceful shutdown...');
// Set shutdown flag to prevent infinite loop
isShuttingDown = true;
// Prevent immediate quit to allow graceful shutdown
event.preventDefault();
try {
// 1. Stop audio capture first (immediate)
listenService.stopMacOSAudioCapture();
console.log('[Shutdown] Audio capture stopped');
// 2. End all active sessions (database operations) - with error handling
try {
await sessionRepository.endAllActiveSessions();
console.log('[Shutdown] Active sessions ended');
} catch (dbError) {
console.warn('[Shutdown] Could not end active sessions (database may be closed):', dbError.message);
}
// 3. Shutdown Ollama service (potentially time-consuming)
console.log('[Shutdown] shutting down Ollama service...');
const ollamaShutdownSuccess = await Promise.race([
ollamaService.shutdown(false), // Graceful shutdown
new Promise(resolve => setTimeout(() => resolve(false), 8000)) // 8s timeout
]);
if (ollamaShutdownSuccess) {
console.log('[Shutdown] Ollama service shut down gracefully');
} else {
console.log('[Shutdown] Ollama shutdown timeout, forcing...');
// Force shutdown if graceful failed
try {
await ollamaService.shutdown(true);
} catch (forceShutdownError) {
console.warn('[Shutdown] Force shutdown also failed:', forceShutdownError.message);
}
}
// 4. Close database connections (final cleanup)
try {
databaseInitializer.close();
console.log('[Shutdown] Database connections closed');
} catch (closeError) {
console.warn('[Shutdown] Error closing database:', closeError.message);
}
console.log('[Shutdown] Graceful shutdown completed successfully');
} catch (error) {
console.error('[Shutdown] Error during graceful shutdown:', error);
// Continue with shutdown even if there were errors
} finally {
// Actually quit the app now
console.log('[Shutdown] Exiting application...');
app.exit(0); // Use app.exit() instead of app.quit() to force quit
}
}); });
app.on('activate', () => { app.on('activate', () => {
@ -247,13 +328,79 @@ app.on('activate', () => {
} }
}); });
function setupWhisperIpcHandlers() {
const { WhisperService } = require('./common/services/whisperService');
const whisperService = new WhisperService();
// Forward download progress events to renderer
whisperService.on('downloadProgress', (data) => {
const windows = BrowserWindow.getAllWindows();
windows.forEach(window => {
window.webContents.send('whisper:download-progress', data);
});
});
// IPC handlers for Whisper operations
ipcMain.handle('whisper:download-model', async (event, modelId) => {
try {
console.log(`[Whisper IPC] Starting download for model: ${modelId}`);
// Ensure WhisperService is initialized first
if (!whisperService.isInitialized) {
console.log('[Whisper IPC] Initializing WhisperService...');
await whisperService.initialize();
}
// Set up progress listener
const progressHandler = (data) => {
if (data.modelId === modelId) {
event.sender.send('whisper:download-progress', data);
}
};
whisperService.on('downloadProgress', progressHandler);
try {
await whisperService.ensureModelAvailable(modelId);
console.log(`[Whisper IPC] Model ${modelId} download completed successfully`);
} finally {
// Cleanup listener
whisperService.removeListener('downloadProgress', progressHandler);
}
return { success: true };
} catch (error) {
console.error(`[Whisper IPC] Failed to download model ${modelId}:`, error);
return { success: false, error: error.message };
}
});
ipcMain.handle('whisper:get-installed-models', async () => {
try {
// Ensure WhisperService is initialized first
if (!whisperService.isInitialized) {
console.log('[Whisper IPC] Initializing WhisperService for model list...');
await whisperService.initialize();
}
const models = await whisperService.getInstalledModels();
return { success: true, models };
} catch (error) {
console.error('[Whisper IPC] Failed to get installed models:', error);
return { success: false, error: error.message };
}
});
}
function setupGeneralIpcHandlers() { function setupGeneralIpcHandlers() {
const userRepository = require('./common/repositories/user'); const userRepository = require('./common/repositories/user');
const presetRepository = require('./common/repositories/preset'); const presetRepository = require('./common/repositories/preset');
ipcMain.handle('save-api-key', (event, apiKey) => { ipcMain.handle('save-api-key', (event, apiKey) => {
try { try {
userRepository.saveApiKey(apiKey, authService.getCurrentUserId()); // The adapter injects the UID and handles local/firebase logic.
// Assuming a default provider if not specified.
userRepository.saveApiKey(apiKey, 'openai');
BrowserWindow.getAllWindows().forEach(win => { BrowserWindow.getAllWindows().forEach(win => {
win.webContents.send('api-key-updated'); win.webContents.send('api-key-updated');
}); });
@ -265,7 +412,8 @@ function setupGeneralIpcHandlers() {
}); });
ipcMain.handle('get-user-presets', () => { ipcMain.handle('get-user-presets', () => {
return presetRepository.getPresets(authService.getCurrentUserId()); // The adapter injects the UID.
return presetRepository.getPresets();
}); });
ipcMain.handle('get-preset-templates', () => { ipcMain.handle('get-preset-templates', () => {
@ -296,6 +444,201 @@ function setupGeneralIpcHandlers() {
setupWebDataHandlers(); setupWebDataHandlers();
} }
function setupOllamaIpcHandlers() {
// Ollama status and installation
ipcMain.handle('ollama:get-status', async () => {
try {
const installed = await ollamaService.isInstalled();
const running = installed ? await ollamaService.isServiceRunning() : false;
const models = await ollamaService.getAllModelsWithStatus();
return {
installed,
running,
models,
success: true
};
} catch (error) {
console.error('[Ollama IPC] Failed to get status:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('ollama:install', async (event) => {
try {
const onProgress = (data) => {
event.sender.send('ollama:install-progress', data);
};
await ollamaService.autoInstall(onProgress);
if (!await ollamaService.isServiceRunning()) {
onProgress({ stage: 'starting', message: 'Starting Ollama service...', progress: 0 });
await ollamaService.startService();
onProgress({ stage: 'starting', message: 'Ollama service started.', progress: 100 });
}
event.sender.send('ollama:install-complete', { success: true });
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to install:', error);
event.sender.send('ollama:install-complete', { success: false, error: error.message });
return { success: false, error: error.message };
}
});
ipcMain.handle('ollama:start-service', async (event) => {
try {
if (!await ollamaService.isServiceRunning()) {
console.log('[Ollama IPC] Starting Ollama service...');
await ollamaService.startService();
}
event.sender.send('ollama:install-complete', { success: true });
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to start service:', error);
event.sender.send('ollama:install-complete', { success: false, error: error.message });
return { success: false, error: error.message };
}
});
// Ensure Ollama is ready (starts service if installed but not running)
ipcMain.handle('ollama:ensure-ready', async () => {
try {
if (await ollamaService.isInstalled() && !await ollamaService.isServiceRunning()) {
console.log('[Ollama IPC] Ollama installed but not running, starting service...');
await ollamaService.startService();
}
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to ensure ready:', error);
return { success: false, error: error.message };
}
});
// Get all models with their status
ipcMain.handle('ollama:get-models', async () => {
try {
const models = await ollamaService.getAllModelsWithStatus();
return { success: true, models };
} catch (error) {
console.error('[Ollama IPC] Failed to get models:', error);
return { success: false, error: error.message };
}
});
// Get model suggestions for autocomplete
ipcMain.handle('ollama:get-model-suggestions', async () => {
try {
const suggestions = await ollamaService.getModelSuggestions();
return { success: true, suggestions };
} catch (error) {
console.error('[Ollama IPC] Failed to get model suggestions:', error);
return { success: false, error: error.message };
}
});
// Pull/install a specific model
ipcMain.handle('ollama:pull-model', async (event, modelName) => {
try {
console.log(`[Ollama IPC] Starting model pull: ${modelName}`);
// Update DB status to installing
await ollamaModelRepository.updateInstallStatus(modelName, false, true);
// Set up progress listener for real-time updates
const progressHandler = (data) => {
if (data.model === modelName) {
event.sender.send('ollama:pull-progress', data);
}
};
const completeHandler = (data) => {
if (data.model === modelName) {
console.log(`[Ollama IPC] Model ${modelName} pull completed`);
// Clean up listeners
ollamaService.removeListener('pull-progress', progressHandler);
ollamaService.removeListener('pull-complete', completeHandler);
}
};
ollamaService.on('pull-progress', progressHandler);
ollamaService.on('pull-complete', completeHandler);
// Pull the model using REST API
await ollamaService.pullModel(modelName);
// Update DB status to installed
await ollamaModelRepository.updateInstallStatus(modelName, true, false);
console.log(`[Ollama IPC] Model ${modelName} pull successful`);
return { success: true };
} catch (error) {
console.error('[Ollama IPC] Failed to pull model:', error);
// Reset status on error
await ollamaModelRepository.updateInstallStatus(modelName, false, false);
return { success: false, error: error.message };
}
});
// Check if a specific model is installed
ipcMain.handle('ollama:is-model-installed', async (event, modelName) => {
try {
const installed = await ollamaService.isModelInstalled(modelName);
return { success: true, installed };
} catch (error) {
console.error('[Ollama IPC] Failed to check model installation:', error);
return { success: false, error: error.message };
}
});
// Warm up a specific model
ipcMain.handle('ollama:warm-up-model', async (event, modelName) => {
try {
const success = await ollamaService.warmUpModel(modelName);
return { success };
} catch (error) {
console.error('[Ollama IPC] Failed to warm up model:', error);
return { success: false, error: error.message };
}
});
// Auto warm-up currently selected model
ipcMain.handle('ollama:auto-warm-up', async () => {
try {
const success = await ollamaService.autoWarmUpSelectedModel();
return { success };
} catch (error) {
console.error('[Ollama IPC] Failed to auto warm-up:', error);
return { success: false, error: error.message };
}
});
// Get warm-up status for debugging
ipcMain.handle('ollama:get-warm-up-status', async () => {
try {
const status = ollamaService.getWarmUpStatus();
return { success: true, status };
} catch (error) {
console.error('[Ollama IPC] Failed to get warm-up status:', error);
return { success: false, error: error.message };
}
});
// Shutdown Ollama service manually
ipcMain.handle('ollama:shutdown', async (event, force = false) => {
try {
console.log(`[Ollama IPC] Manual shutdown requested (force: ${force})`);
const success = await ollamaService.shutdown(force);
return { success };
} catch (error) {
console.error('[Ollama IPC] Failed to shutdown Ollama:', error);
return { success: false, error: error.message };
}
});
console.log('[Ollama IPC] Handlers registered');
}
function setupWebDataHandlers() { function setupWebDataHandlers() {
const sessionRepository = require('./common/repositories/session'); const sessionRepository = require('./common/repositories/session');
const sttRepository = require('./features/listen/stt/repositories'); const sttRepository = require('./features/listen/stt/repositories');
@ -304,89 +647,112 @@ function setupWebDataHandlers() {
const userRepository = require('./common/repositories/user'); const userRepository = require('./common/repositories/user');
const presetRepository = require('./common/repositories/preset'); const presetRepository = require('./common/repositories/preset');
const handleRequest = (channel, responseChannel, payload) => { const handleRequest = async (channel, responseChannel, payload) => {
let result; let result;
const currentUserId = authService.getCurrentUserId(); // const currentUserId = authService.getCurrentUserId(); // No longer needed here
try { try {
switch (channel) { switch (channel) {
// SESSION // SESSION
case 'get-sessions': case 'get-sessions':
result = sessionRepository.getAllByUserId(currentUserId); // Adapter injects UID
result = await sessionRepository.getAllByUserId();
break; break;
case 'get-session-details': case 'get-session-details':
const session = sessionRepository.getById(payload); const session = await sessionRepository.getById(payload);
if (!session) { if (!session) {
result = null; result = null;
break; break;
} }
const transcripts = sttRepository.getAllTranscriptsBySessionId(payload); const [transcripts, ai_messages, summary] = await Promise.all([
const ai_messages = askRepository.getAllAiMessagesBySessionId(payload); sttRepository.getAllTranscriptsBySessionId(payload),
const summary = summaryRepository.getSummaryBySessionId(payload); askRepository.getAllAiMessagesBySessionId(payload),
summaryRepository.getSummaryBySessionId(payload)
]);
result = { session, transcripts, ai_messages, summary }; result = { session, transcripts, ai_messages, summary };
break; break;
case 'delete-session': case 'delete-session':
result = sessionRepository.deleteWithRelatedData(payload); result = await sessionRepository.deleteWithRelatedData(payload);
break; break;
case 'create-session': case 'create-session':
const id = sessionRepository.create(currentUserId, 'ask'); // Adapter injects UID
if (payload.title) { const id = await sessionRepository.create('ask');
sessionRepository.updateTitle(id, payload.title); if (payload && payload.title) {
await sessionRepository.updateTitle(id, payload.title);
} }
result = { id }; result = { id };
break; break;
// USER // USER
case 'get-user-profile': case 'get-user-profile':
result = userRepository.getById(currentUserId); // Adapter injects UID
result = await userRepository.getById();
break; break;
case 'update-user-profile': case 'update-user-profile':
result = userRepository.update({ uid: currentUserId, ...payload }); // Adapter injects UID
result = await userRepository.update(payload);
break; break;
case 'find-or-create-user': case 'find-or-create-user':
result = userRepository.findOrCreate(payload); result = await userRepository.findOrCreate(payload);
break; break;
case 'save-api-key': case 'save-api-key':
result = userRepository.saveApiKey(payload, currentUserId); // Assuming payload is { apiKey, provider }
result = await userRepository.saveApiKey(payload.apiKey, payload.provider);
break; break;
case 'check-api-key-status': case 'check-api-key-status':
const user = userRepository.getById(currentUserId); // Adapter injects UID
const user = await userRepository.getById();
result = { hasApiKey: !!user?.api_key && user.api_key.length > 0 }; result = { hasApiKey: !!user?.api_key && user.api_key.length > 0 };
break; break;
case 'delete-account': case 'delete-account':
result = userRepository.deleteById(currentUserId); // Adapter injects UID
result = await userRepository.deleteById();
break; break;
// PRESET // PRESET
case 'get-presets': case 'get-presets':
result = presetRepository.getPresets(currentUserId); // Adapter injects UID
result = await presetRepository.getPresets();
break; break;
case 'create-preset': case 'create-preset':
result = presetRepository.create({ ...payload, uid: currentUserId }); // Adapter injects UID
result = await presetRepository.create(payload);
settingsService.notifyPresetUpdate('created', result.id, payload.title); settingsService.notifyPresetUpdate('created', result.id, payload.title);
break; break;
case 'update-preset': case 'update-preset':
result = presetRepository.update(payload.id, payload.data, currentUserId); // Adapter injects UID
result = await presetRepository.update(payload.id, payload.data);
settingsService.notifyPresetUpdate('updated', payload.id, payload.data.title); settingsService.notifyPresetUpdate('updated', payload.id, payload.data.title);
break; break;
case 'delete-preset': case 'delete-preset':
result = presetRepository.delete(payload, currentUserId); // Adapter injects UID
result = await presetRepository.delete(payload);
settingsService.notifyPresetUpdate('deleted', payload); settingsService.notifyPresetUpdate('deleted', payload);
break; break;
// BATCH // BATCH
case 'get-batch-data': case 'get-batch-data':
const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions']; const includes = payload ? payload.split(',').map(item => item.trim()) : ['profile', 'presets', 'sessions'];
const batchResult = {}; const promises = {};
if (includes.includes('profile')) { if (includes.includes('profile')) {
batchResult.profile = userRepository.getById(currentUserId); // Adapter injects UID
promises.profile = userRepository.getById();
} }
if (includes.includes('presets')) { if (includes.includes('presets')) {
batchResult.presets = presetRepository.getPresets(currentUserId); // Adapter injects UID
promises.presets = presetRepository.getPresets();
} }
if (includes.includes('sessions')) { if (includes.includes('sessions')) {
batchResult.sessions = sessionRepository.getAllByUserId(currentUserId); // Adapter injects UID
promises.sessions = sessionRepository.getAllByUserId();
} }
const batchResult = {};
const promiseResults = await Promise.all(Object.values(promises));
Object.keys(promises).forEach((key, index) => {
batchResult[key] = promiseResults[index];
});
result = batchResult; result = batchResult;
break; break;