Author SHA1 Message Date
Илья Глазунов a55ee10d2b app development context 2026-09-05 04:37:28 +03:00
Илья Глазунов 8beccdf101 native first 2026-09-05 02:20:11 +03:00
Илья Глазунов 219f35cc04 chore: bump version to 0.7.9 and remove unused additional DMG options
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-21 23:19:57 +03:00
Илья Глазунов b6560f3c6c feat: update DMG configuration to use UDZO format and add additional options 2026-02-20 23:53:12 +03:00
Илья Глазунов b1d9130b50 chore: bump version to 0.7.8 and remove unused DMG options in forge.config.js
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-20 23:33:29 +03:00
Илья Глазунов 07c39455be fix: update release workflow to use macOS runner; bump version to 0.7.7
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-20 23:20:23 +03:00
Илья Глазунов 39fe8d948f feat: update DMG format to UDZO and add additional options; bump version to 0.7.6
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
2026-02-20 23:07:37 +03:00
Илья Глазунов 1330af8d19 feat: update DMG maker configuration and bump version to 0.7.5
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-20 22:54:56 +03:00
Илья Глазунов c161f251ed Update application logos: replace logo.icns, logo.ico, and logo.png with new versions
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-17 15:15:24 +03:00
Илья Глазунов d111b88886 fix: update badge colors for latest and stable version in README.md 2026-02-16 23:44:18 +03:00
Илья Глазунов 043b5d159e feat: update README.md with enhanced description and features; add logo image 2026-02-16 23:36:44 +03:00
Илья Глазунов 851edc6da1 feat: add .npmrc file with node-linker set to hoisted
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
2026-02-16 22:49:23 +03:00
Илья Глазунов c68a546e72 feat: add '@electron/rebuild' dependency version 3.7.2 to pnpm-lock.yaml
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-16 22:46:41 +03:00
Илья Глазунов 09b2530714 chore: bump version to 0.7.1 in package.json
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-02-16 22:44:22 +03:00
Илья Глазунов a9dce5bf3c fix: ensure current directory is included in pnpm workspace packages 2026-02-16 22:43:49 +03:00
Илья Глазунов 31d50c9713 Merge branch 'v0.7.0-update'
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
2026-02-16 22:40:20 +03:00
Shifty bbad79875c Merge pull request 'enhancment/code-highlighting-in-llm-chat' (#6) from enhancment/code-highlighting-in-llm-chat into v0.7.0-update
Reviewed-on: https://git.pyserve.org/Shifty/Mastermind/pulls/6
2026-02-16 19:32:13 +00:00
Илья Глазунов 7f15b65eb1 feat: add light theme support and update theme detection in renderer 2026-02-16 22:29:30 +03:00
Илья Глазунов d6dbaa3141 feat: add syntax highlighting for code blocks in AssistantView 2026-02-16 22:29:24 +03:00
Shifty 2ebde60dcd Merge pull request 'Fixing local transcription flow' (#5) from fix/local-transcription-flow into v0.7.0-update
Reviewed-on: https://git.pyserve.org/Shifty/Mastermind/pulls/5
2026-02-16 16:56:55 +00:00
Илья Глазунов 0d56e06724 feat: add whisper progress tracking and UI updates for download status 2026-02-16 19:55:39 +03:00
Илья Глазунов 526bc4e877 feat: enhance Whisper worker integration with system Node.js detection 2026-02-16 17:10:57 +03:00
Илья Глазунов 684b61755c feat: implement Whisper worker for isolated audio transcription 2026-02-16 11:38:26 +03:00
Илья Глазунов 1b74968006 Add multilingual support in CustomizeView and update speech configuration handling in gemini 2026-02-15 04:00:09 +03:00
Илья Глазунов 4cf48ee0af Refactor window management and global shortcuts handling 2026-02-15 00:34:37 +03:00
Илья Глазунов 494e692738 Add OpenAI dependency and implement model loading in MainView for OpenAI-compatible API 2026-02-14 23:16:41 +03:00
Илья Глазунов 8b216bbb33 Rename project from "Cheating Daddy" to "Mastermind" across all configurations and components to reflect the new branding. 2026-02-14 20:31:35 +03:00
Илья Глазунов bd62cf5524 Add OpenAI-compatible API support with configuration management and response handling 2026-02-14 20:18:02 +03:00
Илья Глазунов bfd76dc0c1 Add logging for transcription handling and disable proactive audio 2026-02-14 04:28:29 +03:00
Илья Глазунов 310b6b3fbd huge refactor 2026-02-14 04:17:46 +03:00
Илья Глазунов 430895d9ab small fixes 2026-02-13 22:11:01 +03:00
Илья Глазунов 3a8d9705a2 Remove deprecated dependencies from pnpm-lock.yaml to streamline package management and improve project maintainability.
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-01-16 01:20:03 +03:00
Илья Глазунов 06e178762d Rename project from "Cheating Daddy" to "Mastermind" across all files, update version to 0.6.0, and implement migration functionality for users with existing configurations. Enhance onboarding experience with migration options and update relevant documentation.
Build and Release / build (x64, ubuntu-latest, linux) (push) Has been skipped
Build and Release / build (arm64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, macos-latest, darwin) (push) Has been cancelled
Build and Release / build (x64, windows-latest, win32) (push) Has been cancelled
Build and Release / release (push) Has been cancelled
2026-01-16 01:16:43 +03:00
Илья Глазунов 656e8f0932 Implement Push-to-Talk feature and enhance audio input settings in AssistantView and CustomizeView. Update README for API key instructions and improve audio processing logic in OpenAI SDK. Adjust pnpm-lock.yaml for dependency updates. 2026-01-16 00:41:58 +03:00
Илья Глазунов 669c019fd8 prettier fix 2026-01-15 23:26:09 +03:00
80 changed files with 16745 additions and 10305 deletions
+83 -82
View File
@@ -1,100 +1,101 @@
name: Build and Release
on:
push:
tags:
- 'v*.*.*'
push:
tags:
- 'v*.*.*'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
jobs:
build:
if: github.server_url == 'https://github.com'
strategy:
matrix:
include:
- os: macos-latest
platform: darwin
arch: x64
- os: macos-latest
platform: darwin
arch: arm64
- os: ubuntu-latest
platform: linux
arch: x64
- os: windows-latest
platform: win32
arch: x64
build:
if: github.server_url == 'https://github.com'
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- os: macos-latest
platform: darwin
arch: x64
- os: macos-latest
platform: darwin
arch: arm64
- os: ubuntu-latest
platform: linux
arch: x64
- os: windows-latest
platform: win32
arch: x64
steps:
- name: Checkout code
uses: actions/checkout@v4
runs-on: ${{ matrix.os }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install dependencies
run: pnpm install
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9
- name: Build for ${{ matrix.platform }}-${{ matrix.arch }}
run: pnpm run make -- --arch=${{ matrix.arch }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Install dependencies
run: pnpm install
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.platform }}-${{ matrix.arch }}
path: |
out/make/**/*.dmg
out/make/**/*.zip
out/make/**/*.exe
out/make/**/*.AppImage
out/make/**/*.deb
out/make/**/*.rpm
if-no-files-found: ignore
- name: Build for ${{ matrix.platform }}-${{ matrix.arch }}
run: pnpm run make -- --arch=${{ matrix.arch }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release:
needs: build
runs-on: ubuntu-latest
if: github.server_url == 'https://github.com'
permissions:
contents: write
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.platform }}-${{ matrix.arch }}
path: |
out/make/**/*.dmg
out/make/**/*.zip
out/make/**/*.exe
out/make/**/*.AppImage
out/make/**/*.deb
out/make/**/*.rpm
if-no-files-found: ignore
compression-level: 0
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: release-*
merge-multiple: true
release:
needs: build
runs-on: macos-latest
if: github.server_url == 'https://github.com'
- name: List artifacts
run: find artifacts -type f | head -50
permissions:
contents: write
- name: Create Draft Release
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: |
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.exe
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
pattern: release-*
merge-multiple: true
- name: List artifacts
run: find artifacts -type f | head -50
- name: Create Draft Release
uses: softprops/action-gh-release@v2
with:
draft: true
generate_release_notes: true
files: |
artifacts/**/*.dmg
artifacts/**/*.zip
artifacts/**/*.exe
artifacts/**/*.AppImage
artifacts/**/*.deb
artifacts/**/*.rpm
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
-2
View File
@@ -1,2 +0,0 @@
src/assets
node_modules
-10
View File
@@ -1,10 +0,0 @@
{
"semi": true,
"tabWidth": 4,
"printWidth": 150,
"singleQuote": true,
"trailingComma": "es5",
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "lf"
}
+24
View File
@@ -0,0 +1,24 @@
{
"configurations": [
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:cheating-daddy}/native/MastermindPOC",
"name": "Debug MastermindPOC (native/MastermindPOC)",
"target": "MastermindPOC",
"configuration": "debug",
"preLaunchTask": "swift: Build Debug MastermindPOC (native/MastermindPOC)"
},
{
"type": "swift",
"request": "launch",
"args": [],
"cwd": "${workspaceFolder:cheating-daddy}/native/MastermindPOC",
"name": "Release MastermindPOC (native/MastermindPOC)",
"target": "MastermindPOC",
"configuration": "release",
"preLaunchTask": "swift: Build Release MastermindPOC (native/MastermindPOC)"
}
]
}
+114 -113
View File
@@ -1,130 +1,131 @@
# Repo Guidelines
# Repository Guidelines
This repository is a fork of [`cheating-daddy`](https://github.com/sohzm/cheating-daddy).
It provides an Electron-based realtime assistant which captures screen and audio
for contextual AI responses. The code is JavaScript and uses Electron Forge for
packaging.
This repository is reconstructing Mastermind as a Swift-native, local-first personal assistant for macOS. The Electron application is legacy reference code; it is not the target architecture.
## Getting started
## Read first
Install dependencies and run the development app:
Before planning or implementing product work, read:
```
1. npm install
2. npm start
1. [`CONTEXT.md`](CONTEXT.md) for canonical domain language.
2. [`docs/product/mastermind-product-brief.md`](docs/product/mastermind-product-brief.md) for scope and non-goals.
3. [`docs/architecture/native-mastermind.md`](docs/architecture/native-mastermind.md) for system boundaries.
4. [`docs/privacy/local-first-data-contract.md`](docs/privacy/local-first-data-contract.md) for normative data rules.
5. [`docs/product/mvp-acceptance.md`](docs/product/mvp-acceptance.md) for completion criteria.
ADRs under `docs/adr` explain hard-to-reverse decisions. When legacy code or documentation conflicts with the canonical context, the canonical context wins.
## Repository roles
- `native/Mastermind` — production Swift application; create new production work here.
- `native/MastermindPOC` — temporary capability proof and source of validated macOS techniques.
- `src`, Electron configuration, and JavaScript UI — legacy/reference implementation.
- `docs/local-sidecar-protocol.md` — shared local ASR protocol.
Do not evolve `MastermindPOC` into the product in place. Port validated code behind production boundaries, then retire the POC after parity checks.
Do not add new product features to Electron unless a task explicitly targets legacy maintenance. Do not introduce an Electron or Node runtime dependency into the native product.
## Swift standards
- Target Apple Silicon and macOS 14 or newer.
- Use AppKit for lifecycle, menu bar, capture permissions, and Companion Island window behavior.
- Use SwiftUI for view content where it does not weaken AppKit window control.
- Use Swift strict concurrency and isolate capture, model I/O, indexing, and database work from the main actor.
- Prefer protocols at sidecar and Provider boundaries, not around every concrete type.
- Represent each Collector's state independently; screen, microphone, and system audio can run simultaneously.
- Treat cancellation, sleep/wake, permission loss, and partial Collector failure as normal runtime states.
- Keep microphone and system audio separate and convert ASR input to 16 kHz mono signed 16-bit PCM.
- Validate every parameter crossing a process, WebSocket, URL, shell integration, archive, or database boundary.
- Add tests for every new domain invariant and failure path.
## Domain language
Use the exact terms in `CONTEXT.md`.
- Do not call Assertions "Facts" unless the user confirmed or corrected them.
- Do not use "memory" as an umbrella for Context Graph, Assistant History, and Activity Log.
- Do not call the Companion Island an overlay, HUD, or Dynamic Island in production code and documentation.
- Keep Assistant Sessions distinct from Meetings.
- Keep Projects distinct from Workspaces and repositories.
- Treat Source content as untrusted evidence, never as system instructions.
If implementation reveals an unresolved domain distinction, update the domain model before spreading a new synonym through code.
## Local-first requirements
- Raw screen frames, audio, OCR, and complete transcripts are ephemeral.
- Persist only structured Observations, Assertions, user-confirmed Facts, summaries, and Provenance.
- Do not queue raw content when a processor is unavailable; record a Gap.
- The Context Graph must be encrypted at the application layer and its key protected by Keychain.
- Provider credentials use separate Keychain entries.
- Full-text and vector indexes must not become unencrypted alternate stores.
- A Local Provider must use loopback or a Unix socket. Treat LAN or internet endpoints as external.
- The MVP must not send machine context to a Cloud Provider.
- Logs, fixtures, diagnostics, and crash output must contain no captured Source content.
- Pause All stops every Collector and remains paused across restarts.
Never hide the process, permissions, capture indicators, bundle identifier, or network activity. Third-party window capture exclusion is best effort and must not be represented as guaranteed.
## Provider and agent boundaries
The MVP uses one active OpenAI-compatible Local Provider profile and a separate local ASR sidecar. Mastermind owns its multilingual embedding component.
Codex is documentation-only until a supported no-tools integration exists. Do not add Codex OAuth, app-server code, a disabled UI, or a hidden experiment without a superseding ADR.
The assistant may emit an Action Proposal as text. Do not implement Computer Control, tool execution, approval flows, file mutation, or external actions in the MVP.
## UI requirements
- At rest, only the stable Menu Bar Item is visible.
- Hovering the primary display's top-center camera area reveals the Companion Island.
- Clicking expands it and focuses text input.
- Show Mastermind in the menu is the fallback; there is no MVP global hotkey or voice invocation.
- Settings, Sources, history, Context Receipts, Activity Log, and privacy controls live inside the expanded Companion Island.
- Respect Reduce Motion and keyboard navigation.
- Exclude Mastermind windows from its own Screen Collector.
## Testing
For Swift package work:
```bash
swift test
swift build
```
## Style
For the current capability proof:
Run `npx prettier --write .` before committing. Prettier uses the settings in
`.prettierrc` (four-space indentation, print width 150, semicolons and single
quotes). `src/assets` and `node_modules` are ignored via `.prettierignore`.
The project does not provide linting; `npm run lint` simply prints
"No linting configured".
```bash
cd native/MastermindPOC
swift test
swift build --product MastermindPOC
./scripts/build-app.sh
```
## Code standards
For explicitly requested legacy Electron maintenance:
Development is gradually migrating toward a TypeScript/React codebase inspired by the
[transcriber](https://github.com/Gatecrashah/transcriber) project. Keep the following
rules in mind as new files are created:
```bash
npm test
```
- **TypeScript strict mode** avoid `any` and prefer explicit interfaces.
- **React components** should be functional with hooks and wrapped in error
boundaries where appropriate.
- **Secure IPC** validate and sanitize all parameters crossing the renderer/main
boundary.
- **Nonblocking audio** heavy processing must stay off the UI thread.
- **Tests** every new feature requires tests once the test suite is available.
Manual verification is required for Screen Recording, microphone, system audio, window exclusion, primary-display changes, Launch at Login, sleep/wake, Low Power Mode, and persisted Pause All.
## Shadcn and Electron
Do not claim the production MVP complete until the scenarios in `docs/product/mvp-acceptance.md` pass.
The interface is being rebuilt with [shadcn/ui](https://ui.shadcn.com) components.
Follow these guidelines when working on UI code:
## Formatting
- **Component directory** place generated files under `src/components/ui` and export them from that folder.
- **Add components with the CLI** run `npx shadcn@latest add <component>`; never hand-roll components.
- **Component pattern** use `React.forwardRef` with the `cn()` helper for class names.
- **Path aliases** import modules from `src` using the `@/` prefix.
- **React 19 + Compiler** target React 19 with the new compiler when available.
- **Context isolation** maintain Electron's context isolation pattern for IPC.
- **TypeScript strict mode** run `npm run typecheck` before claiming work complete.
- **Tailwind theming** rely on CSS variables and utilities in `@/utils/tailwind` for styling.
- **Testing without running** confirm `npm run typecheck` and module resolution with `node -e "require('<file>')"`.
- Use the repository's Swift formatter configuration when one exists; otherwise follow standard Swift API Design Guidelines and existing native code style.
- Use four-space indentation in JavaScript and Markdown examples where indentation is semantic.
- Use Prettier only for files it supports; do not reformat generated assets or unrelated legacy code.
- Keep ADRs short and record only decisions that are hard to reverse, surprising without context, and based on a real trade-off.
## Tests
## Upstream changes
No automated tests yet. When a suite is added, run `npm test` before each
commit. Until then, at minimum ensure `npm install` and `npm start` work after
merging upstream changes.
This remains a fork of [`sohzm/cheating-daddy`](https://github.com/sohzm/cheating-daddy), but upstream Electron changes are not automatically product direction.
## Merging upstream PRs
Before cherry-picking upstream work:
Pull requests from <https://github.com/sohzm/cheating-daddy> are commonly
cherrypicked here. When merging:
1. Inspect the diff and keep commit messages short (`feat:` / `fix:` etc.).
2. After merging, run the application locally to verify it still builds and
functions.
## Strategy and Future Work
We plan to extend this project with ideas from the
[`transcriber`](https://github.com/Gatecrashah/transcriber) project which also
uses Electron. Key goals are:
- **Local Transcription** integrate `whisper.cpp` to allow offline speech-to-
text. Investigate the architecture used in `transcriber/src/main` for model
validation and GPU acceleration.
- **Dual Audio Capture** capture microphone and system audio simultaneously.
`transcriber` shows one approach using a native helper for macOS and
Electron's `getDisplayMedia` for other platforms.
- **Speaker Diarization** explore tinydiarize for identifying speakers in mono
audio streams.
- **Voice Activity Detection** skip silent or lowquality segments before
sending to the AI service.
- **Improved Note Handling** store transcriptions locally and associate them
with meeting notes, similar to `transcriber`'s note management system.
- **Testing Infrastructure** adopt Jest and React Testing Library (if React is
introduced) to cover audio capture and transcription modules.
### TODO
1. Research and prototype local transcription using `whisper.cpp`.
2. Add dualstream audio capture logic for crossplatform support.
3. Investigate speaker diarization options and integrate when feasible.
4. Plan a migration path toward a proper testing setup (Jest or similar).
5. Document security considerations for audio storage and processing.
6. Rebuild the entire UI using shadcn components.
These plans are aspirational; implement them gradually while keeping the app
functional.
## Audio processing principles
When implementing transcription features borrow the following rules from
`transcriber`:
- **16 kHz compatibility** resample all audio before sending to whisper.cpp.
- **Dualstream architecture** capture microphone and system audio on separate
channels.
- **Speaker diarization** integrate tinydiarize (`--tinydiarize` flag) for mono
audio and parse `[SPEAKER_TURN]` markers to label speakers (Speaker A, B, C…).
- **Voice activity detection** prefilter silent segments to improve speed.
- **Quality preservation** keep sample fidelity and avoid blocking the UI
during heavy processing.
- **Memory efficiency** stream large audio files instead of loading them all at
once.
- **Error recovery** handle audio device failures gracefully.
## Privacy by design
- **Local processing** transcriptions should happen locally whenever possible.
- **User control** provide clear options for data retention and deletion.
- **Transparency** document what is stored and where.
- **Minimal data** only persist what is required for functionality.
## LLM plans
There are placeholder files for future LLM integration (e.g. Qwen models via
`llama.cpp`). Continue development after the core transcription pipeline is
stable and ensure tests cover this new functionality.
1. Inspect whether it serves legacy maintenance or the native product.
2. Keep only reusable protocol, test, or migration value.
3. Reject stealth, anti-detection, insecure IPC, and cloud-by-default behavior.
4. Run the relevant Swift and legacy tests.
+111
View File
@@ -0,0 +1,111 @@
# Mastermind
Mastermind is a personal, local-first macOS assistant that turns activity on the user's Mac into inspectable context for grounded assistance.
## Product
**Mastermind**:
The personal assistant product and the only target product name.
_Avoid_: Cheating Daddy, Mastermind Native, AI helper
**Local Profile**:
The local identity whose sources, knowledge, history, and policies belong to the current macOS user. A future cloud account may authenticate services but does not own the Local Profile.
_Avoid_: User account, cloud profile
**Companion Island**:
Mastermind's normally hidden interaction surface at the top center of the primary display. Hover reveals a capsule and clicking expands it into the assistant interface.
_Avoid_: Overlay, HUD, Dynamic Island, floating window
## Context acquisition
**Source**:
A configured origin from which Mastermind may observe context, such as a workspace, calendar, screen, audio stream, or terminal integration.
_Avoid_: Connector, data source
**Collector**:
The independently operating part of Mastermind that observes one kind of Source and emits Observations.
_Avoid_: Capture mode, watcher, sensor
**Observation**:
Immutable, time-stamped evidence emitted by a Collector about a Source. An Observation is evidence, not a claim that Mastermind treats as true.
_Avoid_: Event, Fact, memory
**Gap**:
An explicit interval in which an enabled Collector could not produce usable Observations.
_Avoid_: Missing memory, silent failure
## Knowledge
**Context Graph**:
The Local Profile's structured, time-aware representation of entities, relationships, Observations, Assertions, and Facts.
_Avoid_: Memory, activity database, transcript archive
**Assertion**:
A time-scoped claim inferred from one or more Observations, with confidence and Provenance. Assertions may conflict and expire.
_Avoid_: Fact, summary
**Fact**:
An Assertion explicitly confirmed or corrected by the user. Facts remain until the user removes or supersedes them.
_Avoid_: High-confidence inference, model output
**Provenance**:
The trace from an Assertion, Fact, summary, or answer back to the Source and Observations that support it.
_Avoid_: Citation text, model reasoning
**Person**:
A human relevant to the Local Profile's work.
_Avoid_: Speaker, participant record
**Project**:
An ongoing effort directed toward an outcome. A Project may span multiple Workspaces, Tasks, Events, Meetings, and Artifacts.
_Avoid_: Repository, folder, session
**Task**:
An actionable unit of work represented by an external source. Mastermind understands Tasks but is not their system of record.
_Avoid_: Action Proposal, reminder
**Event**:
A time-bounded occurrence meaningful to the user's work, such as a deadline or milestone.
_Avoid_: Observation, log entry
**Meeting**:
A human interaction inferred from calendar, application, and audio Observations. Audio channel roles do not establish a participant's identity.
_Avoid_: Assistant Session, Conversation
**Artifact**:
A persistent work product or reference relevant to a Project, such as a document or source file.
_Avoid_: Observation, screen frame
**Workspace**:
An explicitly connected directory or repository that provides Artifacts and project context.
_Avoid_: Project, home directory
## Assistance and trust
**Assistant Session**:
A bounded text interaction in the Companion Island using one selected Provider. Assistant Sessions are distinct from Meetings.
_Avoid_: Conversation, capture session, chat memory
**Provider**:
An inference service used to generate assistance. A Local Provider runs on the same Mac; a Cloud Provider receives data outside the Mac.
_Avoid_: Model, agent, sidecar
**Provider Context Permission**:
The user's explicit permission for a Cloud Provider to receive locally selected context. Permission is denied by default and can be revoked for future requests.
_Avoid_: Source Policy, blanket consent
**Context Receipt**:
The inspectable record attached to an answer that identifies its supporting context, Provider, and any external data transfer.
_Avoid_: Citation, Activity Log
**Activity Log**:
The local audit trail of Collector lifecycle, permission failures, gaps, and Provider requests without captured content.
_Avoid_: Assistant History, transcript
**Assistant History**:
The persistent local record of Assistant Sessions. It is separate from the Context Graph and Activity Log.
_Avoid_: Memory, Context Graph
**Action Proposal**:
A plan or draft suggested by Mastermind for the user to carry out. It does not authorize Mastermind to act on the Mac.
_Avoid_: Tool call, agent action, Task
+75 -41
View File
@@ -1,60 +1,94 @@
<img width="1299" height="424" alt="cd (1)" src="https://github.com/user-attachments/assets/b25fff4d-043d-4f38-9985-f832ae0d0f6e" />
<div align="center">
<img src="assets/images/logo.png" alt="Mastermind Logo" width="200"/>
## Recall.ai - API for desktop recording
# Mastermind
If youre looking for a hosted desktop recording API, consider checking out [Recall.ai](https://www.recall.ai/product/desktop-recording-sdk/?utm_source=github&utm_medium=sponsorship&utm_campaign=sohzm-cheating-daddy), an API that records Zoom, Google Meet, Microsoft Teams, in-person meetings, and more.
### A personal local-first assistant for macOS
This project is sponsored by Recall.ai.
</div>
---
Mastermind is a Swift-native assistant that builds an inspectable understanding of work happening on a Mac and uses it to answer grounded questions. Its primary interface is the normally hidden Companion Island at the top center of the primary display; a Menu Bar Item remains present while the app runs.
> [!NOTE]
> Use latest MacOS and Windows version, older versions have limited support
The production product is currently in reconstruction. `native/MastermindPOC` proves the macOS capture and windowing capabilities, while the Electron application under `src` is legacy reference code.
> [!NOTE]
> During testing it wont answer if you ask something, you need to simulate interviewer asking question, which it will answer
## Canonical context
A real-time AI assistant that provides contextual help during video calls, interviews, presentations, and meetings using screen capture and audio analysis.
Start here before product or implementation work:
## Features
- [`CONTEXT.md`](CONTEXT.md) — canonical domain language.
- [`docs/product/mastermind-product-brief.md`](docs/product/mastermind-product-brief.md) — product boundary and MVP.
- [`docs/product/companion-island.md`](docs/product/companion-island.md) — interaction model.
- [`docs/product/mvp-acceptance.md`](docs/product/mvp-acceptance.md) — completion criteria.
- [`docs/architecture/native-mastermind.md`](docs/architecture/native-mastermind.md) — production architecture.
- [`docs/privacy/local-first-data-contract.md`](docs/privacy/local-first-data-contract.md) — normative privacy and data rules.
- [`docs/adr`](docs/adr) — hard-to-reverse decisions and their rationale.
- **Live AI Assistance**: Real-time help powered by Google Gemini 2.0 Flash Live
- **Screen & Audio Capture**: Analyzes what you see and hear for contextual responses
- **Multiple Profiles**: Interview, Sales Call, Business Meeting, Presentation, Negotiation
- **Transparent Overlay**: Always-on-top window that can be positioned anywhere
- **Click-through Mode**: Make window transparent to clicks when needed
- **Cross-platform**: Works on macOS, Windows, and Linux (kinda, dont use, just for testing rn)
When older code or text conflicts with these documents, the canonical context wins.
## Setup
## MVP direction
1. **Get a Gemini API Key**: Visit [Google AI Studio](https://aistudio.google.com/apikey)
2. **Install Dependencies**: `npm install`
3. **Run the App**: `npm start`
- Apple Silicon and macOS 14 or newer.
- Swift/AppKit host with SwiftUI content where appropriate.
- Continuous local context from screen, microphone, system audio, selected workspaces, selected calendars and Reminders lists, and terminal metadata.
- Ephemeral raw screen/audio/transcript data; only structured local derivations persist.
- Encrypted local Context Graph with inspectable Provenance.
- User-confirmed Facts kept distinct from inferred Assertions.
- OpenAI-compatible Local Provider over loopback or a Unix socket.
- Separate local ASR sidecar using 16 kHz mono PCM.
- Russian, English, and mixed-language work.
- Text answers, plans, and drafts only; no autonomous actions.
## Usage
Codex is a future Provider direction and is deliberately absent from the MVP until an official no-tools integration boundary exists.
1. Enter your Gemini API key in the main window
2. Choose your profile and language in settings
3. Click "Start Session" to begin
4. Position the window using keyboard shortcuts
5. The AI will provide real-time assistance based on your screen and what interview asks
## Privacy boundary
## Keyboard Shortcuts
The MVP does not send machine context to cloud services. Mastermind does not hide its process, permissions, capture indicators, or network activity from macOS or managed environments.
- **Window Movement**: `Ctrl/Cmd + Arrow Keys` - Move window
- **Click-through**: `Ctrl/Cmd + M` - Toggle mouse events
- **Close/Back**: `Ctrl/Cmd + \` - Close window or go back
- **Send Message**: `Enter` - Send text to AI
Window exclusion from the app's own capture is required. Exclusion from third-party capture is best effort and is never guaranteed.
## Audio Capture
## Current native capability proof
- **macOS**: [SystemAudioDump](https://github.com/Mohammed-Yasin-Mulla/Sound) for system audio
- **Windows**: Loopback audio capture
- **Linux**: Microphone input
`native/MastermindPOC` currently demonstrates:
## Requirements
- a menu bar application and transparent AppKit panel;
- ScreenCaptureKit screen and system-audio capture;
- AVAudioEngine microphone capture;
- separate 16 kHz PCM output for microphone and system audio;
- current-process window exclusion;
- click-through, hide/show, opacity settings, and Pause All;
- Swift unit tests and app-bundle build script.
- Electron-compatible OS (macOS, Windows, Linux)
- Gemini API key
- Screen recording permissions
- Microphone/audio permissions
See [`docs/migration/native-poc-results.md`](docs/migration/native-poc-results.md) for verified and manual checks. The POC is not the production application and will be retired after its validated capabilities are ported to `native/Mastermind`.
### Build and test the POC
```bash
cd native/MastermindPOC
swift test
swift build --product MastermindPOC
./scripts/build-app.sh
```
## Legacy Electron application
The Electron implementation remains available as reference during reconstruction. New product functionality belongs in the Swift application.
```bash
npm install
npm test
npm start
```
Do not use Electron UI, storage, provider coupling, or marketing copy as the source of truth for the native product.
## Responsible boundary
Mastermind is a personal work assistant. It is not a proctoring bypass, process-hiding system, monitoring-evasion tool, or means to conceal prohibited AI use. AI output can be wrong; grounded answers must expose evidence and uncertainty.
## Contributing
Read [`AGENTS.md`](AGENTS.md) and the canonical context before making changes. Keep production work inside the Swift-native direction and preserve the local-first data contract.
## License
Mastermind is licensed under GPL-3.0. See [`LICENSE`](LICENSE).
Binary file not shown.

After

Width:  |  Height:  |  Size: 238 KiB

@@ -0,0 +1,3 @@
# Swift-native host with local sidecars
Mastermind's production runtime is a Swift/AppKit macOS host that owns UI, lifecycle, permissions, capture, policy, and storage. Local LLM and ASR processes stay behind loopback or Unix-socket protocols, while Electron is retained only as legacy reference; this keeps macOS capabilities native without forcing model runtimes into the application process.
@@ -0,0 +1,3 @@
# Encrypted observation ledger
The Context Graph uses application-encrypted SQLite/SQLCipher as its source of truth. Collectors append immutable Observations, project versioned Assertions, and create Facts only through explicit user confirmation; this preserves Provenance and temporal conflicts while keeping full-text and vector indexes rebuildable instead of making a separate graph service authoritative.
@@ -0,0 +1,3 @@
# Continuous Collectors with ephemeral input
Enabled screen, microphone, system-audio, workspace, calendar, and terminal Collectors run continuously and adapt to power and thermal pressure. Raw frames, audio, OCR, and full transcripts remain ephemeral while only structured local derivations persist; this trades recoverability for a strict local data-minimization boundary, so processor outages are recorded as Gaps rather than buffered raw content.
@@ -0,0 +1,3 @@
# Defer Codex until a no-tools boundary exists
Codex integration is excluded from the MVP even though it remains a future Provider direction. The official app-server is an agent protocol and its read-only sandbox does not provide a documented no-tools guarantee, so Mastermind will not ship OAuth, app-server code, or a hidden experimental path until a supported draft-only boundary can enforce the product's no-action contract.
+217
View File
@@ -0,0 +1,217 @@
# Native Mastermind Architecture
## Scope
This document defines the production architecture for Mastermind. Domain language comes from [`../../CONTEXT.md`](../../CONTEXT.md), product scope from [`../product/mastermind-product-brief.md`](../product/mastermind-product-brief.md), and privacy invariants from [`../privacy/local-first-data-contract.md`](../privacy/local-first-data-contract.md).
The production application lives under `native/Mastermind`. `native/MastermindPOC` is a capability reference, not the production architecture. The Electron application is legacy source material and is not part of the production runtime.
## Runtime boundary
The application host is Swift-native:
- AppKit owns lifecycle, windows, menu bar behavior, permissions, and capture.
- SwiftUI may render Companion Island content inside AppKit-controlled windows.
- Swift concurrency isolates Collectors and processing from the main actor.
- The host owns the encrypted Context Graph and all policy decisions.
- Local model processes remain outside the host behind narrow protocols.
- No Node or Electron process is required.
Permitted external local processes:
- an OpenAI-compatible Local Provider on loopback or a Unix socket;
- a local ASR sidecar using the existing WebSocket protocol.
Mastermind owns a small multilingual embedding component because chat endpoints do not reliably expose embeddings.
## System shape
```mermaid
flowchart LR
subgraph host [Swift native host]
AppCoordinator --> MenuBar
AppCoordinator --> CompanionIsland
AppCoordinator --> CollectorSupervisor
CollectorSupervisor --> ScreenCollector
CollectorSupervisor --> AudioCollectors
CollectorSupervisor --> WorkspaceCollector
CollectorSupervisor --> CalendarCollector
CollectorSupervisor --> TerminalCollector
ScreenCollector --> LocalReduction
AudioCollectors --> LocalReduction
WorkspaceCollector --> LocalReduction
CalendarCollector --> LocalReduction
TerminalCollector --> LocalReduction
LocalReduction --> ObservationLedger
ObservationLedger --> KnowledgeProjector
KnowledgeProjector --> ContextGraph
ContextGraph --> Retriever
Retriever --> AssistantSession
AssistantSession --> ContextReceipt
CollectorSupervisor --> ActivityLog
AssistantSession --> ActivityLog
end
AudioCollectors --> AsrSidecar
AssistantSession --> LocalProvider
EmbeddingModel --> KnowledgeProjector
EmbeddingModel --> Retriever
```
Every arrow carrying Source content stays on the Mac. Activity Log entries contain metadata, not Source content.
## Application components
### AppCoordinator
Owns lifecycle and composes the application. It restores the persisted paused state before starting Collectors, coordinates sleep/wake and Launch at Login, and never places heavy processing on the main actor.
### CompanionIslandController
Owns the hidden, revealed, and expanded window states on the primary display. The controller separates non-activating hover behavior from the key window used for text entry and management.
### MenuBarController
Owns the stable status glyph and detailed menu. Collector states are represented in menu content rather than by changing the glyph.
### CollectorSupervisor
Owns independent Collector lifecycles. It starts enabled Collectors, pauses them atomically, persists Pause All, applies power and thermal policy, and records Gaps. One Collector failure must not collapse the rest of the pipeline.
### ScreenCollector
Uses ScreenCaptureKit for pixels and Accessibility for application/window structure. The active display is the display containing the key window, falling back to pointer location and then the primary display. Changed-region detection, OCR, and deduplication reduce pixels locally before immediate disposal.
Mastermind windows are excluded from its own stream. Third-party exclusion is a best-effort window policy, not a security guarantee.
### AudioCollectors
Microphone and system audio are separate Collectors and remain separate. Both emit 16 kHz mono signed 16-bit PCM frames to VAD and local ASR. The pipeline labels channel role as User Speech or System Speech but does not infer a Person from voice.
### WorkspaceCollector
Observes explicitly connected roots. It respects `.gitignore`, configured excludes, binary detection, and size limits, and emits changes to Artifacts and repository metadata.
### CalendarCollector
Uses EventKit read-only for selected calendars and Reminders lists. External applications remain systems of record for Tasks and Events.
### TerminalCollector
Receives shell integration events for working directory, command, exit status, duration, and Git metadata. It does not persist stdout or stderr by default.
## Knowledge pipeline
### Observation ledger
Collectors append immutable Observations. Each record includes:
- stable identifier;
- Source identifier and Collector kind;
- observed time and source event time when available;
- structured payload;
- sensitivity metadata;
- derivation version.
Observations are retained until age or size eviction. Corrections never mutate them.
### Knowledge projector
The projector derives versioned Assertions, entity relationships, short summaries, and Provenance. Multiple contradictory Assertions may coexist. A current view selects relevant Assertions without erasing history.
Only explicit user confirmation or correction promotes an Assertion to a Fact. A correction creates a new version and supersedes prior current views; it does not rewrite supporting Observations.
Entity resolution automatically links only high-confidence matches. Ambiguous entities remain separate and can be merged or split from a Context Receipt.
### Context store
The source of truth is encrypted SQLite/SQLCipher with explicit schema migrations. It stores:
- Sources and Collector configuration;
- immutable Observations;
- entities and relations;
- versioned Assertions and Facts;
- Provenance;
- Assistant History;
- Context Receipts;
- Activity Log metadata;
- retention and Provider policies.
Full-text and vector indexes are derived and rebuildable. They must not become unencrypted alternate stores.
The database key is generated locally and protected by Keychain. Provider secrets use separate Keychain entries. An encrypted, versioned archive supports manual export and import without Provider credentials.
### Retention
The default inferred-context budget is 90 days and 2 GB. Eviction considers `lastObservedAt` and utility. User-confirmed Facts are pinned. Assistant History has no automatic TTL and supports per-session and complete deletion.
Raw frames, audio, OCR, and full transcripts never enter the Context store. If local reduction cannot complete, the system records a Gap.
## Retrieval and assistance
The Retriever combines:
- structured entity and relationship queries;
- temporal filtering;
- full-text search;
- multilingual vector similarity;
- recency and confidence;
- Provenance completeness.
Retrieved Source content is always untrusted data. It cannot alter system instructions or grant capabilities.
An Assistant Session binds to one Provider profile. The MVP has one active OpenAI-compatible Local Provider profile at a time and streams text responses. Failure leaves the query draft intact and preserves access to graph search, history, settings, and diagnostics.
Every answer produces a Context Receipt containing:
- Provider profile and locality;
- supporting Sources, Observations, Assertions, and Facts;
- time range and stale/conflict indicators;
- any external-transfer record.
## State model
Capture and assistant state are orthogonal. Do not reproduce the POC's single `TrustStatus` enum, which loses information when screen, microphone, and system audio run simultaneously.
Model at least:
- one `CollectorState` per Collector: disabled, starting, running, paused, permission-needed, degraded, or failed;
- an application pause state persisted across launches;
- Companion Island presentation state;
- Assistant Session state;
- Local Provider and sidecar health;
- Context Graph migration and maintenance state.
The menu and Activity Log project these states for the user.
## Failure rules
- Permission loss changes only the affected Collector to permission-needed.
- Processor or sidecar failure creates Gaps; it never creates a raw-content backlog.
- Database or encryption failure prevents new context writes and surfaces a blocking error.
- Thermal and low-power pressure reduce sampling and local processing.
- Quit stops all Collectors and flushes metadata.
- Relaunch while paused remains paused.
## Future boundaries
### Cloud Provider
A future Cloud Provider must implement the Provider boundary and Provider Context Permission. It may receive only minimized, locally filtered context and must generate a Context Receipt for every request.
### Codex
Codex remains documentation-only until it exposes a supported no-tools integration. The MVP has no Codex code or UI.
### Agent actions
The MVP may emit an Action Proposal as text. Computer Control, Tool execution, approvals, and action auditing form a future bounded context and are not represented by executable stubs in the MVP.
## Migration direction
1. Treat `native/MastermindPOC` as evidence that the required macOS capture and window primitives are viable.
2. Create the production application under `native/Mastermind`.
3. Port validated capability code behind production state and protocol boundaries.
4. Build the Context Graph vertical slice and primary acceptance scenario.
5. Retire the POC after production parity checks pass.
6. Keep Electron code as legacy reference only; do not migrate its data or runtime.
+77
View File
@@ -0,0 +1,77 @@
# Local ASR Sidecar Protocol
Mastermind local-first mode expects speech-to-text to run as an external local
streaming service. The app connects to the service over WebSocket and sends
16 kHz mono PCM audio.
## Default Endpoint
```text
ws://127.0.0.1:8765/v1/asr/stream
```
The endpoint is configurable in the Local AI settings.
## Client Start Message
After the WebSocket opens, the app sends a JSON start frame:
```json
{
"type": "start",
"sampleRate": 16000,
"channels": 1,
"encoding": "pcm_s16le",
"language": "en-US"
}
```
After that, the app sends binary frames containing raw little-endian signed
16-bit PCM audio at 16 kHz.
## Sidecar Events
The sidecar should send JSON text frames:
```json
{ "type": "ready" }
```
```json
{ "type": "partial", "text": "intermediate transcript" }
```
```json
{ "type": "final", "text": "final transcript" }
```
```json
{ "type": "error", "error": "human-readable error" }
```
Only `final` transcript events enter local semantic reduction. `partial` events
are transient status and must not be persisted.
## v1 Scope
- Required language modes: English (`en-US`), Russian (`ru-RU`), and automatic
or mixed Russian-English recognition (`auto` or an implementation-equivalent
mode).
- Sidecar implementation is external to this repository.
- The app does not require a specific Nemotron, NeMo, Riva, or ONNX runtime as
long as the WebSocket protocol above is implemented.
- The endpoint must be loopback-only. LAN and internet ASR endpoints are not
Local Providers.
## Native macOS Client Compatibility
The Swift-native app uses the same WebSocket protocol as the Electron client.
Audio frames are raw 16 kHz mono signed 16-bit little-endian PCM.
Microphone and system audio use independent client connections so the sidecar
does not mix channel roles. The sidecar does not need to know whether a client
is Electron or Swift; Mastermind associates each connection with its Source.
Full transcripts are ephemeral sensitive input. The Swift client consumes a
`final` event, derives structured local context, and then releases transcript
content instead of storing it in Assistant History or the Context Graph.
+65
View File
@@ -0,0 +1,65 @@
# Native macOS POC Results
> This document records capability evidence, not the production product specification.
> Canonical direction is defined by [`../../CONTEXT.md`](../../CONTEXT.md),
> [`../product/mastermind-product-brief.md`](../product/mastermind-product-brief.md), and
> [`../architecture/native-mastermind.md`](../architecture/native-mastermind.md).
## Implemented
- SwiftPM-based macOS POC under `native/MastermindPOC`.
- AppKit lifecycle with a menu bar status item.
- Transparent non-activating overlay window with hide/show and click-through controls.
- Overlay window uses `NSWindowSharingType.none` and ScreenCaptureKit filtering excludes the current process windows from app-owned capture.
- ScreenCaptureKit screen stream proof with frame counters.
- ScreenCaptureKit system audio proof with best-effort PCM extraction to 16 kHz mono signed 16-bit little-endian frames.
- AVAudioEngine microphone proof with 16 kHz mono signed 16-bit little-endian frames.
- Separate microphone and system audio counters in the overlay.
- Compact HUD controls for Settings, Hide, and Quit.
- Draggable overlay title area.
- Settings panel with persisted overlay background opacity.
- Swift unit tests for status labels, PCM frame metadata, PCM S16LE encoding, and separate audio counters.
- App bundle build script at `native/MastermindPOC/scripts/build-app.sh`.
## Verified Automatically
- `swift test` passes for `MastermindPOCCore`.
- `swift build --product MastermindPOC` compiles the native executable.
- `native/MastermindPOC/scripts/build-app.sh` produces `native/MastermindPOC/build/MastermindPOC.app`.
- Existing Electron local provider tests pass when run outside the managed sandbox restrictions.
## Manual Verification Needed
- Launch `native/MastermindPOC/build/MastermindPOC.app`.
- Confirm the menu bar item is visible while the app runs.
- Confirm the overlay appears locally and can be hidden.
- Confirm the overlay can be dragged by its top title bar.
- Confirm the overlay Settings button opens the settings panel.
- Confirm the opacity slider changes only the HUD background opacity and persists across relaunches.
- Confirm the overlay Quit button exits the app.
- Confirm click-through lets pointer events pass to apps underneath.
- Grant Screen Recording permission when macOS prompts, then restart the POC if needed.
- Start `Screen + System Audio` and confirm screen frame and system PCM counters increase.
- Start `Microphone` and confirm microphone PCM counters increase while speaking.
- Confirm `Pause All Capture` stops screen, system audio, and microphone updates.
- Confirm app-owned screen capture does not include the Mastermind overlay.
## Boundary
This POC is not a stealth or anti-detection system. It shows a menu bar item while running, requests normal macOS permissions, and only excludes its own overlay from the context it captures for itself.
## Role in production migration
`native/MastermindPOC` proves that AppKit windowing, ScreenCaptureKit screen and system-audio capture, AVAudioEngine microphone capture, current-process exclusion, and 16 kHz PCM conversion are viable.
Production development moves to `native/Mastermind`. Validated capability code may be ported behind production boundaries, but the POC is not renamed into the product and is retired after parity checks pass.
The production design intentionally differs from the POC:
- the Companion Island is hidden at launch and anchored to the primary display's top center;
- enabled Collectors start continuously after onboarding instead of through manual start menu items;
- the Menu Bar Item uses a stable glyph with detailed states inside its menu;
- screen, microphone, and system-audio states are simultaneous per-Collector states rather than one `TrustStatus`;
- settings and management live inside the expanded Companion Island;
- screen context follows the active display rather than always selecting the main display;
- the Context Graph, Local Provider, local ASR client, retention, encryption, Context Receipts, and Activity Log are production requirements not implemented by the POC.
+145
View File
@@ -0,0 +1,145 @@
# Local-First Data Contract
This document is normative. Product and implementation work must preserve these boundaries unless a superseding ADR explicitly changes them.
## Local-first definition
Mastermind's Local Profile, Source configuration, captured context, Context Graph, Assistant History, Activity Log, embeddings, and inference remain on the user's Mac in the MVP.
A Provider is local only when it is reachable through loopback or a Unix socket on the same Mac. LAN and internet endpoints are external and are not supported by the MVP.
The application must remain useful without internet access. It may not silently fall back from local processing to a Cloud Provider.
## Data classes
### Ephemeral sensitive input
- screen frames and changed image regions;
- microphone and system-audio PCM;
- OCR output;
- complete ASR transcripts;
- transient prompts assembled for local derivation.
These values exist only in bounded processing buffers and must be destroyed after local reduction. They must not enter logs, crash reports, archives, fixtures, or the Context store.
### Persisted context
- structured Observations;
- Assertions, Facts, summaries, and entity relationships;
- Provenance references;
- Source and Collector configuration;
- Assistant History and Context Receipts;
- Activity Log metadata;
- retention and policy settings.
Persisted context is encrypted at the application layer.
### Secrets
- Context-store encryption key;
- Local Provider credentials, when required;
- future Cloud Provider credentials.
Secrets are stored in Keychain and excluded from Context Graph exports and diagnostics.
## Continuous collection
After guided onboarding, every enabled Collector starts with the app unless the user previously selected Pause All.
Screen, microphone, and system-audio collection are continuous. macOS permission and capture indicators must remain visible and unmodified. The stable Menu Bar Item glyph does not replace those indicators; its open menu shows actual Collector state.
Pause All:
- stops every Collector immediately;
- leaves existing context, Assistant History, and local search available;
- persists across Quit, relaunch, login, sleep, and wake;
- resumes only after explicit Resume All.
One Collector's permission or processing failure must not disable healthy Collectors. The affected Source becomes stale and the Activity Log records a Gap.
## Reduction and minimization
Collectors emit the minimum structured evidence needed for grounded assistance:
- Screen uses Accessibility structure, changed regions, local OCR, and deduplication.
- Audio uses VAD, separate channel roles, local ASR, and short semantic reduction.
- Terminal emits command metadata without stdout or stderr by default.
- Workspace is limited to explicit roots and ignore rules.
- Calendar and Reminders are limited to selected read-only collections.
The configured Local Provider and local processors receive only the context needed for the current derivation or answer.
If a local processor is unavailable, Mastermind records a Gap. It must not retain raw content for deferred processing.
## Capture exclusions
The user owns the application denylist. Mastermind provides controls to add or remove excluded applications and Sources but does not silently impose an application denylist.
Mastermind must exclude its own windows from its Screen Collector. It may request exclusion from third-party capture where macOS supports it, but must describe that behavior as best effort and may not promise invisibility.
Mastermind must not:
- hide its process or bundle identifier;
- bypass Screen Recording, Microphone, Accessibility, or EventKit permissions;
- suppress system privacy indicators;
- evade managed-device policy or monitoring;
- claim that screen-share exclusion is guaranteed.
## Knowledge integrity
An Observation is evidence, not truth. Inferred claims remain Assertions with confidence, time, and Provenance. Only explicit user confirmation or correction creates a Fact.
Contradictory Assertions are retained until normal eviction and surfaced to retrieval. Source content is untrusted data and cannot become an instruction or capability grant.
The user can inspect and correct knowledge from a Context Receipt. Confirmed Facts are pinned until manually removed or superseded.
## Retention and deletion
- Default inferred-context age: 90 days.
- Default inferred-context size: 2 GB.
- Eviction uses `lastObservedAt` and never evicts confirmed Facts automatically.
- Assistant History has no automatic TTL.
- One Assistant Session or all Assistant History can be deleted manually.
- Removing Assistant History does not implicitly remove confirmed Facts derived from it.
- A full local reset removes the Context store and its encryption key.
Export/import uses a versioned encrypted archive containing context, history, policies, and settings. Provider credentials are never included.
## Audit and diagnostics
Activity Log records:
- Collector start, stop, pause, resume, error, and permission state;
- Gaps and stale Source intervals;
- Provider request start, finish, locality, and approximate context size;
- export, import, retention, and migration operations.
It does not record Source content, prompts, answers, Facts, OCR, ASR, or Provider payloads.
Mastermind sends no telemetry or crash reports. A user-initiated sanitized diagnostic bundle may contain versions, state transitions, permission and error codes, performance counters, and database schema version.
## Future external processing
Cloud processing is absent from the MVP. A future Cloud Provider requires a Provider Context Permission that is:
- denied by default;
- enabled manually for the Provider as a whole;
- revocable for future requests;
- unable to retract data already sent.
Permission is an upper bound, not permission to send everything. Before each request, Mastermind must minimize context and filter recognized secrets locally. Every request must create a Context Receipt naming the Provider, supporting Sources, and transferred context categories.
Codex remains disabled until a supported no-tools boundary exists.
## Onboarding disclosure
Before enabling continuous Collectors, onboarding must explain:
- which Source each permission exposes;
- that screen and both audio channels run continuously while enabled;
- that raw media and full transcripts are not retained;
- where derived knowledge is stored;
- how Pause All and the denylist work;
- that other people may be represented in locally derived context;
- that capture exclusion is best effort;
- that the MVP performs no cloud context transfer.
+87
View File
@@ -0,0 +1,87 @@
# Companion Island
## Purpose
The Companion Island is Mastermind's only primary interaction surface. It keeps the assistant absent from the desktop until requested while making it available at a stable physical location.
It is inspired by the expansion behavior of Dynamic Island, but it is a Mastermind concept and must not be described as an Apple system feature.
## Placement
- The Companion Island belongs to the primary display only.
- Its activation area is centered on the display's top edge.
- On a notched display, the activation area follows the camera housing.
- On a display without a notch, the same area behaves as a virtual camera housing.
- Changing the primary display relocates the Companion Island.
## Interaction states
### Hidden
No Companion Island content is visible. Moving the pointer into the activation area begins the reveal transition.
### Revealed
A compact capsule grows from the camera area without taking keyboard focus. Leaving the activation region without clicking collapses it after a short grace period.
### Expanded
Clicking the revealed capsule expands it downward into an interactive panel and focuses text input. Expansion must feel spatially connected to the camera area rather than like an unrelated window appearing.
The expanded surface contains navigation for:
- Assistant;
- Assistant History;
- Sources;
- Local Provider;
- Context Graph and Context Receipts;
- Activity Log;
- Privacy, storage, export/import, and diagnostics.
Escape, an explicit close action, or clicking outside the panel returns it to Hidden. Long-running local work continues after collapse and is visible when the panel is reopened.
### Paused
Paused is a Collector condition, not a separate window mode. The user can still open the Companion Island, search existing context, and use Assistant History while all Collectors remain stopped.
## Menu Bar Item
The Menu Bar Item is visible whenever Mastermind runs. Its glyph does not change with capture state.
Its menu must expose:
- current state of every enabled Collector;
- permission and processing errors;
- Pause All or Resume All;
- Show Mastermind;
- Launch at Login state;
- Quit Mastermind.
Show Mastermind expands the Companion Island and is the fallback when pointer activation is unavailable. The MVP has no global hotkey.
## Focus and accessibility
- Hidden and Revealed do not steal focus.
- Expanded accepts keyboard focus and text input.
- Pointer activation must not create a dead strip that prevents access to the macOS menu bar.
- Animation respects Reduce Motion.
- The interface remains keyboard-navigable after it is expanded.
- Collector state and errors are conveyed with text, not color alone.
## Capture behavior
Mastermind excludes its own windows from the Screen Collector. It also requests exclusion from third-party capture where supported by macOS.
Capture exclusion is best effort. The UI must not promise that the Companion Island is invisible to every screen-sharing or recording application. Mastermind never hides its process or system permission indicators.
## Acceptance checks
- At launch, only the Menu Bar Item is visible.
- Hovering the primary display's camera area reveals the capsule.
- A notchless primary display gets the same top-center interaction.
- A click expands the panel and focuses text input.
- Show Mastermind works when hover activation cannot be used.
- Escape and click-away collapse the panel.
- Pause All does not prevent access to existing knowledge or history.
- The Companion Island is absent from Mastermind's own captured frames.
- Changing the primary display relocates the activation area.
+134
View File
@@ -0,0 +1,134 @@
# Mastermind Product Brief
## Product thesis
Mastermind is a personal, local-first assistant for macOS. It continuously builds an inspectable understanding of the user's work and answers grounded questions about what happened, what matters now, and what may come next.
Mastermind is not an interview helper, a hidden proctoring tool, or a generic shell around a cloud agent. The first product is a personal/internal Swift application for one macOS user.
## Core promise
The user's machine context remains on the Mac. Screen, audio, files, calendar, reminders, and terminal activity are reduced to useful knowledge locally. Mastermind does not transmit that context to a cloud service in the MVP.
The assistant distinguishes observed evidence, inferred claims, and user-confirmed facts. Every grounded answer can show what evidence it used and where that evidence came from.
## Target platform
- Apple Silicon Mac.
- macOS 14 or newer.
- One Local Profile for the current macOS user.
- No Mastermind account, backend, telemetry, or synchronization in the MVP.
- The domain keeps a future cloud identity separate from the Local Profile.
- Personal/internal distribution; Mac App Store constraints are out of scope.
## Primary experience
Mastermind runs as a menu bar application without a Dock presence. Its Menu Bar Item is always present while the app runs and uses a stable glyph. Opening its menu reveals Collector states, permission failures, Pause All or Resume All, Show Mastermind, and Quit.
The Companion Island is hidden by default. Hovering the top-center camera area of the primary display reveals a compact capsule with a smooth animation. Clicking expands the capsule into the complete Mastermind interface. A virtual top-center activation area provides the same behavior when the primary display has no physical notch. Show Mastermind in the menu is the fallback; the MVP has no global shortcut or voice invocation.
The expanded Companion Island contains:
- text input and streamed answers;
- Assistant History;
- Context Receipts and Fact correction;
- Source and Collector management;
- Local Provider configuration;
- Context Graph limits and encrypted export/import;
- Activity Log and diagnostics.
Mastermind is on-demand, not proactive. Background collection may update status and knowledge, but the assistant does not interrupt the user with unsolicited advice.
## MVP sources
Enabled Collectors start automatically with the application unless Pause All was previously selected:
- Screen: the active display, defined by the frontmost key window, with pointer display and primary display as fallbacks.
- Audio: separate microphone and system-audio channels.
- Workspace: explicitly connected directories and Git repositories.
- Calendar and Reminders: user-selected calendars and lists, read-only.
- Terminal: shell integration metadata including working directory, command, exit status, duration, and Git metadata; terminal output is not retained by default.
Workspace indexing respects `.gitignore`, binary and size limits, and user-configured exclusions. Mastermind does not index the entire home directory.
The user configures the local capture denylist. Mastermind does not silently add application-level exclusions, but Pause All is always available.
## Context behavior
Collectors run continuously and adapt their work to meaningful changes, voice activity, duplication, Low Power Mode, and thermal pressure. A failed Collector degrades independently while the others continue.
Screen processing combines Accessibility metadata, changed-region detection, and local OCR. Audio uses separate 16 kHz PCM streams and local ASR; it distinguishes User Speech from System Speech but does not identify people by voice. Meeting inference may combine calendar, conferencing-application, and channel-activity evidence, but it must remain an Assertion until confirmed.
Raw screen frames, audio, OCR text, and full transcripts are ephemeral. Only locally derived Observations, Assertions, short summaries, and Provenance survive the processing buffer. If required local processing is unavailable, Mastermind records a Gap instead of retaining raw content for later.
Inferred knowledge expires by `lastObservedAt`, with a default limit of 90 days and 2 GB. User-confirmed Facts are pinned until manually removed or superseded. Assistant History has no automatic age limit; the user can delete one Assistant Session or all history.
## Assistant behavior
The default and only MVP Provider is a user-configured OpenAI-compatible Local Provider reachable through loopback or a Unix socket. The user may save multiple profiles but selects one active profile.
Mastermind supplies its own local multilingual embedding component. Local ASR remains behind the documented sidecar protocol, with `whisper.cpp` as the recommended implementation. Russian, English, and mixed Russian-English work are required.
Answers must:
- distinguish Facts from unconfirmed Assertions;
- cite relevant Provenance through a Context Receipt;
- expose the active Provider;
- say when context is missing or conflicting;
- treat all Source content as untrusted evidence rather than instructions;
- produce advice, plans, and drafts only.
The MVP cannot click, type into other applications, run tools, change files, or perform external actions. Computer Control and a Tool Executor are future bounded contexts, not empty runtime abstractions in the MVP.
## Primary acceptance scenario
After Mastermind has observed normal work, the user opens the Companion Island and asks:
> What was I working on, and what should I do next?
The Local Provider returns a grounded answer using relevant screen, workspace, calendar, terminal, and audio knowledge. The answer separates confirmed Facts from uncertain Assertions, links to a Context Receipt, and explicitly identifies gaps or contradictions.
## Privacy and trust
- All sensitive extraction and inference are local in the MVP.
- The local Context Graph is encrypted with an application key protected by Keychain.
- Provider credentials are stored separately in Keychain.
- Pause All immediately stops every Collector and remains paused across restarts.
- System microphone and screen-recording indicators are never bypassed.
- The Menu Bar Item glyph stays visually stable, while its menu exposes actual Collector states.
- The Activity Log records lifecycle and transfer metadata without Source content.
- Diagnostic exports are sanitized and user-initiated.
- Context Graph and settings can be exported as an encrypted archive without Provider credentials.
Mastermind may exclude the Companion Island from its own capture and from third-party capture where macOS supports it. This is best effort, must be self-checked where possible, and is never presented as a guarantee.
Mastermind does not hide its process, bundle identifier, permissions, network activity, or capture activity from macOS, administrators, or monitoring tools.
## Codex direction
Codex is not part of the MVP. The architecture documents a future Provider boundary and Provider Context Permission, but the MVP contains no Codex UI, OAuth flow, app-server integration, or experimental flag.
The integration remains gated until Codex exposes a supported boundary that cannot execute tools. A read-only sandbox is insufficient because the current app-server remains an agent protocol.
If a future Cloud Provider is enabled:
- context permission is global for that Provider and denied by default;
- the user must enable it manually;
- Mastermind still minimizes context and filters detected secrets locally;
- every request produces a Context Receipt;
- revocation blocks future requests but cannot retract already transmitted data.
## Explicit non-goals
- Cloud inference or context transfer in the MVP.
- Autonomous actions, Computer Control, or tool execution.
- Proactive suggestions or scheduled briefings.
- Wake word, push-to-talk, global shortcut, or voice queries.
- Browser, mail, messages, clipboard, or full home-directory indexing.
- Voice identity and speaker attribution to a Person.
- Owning or synchronizing the user's tasks and calendar.
- Multi-display Companion Island behavior.
- A visual whole-graph explorer.
- Electron feature development or an Electron bridge.
- Intel Mac, Windows, Linux, or Mac App Store support.
- Stealth, anti-detection, permission bypass, or guaranteed screen-share invisibility.
+87
View File
@@ -0,0 +1,87 @@
# MVP Acceptance
The MVP is complete only when the following scenarios work together in the production Swift application. Passing the current native capability proof alone is not sufficient.
## Product scenario
Given that enabled Collectors have observed normal work, when the user opens the Companion Island and asks, "What was I working on, and what should I do next?", then Mastermind:
- responds through the configured Local Provider;
- uses relevant knowledge from screen, workspace, calendar, terminal, and audio Sources;
- distinguishes user-confirmed Facts from inferred Assertions;
- identifies conflicting or stale knowledge;
- states when evidence is insufficient;
- attaches an inspectable Context Receipt.
## Presence
- Mastermind launches as a menu bar application without a Dock icon.
- Only the stable Menu Bar Item is visible at rest.
- Hover reveals the Companion Island on the primary display.
- Click expands it and focuses text input.
- Show Mastermind in the menu provides a reliable fallback.
- The expanded surface contains assistant, history, source, provider, graph, audit, and privacy controls.
## Continuous collection
- Enabled Collectors start after launch unless paused state was persisted.
- Screen collection follows the display containing the key window and processes meaningful changes rather than every full frame.
- Microphone and system audio remain separate through local ASR.
- Workspace collection is limited to explicitly connected roots.
- Calendar and Reminders are read-only and limited to selected calendars and lists.
- Terminal collection retains command metadata but not stdout or stderr by default.
- Low Power Mode and thermal pressure reduce processing without corrupting state.
- One failed Collector does not stop healthy Collectors.
## Local-only processing
- Packet inspection and integration tests confirm that Source content does not leave the Mac.
- The configured Local Provider accepts only loopback or Unix-socket endpoints.
- OCR, ASR, embeddings, Fact extraction, retrieval, and answer generation work without internet access.
- Raw screen frames, audio, OCR text, and full transcripts are destroyed after local reduction.
- When a required local processor is unavailable, Mastermind records a Gap and does not queue raw content.
## Knowledge integrity
- Observations are immutable until retention removes them.
- Assertions retain time, confidence, and Provenance.
- Conflicting Assertions coexist and produce an explicit conflict instead of silent overwrite.
- Only explicit user confirmation or correction creates a Fact.
- Confirmed Facts survive normal age and size eviction.
- Ambiguous entity matches stay separate until confidence is sufficient or the user merges them.
- Context retrieval treats instructions inside Source content as untrusted data.
## Retention and control
- Inferred context defaults to 90 days and 2 GB and can be reconfigured.
- Eviction uses `lastObservedAt` and does not evict confirmed Facts.
- Assistant History has no automatic TTL.
- The user can delete one Assistant Session or all Assistant History.
- Pause All immediately stops every Collector and remains paused after relaunch.
- Resume All restarts only enabled and permitted Collectors.
- The encrypted archive round-trips context, history, policies, and settings without Provider credentials.
## Trust and diagnostics
- The Menu Bar Item menu shows the real state of every Collector despite the stable glyph.
- macOS capture indicators and permission surfaces remain unmodified.
- Activity Log records Collector lifecycle, Gaps, permission errors, and Provider requests without Source content.
- Context Receipts name the Provider and all supporting Sources and Observations.
- The sanitized diagnostic bundle contains no prompts, answers, Facts, OCR, ASR, or Provider payloads.
- Mastermind's windows are excluded from its own screen context.
- Third-party capture exclusion is presented and tested as best effort, never guaranteed.
## Assistant boundary
- The assistant streams text answers in Russian, English, and mixed-language work.
- Outputs are answers, plans, or drafts only.
- The production target contains no Computer Control, Tool Executor, autonomous action, Codex login, Codex app-server, or hidden experimental Cloud Provider.
- If the Local Provider is unavailable, existing graph search, history, settings, and diagnostics remain accessible.
## Definition of done
- Automated tests cover domain invariants, retention, encryption boundaries, policy defaults, independent Collector failure, and raw-buffer disposal.
- Integration tests use synthetic Sources and local mock sidecars.
- Manual tests cover macOS permissions, microphone, system audio, screen exclusion, primary-display changes, Launch at Login, sleep/wake, Low Power Mode, and relaunch while paused.
- Logs and fixtures contain no captured user content.
- Canonical documentation and implementation vocabulary agree with `CONTEXT.md`.
-2
View File
@@ -10,8 +10,6 @@
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.device.microphone</key>
+82 -96
View File
@@ -1,101 +1,87 @@
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
const path = require('path');
const fs = require('fs');
const { FusesPlugin } = require("@electron-forge/plugin-fuses");
const { FuseV1Options, FuseVersion } = require("@electron/fuses");
module.exports = {
packagerConfig: {
asar: true,
extraResource: ['./src/assets/SystemAudioDump'],
name: 'Cheating Daddy',
icon: 'src/assets/logo',
// Fix executable permissions after packaging
afterCopy: [
(buildPath, electronVersion, platform, arch, callback) => {
if (platform === 'darwin') {
const systemAudioDump = path.join(buildPath, '..', 'Resources', 'SystemAudioDump');
if (fs.existsSync(systemAudioDump)) {
try {
fs.chmodSync(systemAudioDump, 0o755);
console.log('✓ Set executable permissions for SystemAudioDump');
} catch (err) {
console.error('✗ Failed to set permissions:', err.message);
}
} else {
console.warn('SystemAudioDump not found at:', systemAudioDump);
}
}
callback();
},
],
// use `security find-identity -v -p codesigning` to find your identity
// for macos signing
// Disabled for local builds - ad-hoc signing causes issues
// osxSign: {
// identity: '-', // ad-hoc signing (no Apple Developer account needed)
// optionsForFile: (filePath) => {
// return {
// entitlements: 'entitlements.plist',
// };
// },
// },
// notarize is off - requires Apple Developer account
// osxNotarize: {
// appleId: 'your apple id',
// appleIdPassword: 'app specific password',
// teamId: 'your team id',
// },
packagerConfig: {
asar: {
unpack:
"**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**",
},
rebuildConfig: {},
makers: [
{
name: '@electron-forge/maker-squirrel',
config: {
name: 'cheating-daddy',
productName: 'Cheating Daddy',
shortcutName: 'Cheating Daddy',
createDesktopShortcut: true,
createStartMenuShortcut: true,
},
extraResource: ["./src/assets/SystemAudioDump"],
name: "Mastermind",
icon: "src/assets/logo",
// use `security find-identity -v -p codesigning` to find your identity
// for macos signing
// also fuck apple
// osxSign: {
// identity: '<paste your identity here>',
// optionsForFile: (filePath) => {
// return {
// entitlements: 'entitlements.plist',
// };
// },
// },
// notarize if off cuz i ran this for 6 hours and it still didnt finish
// osxNotarize: {
// appleId: 'your apple id',
// appleIdPassword: 'app specific password',
// teamId: 'your team id',
// },
},
rebuildConfig: {
// Ensure onnxruntime-node is rebuilt against Electron's Node.js headers
// so the native binding matches the ABI used in packaged builds.
onlyModules: ["onnxruntime-node", "sharp"],
},
makers: [
{
name: "@electron-forge/maker-squirrel",
config: {
name: "mastermind",
productName: "Mastermind",
shortcutName: "Mastermind",
createDesktopShortcut: true,
createStartMenuShortcut: true,
},
},
{
name: "@electron-forge/maker-dmg",
platforms: ["darwin"],
config: {
format: "UDZO",
icon: "src/assets/logo.icns",
},
},
{
name: "@reforged/maker-appimage",
platforms: ["linux"],
config: {
options: {
name: "Mastermind",
productName: "Mastermind",
genericName: "AI Assistant",
description: "AI assistant for interviews and learning",
categories: ["Development", "Education"],
icon: "src/assets/logo.png",
},
{
name: '@electron-forge/maker-dmg',
platforms: ['darwin'],
config: {
name: 'CheatingDaddy',
format: 'ULFO',
},
},
{
name: '@reforged/maker-appimage',
platforms: ['linux'],
config: {
options: {
name: 'Cheating Daddy',
productName: 'Cheating Daddy',
genericName: 'AI Assistant',
description: 'AI assistant for interviews and learning',
categories: ['Development', 'Education'],
icon: 'src/assets/logo.png'
}
},
},
],
plugins: [
{
name: '@electron-forge/plugin-auto-unpack-natives',
config: {},
},
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
},
},
],
plugins: [
{
name: "@electron-forge/plugin-auto-unpack-natives",
config: {},
},
// Fuses are used to enable/disable various Electron functionality
// at package time, before code signing the application
new FusesPlugin({
version: FuseVersion.V1,
[FuseV1Options.RunAsNode]: false,
[FuseV1Options.EnableCookieEncryption]: true,
[FuseV1Options.EnableNodeOptionsEnvironmentVariable]: false,
[FuseV1Options.EnableNodeCliInspectArguments]: false,
[FuseV1Options.EnableEmbeddedAsarIntegrityValidation]: true,
[FuseV1Options.OnlyLoadAppFromAsar]: true,
}),
],
};
+2
View File
@@ -0,0 +1,2 @@
.build/
build/
+34
View File
@@ -0,0 +1,34 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MastermindPOC",
platforms: [
.macOS(.v14),
],
products: [
.executable(name: "MastermindPOC", targets: ["MastermindPOC"]),
.library(name: "MastermindPOCCore", targets: ["MastermindPOCCore"]),
],
targets: [
.target(name: "MastermindPOCCore"),
.executableTarget(
name: "MastermindPOC",
dependencies: ["MastermindPOCCore"],
linkerSettings: [
.linkedFramework("AppKit"),
.linkedFramework("AVFoundation"),
.linkedFramework("CoreGraphics"),
.linkedFramework("CoreMedia"),
.linkedFramework("CoreVideo"),
.linkedFramework("ScreenCaptureKit"),
.linkedFramework("SwiftUI"),
]
),
.testTarget(
name: "MastermindPOCCoreTests",
dependencies: ["MastermindPOCCore"]
),
]
)
@@ -0,0 +1,183 @@
import AppKit
import AVFoundation
import CoreGraphics
import MastermindPOCCore
final class AppDelegate: NSObject, NSApplicationDelegate {
private let settingsStore = OverlaySettingsStore()
private lazy var overlayController = OverlayWindowController(
initialSettings: settingsStore.settings,
actions: OverlayActions(
openSettings: { [weak self] in self?.showSettings() },
hideOverlay: { [weak self] in self?.hideOverlay() },
quitApp: { NSApplication.shared.terminate(nil) }
)
)
private lazy var settingsWindowController = SettingsWindowController(settingsStore: settingsStore)
private var stats = AudioPipelineStats()
private var screenFrameCount = 0
private lazy var captureCoordinator = CaptureCoordinator(
onScreenFrame: { [weak self] frameCount in
Task { @MainActor in
self?.recordScreenFrame(frameCount)
}
},
onSystemAudioFrame: { [weak self] frame in
Task { @MainActor in
self?.recordAudioFrame(frame)
}
},
onError: { [weak self] message in
Task { @MainActor in
self?.setStatus(.error(message))
}
}
)
private lazy var microphoneEngine = MicrophoneCaptureEngine(
onFrame: { [weak self] frame in
Task { @MainActor in
self?.recordAudioFrame(frame)
}
},
onError: { [weak self] message in
Task { @MainActor in
self?.setStatus(.error(message))
}
}
)
private lazy var menuBarController = MenuBarController(
actions: MenuBarController.Actions(
showOverlay: { [weak self] in self?.showOverlay() },
hideOverlay: { [weak self] in self?.hideOverlay() },
openSettings: { [weak self] in self?.showSettings() },
toggleClickThrough: { [weak self] in self?.toggleClickThrough() },
startScreenAndSystemAudio: { [weak self] in self?.startScreenAndSystemAudio() },
startMicrophone: { [weak self] in self?.startMicrophone() },
pauseAllCapture: { [weak self] in self?.pauseAllCapture() },
quit: { NSApplication.shared.terminate(nil) }
)
)
func applicationDidFinishLaunching(_ notification: Notification) {
settingsStore.onChange = { [weak self] settings in
self?.overlayController.viewModel.backgroundOpacity = settings.backgroundOpacity
}
overlayController.show()
setStatus(.idle)
refreshCounters()
}
private func showOverlay() {
overlayController.show()
menuBarController.setOverlayVisible(true)
}
private func hideOverlay() {
overlayController.hide()
menuBarController.setOverlayVisible(false)
}
private func showSettings() {
settingsWindowController.show()
}
private func toggleClickThrough() {
let enabled = overlayController.toggleClickThrough()
overlayController.viewModel.clickThroughEnabled = enabled
menuBarController.setClickThrough(enabled)
}
private func startScreenAndSystemAudio() {
Task {
do {
try await captureCoordinator.start(excludingWindowIDs: overlayController.excludedWindowIDs)
setStatus(.screenContext)
} catch {
setStatus(.error(error.localizedDescription))
}
}
}
private func startMicrophone() {
Task {
do {
try await requestMicrophoneAccess()
try microphoneEngine.start()
setStatus(.listening)
} catch {
setStatus(.error(error.localizedDescription))
}
}
}
private func pauseAllCapture() {
Task {
await captureCoordinator.stop()
microphoneEngine.stop()
setStatus(.paused)
}
}
private func recordScreenFrame(_ frameCount: Int) {
screenFrameCount = frameCount
overlayController.viewModel.screenFrames = frameCount
setStatus(.screenContext)
}
private func recordAudioFrame(_ frame: PCMFrame) {
stats.recordFrame(source: frame.source, byteCount: frame.pcmS16LE.count)
refreshCounters()
switch frame.source {
case .microphone:
setStatus(.listening)
case .system:
setStatus(.systemAudio)
}
}
private func refreshCounters() {
overlayController.viewModel.microphoneFrames = stats.microphoneFrames
overlayController.viewModel.microphoneBytes = stats.microphoneBytes
overlayController.viewModel.systemFrames = stats.systemFrames
overlayController.viewModel.systemBytes = stats.systemBytes
}
private func setStatus(_ status: TrustStatus) {
overlayController.viewModel.apply(status: status)
menuBarController.setStatus(status)
}
private func requestMicrophoneAccess() async throws {
switch AVCaptureDevice.authorizationStatus(for: .audio) {
case .authorized:
return
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .audio)
if granted {
return
}
throw POCError("Microphone permission was denied")
case .denied, .restricted:
throw POCError("Microphone permission is not available")
@unknown default:
throw POCError("Unknown microphone permission state")
}
}
}
struct POCError: LocalizedError {
let message: String
init(_ message: String) {
self.message = message
}
var errorDescription: String? {
message
}
}
@@ -0,0 +1,100 @@
import CoreGraphics
import CoreMedia
import CoreVideo
import Foundation
import MastermindPOCCore
import ScreenCaptureKit
final class CaptureCoordinator: NSObject, SCStreamOutput, SCStreamDelegate {
private let outputQueue = DispatchQueue(label: "app.mastermind.poc.screencapture")
private let onScreenFrame: (Int) -> Void
private let onSystemAudioFrame: (PCMFrame) -> Void
private let onError: (String) -> Void
private var stream: SCStream?
private var screenFrameCount = 0
init(
onScreenFrame: @escaping (Int) -> Void,
onSystemAudioFrame: @escaping (PCMFrame) -> Void,
onError: @escaping (String) -> Void
) {
self.onScreenFrame = onScreenFrame
self.onSystemAudioFrame = onSystemAudioFrame
self.onError = onError
super.init()
}
func start(excludingWindowIDs: Set<CGWindowID>) async throws {
await stop()
let content = try await SCShareableContent.current
guard let display = content.displays.first(where: { $0.displayID == CGMainDisplayID() }) ?? content.displays.first else {
throw POCError("No capturable display found")
}
let currentPID = ProcessInfo.processInfo.processIdentifier
let excludedWindows = content.windows.filter { window in
excludingWindowIDs.contains(window.windowID) || window.owningApplication?.processID == currentPID
}
let filter = SCContentFilter(display: display, excludingWindows: excludedWindows)
let configuration = SCStreamConfiguration()
configuration.width = max(1, display.width)
configuration.height = max(1, display.height)
configuration.minimumFrameInterval = CMTime(value: 1, timescale: 2)
configuration.pixelFormat = kCVPixelFormatType_32BGRA
configuration.queueDepth = 3
configuration.capturesAudio = true
configuration.sampleRate = 16_000
configuration.channelCount = 1
configuration.excludesCurrentProcessAudio = true
let stream = SCStream(filter: filter, configuration: configuration, delegate: self)
try stream.addStreamOutput(self, type: .screen, sampleHandlerQueue: outputQueue)
try stream.addStreamOutput(self, type: .audio, sampleHandlerQueue: outputQueue)
try await stream.startCapture()
screenFrameCount = 0
self.stream = stream
}
func stop() async {
guard let stream else {
return
}
do {
try await stream.stopCapture()
} catch {
onError("Screen capture stop failed: \(error.localizedDescription)")
}
self.stream = nil
}
func stream(_ stream: SCStream, didOutputSampleBuffer sampleBuffer: CMSampleBuffer, of type: SCStreamOutputType) {
guard CMSampleBufferIsValid(sampleBuffer) else {
return
}
switch type {
case .screen:
screenFrameCount += 1
onScreenFrame(screenFrameCount)
case .audio:
if let frame = SampleBufferPCMExtractor.extractFrame(from: sampleBuffer, source: .system) {
onSystemAudioFrame(frame)
}
case .microphone:
return
@unknown default:
return
}
}
func stream(_ stream: SCStream, didStopWithError error: Error) {
onError("Screen capture stopped: \(error.localizedDescription)")
}
}
@@ -0,0 +1,121 @@
import AppKit
import MastermindPOCCore
final class MenuBarController: NSObject {
struct Actions {
let showOverlay: () -> Void
let hideOverlay: () -> Void
let openSettings: () -> Void
let toggleClickThrough: () -> Void
let startScreenAndSystemAudio: () -> Void
let startMicrophone: () -> Void
let pauseAllCapture: () -> Void
let quit: () -> Void
}
private let actions: Actions
private let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let statusMenuItem = NSMenuItem(title: "Mastermind: Idle", action: nil, keyEquivalent: "")
private let showItem = NSMenuItem(title: "Show Assistant", action: #selector(showOverlay), keyEquivalent: "")
private let hideItem = NSMenuItem(title: "Hide Assistant", action: #selector(hideOverlay), keyEquivalent: "")
private let clickThroughItem = NSMenuItem(title: "Enable Click-Through", action: #selector(toggleClickThrough), keyEquivalent: "")
init(actions: Actions) {
self.actions = actions
super.init()
configureMenu()
setStatus(.idle)
}
func setStatus(_ status: TrustStatus) {
let title = status.menuBarTitle
statusMenuItem.title = title
statusItem.button?.title = title
}
func setOverlayVisible(_ visible: Bool) {
showItem.isEnabled = !visible
hideItem.isEnabled = visible
}
func setClickThrough(_ enabled: Bool) {
clickThroughItem.title = enabled ? "Disable Click-Through" : "Enable Click-Through"
clickThroughItem.state = enabled ? .on : .off
}
private func configureMenu() {
statusItem.button?.title = "Mastermind: Idle"
statusItem.button?.toolTip = "Mastermind AI helper status"
let menu = NSMenu()
statusMenuItem.isEnabled = false
menu.addItem(statusMenuItem)
menu.addItem(.separator())
showItem.target = self
hideItem.target = self
clickThroughItem.target = self
menu.addItem(showItem)
menu.addItem(hideItem)
menu.addItem(clickThroughItem)
menu.addItem(.separator())
let settingsItem = NSMenuItem(title: "Settings...", action: #selector(openSettings), keyEquivalent: ",")
settingsItem.target = self
menu.addItem(settingsItem)
menu.addItem(.separator())
let screenItem = NSMenuItem(title: "Start Screen + System Audio", action: #selector(startScreenAndSystemAudio), keyEquivalent: "")
screenItem.target = self
menu.addItem(screenItem)
let microphoneItem = NSMenuItem(title: "Start Microphone", action: #selector(startMicrophone), keyEquivalent: "")
microphoneItem.target = self
menu.addItem(microphoneItem)
let pauseItem = NSMenuItem(title: "Pause All Capture", action: #selector(pauseAllCapture), keyEquivalent: "")
pauseItem.target = self
menu.addItem(pauseItem)
menu.addItem(.separator())
let quitItem = NSMenuItem(title: "Quit Mastermind POC", action: #selector(quit), keyEquivalent: "q")
quitItem.target = self
menu.addItem(quitItem)
statusItem.menu = menu
setOverlayVisible(true)
setClickThrough(false)
}
@objc private func showOverlay() {
actions.showOverlay()
}
@objc private func hideOverlay() {
actions.hideOverlay()
}
@objc private func openSettings() {
actions.openSettings()
}
@objc private func toggleClickThrough() {
actions.toggleClickThrough()
}
@objc private func startScreenAndSystemAudio() {
actions.startScreenAndSystemAudio()
}
@objc private func startMicrophone() {
actions.startMicrophone()
}
@objc private func pauseAllCapture() {
actions.pauseAllCapture()
}
@objc private func quit() {
actions.quit()
}
}
@@ -0,0 +1,54 @@
import AVFoundation
import Foundation
import MastermindPOCCore
final class MicrophoneCaptureEngine {
private let engine = AVAudioEngine()
private let onFrame: (PCMFrame) -> Void
private let onError: (String) -> Void
private var isRunning = false
init(onFrame: @escaping (PCMFrame) -> Void, onError: @escaping (String) -> Void) {
self.onFrame = onFrame
self.onError = onError
}
func start() throws {
guard !isRunning else {
return
}
let inputNode = engine.inputNode
let format = inputNode.outputFormat(forBus: 0)
inputNode.removeTap(onBus: 0)
inputNode.installTap(onBus: 0, bufferSize: 1024, format: format) { [weak self] buffer, _ in
guard let self else {
return
}
if let frame = MicrophonePCMConverter.extractFrame(from: buffer) {
self.onFrame(frame)
}
}
do {
try engine.start()
isRunning = true
} catch {
inputNode.removeTap(onBus: 0)
onError("Microphone start failed: \(error.localizedDescription)")
throw error
}
}
func stop() {
guard isRunning else {
return
}
engine.inputNode.removeTap(onBus: 0)
engine.stop()
isRunning = false
}
}
@@ -0,0 +1,57 @@
import AVFoundation
import Foundation
import MastermindPOCCore
enum MicrophonePCMConverter {
static func extractFrame(from buffer: AVAudioPCMBuffer) -> PCMFrame? {
let frameCount = Int(buffer.frameLength)
guard frameCount > 0 else {
return nil
}
let channelCount = max(1, Int(buffer.format.channelCount))
let sourceRate = Int(buffer.format.sampleRate.rounded())
let samples: [Float]
if let floatData = buffer.floatChannelData {
samples = mixFloatChannels(floatData, channelCount: channelCount, frameCount: frameCount)
} else if let int16Data = buffer.int16ChannelData {
samples = mixInt16Channels(int16Data, channelCount: channelCount, frameCount: frameCount)
} else {
return nil
}
let mono16k = PCM16LE.resampleLinear(samples: samples, sourceRate: sourceRate, targetRate: 16_000)
let data = PCM16LE.encode(samples: mono16k)
return PCMFrame(source: .microphone, sampleRate: 16_000, channels: 1, pcmS16LE: data)
}
private static func mixFloatChannels(_ channelData: UnsafePointer<UnsafeMutablePointer<Float>>, channelCount: Int, frameCount: Int) -> [Float] {
var mono = [Float](repeating: 0, count: frameCount)
let divisor = Float(channelCount)
for channel in 0..<channelCount {
let channelPointer = channelData[channel]
for frame in 0..<frameCount {
mono[frame] += channelPointer[frame] / divisor
}
}
return mono
}
private static func mixInt16Channels(_ channelData: UnsafePointer<UnsafeMutablePointer<Int16>>, channelCount: Int, frameCount: Int) -> [Float] {
var mono = [Float](repeating: 0, count: frameCount)
let divisor = Float(channelCount)
for channel in 0..<channelCount {
let channelPointer = channelData[channel]
for frame in 0..<frameCount {
mono[frame] += (Float(channelPointer[frame]) / Float(Int16.max)) / divisor
}
}
return mono
}
}
@@ -0,0 +1,33 @@
import Foundation
import MastermindPOCCore
final class OverlaySettingsStore: ObservableObject {
static let backgroundOpacityKey = "overlay.backgroundOpacity"
@Published private(set) var settings: OverlaySettings
var onChange: ((OverlaySettings) -> Void)?
private let defaults: UserDefaults
init(defaults: UserDefaults = .standard) {
self.defaults = defaults
if defaults.object(forKey: Self.backgroundOpacityKey) == nil {
settings = .default
} else {
settings = OverlaySettings(backgroundOpacity: defaults.double(forKey: Self.backgroundOpacityKey))
}
}
var backgroundOpacity: Double {
settings.backgroundOpacity
}
func updateBackgroundOpacity(_ opacity: Double) {
let nextSettings = OverlaySettings(backgroundOpacity: opacity)
settings = nextSettings
defaults.set(nextSettings.backgroundOpacity, forKey: Self.backgroundOpacityKey)
onChange?(nextSettings)
}
}
@@ -0,0 +1,164 @@
import MastermindPOCCore
import SwiftUI
final class OverlayViewModel: ObservableObject {
@Published var statusTitle = "Mastermind: Idle"
@Published var statusDetail = "Native macOS companion proof"
@Published var clickThroughEnabled = false
@Published var backgroundOpacity = OverlaySettings.default.backgroundOpacity
@Published var screenFrames = 0
@Published var microphoneFrames = 0
@Published var microphoneBytes = 0
@Published var systemFrames = 0
@Published var systemBytes = 0
func apply(status: TrustStatus) {
statusTitle = status.menuBarTitle
switch status {
case .idle:
statusDetail = "Ready. Capture is off."
case .listening:
statusDetail = "Microphone capture is active."
case .screenContext:
statusDetail = "Screen context proof is active."
case .systemAudio:
statusDetail = "System audio proof is active."
case .agentWorking:
statusDetail = "Agent work placeholder."
case .paused:
statusDetail = "All capture paused."
case .permissionNeeded(let message):
statusDetail = message
case .error(let message):
statusDetail = message
}
}
}
struct OverlayView: View {
@ObservedObject var viewModel: OverlayViewModel
let actions: OverlayActions
var body: some View {
VStack(alignment: .leading, spacing: 12) {
HudTitleBar(viewModel: viewModel, actions: actions)
VStack(alignment: .leading, spacing: 4) {
Text(viewModel.statusTitle)
.font(.system(.title3, design: .rounded, weight: .semibold))
.foregroundStyle(.white)
Text(viewModel.statusDetail)
.font(.callout)
.foregroundStyle(.white.opacity(0.78))
.lineLimit(2)
}
Divider()
.overlay(.white.opacity(0.2))
HStack(spacing: 14) {
CounterView(label: "Screen", value: viewModel.screenFrames)
CounterView(label: "Mic", value: viewModel.microphoneFrames)
CounterView(label: "System", value: viewModel.systemFrames)
}
Text("PCM bytes mic \(viewModel.microphoneBytes) | system \(viewModel.systemBytes)")
.font(.caption2.monospacedDigit())
.foregroundStyle(.white.opacity(0.62))
}
.padding(18)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(viewModel.backgroundOpacity))
.stroke(.white.opacity(0.16), lineWidth: 1)
)
}
}
private struct HudTitleBar: View {
@ObservedObject var viewModel: OverlayViewModel
let actions: OverlayActions
var body: some View {
HStack(spacing: 10) {
ZStack(alignment: .leading) {
WindowDragRegion()
HStack(spacing: 8) {
Image(systemName: "sparkles")
.font(.caption.weight(.semibold))
Text("Mastermind")
.font(.headline)
Text(viewModel.clickThroughEnabled ? "Click-through" : "Interactive")
.font(.caption)
.foregroundStyle(.white.opacity(0.66))
}
.foregroundStyle(.white)
.allowsHitTesting(false)
}
.frame(height: 28)
HStack(spacing: 6) {
HudIconButton(systemName: "gearshape", help: "Settings", action: actions.openSettings)
HudIconButton(systemName: "eye.slash", help: "Hide assistant", action: actions.hideOverlay)
HudIconButton(systemName: "xmark", help: "Quit Mastermind POC", role: .destructive, action: actions.quitApp)
}
}
}
}
private struct HudIconButton: View {
let systemName: String
let help: String
var role: ButtonRole?
let action: () -> Void
var body: some View {
Button(role: role, action: action) {
Image(systemName: systemName)
.font(.caption.weight(.semibold))
.frame(width: 24, height: 24)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.foregroundStyle(.white.opacity(0.82))
.background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 6, style: .continuous))
.help(help)
}
}
private struct WindowDragRegion: NSViewRepresentable {
func makeNSView(context: Context) -> DragHandleView {
DragHandleView()
}
func updateNSView(_ nsView: DragHandleView, context: Context) {}
}
private final class DragHandleView: NSView {
override var mouseDownCanMoveWindow: Bool {
true
}
override func mouseDown(with event: NSEvent) {
window?.performDrag(with: event)
}
}
private struct CounterView: View {
let label: String
let value: Int
var body: some View {
VStack(alignment: .leading, spacing: 3) {
Text(label)
.font(.caption2)
.foregroundStyle(.white.opacity(0.58))
Text("\(value)")
.font(.caption.monospacedDigit().weight(.semibold))
.foregroundStyle(.white)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
@@ -0,0 +1,58 @@
import AppKit
import CoreGraphics
import MastermindPOCCore
import SwiftUI
final class OverlayWindowController {
let viewModel = OverlayViewModel()
private let panel: NSPanel
private var clickThroughEnabled = false
init(initialSettings: OverlaySettings, actions: OverlayActions) {
let screenFrame = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1280, height: 800)
let size = NSSize(width: 440, height: 210)
let origin = NSPoint(
x: screenFrame.maxX - size.width - 28,
y: screenFrame.maxY - size.height - 28
)
panel = NSPanel(
contentRect: NSRect(origin: origin, size: size),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered,
defer: false
)
panel.isReleasedWhenClosed = false
panel.isOpaque = false
panel.backgroundColor = .clear
panel.hasShadow = true
panel.level = .floating
panel.isMovableByWindowBackground = true
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]
panel.sharingType = .none
panel.title = "Mastermind POC Overlay"
viewModel.backgroundOpacity = initialSettings.backgroundOpacity
panel.contentView = NSHostingView(rootView: OverlayView(viewModel: viewModel, actions: actions))
}
var excludedWindowIDs: Set<CGWindowID> {
[CGWindowID(panel.windowNumber)]
}
func show() {
panel.orderFrontRegardless()
}
func hide() {
panel.orderOut(nil)
}
@discardableResult
func toggleClickThrough() -> Bool {
clickThroughEnabled.toggle()
panel.ignoresMouseEvents = clickThroughEnabled
return clickThroughEnabled
}
}
@@ -0,0 +1,88 @@
import AudioToolbox
import CoreMedia
import Foundation
import MastermindPOCCore
enum SampleBufferPCMExtractor {
static func extractFrame(from sampleBuffer: CMSampleBuffer, source: AudioSource) -> PCMFrame? {
guard let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer),
let streamDescription = CMAudioFormatDescriptionGetStreamBasicDescription(formatDescription)
else {
return nil
}
let asbd = streamDescription.pointee
guard asbd.mFormatID == kAudioFormatLinearPCM else {
return nil
}
var bufferListSize = 0
var blockBuffer: CMBlockBuffer?
var status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
sampleBuffer,
bufferListSizeNeededOut: &bufferListSize,
bufferListOut: nil,
bufferListSize: 0,
blockBufferAllocator: kCFAllocatorDefault,
blockBufferMemoryAllocator: kCFAllocatorDefault,
flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
blockBufferOut: &blockBuffer
)
guard status == noErr, bufferListSize > 0 else {
return nil
}
let bufferListPointer = UnsafeMutableRawPointer.allocate(byteCount: bufferListSize, alignment: MemoryLayout<AudioBufferList>.alignment)
defer {
bufferListPointer.deallocate()
}
let audioBufferList = bufferListPointer.bindMemory(to: AudioBufferList.self, capacity: 1)
status = CMSampleBufferGetAudioBufferListWithRetainedBlockBuffer(
sampleBuffer,
bufferListSizeNeededOut: nil,
bufferListOut: audioBufferList,
bufferListSize: bufferListSize,
blockBufferAllocator: kCFAllocatorDefault,
blockBufferMemoryAllocator: kCFAllocatorDefault,
flags: kCMSampleBufferFlag_AudioBufferList_Assure16ByteAlignment,
blockBufferOut: &blockBuffer
)
guard status == noErr else {
return nil
}
let buffers = UnsafeMutableAudioBufferListPointer(audioBufferList)
guard let firstBuffer = buffers.first,
let mData = firstBuffer.mData
else {
return nil
}
let sourceRate = Int(asbd.mSampleRate.rounded())
let byteCount = Int(firstBuffer.mDataByteSize)
let rawPointer = UnsafeRawPointer(mData)
let samples: [Float]
if asbd.mBitsPerChannel == 32, asbd.mFormatFlags & kAudioFormatFlagIsFloat != 0 {
let sampleCount = byteCount / MemoryLayout<Float>.size
let pointer = rawPointer.bindMemory(to: Float.self, capacity: sampleCount)
samples = (0..<sampleCount).map { pointer[$0] }
} else if asbd.mBitsPerChannel == 16, asbd.mFormatFlags & kAudioFormatFlagIsSignedInteger != 0 {
let sampleCount = byteCount / MemoryLayout<Int16>.size
let pointer = rawPointer.bindMemory(to: Int16.self, capacity: sampleCount)
samples = (0..<sampleCount).map { index in
Float(Int16(littleEndian: pointer[index])) / Float(Int16.max)
}
} else {
return nil
}
let mono16k = PCM16LE.resampleLinear(samples: samples, sourceRate: sourceRate, targetRate: 16_000)
let data = PCM16LE.encode(samples: mono16k)
return PCMFrame(source: source, sampleRate: 16_000, channels: 1, pcmS16LE: data)
}
}
@@ -0,0 +1,64 @@
import AppKit
import MastermindPOCCore
import SwiftUI
final class SettingsWindowController {
private let panel: NSPanel
init(settingsStore: OverlaySettingsStore) {
panel = NSPanel(
contentRect: NSRect(x: 0, y: 0, width: 360, height: 160),
styleMask: [.titled, .closable, .utilityWindow],
backing: .buffered,
defer: false
)
panel.isReleasedWhenClosed = false
panel.hidesOnDeactivate = false
panel.title = "Mastermind Settings"
panel.level = .floating
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
panel.contentView = NSHostingView(rootView: SettingsView(settingsStore: settingsStore))
}
func show() {
panel.center()
NSApp.activate(ignoringOtherApps: true)
panel.makeKeyAndOrderFront(nil)
}
}
private struct SettingsView: View {
@ObservedObject var settingsStore: OverlaySettingsStore
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text("Window")
.font(.headline)
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Opacity")
Spacer()
Text("\(Int(settingsStore.backgroundOpacity * 100))%")
.font(.caption.monospacedDigit())
.foregroundStyle(.secondary)
}
Slider(
value: Binding(
get: { settingsStore.backgroundOpacity },
set: { settingsStore.updateBackgroundOpacity($0) }
),
in: OverlaySettings.minimumOpacity...OverlaySettings.maximumOpacity
)
}
Text("Changes apply to the HUD background only.")
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(20)
.frame(width: 360, height: 160)
}
}
@@ -0,0 +1,8 @@
import AppKit
let app = NSApplication.shared
let delegate = AppDelegate()
app.delegate = delegate
app.setActivationPolicy(.accessory)
app.run()
@@ -0,0 +1,24 @@
public struct AudioPipelineStats: Equatable {
public private(set) var microphoneFrames: Int
public private(set) var microphoneBytes: Int
public private(set) var systemFrames: Int
public private(set) var systemBytes: Int
public init(microphoneFrames: Int = 0, microphoneBytes: Int = 0, systemFrames: Int = 0, systemBytes: Int = 0) {
self.microphoneFrames = microphoneFrames
self.microphoneBytes = microphoneBytes
self.systemFrames = systemFrames
self.systemBytes = systemBytes
}
public mutating func recordFrame(source: AudioSource, byteCount: Int) {
switch source {
case .microphone:
microphoneFrames += 1
microphoneBytes += byteCount
case .system:
systemFrames += 1
systemBytes += byteCount
}
}
}
@@ -0,0 +1,11 @@
public struct OverlayActions {
public let openSettings: () -> Void
public let hideOverlay: () -> Void
public let quitApp: () -> Void
public init(openSettings: @escaping () -> Void, hideOverlay: @escaping () -> Void, quitApp: @escaping () -> Void) {
self.openSettings = openSettings
self.hideOverlay = hideOverlay
self.quitApp = quitApp
}
}
@@ -0,0 +1,15 @@
public struct OverlaySettings: Equatable {
public static let minimumOpacity = 0.35
public static let maximumOpacity = 0.95
public static let `default` = OverlaySettings(backgroundOpacity: 0.72)
public let backgroundOpacity: Double
public init(backgroundOpacity: Double) {
self.backgroundOpacity = Self.clamp(backgroundOpacity)
}
public static func clamp(_ opacity: Double) -> Double {
min(maximumOpacity, max(minimumOpacity, opacity))
}
}
@@ -0,0 +1,51 @@
import Foundation
public enum PCM16LE {
public static func encode(samples: [Float]) -> Data {
var data = Data()
data.reserveCapacity(samples.count * MemoryLayout<Int16>.size)
for sample in samples {
let clamped = max(-1.0, min(1.0, sample))
let scaled: Int16
if clamped >= 1.0 {
scaled = Int16.max
} else if clamped <= -1.0 {
scaled = Int16.min
} else {
scaled = Int16((clamped * Float(Int16.max)).rounded())
}
var littleEndian = scaled.littleEndian
withUnsafeBytes(of: &littleEndian) { bytes in
data.append(contentsOf: bytes)
}
}
return data
}
public static func resampleLinear(samples: [Float], sourceRate: Int, targetRate: Int = 16_000) -> [Float] {
guard sourceRate > 0, targetRate > 0, !samples.isEmpty else {
return []
}
guard sourceRate != targetRate else {
return samples
}
let ratio = Double(sourceRate) / Double(targetRate)
let outputCount = max(1, Int(Double(samples.count) / ratio))
return (0..<outputCount).map { index in
let sourcePosition = Double(index) * ratio
let lowerIndex = Int(sourcePosition)
let upperIndex = min(lowerIndex + 1, samples.count - 1)
let fraction = Float(sourcePosition - Double(lowerIndex))
let lower = samples[min(lowerIndex, samples.count - 1)]
let upper = samples[upperIndex]
return lower + ((upper - lower) * fraction)
}
}
}
@@ -0,0 +1,22 @@
import Foundation
public enum AudioSource: String, Equatable {
case microphone
case system
}
public struct PCMFrame: Equatable {
public let source: AudioSource
public let sampleRate: Int
public let channels: Int
public let pcmS16LE: Data
public let timestamp: Date
public init(source: AudioSource, sampleRate: Int, channels: Int, pcmS16LE: Data, timestamp: Date = Date()) {
self.source = source
self.sampleRate = sampleRate
self.channels = channels
self.pcmS16LE = pcmS16LE
self.timestamp = timestamp
}
}
@@ -0,0 +1,40 @@
public enum TrustStatus: Equatable {
case idle
case listening
case screenContext
case systemAudio
case agentWorking
case paused
case permissionNeeded(String)
case error(String)
public var menuBarTitle: String {
switch self {
case .idle:
return "Mastermind: Idle"
case .listening:
return "Mastermind: Listening"
case .screenContext:
return "Mastermind: Screen"
case .systemAudio:
return "Mastermind: System Audio"
case .agentWorking:
return "Mastermind: Working"
case .paused:
return "Mastermind: Paused"
case .permissionNeeded:
return "Mastermind: Permission"
case .error:
return "Mastermind: Error"
}
}
public var isCapturing: Bool {
switch self {
case .listening, .screenContext, .systemAudio:
return true
case .idle, .agentWorking, .paused, .permissionNeeded, .error:
return false
}
}
}
@@ -0,0 +1,51 @@
import XCTest
@testable import MastermindPOCCore
final class AudioModelTests: XCTestCase {
func testPCMFrameStoresSidecarCompatibleAudioMetadata() {
let data = Data([0x00, 0x00, 0xff, 0x7f])
let timestamp = Date(timeIntervalSince1970: 42)
let frame = PCMFrame(
source: .microphone,
sampleRate: 16_000,
channels: 1,
pcmS16LE: data,
timestamp: timestamp
)
XCTAssertEqual(frame.source, .microphone)
XCTAssertEqual(frame.sampleRate, 16_000)
XCTAssertEqual(frame.channels, 1)
XCTAssertEqual(frame.pcmS16LE, data)
XCTAssertEqual(frame.timestamp, timestamp)
}
func testPCM16LEClampsAndEncodesLittleEndianSamples() {
let encoded = PCM16LE.encode(samples: [-2.0, -1.0, 0.0, 0.5, 2.0])
XCTAssertEqual(
Array(encoded),
[
0x00, 0x80,
0x00, 0x80,
0x00, 0x00,
0x00, 0x40,
0xff, 0x7f,
]
)
}
func testAudioStatsCountMicrophoneAndSystemFramesSeparately() {
var stats = AudioPipelineStats()
stats.recordFrame(source: .microphone, byteCount: 320)
stats.recordFrame(source: .system, byteCount: 640)
stats.recordFrame(source: .microphone, byteCount: 160)
XCTAssertEqual(stats.microphoneFrames, 2)
XCTAssertEqual(stats.microphoneBytes, 480)
XCTAssertEqual(stats.systemFrames, 1)
XCTAssertEqual(stats.systemBytes, 640)
}
}
@@ -0,0 +1,24 @@
import XCTest
@testable import MastermindPOCCore
final class OverlayActionsTests: XCTestCase {
func testOverlayActionsInvokeInjectedCallbacks() {
var openedSettings = false
var hidOverlay = false
var quitApp = false
let actions = OverlayActions(
openSettings: { openedSettings = true },
hideOverlay: { hidOverlay = true },
quitApp: { quitApp = true }
)
actions.openSettings()
actions.hideOverlay()
actions.quitApp()
XCTAssertTrue(openedSettings)
XCTAssertTrue(hidOverlay)
XCTAssertTrue(quitApp)
}
}
@@ -0,0 +1,26 @@
import XCTest
@testable import MastermindPOCCore
final class OverlaySettingsTests: XCTestCase {
func testDefaultOpacityIsReadableHudDefault() {
XCTAssertEqual(OverlaySettings.default.backgroundOpacity, 0.72, accuracy: 0.0001)
}
func testOpacityBelowMinimumClampsToMinimum() {
let settings = OverlaySettings(backgroundOpacity: 0.1)
XCTAssertEqual(settings.backgroundOpacity, 0.35, accuracy: 0.0001)
}
func testOpacityAboveMaximumClampsToMaximum() {
let settings = OverlaySettings(backgroundOpacity: 1.0)
XCTAssertEqual(settings.backgroundOpacity, 0.95, accuracy: 0.0001)
}
func testValidOpacityStaysUnchanged() {
let settings = OverlaySettings(backgroundOpacity: 0.64)
XCTAssertEqual(settings.backgroundOpacity, 0.64, accuracy: 0.0001)
}
}
@@ -0,0 +1,21 @@
import XCTest
@testable import MastermindPOCCore
final class TrustStatusTests: XCTestCase {
func testMenuBarTitlesDescribeVisibleAssistantState() {
XCTAssertEqual(TrustStatus.idle.menuBarTitle, "Mastermind: Idle")
XCTAssertEqual(TrustStatus.listening.menuBarTitle, "Mastermind: Listening")
XCTAssertEqual(TrustStatus.screenContext.menuBarTitle, "Mastermind: Screen")
XCTAssertEqual(TrustStatus.systemAudio.menuBarTitle, "Mastermind: System Audio")
XCTAssertEqual(TrustStatus.paused.menuBarTitle, "Mastermind: Paused")
XCTAssertEqual(TrustStatus.error("No permission").menuBarTitle, "Mastermind: Error")
}
func testCaptureStatesIdentifyActiveCapture() {
XCTAssertFalse(TrustStatus.idle.isCapturing)
XCTAssertFalse(TrustStatus.paused.isCapturing)
XCTAssertTrue(TrustStatus.listening.isCapturing)
XCTAssertTrue(TrustStatus.screenContext.isCapturing)
XCTAssertTrue(TrustStatus.systemAudio.isCapturing)
}
}
+58
View File
@@ -0,0 +1,58 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
APP_NAME="MastermindPOC"
BUILD_DIR="$ROOT_DIR/build"
APP_DIR="$BUILD_DIR/$APP_NAME.app"
CONTENTS_DIR="$APP_DIR/Contents"
MACOS_DIR="$CONTENTS_DIR/MacOS"
export CLANG_MODULE_CACHE_PATH="$ROOT_DIR/.build/module-cache"
cd "$ROOT_DIR"
swift build --product "$APP_NAME"
rm -rf "$APP_DIR"
mkdir -p "$MACOS_DIR"
cp "$ROOT_DIR/.build/debug/$APP_NAME" "$MACOS_DIR/$APP_NAME"
chmod +x "$MACOS_DIR/$APP_NAME"
cat > "$CONTENTS_DIR/Info.plist" <<'PLIST'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleDisplayName</key>
<string>Mastermind POC</string>
<key>CFBundleExecutable</key>
<string>MastermindPOC</string>
<key>CFBundleIdentifier</key>
<string>app.mastermind.poc</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Mastermind POC</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSMinimumSystemVersion</key>
<string>14.0</string>
<key>LSUIElement</key>
<true/>
<key>NSMicrophoneUsageDescription</key>
<string>Mastermind POC uses microphone audio only when you start microphone capture.</string>
</dict>
</plist>
PLIST
printf 'APPL????' > "$CONTENTS_DIR/PkgInfo"
echo "Built $APP_DIR"
+55 -43
View File
@@ -1,46 +1,58 @@
{
"name": "cheating-daddy",
"productName": "cheating-daddy",
"version": "0.5.11",
"description": "cheating daddy",
"main": "src/index.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "echo \"No linting configured\""
},
"keywords": [
"cheating daddy",
"cheating daddy ai",
"cheating daddy ai assistant",
"cheating daddy ai assistant for interviews",
"cheating daddy ai assistant for interviews"
],
"author": {
"name": "ShiftyX1",
"email": "lead@pyserve.org"
},
"license": "GPL-3.0",
"dependencies": {
"@google/genai": "^1.35.0",
"electron-squirrel-startup": "^1.0.1",
"openai": "^6.16.0",
"ws": "^8.18.0"
},
"devDependencies": {
"@electron-forge/cli": "^7.11.1",
"@electron-forge/maker-deb": "^7.11.1",
"@electron-forge/maker-dmg": "^7.11.1",
"@electron-forge/maker-rpm": "^7.11.1",
"@electron-forge/maker-squirrel": "^7.11.1",
"@electron-forge/maker-zip": "^7.11.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
"@electron-forge/plugin-fuses": "^7.11.1",
"@electron/fuses": "^2.0.0",
"@electron/osx-sign": "^2.3.0",
"@reforged/maker-appimage": "^5.1.1",
"electron": "^39.2.7"
"name": "mastermind",
"productName": "Mastermind",
"version": "0.7.9",
"description": "Mastermind AI assistant",
"main": "src/index.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"lint": "echo \"No linting configured\"",
"test:local-providers": "node --test test/localProviders.test.js",
"mock:nemotron-sidecar": "node scripts/mock-nemotron-sidecar.js",
"postinstall": "electron-rebuild -f -w onnxruntime-node"
},
"keywords": [
"mastermind",
"mastermind ai",
"mastermind ai assistant",
"mastermind ai assistant for interviews",
"mastermind ai assistant for interviews"
],
"author": {
"name": "ShiftyX1",
"email": "lead@pyserve.org"
},
"license": "GPL-3.0",
"dependencies": {
"@google/genai": "^1.41.0",
"@huggingface/transformers": "^3.8.1",
"electron-squirrel-startup": "^1.0.1",
"ollama": "^0.6.3",
"openai": "^6.22.0",
"p-retry": "^4.6.2",
"ws": "^8.19.0"
},
"devDependencies": {
"@electron-forge/cli": "^7.8.1",
"@electron-forge/maker-deb": "^7.8.1",
"@electron-forge/maker-dmg": "^7.8.1",
"@electron-forge/maker-rpm": "^7.8.1",
"@electron-forge/maker-squirrel": "^7.8.1",
"@electron-forge/maker-zip": "^7.8.1",
"@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
"@electron-forge/plugin-fuses": "^7.8.1",
"@electron/fuses": "^1.8.0",
"@electron/rebuild": "^3.7.1",
"@reforged/maker-appimage": "^5.0.0",
"electron": "^30.0.5",
"electron-icon-builder": "^2.0.1"
},
"pnpm": {
"overrides": {
"p-retry": "4.6.2"
}
}
}
+2327 -187
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
packages:
- '.'
onlyBuiltDependencies:
- electron
- electron-winstaller
- fs-xattr
- macos-alias
- onnxruntime-node
- protobufjs
- sharp
+66
View File
@@ -0,0 +1,66 @@
#!/usr/bin/env node
const WebSocket = require("ws");
const port = Number(process.env.MOCK_NEMOTRON_PORT || 8765);
const transcript =
process.env.MOCK_NEMOTRON_TRANSCRIPT ||
"hello from the mock nemotron sidecar";
const server = new WebSocket.Server({ host: "127.0.0.1", port });
server.on("connection", (socket) => {
let binaryChunks = 0;
let finalSent = false;
socket.on("message", (data, isBinary) => {
if (!isBinary) {
let message = null;
try {
message = JSON.parse(data.toString("utf8"));
} catch (_) {
socket.send(JSON.stringify({ type: "error", error: "Invalid JSON" }));
return;
}
if (message.type === "start") {
socket.send(JSON.stringify({ type: "ready" }));
}
return;
}
binaryChunks += 1;
if (binaryChunks === 1) {
socket.send(
JSON.stringify({
type: "partial",
text: transcript.split(" ").slice(0, 3).join(" "),
}),
);
}
if (!finalSent && binaryChunks >= 5) {
finalSent = true;
socket.send(JSON.stringify({ type: "final", text: transcript }));
}
});
});
server.on("listening", () => {
console.log(
`Mock Nemotron sidecar listening on ws://127.0.0.1:${port}/v1/asr/stream`,
);
});
server.on("error", (error) => {
console.error("Mock Nemotron sidecar error:", error);
process.exitCode = 1;
});
function shutdown() {
server.close(() => process.exit(0));
}
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 190 KiB

+13 -209
View File
@@ -3,7 +3,7 @@ import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
export class AppHeader extends LitElement {
static styles = css`
* {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: var(--font);
cursor: default;
user-select: none;
}
@@ -14,14 +14,14 @@ export class AppHeader extends LitElement {
align-items: center;
padding: var(--header-padding);
background: var(--header-background);
border-bottom: 1px solid var(--border-color);
border-bottom: 1px solid var(--border);
}
.header-title {
flex: 1;
font-size: var(--header-font-size);
font-weight: 500;
color: var(--text-color);
color: var(--text-primary);
-webkit-app-region: drag;
}
@@ -39,8 +39,8 @@ export class AppHeader extends LitElement {
.button {
background: transparent;
color: var(--text-color);
border: 1px solid var(--border-color);
color: var(--text-primary);
border: 1px solid var(--border);
padding: var(--header-button-padding);
border-radius: 3px;
font-size: var(--header-font-size-small);
@@ -73,7 +73,7 @@ export class AppHeader extends LitElement {
.icon-button:hover {
background: var(--hover-background);
color: var(--text-color);
color: var(--text-primary);
}
:host([isclickthrough]) .button:hover,
@@ -86,7 +86,7 @@ export class AppHeader extends LitElement {
padding: 2px 6px;
border-radius: 3px;
font-size: 11px;
font-family: 'SF Mono', Monaco, monospace;
font-family: var(--font-mono);
}
.click-through-indicator {
@@ -95,7 +95,7 @@ export class AppHeader extends LitElement {
background: var(--key-background);
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
font-family: var(--font-mono);
}
.update-button {
@@ -120,148 +120,6 @@ export class AppHeader extends LitElement {
.update-button:hover {
background: rgba(241, 76, 76, 0.1);
}
.status-wrapper {
position: relative;
display: inline-flex;
align-items: center;
}
.status-text {
font-size: var(--header-font-size-small);
color: var(--text-secondary);
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-text.error {
color: #f14c4c;
}
.status-tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--tooltip-bg, #1a1a1a);
color: var(--tooltip-text, #ffffff);
padding: 10px 14px;
border-radius: 6px;
font-size: 12px;
max-width: 300px;
word-wrap: break-word;
white-space: normal;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
line-height: 1.4;
}
.status-tooltip::before {
content: '';
position: absolute;
bottom: 100%;
right: 16px;
border: 6px solid transparent;
border-bottom-color: var(--tooltip-bg, #1a1a1a);
}
.status-wrapper:hover .status-tooltip {
opacity: 1;
visibility: visible;
}
.status-tooltip .tooltip-label {
font-size: 10px;
text-transform: uppercase;
opacity: 0.6;
margin-bottom: 4px;
}
.status-tooltip .tooltip-content {
color: #f14c4c;
}
.model-info {
display: flex;
gap: 6px;
align-items: center;
}
.model-badge {
font-size: 10px;
color: var(--text-muted);
background: var(--key-background);
padding: 2px 6px;
border-radius: 3px;
font-family: 'SF Mono', Monaco, monospace;
max-width: 100px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.model-badge-wrapper {
position: relative;
display: inline-flex;
}
.model-badge-wrapper .model-tooltip {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
background: var(--tooltip-bg, #1a1a1a);
color: var(--tooltip-text, #ffffff);
padding: 10px 14px;
border-radius: 6px;
font-size: 12px;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: opacity 0.15s ease, visibility 0.15s ease;
pointer-events: none;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 1000;
}
.model-badge-wrapper .model-tooltip::before {
content: '';
position: absolute;
bottom: 100%;
right: 16px;
border: 6px solid transparent;
border-bottom-color: var(--tooltip-bg, #1a1a1a);
}
.model-badge-wrapper:hover .model-tooltip {
opacity: 1;
visibility: visible;
}
.model-tooltip-row {
display: flex;
justify-content: space-between;
gap: 16px;
margin-bottom: 4px;
}
.model-tooltip-row:last-child {
margin-bottom: 0;
}
.model-tooltip-label {
opacity: 0.7;
}
.model-tooltip-value {
font-family: 'SF Mono', Monaco, monospace;
}
`;
static properties = {
@@ -276,8 +134,6 @@ export class AppHeader extends LitElement {
onHideToggleClick: { type: Function },
isClickThrough: { type: Boolean, reflect: true },
updateAvailable: { type: Boolean },
aiProvider: { type: String },
modelInfo: { type: Object },
};
constructor() {
@@ -294,8 +150,6 @@ export class AppHeader extends LitElement {
this.isClickThrough = false;
this.updateAvailable = false;
this._timerInterval = null;
this.aiProvider = 'gemini';
this.modelInfo = { model: '', visionModel: '', whisperModel: '' };
}
connectedCallback() {
@@ -388,15 +242,15 @@ export class AppHeader extends LitElement {
getViewTitle() {
const titles = {
onboarding: 'Welcome to Cheating Daddy',
main: 'Cheating Daddy',
onboarding: 'Welcome to Mastermind',
main: 'Mastermind',
customize: 'Customize',
help: 'Help & Shortcuts',
history: 'Conversation History',
advanced: 'Advanced Tools',
assistant: 'Cheating Daddy',
assistant: 'Mastermind',
};
return titles[this.currentView] || 'Cheating Daddy';
return titles[this.currentView] || 'Mastermind';
}
getElapsedTime() {
@@ -417,49 +271,8 @@ export class AppHeader extends LitElement {
return navigationViews.includes(this.currentView);
}
getProviderDisplayName() {
const names = {
'gemini': 'Gemini',
'openai-realtime': 'OpenAI Realtime',
'openai-sdk': 'OpenAI SDK'
};
return names[this.aiProvider] || this.aiProvider;
}
renderModelInfo() {
// Only show model info for OpenAI SDK provider
if (this.aiProvider !== 'openai-sdk' || !this.modelInfo) {
return '';
}
const { model, visionModel, whisperModel } = this.modelInfo;
// Show a compact badge with tooltip for model details
return html`
<div class="model-badge-wrapper">
<span class="model-badge" title="Models">${model || 'gpt-4o'}</span>
<div class="model-tooltip">
<div class="model-tooltip-row">
<span class="model-tooltip-label">Text</span>
<span class="model-tooltip-value">${model || 'gpt-4o'}</span>
</div>
<div class="model-tooltip-row">
<span class="model-tooltip-label">Vision</span>
<span class="model-tooltip-value">${visionModel || 'gpt-4o'}</span>
</div>
<div class="model-tooltip-row">
<span class="model-tooltip-label">Speech</span>
<span class="model-tooltip-value">${whisperModel || 'whisper-1'}</span>
</div>
</div>
</div>
`;
}
render() {
const elapsedTime = this.getElapsedTime();
const isError = this.statusText && (this.statusText.toLowerCase().includes('error') || this.statusText.toLowerCase().includes('failed'));
const shortStatus = isError ? 'Error' : this.statusText;
return html`
<div class="header">
@@ -467,17 +280,8 @@ export class AppHeader extends LitElement {
<div class="header-actions">
${this.currentView === 'assistant'
? html`
${this.renderModelInfo()}
<span>${elapsedTime}</span>
<div class="status-wrapper">
<span class="status-text ${isError ? 'error' : ''}">${shortStatus}</span>
${isError ? html`
<div class="status-tooltip">
<div class="tooltip-label">Error Details</div>
<div class="tooltip-content">${this.statusText}</div>
</div>
` : ''}
</div>
<span>${this.statusText}</span>
${this.isClickThrough ? html`<span class="click-through-indicator">click-through</span>` : ''}
`
: ''}
File diff suppressed because it is too large Load Diff
+160
View File
@@ -0,0 +1,160 @@
import { html, css, LitElement } from "../../assets/lit-core-2.7.4.min.js";
import { unifiedPageStyles } from "./sharedPageStyles.js";
export class AICustomizeView extends LitElement {
static styles = [
unifiedPageStyles,
css`
.unified-page {
height: 100%;
}
.unified-wrap {
height: 100%;
}
section.surface {
flex: 1;
display: flex;
flex-direction: column;
}
.form-grid {
flex: 1;
display: flex;
flex-direction: column;
}
.form-group.vertical {
flex: 1;
display: flex;
flex-direction: column;
}
textarea.control {
flex: 1;
resize: none;
overflow-y: auto;
min-height: 0;
}
`,
];
static properties = {
selectedProfile: { type: String },
onProfileChange: { type: Function },
_context: { state: true },
_providerMode: { state: true },
};
constructor() {
super();
this.selectedProfile = "interview";
this.onProfileChange = () => {};
this._context = "";
this._providerMode = "local";
this._loadFromStorage();
}
async _loadFromStorage() {
try {
const prefs = await cheatingDaddy.storage.getPreferences();
this._context = prefs.customPrompt || "";
this._providerMode = prefs.providerMode || "local";
this.requestUpdate();
} catch (error) {
console.error("Error loading AI customize storage:", error);
}
}
_handleProfileChange(e) {
this.onProfileChange(e.target.value);
}
async _handleProviderModeChange(e) {
this._providerMode = e.target.value;
await cheatingDaddy.storage.updatePreference(
"providerMode",
this._providerMode,
);
this.requestUpdate();
}
async _saveContext(val) {
this._context = val;
await cheatingDaddy.storage.updatePreference("customPrompt", val);
}
_getProfileName(profile) {
const names = {
interview: "Job Interview",
sales: "Sales Call",
meeting: "Business Meeting",
presentation: "Presentation",
negotiation: "Negotiation",
exam: "Exam Assistant",
};
return names[profile] || profile;
}
render() {
const profiles = [
{ value: "interview", label: "Job Interview" },
{ value: "sales", label: "Sales Call" },
{ value: "meeting", label: "Business Meeting" },
{ value: "presentation", label: "Presentation" },
{ value: "negotiation", label: "Negotiation" },
{ value: "exam", label: "Exam Assistant" },
];
return html`
<div class="unified-page">
<div class="unified-wrap">
<div>
<div class="page-title">AI Context</div>
</div>
<section class="surface">
<div class="form-grid">
<div class="form-group">
<label class="form-label">Regime</label>
<select
class="control"
.value=${this._providerMode}
@change=${this._handleProviderModeChange}
>
<option value="byok">BYOK (API Keys)</option>
<option value="local">Local AI (LM Studio)</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Profile</label>
<select
class="control"
.value=${this.selectedProfile}
@change=${this._handleProfileChange}
>
${profiles.map(
(profile) =>
html`<option value=${profile.value}>
${profile.label}
</option>`,
)}
</select>
</div>
<div class="form-group vertical">
<label class="form-label">Custom Instructions</label>
<textarea
class="control"
placeholder="Resume details, role requirements, constraints..."
.value=${this._context}
@input=${(e) => this._saveContext(e.target.value)}
></textarea>
<div class="form-help">
Sent as context at session start. Keep it short.
</div>
</div>
</div>
</section>
</div>
</div>
`;
}
}
customElements.define("ai-customize-view", AICustomizeView);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
import { unifiedPageStyles } from './sharedPageStyles.js';
export class FeedbackView extends LitElement {
static styles = [
unifiedPageStyles,
css`
.feedback-form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.feedback-input {
width: 100%;
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--text-primary);
font-size: var(--font-size-sm);
font-family: var(--font);
}
.feedback-input:focus {
outline: none;
border-color: var(--accent);
}
.feedback-input::placeholder {
color: var(--text-muted);
}
textarea.feedback-input {
min-height: 140px;
resize: vertical;
line-height: 1.45;
}
input.feedback-input {
max-width: 260px;
}
.feedback-row {
display: flex;
align-items: center;
gap: var(--space-sm);
}
.feedback-submit {
padding: var(--space-sm) var(--space-md);
border: none;
border-radius: var(--radius-sm);
background: var(--accent);
color: var(--btn-primary-text, #fff);
font-size: var(--font-size-sm);
font-weight: var(--font-weight-medium);
cursor: pointer;
transition: opacity var(--transition);
white-space: nowrap;
}
.feedback-submit:hover {
opacity: 0.85;
}
.feedback-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.feedback-status {
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.feedback-status.success {
color: var(--success);
}
.feedback-status.error {
color: var(--danger);
}
.attach-info {
display: flex;
align-items: center;
gap: var(--space-xs);
font-size: var(--font-size-xs);
color: var(--text-muted);
cursor: pointer;
user-select: none;
}
.attach-info input[type="checkbox"] {
cursor: pointer;
accent-color: var(--accent);
}
`,
];
static properties = {
_feedbackText: { state: true },
_feedbackEmail: { state: true },
_feedbackStatus: { state: true },
_feedbackSending: { state: true },
_attachInfo: { state: true },
_version: { state: true },
};
constructor() {
super();
this._feedbackText = '';
this._feedbackEmail = '';
this._feedbackStatus = '';
this._feedbackSending = false;
this._attachInfo = true;
this._version = '';
this._loadVersion();
}
async _loadVersion() {
try {
this._version = await cheatingDaddy.getVersion();
this.requestUpdate();
} catch (e) {}
}
_getOS() {
const p = navigator.platform || '';
if (p.includes('Mac')) return 'macOS';
if (p.includes('Win')) return 'Windows';
if (p.includes('Linux')) return 'Linux';
return p;
}
async _submitFeedback() {
const text = this._feedbackText.trim();
if (!text || this._feedbackSending) return;
let content = text;
if (this._attachInfo) {
content += `\n\nsent from ${this._getOS()} version ${this._version}`;
}
if (content.length > 2000) {
this._feedbackStatus = 'error:Max 2000 characters';
this.requestUpdate();
return;
}
this._feedbackSending = true;
this._feedbackStatus = '';
this.requestUpdate();
try {
const body = { feedback: content };
if (this._feedbackEmail.trim()) {
body.email = this._feedbackEmail.trim();
}
const res = await fetch('https://api.cheatingdaddy.com/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (res.ok) {
this._feedbackText = '';
this._feedbackEmail = '';
this._feedbackStatus = 'success:Feedback sent, thank you!';
} else if (res.status === 429) {
this._feedbackStatus = 'error:Please wait a few minutes before sending again';
} else {
this._feedbackStatus = 'error:Failed to send feedback';
}
} catch (e) {
this._feedbackStatus = 'error:Could not connect to server';
}
this._feedbackSending = false;
this.requestUpdate();
}
render() {
return html`
<div class="unified-page">
<div class="unified-wrap">
<div class="page-title">Feedback</div>
<section class="surface">
<div class="feedback-form">
<textarea
class="feedback-input"
placeholder="Bug reports, feature requests, anything..."
.value=${this._feedbackText}
@input=${e => { this._feedbackText = e.target.value; }}
maxlength="2000"
></textarea>
<input
class="feedback-input"
type="email"
placeholder="Email (optional)"
.value=${this._feedbackEmail}
@input=${e => { this._feedbackEmail = e.target.value; }}
/>
<label class="attach-info">
<input
type="checkbox"
.checked=${this._attachInfo}
@change=${e => { this._attachInfo = e.target.checked; }}
/>
Attach OS and app version
</label>
<div class="feedback-row">
<button
class="feedback-submit"
@click=${() => this._submitFeedback()}
?disabled=${!this._feedbackText.trim() || this._feedbackSending}
>
${this._feedbackSending ? 'Sending...' : 'Send Feedback'}
</button>
${this._feedbackStatus ? html`
<span class="feedback-status ${this._feedbackStatus.split(':')[0]}">
${this._feedbackStatus.split(':').slice(1).join(':')}
</span>
` : ''}
</div>
</div>
</section>
</div>
</div>
`;
}
}
customElements.define('feedback-view', FeedbackView);
+113 -417
View File
@@ -1,229 +1,95 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
import { resizeLayout } from '../../utils/windowResize.js';
import { unifiedPageStyles } from './sharedPageStyles.js';
export class HelpView extends LitElement {
static styles = css`
* {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
cursor: default;
user-select: none;
}
static styles = [
unifiedPageStyles,
css`
.shortcut-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var(--space-sm);
}
:host {
display: block;
padding: 0;
}
.shortcut-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-sm);
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--bg-elevated);
}
.help-container {
display: flex;
flex-direction: column;
}
.shortcut-label {
color: var(--text-secondary);
font-size: var(--font-size-xs);
}
.option-group {
padding: 16px 12px;
border-bottom: 1px solid var(--border-color);
}
.shortcut-keys {
display: inline-flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
.option-group:last-child {
border-bottom: none;
}
.key {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 2px 6px;
font-size: var(--font-size-xs);
color: var(--text-primary);
background: var(--bg-surface);
font-family: var(--font-mono);
}
.option-label {
font-size: 11px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-bottom: 12px;
}
.list {
display: grid;
gap: var(--space-sm);
}
.description {
color: var(--text-secondary);
font-size: 12px;
line-height: 1.4;
user-select: text;
cursor: text;
}
.list-item {
padding: var(--space-sm);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text-secondary);
font-size: var(--font-size-sm);
line-height: 1.45;
background: var(--bg-elevated);
}
.description strong {
color: var(--text-color);
font-weight: 500;
}
.link-row {
display: flex;
flex-wrap: wrap;
gap: var(--space-sm);
}
.link {
color: var(--text-color);
text-decoration: underline;
text-underline-offset: 2px;
cursor: pointer;
}
.link-button {
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 10px;
background: var(--bg-elevated);
color: var(--text-primary);
font-size: var(--font-size-sm);
cursor: pointer;
transition: border-color var(--transition), color var(--transition), background var(--transition);
}
.key {
background: var(--bg-tertiary);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 2px 6px;
border-radius: 3px;
font-size: 10px;
font-family: 'SF Mono', Monaco, monospace;
font-weight: 500;
margin: 0 1px;
white-space: nowrap;
}
.link-button:hover {
color: var(--text-primary);
border-color: var(--accent);
background: rgba(63, 125, 229, 0.14);
}
.keyboard-section {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 12px;
margin-top: 8px;
}
@media (max-width: 820px) {
.shortcut-grid {
grid-template-columns: 1fr;
}
}
.keyboard-group {
padding: 10px 0;
border-bottom: 1px solid var(--border-color);
}
.keyboard-group:last-child {
border-bottom: none;
}
.keyboard-group-title {
font-weight: 600;
font-size: 12px;
color: var(--text-color);
margin-bottom: 8px;
}
.shortcut-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
font-size: 11px;
}
.shortcut-description {
color: var(--text-secondary);
}
.shortcut-keys {
display: flex;
gap: 2px;
}
.profiles-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 8px;
margin-top: 8px;
}
.profile-item {
padding: 8px 0;
border-bottom: 1px solid var(--border-color);
}
.profile-item:last-child {
border-bottom: none;
}
.profile-name {
font-weight: 500;
font-size: 12px;
color: var(--text-color);
margin-bottom: 2px;
}
.profile-description {
font-size: 11px;
color: var(--text-muted);
line-height: 1.3;
}
.community-links {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.community-link {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 3px;
color: var(--text-color);
font-size: 11px;
font-weight: 500;
transition: background 0.1s ease;
cursor: pointer;
}
.community-link:hover {
background: var(--hover-background);
}
.community-link svg {
width: 14px;
height: 14px;
flex-shrink: 0;
}
.open-logs-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
background: var(--bg-tertiary);
border: 1px solid var(--border-color);
border-radius: 4px;
color: var(--text-color);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: background 0.15s ease;
}
.open-logs-btn:hover {
background: var(--hover-background);
}
.usage-steps {
counter-reset: step-counter;
}
.usage-step {
counter-increment: step-counter;
position: relative;
padding-left: 24px;
margin-bottom: 8px;
font-size: 11px;
line-height: 1.4;
color: var(--text-secondary);
}
.usage-step::before {
content: counter(step-counter);
position: absolute;
left: 0;
top: 0;
width: 16px;
height: 16px;
background: var(--bg-tertiary);
color: var(--text-color);
border-radius: 3px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 600;
}
.usage-step strong {
color: var(--text-color);
}
`;
`,
];
static properties = {
onExternalLinkClick: { type: Function },
@@ -249,12 +115,6 @@ export class HelpView extends LitElement {
}
}
connectedCallback() {
super.connectedCallback();
// Resize window for this view
resizeLayout();
}
getDefaultKeybinds() {
const isMac = cheatingDaddy.isMacOS || navigator.platform.includes('Mac');
return {
@@ -272,222 +132,58 @@ export class HelpView extends LitElement {
};
}
formatKeybind(keybind) {
_formatKeybind(keybind) {
return keybind.split('+').map(key => html`<span class="key">${key}</span>`);
}
handleExternalLinkClick(url) {
_open(url) {
this.onExternalLinkClick(url);
}
render() {
const isMacOS = cheatingDaddy.isMacOS || false;
const isLinux = cheatingDaddy.isLinux || false;
const shortcutRows = [
['Move Window Up', this.keybinds.moveUp],
['Move Window Down', this.keybinds.moveDown],
['Move Window Left', this.keybinds.moveLeft],
['Move Window Right', this.keybinds.moveRight],
['Toggle Visibility', this.keybinds.toggleVisibility],
['Toggle Click-through', this.keybinds.toggleClickThrough],
['Ask Next Step', this.keybinds.nextStep],
['Previous Response', this.keybinds.previousResponse],
['Next Response', this.keybinds.nextResponse],
['Scroll Response Up', this.keybinds.scrollUp],
['Scroll Response Down', this.keybinds.scrollDown],
];
return html`
<div class="help-container">
<div class="option-group">
<div class="option-label">
<span>Community & Support</span>
</div>
<div class="community-links">
<div class="community-link" @click=${() => this.handleExternalLinkClick('https://cheatingdaddy.com')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 11.9976C14 9.5059 11.683 7 8.85714 7C8.52241 7 7.41904 7.00001 7.14286 7.00001C4.30254 7.00001 2 9.23752 2 11.9976C2 14.376 3.70973 16.3664 6 16.8714C6.36756 16.9525 6.75006 16.9952 7.14286 16.9952"></path>
<path d="M10 11.9976C10 14.4893 12.317 16.9952 15.1429 16.9952C15.4776 16.9952 16.581 16.9952 16.8571 16.9952C19.6975 16.9952 22 14.7577 22 11.9976C22 9.6192 20.2903 7.62884 18 7.12383C17.6324 7.04278 17.2499 6.99999 16.8571 6.99999"></path>
</svg>
Website
</div>
<div class="community-link" @click=${() => this.handleExternalLinkClick('https://github.com/sohzm/cheating-daddy')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M16 22.0268V19.1568C16.0375 18.68 15.9731 18.2006 15.811 17.7506C15.6489 17.3006 15.3929 16.8902 15.06 16.5468C18.2 16.1968 21.5 15.0068 21.5 9.54679C21.4997 8.15062 20.9627 6.80799 20 5.79679C20.4558 4.5753 20.4236 3.22514 19.91 2.02679C19.91 2.02679 18.73 1.67679 16 3.50679C13.708 2.88561 11.292 2.88561 8.99999 3.50679C6.26999 1.67679 5.08999 2.02679 5.08999 2.02679C4.57636 3.22514 4.54413 4.5753 4.99999 5.79679C4.03011 6.81549 3.49251 8.17026 3.49999 9.57679C3.49999 14.9968 6.79998 16.1868 9.93998 16.5768C9.61098 16.9168 9.35725 17.3222 9.19529 17.7667C9.03334 18.2112 8.96679 18.6849 8.99999 19.1568V22.0268"></path>
<path d="M9 20.0267C6 20.9999 3.5 20.0267 2 17.0267"></path>
</svg>
GitHub
</div>
<div class="community-link" @click=${() => this.handleExternalLinkClick('https://discord.gg/GCBdubnXfJ')}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M5.5 16C10.5 18.5 13.5 18.5 18.5 16"></path>
<path d="M15.5 17.5L16.5 19.5C16.5 19.5 20.6713 18.1717 22 16C22 15 22.5301 7.85339 19 5.5C17.5 4.5 15 4 15 4L14 6H12"></path>
<path d="M8.52832 17.5L7.52832 19.5C7.52832 19.5 3.35699 18.1717 2.02832 16C2.02832 15 1.49823 7.85339 5.02832 5.5C6.52832 4.5 9.02832 4 9.02832 4L10.0283 6H12.0283"></path>
<path d="M8.5 14C7.67157 14 7 13.1046 7 12C7 10.8954 7.67157 10 8.5 10C9.32843 10 10 10.8954 10 12C10 13.1046 9.32843 14 8.5 14Z"></path>
<path d="M15.5 14C14.6716 14 14 13.1046 14 12C14 10.8954 14.6716 10 15.5 10C16.3284 10 17 10.8954 17 12C17 13.1046 16.3284 14 15.5 14Z"></path>
</svg>
Discord
</div>
</div>
</div>
<div class="unified-page">
<div class="unified-wrap">
<div class="page-title">Help</div>
<div class="option-group">
<div class="option-label">
<span>Keyboard Shortcuts</span>
</div>
<div class="keyboard-section">
<div class="keyboard-group">
<div class="keyboard-group-title">Window Movement</div>
<div class="shortcut-item">
<span class="shortcut-description">Move window up</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveUp)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Move window down</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveDown)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Move window left</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveLeft)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Move window right</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveRight)}</div>
</div>
<section class="surface">
<div class="surface-title">Support</div>
<div class="link-row">
<button class="link-button" @click=${() => this._open('https://cheatingdaddy.com')}>Website</button>
<button class="link-button" @click=${() => this._open('https://github.com/sohzm/cheating-daddy')}>GitHub</button>
<button class="link-button" @click=${() => this._open('https://discord.gg/GCBdubnXfJ')}>Discord</button>
</div>
</section>
<div class="keyboard-group">
<div class="keyboard-group-title">Window Control</div>
<div class="shortcut-item">
<span class="shortcut-description">Toggle click-through mode</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.toggleClickThrough)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Toggle window visibility</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.toggleVisibility)}</div>
</div>
<section class="surface">
<div class="surface-title">Keyboard Shortcuts</div>
<div class="shortcut-grid">
${shortcutRows.map(([label, keys]) => html`
<div class="shortcut-row">
<span class="shortcut-label">${label}</span>
<span class="shortcut-keys">${this._formatKeybind(keys)}</span>
</div>
`)}
</div>
<div class="keyboard-group">
<div class="keyboard-group-title">AI Actions</div>
<div class="shortcut-item">
<span class="shortcut-description">Take screenshot and ask for next step</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.nextStep)}</div>
</div>
</div>
<div class="keyboard-group">
<div class="keyboard-group-title">Response Navigation</div>
<div class="shortcut-item">
<span class="shortcut-description">Previous response</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.previousResponse)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Next response</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.nextResponse)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Scroll response up</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.scrollUp)}</div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">Scroll response down</span>
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.scrollDown)}</div>
</div>
</div>
<div class="keyboard-group">
<div class="keyboard-group-title">Text Input</div>
<div class="shortcut-item">
<span class="shortcut-description">Send message to AI</span>
<div class="shortcut-keys"><span class="key">Enter</span></div>
</div>
<div class="shortcut-item">
<span class="shortcut-description">New line in text input</span>
<div class="shortcut-keys"><span class="key">Shift</span><span class="key">Enter</span></div>
</div>
</div>
</div>
<div class="description" style="margin-top: 12px; text-align: center;">
You can customize these shortcuts in Settings.
</div>
</div>
<div class="option-group">
<div class="option-label">
<span>How to Use</span>
</div>
<div class="usage-steps">
<div class="usage-step"><strong>Start a Session:</strong> Enter your Gemini API key and click "Start Session"</div>
<div class="usage-step"><strong>Customize:</strong> Choose your profile and language in the settings</div>
<div class="usage-step">
<strong>Position Window:</strong> Use keyboard shortcuts to move the window to your desired location
</div>
<div class="usage-step">
<strong>Click-through Mode:</strong> Use ${this.formatKeybind(this.keybinds.toggleClickThrough)} to make the window
click-through
</div>
<div class="usage-step"><strong>Get AI Help:</strong> The AI will analyze your screen and audio to provide assistance</div>
<div class="usage-step"><strong>Text Messages:</strong> Type questions or requests to the AI using the text input</div>
<div class="usage-step">
<strong>Navigate Responses:</strong> Use ${this.formatKeybind(this.keybinds.previousResponse)} and
${this.formatKeybind(this.keybinds.nextResponse)} to browse through AI responses
</div>
</div>
</div>
<div class="option-group">
<div class="option-label">
<span>Supported Profiles</span>
</div>
<div class="profiles-grid">
<div class="profile-item">
<div class="profile-name">Job Interview</div>
<div class="profile-description">Get help with interview questions and responses</div>
</div>
<div class="profile-item">
<div class="profile-name">Sales Call</div>
<div class="profile-description">Assistance with sales conversations and objection handling</div>
</div>
<div class="profile-item">
<div class="profile-name">Business Meeting</div>
<div class="profile-description">Support for professional meetings and discussions</div>
</div>
<div class="profile-item">
<div class="profile-name">Presentation</div>
<div class="profile-description">Help with presentations and public speaking</div>
</div>
<div class="profile-item">
<div class="profile-name">Negotiation</div>
<div class="profile-description">Guidance for business negotiations and deals</div>
</div>
<div class="profile-item">
<div class="profile-name">Exam Assistant</div>
<div class="profile-description">Academic assistance for test-taking and exam questions</div>
</div>
</div>
</div>
<div class="option-group">
<div class="option-label">
<span>Audio Input</span>
</div>
<div class="description">The AI listens to conversations and provides contextual assistance based on what it hears.</div>
</div>
<div class="option-group">
<div class="option-label">
<span>Troubleshooting</span>
</div>
<div class="description" style="margin-bottom: 12px;">
If you're experiencing issues with audio capture or other features, check the application logs for diagnostic information.
</div>
<button class="open-logs-btn" @click=${this.openLogsFolder}>
📁 Open Logs Folder
</button>
</section>
</div>
</div>
`;
}
async openLogsFolder() {
try {
const { ipcRenderer } = require('electron');
const result = await ipcRenderer.invoke('open-logs-folder');
if (!result.success) {
console.error('Failed to open logs folder:', result.error);
}
} catch (err) {
console.error('Error opening logs folder:', err);
}
}
}
customElements.define('help-view', HelpView);
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+222 -461
View File
@@ -3,13 +3,7 @@ import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
export class OnboardingView extends LitElement {
static styles = css`
* {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
font-family: var(--font);
cursor: default;
user-select: none;
margin: 0;
@@ -27,44 +21,20 @@ export class OnboardingView extends LitElement {
overflow: hidden;
}
.onboarding-container {
position: relative;
.onboarding {
width: 100%;
height: 100%;
background: #0a0a0a;
overflow: hidden;
}
.close-button {
position: absolute;
top: 12px;
right: 12px;
z-index: 10;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
width: 32px;
height: 32px;
position: relative;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition: all 0.2s ease;
color: rgba(255, 255, 255, 0.6);
border-radius: 12px;
border: 1px solid rgba(0, 0, 0, 0.08);
overflow: hidden;
background: #f0f0f0;
}
.close-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.9);
}
.close-button svg {
width: 16px;
height: 16px;
}
.gradient-canvas {
canvas.aurora {
position: absolute;
top: 0;
left: 0;
@@ -73,166 +43,104 @@ export class OnboardingView extends LitElement {
z-index: 0;
}
.content-wrapper {
canvas.dither {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 60px;
width: 100%;
height: 100%;
z-index: 1;
display: flex;
flex-direction: column;
justify-content: center;
padding: 32px 48px;
max-width: 500px;
color: #e5e5e5;
overflow: hidden;
opacity: 0.12;
mix-blend-mode: overlay;
pointer-events: none;
image-rendering: pixelated;
}
.slide-icon {
width: 48px;
height: 48px;
margin-bottom: 16px;
opacity: 0.9;
display: block;
.slide {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
max-width: 400px;
padding: var(--space-xl);
gap: var(--space-md);
}
.slide-title {
font-size: 28px;
font-weight: 600;
margin-bottom: 12px;
color: #ffffff;
line-height: 1.3;
color: #111111;
line-height: 1.2;
}
.slide-content {
font-size: 16px;
.slide-text {
font-size: 13px;
line-height: 1.5;
margin-bottom: 24px;
color: #b8b8b8;
font-weight: 400;
color: #666666;
}
.context-textarea {
.context-input {
width: 100%;
height: 100px;
padding: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
min-height: 120px;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 8px;
background: rgba(255, 255, 255, 0.05);
color: #e5e5e5;
font-size: 14px;
font-family: inherit;
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(8px);
color: #111111;
font-size: 13px;
font-family: var(--font);
line-height: 1.5;
resize: vertical;
transition: all 0.2s ease;
margin-bottom: 24px;
text-align: left;
}
.context-textarea::placeholder {
color: rgba(255, 255, 255, 0.4);
font-size: 14px;
.context-input::placeholder {
color: #999999;
}
.context-textarea:focus {
.context-input:focus {
outline: none;
border-color: rgba(255, 255, 255, 0.2);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(0, 0, 0, 0.3);
}
.feature-list {
max-width: 100%;
}
.feature-item {
.actions {
display: flex;
flex-direction: column;
align-items: center;
margin-bottom: 12px;
font-size: 15px;
color: #b8b8b8;
gap: 8px;
margin-top: 8px;
}
.feature-icon {
font-size: 16px;
margin-right: 12px;
opacity: 0.8;
}
.navigation {
position: absolute;
bottom: 0;
left: 0;
right: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 24px;
background: rgba(0, 0, 0, 0.3);
backdrop-filter: blur(10px);
border-top: 1px solid rgba(255, 255, 255, 0.05);
height: 60px;
box-sizing: border-box;
}
.nav-button {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #e5e5e5;
padding: 8px 16px;
border-radius: 6px;
.btn-primary {
background: #111111;
border: none;
color: #ffffff;
padding: 10px 32px;
border-radius: 8px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 36px;
min-height: 36px;
transition: opacity 0.15s;
}
.nav-button:hover {
background: rgba(255, 255, 255, 0.12);
border-color: rgba(255, 255, 255, 0.2);
.btn-primary:hover {
opacity: 0.85;
}
.nav-button:active {
transform: scale(0.98);
}
.nav-button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.nav-button:disabled:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.1);
transform: none;
}
.progress-dots {
display: flex;
gap: 12px;
align-items: center;
}
.dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
transition: all 0.2s ease;
.btn-back {
background: none;
border: none;
color: #888888;
font-size: 11px;
cursor: pointer;
padding: 4px 8px;
}
.dot:hover {
background: rgba(255, 255, 255, 0.4);
}
.dot.active {
background: rgba(255, 255, 255, 0.8);
transform: scale(1.2);
.btn-back:hover {
color: #555555;
}
`;
@@ -240,7 +148,6 @@ export class OnboardingView extends LitElement {
currentSlide: { type: Number },
contextText: { type: String },
onComplete: { type: Function },
onClose: { type: Function },
};
constructor() {
@@ -248,220 +155,151 @@ export class OnboardingView extends LitElement {
this.currentSlide = 0;
this.contextText = '';
this.onComplete = () => {};
this.onClose = () => {};
this.canvas = null;
this.ctx = null;
this.animationId = null;
// Transition properties
this.isTransitioning = false;
this.transitionStartTime = 0;
this.transitionDuration = 800; // 800ms fade duration
this.previousColorScheme = null;
// Subtle dark color schemes for each slide
this.colorSchemes = [
// Slide 1 - Welcome (Very dark purple/gray)
[
[25, 25, 35], // Dark gray-purple
[20, 20, 30], // Darker gray
[30, 25, 40], // Slightly purple
[15, 15, 25], // Very dark
[35, 30, 45], // Muted purple
[10, 10, 20], // Almost black
],
// Slide 2 - Privacy (Dark blue-gray)
[
[20, 25, 35], // Dark blue-gray
[15, 20, 30], // Darker blue-gray
[25, 30, 40], // Slightly blue
[10, 15, 25], // Very dark blue
[30, 35, 45], // Muted blue
[5, 10, 20], // Almost black
],
// Slide 3 - Context (Dark neutral)
[
[25, 25, 25], // Neutral dark
[20, 20, 20], // Darker neutral
[30, 30, 30], // Light dark
[15, 15, 15], // Very dark
[35, 35, 35], // Lighter dark
[10, 10, 10], // Almost black
],
// Slide 4 - Features (Dark green-gray)
[
[20, 30, 25], // Dark green-gray
[15, 25, 20], // Darker green-gray
[25, 35, 30], // Slightly green
[10, 20, 15], // Very dark green
[30, 40, 35], // Muted green
[5, 15, 10], // Almost black
],
// Slide 5 - Complete (Dark warm gray)
[
[30, 25, 20], // Dark warm gray
[25, 20, 15], // Darker warm
[35, 30, 25], // Slightly warm
[20, 15, 10], // Very dark warm
[40, 35, 30], // Muted warm
[15, 10, 5], // Almost black
],
];
this._animId = null;
this._time = 0;
}
firstUpdated() {
this.canvas = this.shadowRoot.querySelector('.gradient-canvas');
this.ctx = this.canvas.getContext('2d');
this.resizeCanvas();
this.startGradientAnimation();
window.addEventListener('resize', () => this.resizeCanvas());
this._startAurora();
this._drawDither();
}
disconnectedCallback() {
super.disconnectedCallback();
if (this.animationId) {
cancelAnimationFrame(this.animationId);
if (this._animId) cancelAnimationFrame(this._animId);
}
_drawDither() {
const canvas = this.shadowRoot.querySelector('canvas.dither');
if (!canvas) return;
const blockSize = 5;
const cols = Math.ceil(canvas.offsetWidth / blockSize);
const rows = Math.ceil(canvas.offsetHeight / blockSize);
canvas.width = cols;
canvas.height = rows;
const ctx = canvas.getContext('2d');
const img = ctx.createImageData(cols, rows);
for (let i = 0; i < img.data.length; i += 4) {
const v = Math.random() > 0.5 ? 255 : 0;
img.data[i] = v;
img.data[i + 1] = v;
img.data[i + 2] = v;
img.data[i + 3] = 255;
}
window.removeEventListener('resize', () => this.resizeCanvas());
ctx.putImageData(img, 0, 0);
}
resizeCanvas() {
if (!this.canvas) return;
_startAurora() {
const canvas = this.shadowRoot.querySelector('canvas.aurora');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const rect = this.getBoundingClientRect();
this.canvas.width = rect.width;
this.canvas.height = rect.height;
}
const scale = 0.35;
const resize = () => {
canvas.width = Math.floor(canvas.offsetWidth * scale);
canvas.height = Math.floor(canvas.offsetHeight * scale);
};
resize();
startGradientAnimation() {
if (!this.ctx) return;
const blobs = [
{ parts: [
{ ox: 0, oy: 0, r: 1.0 },
{ ox: 0.22, oy: 0.1, r: 0.85 },
{ ox: 0.11, oy: 0.05, r: 0.5 },
], color: [180, 200, 230], x: 0.15, y: 0.2, vx: 0.35, vy: 0.25, phase: 0 },
const animate = timestamp => {
this.drawGradient(timestamp);
this.animationId = requestAnimationFrame(animate);
{ parts: [
{ ox: 0, oy: 0, r: 0.95 },
{ ox: 0.18, oy: -0.08, r: 0.75 },
{ ox: 0.09, oy: -0.04, r: 0.4 },
], color: [190, 180, 220], x: 0.75, y: 0.2, vx: -0.3, vy: 0.35, phase: 1.2 },
{ parts: [
{ ox: 0, oy: 0, r: 0.9 },
{ ox: 0.24, oy: 0.12, r: 0.9 },
{ ox: 0.12, oy: 0.06, r: 0.35 },
], color: [210, 195, 215], x: 0.5, y: 0.65, vx: 0.25, vy: -0.3, phase: 2.4 },
{ parts: [
{ ox: 0, oy: 0, r: 0.8 },
{ ox: -0.15, oy: 0.18, r: 0.7 },
{ ox: -0.07, oy: 0.09, r: 0.45 },
], color: [175, 210, 210], x: 0.1, y: 0.75, vx: 0.4, vy: 0.2, phase: 3.6 },
{ parts: [
{ ox: 0, oy: 0, r: 0.75 },
{ ox: 0.12, oy: -0.15, r: 0.65 },
{ ox: 0.06, oy: -0.07, r: 0.35 },
], color: [220, 210, 195], x: 0.85, y: 0.55, vx: -0.28, vy: -0.32, phase: 4.8 },
{ parts: [
{ ox: 0, oy: 0, r: 0.95 },
{ ox: -0.2, oy: -0.12, r: 0.75 },
{ ox: -0.1, oy: -0.06, r: 0.4 },
], color: [170, 190, 225], x: 0.6, y: 0.1, vx: -0.2, vy: 0.38, phase: 6.0 },
{ parts: [
{ ox: 0, oy: 0, r: 0.85 },
{ ox: 0.17, oy: 0.15, r: 0.75 },
{ ox: 0.08, oy: 0.07, r: 0.35 },
], color: [200, 190, 220], x: 0.35, y: 0.4, vx: 0.32, vy: -0.22, phase: 7.2 },
{ parts: [
{ ox: 0, oy: 0, r: 0.75 },
{ ox: -0.13, oy: 0.18, r: 0.65 },
{ ox: -0.06, oy: 0.1, r: 0.4 },
], color: [215, 205, 200], x: 0.9, y: 0.85, vx: -0.35, vy: -0.25, phase: 8.4 },
{ parts: [
{ ox: 0, oy: 0, r: 0.7 },
{ ox: 0.16, oy: -0.1, r: 0.6 },
{ ox: 0.08, oy: -0.05, r: 0.35 },
], color: [185, 210, 205], x: 0.45, y: 0.9, vx: 0.22, vy: -0.4, phase: 9.6 },
];
const baseRadius = 0.32;
const draw = () => {
this._time += 0.012;
const w = canvas.width;
const h = canvas.height;
const dim = Math.min(w, h);
ctx.fillStyle = '#f0f0f0';
ctx.fillRect(0, 0, w, h);
for (const blob of blobs) {
const t = this._time;
const cx = (blob.x + Math.sin(t * blob.vx + blob.phase) * 0.22) * w;
const cy = (blob.y + Math.cos(t * blob.vy + blob.phase * 0.7) * 0.22) * h;
for (const part of blob.parts) {
const wobble = Math.sin(t * 2.5 + part.ox * 25 + blob.phase) * 0.02;
const px = cx + (part.ox + wobble) * dim;
const py = cy + (part.oy + wobble * 0.7) * dim;
const pr = part.r * baseRadius * dim;
const grad = ctx.createRadialGradient(px, py, 0, px, py, pr);
grad.addColorStop(0, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0.55)`);
grad.addColorStop(0.4, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0.3)`);
grad.addColorStop(0.7, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0.1)`);
grad.addColorStop(1, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0)`);
ctx.fillStyle = grad;
ctx.fillRect(0, 0, w, h);
}
}
this._animId = requestAnimationFrame(draw);
};
animate(0);
}
drawGradient(timestamp) {
if (!this.ctx || !this.canvas) return;
const { width, height } = this.canvas;
let colors = this.colorSchemes[this.currentSlide];
// Handle color scheme transitions
if (this.isTransitioning && this.previousColorScheme) {
const elapsed = timestamp - this.transitionStartTime;
const progress = Math.min(elapsed / this.transitionDuration, 1);
// Use easing function for smoother transition
const easedProgress = this.easeInOutCubic(progress);
colors = this.interpolateColorSchemes(this.previousColorScheme, this.colorSchemes[this.currentSlide], easedProgress);
// End transition when complete
if (progress >= 1) {
this.isTransitioning = false;
this.previousColorScheme = null;
}
}
const time = timestamp * 0.0005; // Much slower animation
// Create moving gradient with subtle flow
const flowX = Math.sin(time * 0.7) * width * 0.3;
const flowY = Math.cos(time * 0.5) * height * 0.2;
const gradient = this.ctx.createLinearGradient(flowX, flowY, width + flowX * 0.5, height + flowY * 0.5);
// Very subtle color variations with movement
colors.forEach((color, index) => {
const offset = index / (colors.length - 1);
const wave = Math.sin(time + index * 0.3) * 0.05; // Very subtle wave
const r = Math.max(0, Math.min(255, color[0] + wave * 5));
const g = Math.max(0, Math.min(255, color[1] + wave * 5));
const b = Math.max(0, Math.min(255, color[2] + wave * 5));
gradient.addColorStop(offset, `rgb(${r}, ${g}, ${b})`);
});
// Fill with moving gradient
this.ctx.fillStyle = gradient;
this.ctx.fillRect(0, 0, width, height);
// Add a second layer with radial gradient for more depth
const centerX = width * 0.5 + Math.sin(time * 0.3) * width * 0.15;
const centerY = height * 0.5 + Math.cos(time * 0.4) * height * 0.1;
const radius = Math.max(width, height) * 0.8;
const radialGradient = this.ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
// Very subtle radial overlay
radialGradient.addColorStop(0, `rgba(${colors[0][0] + 10}, ${colors[0][1] + 10}, ${colors[0][2] + 10}, 0.1)`);
radialGradient.addColorStop(0.5, `rgba(${colors[2][0]}, ${colors[2][1]}, ${colors[2][2]}, 0.05)`);
radialGradient.addColorStop(
1,
`rgba(${colors[colors.length - 1][0]}, ${colors[colors.length - 1][1]}, ${colors[colors.length - 1][2]}, 0.03)`
);
this.ctx.globalCompositeOperation = 'overlay';
this.ctx.fillStyle = radialGradient;
this.ctx.fillRect(0, 0, width, height);
this.ctx.globalCompositeOperation = 'source-over';
}
nextSlide() {
if (this.currentSlide < 4) {
this.startColorTransition(this.currentSlide + 1);
} else {
this.completeOnboarding();
}
}
prevSlide() {
if (this.currentSlide > 0) {
this.startColorTransition(this.currentSlide - 1);
}
}
startColorTransition(newSlide) {
this.previousColorScheme = [...this.colorSchemes[this.currentSlide]];
this.currentSlide = newSlide;
this.isTransitioning = true;
this.transitionStartTime = performance.now();
}
// Interpolate between two color schemes
interpolateColorSchemes(scheme1, scheme2, progress) {
return scheme1.map((color1, index) => {
const color2 = scheme2[index];
return [
color1[0] + (color2[0] - color1[0]) * progress,
color1[1] + (color2[1] - color1[1]) * progress,
color1[2] + (color2[2] - color1[2]) * progress,
];
});
}
// Easing function for smooth transitions
easeInOutCubic(t) {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
draw();
}
handleContextInput(e) {
this.contextText = e.target.value;
}
async handleClose() {
if (window.require) {
const { ipcRenderer } = window.require('electron');
await ipcRenderer.invoke('quit-application');
}
}
async completeOnboarding() {
if (this.contextText.trim()) {
await cheatingDaddy.storage.updatePreference('customPrompt', this.contextText.trim());
@@ -470,120 +308,43 @@ export class OnboardingView extends LitElement {
this.onComplete();
}
getSlideContent() {
const slides = [
{
icon: 'assets/onboarding/welcome.svg',
title: 'Welcome to Cheating Daddy',
content:
'Your AI assistant that listens and watches, then provides intelligent suggestions automatically during interviews and meetings.',
},
{
icon: 'assets/onboarding/security.svg',
title: 'Completely Private',
content: 'Invisible to screen sharing apps and recording software. Your secret advantage stays completely hidden from others.',
},
{
icon: 'assets/onboarding/context.svg',
title: 'Add Your Context',
content: 'Share relevant information to help the AI provide better, more personalized assistance.',
showTextarea: true,
},
{
icon: 'assets/onboarding/customize.svg',
title: 'Additional Features',
content: '',
showFeatures: true,
},
{
icon: 'assets/onboarding/ready.svg',
title: 'Ready to Go',
content: 'Add your Gemini API key in settings and start getting AI-powered assistance in real-time.',
},
];
renderSlide() {
if (this.currentSlide === 0) {
return html`
<div class="slide">
<div class="slide-title">Mastermind</div>
<div class="slide-text">Real-time AI that listens, watches, and helps during interviews, meetings, and exams.</div>
<div class="actions">
<button class="btn-primary" @click=${() => { this.currentSlide = 1; }}>Continue</button>
</div>
</div>
`;
}
return slides[this.currentSlide];
return html`
<div class="slide">
<div class="slide-title">Add context</div>
<div class="slide-text">Paste your resume or any info the AI should know. You can skip this and add it later.</div>
<textarea
class="context-input"
placeholder="Resume, job description, notes..."
.value=${this.contextText}
@input=${this.handleContextInput}
></textarea>
<div class="actions">
<button class="btn-primary" @click=${this.completeOnboarding}>Get Started</button>
<button class="btn-back" @click=${() => { this.currentSlide = 0; }}>Back</button>
</div>
</div>
`;
}
render() {
const slide = this.getSlideContent();
return html`
<div class="onboarding-container">
<button class="close-button" @click=${this.handleClose} title="Close">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
</svg>
</button>
<canvas class="gradient-canvas"></canvas>
<div class="content-wrapper">
<img class="slide-icon" src="${slide.icon}" alt="${slide.title} icon" />
<div class="slide-title">${slide.title}</div>
<div class="slide-content">${slide.content}</div>
${slide.showTextarea
? html`
<textarea
class="context-textarea"
placeholder="Paste your resume, job description, or any relevant context here..."
.value=${this.contextText}
@input=${this.handleContextInput}
></textarea>
`
: ''}
${slide.showFeatures
? html`
<div class="feature-list">
<div class="feature-item">
<span class="feature-icon">-</span>
Customize AI behavior and responses
</div>
<div class="feature-item">
<span class="feature-icon">-</span>
Review conversation history
</div>
<div class="feature-item">
<span class="feature-icon">-</span>
Adjust capture settings and intervals
</div>
</div>
`
: ''}
</div>
<div class="navigation">
<button class="nav-button" @click=${this.prevSlide} ?disabled=${this.currentSlide === 0}>
<svg width="16px" height="16px" stroke-width="2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
<div class="progress-dots">
${[0, 1, 2, 3, 4].map(
index => html`
<div
class="dot ${index === this.currentSlide ? 'active' : ''}"
@click=${() => {
if (index !== this.currentSlide) {
this.startColorTransition(index);
}
}}
></div>
`
)}
</div>
<button class="nav-button" @click=${this.nextSlide}>
${this.currentSlide === 4
? 'Get Started'
: html`
<svg width="16px" height="16px" stroke-width="2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
`}
</button>
</div>
<div class="onboarding">
<canvas class="aurora"></canvas>
<canvas class="dither"></canvas>
${this.renderSlide()}
</div>
`;
}
-175
View File
@@ -1,175 +0,0 @@
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
export class ScreenPickerDialog extends LitElement {
static properties = {
sources: { type: Array },
visible: { type: Boolean },
};
static styles = css`
:host {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.8);
z-index: 10000;
align-items: center;
justify-content: center;
}
:host([visible]) {
display: flex;
}
.dialog {
background: var(--background-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 24px;
max-width: 800px;
max-height: 80vh;
overflow-y: auto;
}
h2 {
margin: 0 0 16px 0;
color: var(--text-color);
font-size: 18px;
font-weight: 500;
}
.sources-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.source-item {
background: var(--input-background);
border: 2px solid transparent;
border-radius: 6px;
padding: 12px;
cursor: pointer;
transition: all 0.2s ease;
}
.source-item:hover {
border-color: var(--border-default);
background: var(--button-hover);
}
.source-item.selected {
border-color: var(--accent-color);
background: var(--button-hover);
}
.source-thumbnail {
width: 100%;
height: 120px;
object-fit: contain;
background: #1a1a1a;
border-radius: 4px;
margin-bottom: 8px;
}
.source-name {
color: var(--text-color);
font-size: 13px;
text-align: center;
word-break: break-word;
}
.buttons {
display: flex;
gap: 8px;
justify-content: flex-end;
}
button {
background: var(--button-background);
color: var(--text-color);
border: 1px solid var(--border-color);
padding: 8px 16px;
border-radius: 3px;
cursor: pointer;
font-size: 13px;
transition: background-color 0.1s ease;
}
button:hover {
background: var(--button-hover);
}
button.primary {
background: var(--accent-color);
color: white;
border-color: var(--accent-color);
}
button.primary:hover {
background: var(--accent-hover);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
`;
constructor() {
super();
this.sources = [];
this.visible = false;
this.selectedSource = null;
}
selectSource(source) {
this.selectedSource = source;
this.requestUpdate();
}
confirm() {
if (this.selectedSource) {
this.dispatchEvent(
new CustomEvent('source-selected', {
detail: { source: this.selectedSource },
})
);
}
}
cancel() {
this.dispatchEvent(new CustomEvent('cancelled'));
}
render() {
return html`
<div class="dialog">
<h2>Choose screen or window to share</h2>
<div class="sources-grid">
${this.sources.map(
source => html`
<div
class="source-item ${this.selectedSource?.id === source.id ? 'selected' : ''}"
@click=${() => this.selectSource(source)}
>
<img class="source-thumbnail" src="${source.thumbnail}" alt="${source.name}" />
<div class="source-name">${source.name}</div>
</div>
`
)}
</div>
<div class="buttons">
<button @click=${this.cancel}>Cancel</button>
<button class="primary" @click=${this.confirm} ?disabled=${!this.selectedSource}>Share</button>
</div>
</div>
`;
}
}
customElements.define('screen-picker-dialog', ScreenPickerDialog);
+172
View File
@@ -0,0 +1,172 @@
import { css } from '../../assets/lit-core-2.7.4.min.js';
export const unifiedPageStyles = css`
* {
box-sizing: border-box;
font-family: var(--font);
cursor: default;
user-select: none;
}
:host {
display: block;
height: 100%;
}
.unified-page {
height: 100%;
overflow-y: auto;
padding: var(--space-lg);
background: var(--bg-app);
}
.unified-wrap {
width: 100%;
max-width: 1160px;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: var(--space-md);
min-height: 100%;
}
.page-title {
font-size: var(--font-size-xl);
font-weight: var(--font-weight-semibold);
color: var(--text-primary);
margin-bottom: 4px;
}
.page-subtitle {
color: var(--text-muted);
font-size: var(--font-size-sm);
}
.surface {
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--bg-surface);
padding: var(--space-md);
}
.surface-title {
color: var(--text-primary);
font-size: var(--font-size-md);
font-weight: var(--font-weight-semibold);
margin-bottom: 4px;
}
.surface-subtitle {
color: var(--text-muted);
font-size: var(--font-size-xs);
margin-bottom: var(--space-md);
}
.form-grid {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.form-row {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.form-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-md);
}
.form-group.vertical {
flex-direction: column;
align-items: stretch;
}
.form-label {
color: var(--text-secondary);
font-size: var(--font-size-sm);
white-space: nowrap;
flex-shrink: 0;
}
.form-help {
color: var(--text-muted);
font-size: var(--font-size-xs);
line-height: 1.4;
}
.control {
width: 200px;
background: var(--bg-elevated);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 12px;
font-size: var(--font-size-sm);
transition: border-color var(--transition), box-shadow var(--transition);
}
.control:hover:not(:focus) {
border-color: var(--border-strong);
}
.control:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 1px var(--accent);
}
select.control {
appearance: none;
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b6b6b' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
background-position: right 8px center;
background-repeat: no-repeat;
background-size: 12px;
padding-right: 28px;
cursor: pointer;
}
textarea.control {
width: 100%;
min-height: 100px;
resize: vertical;
line-height: 1.45;
}
.chip {
display: inline-flex;
align-items: center;
border-radius: var(--radius-sm);
background: var(--bg-elevated);
color: var(--text-secondary);
padding: 2px 8px;
font-size: var(--font-size-xs);
font-family: var(--font-mono);
}
.pill {
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 8px;
font-size: var(--font-size-xs);
color: var(--text-muted);
}
.muted {
color: var(--text-muted);
}
.danger {
color: var(--danger);
}
@media (max-width: 640px) {
.unified-page {
padding: var(--space-md);
}
}
`;
+141 -87
View File
@@ -5,75 +5,112 @@
<title>Screen and Audio Capture</title>
<style>
:root {
/* Backgrounds - with default 0.8 transparency */
--background-transparent: transparent;
--bg-primary: rgba(30, 30, 30, 0.8);
--bg-secondary: rgba(37, 37, 38, 0.8);
--bg-tertiary: rgba(45, 45, 45, 0.8);
--bg-hover: rgba(50, 50, 50, 0.8);
/* Backgrounds */
--bg-app: #0A0A0A;
--bg-surface: #111111;
--bg-elevated: #191919;
--bg-hover: #1F1F1F;
/* Text */
--text-color: #e5e5e5;
--text-secondary: #a0a0a0;
--text-muted: #6b6b6b;
--description-color: #a0a0a0;
--placeholder-color: #6b6b6b;
--text-primary: #F5F5F5;
--text-secondary: #999999;
--text-muted: #555555;
/* Borders */
--border-color: #3c3c3c;
--border-subtle: #3c3c3c;
--border-default: #4a4a4a;
/* Borders & Lines */
--border: #222222;
--border-strong: #333333;
/* Component backgrounds - with default 0.8 transparency */
--header-background: rgba(30, 30, 30, 0.8);
--header-actions-color: #a0a0a0;
--main-content-background: rgba(30, 30, 30, 0.8);
/* Accent */
--accent: #3B82F6;
--accent-hover: #2563EB;
/* Status */
--success: #22C55E;
--warning: #D4A017;
--danger: #EF4444;
/* Typography */
--font: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
--font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
--font-size-xs: 11px;
--font-size-sm: 13px;
--font-size-base: 14px;
--font-size-lg: 16px;
--font-size-xl: 20px;
--font-size-2xl: 28px;
--font-weight-normal: 400;
--font-weight-medium: 500;
--font-weight-semibold: 600;
--line-height: 1.6;
/* Spacing */
--space-xs: 4px;
--space-sm: 8px;
--space-md: 16px;
--space-lg: 24px;
--space-xl: 40px;
--space-2xl: 64px;
/* Radius */
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
/* Transitions */
--transition: 150ms ease;
/* Sidebar */
--sidebar-width: 220px;
--sidebar-width-collapsed: 60px;
/* Legacy compatibility — mapped to new tokens */
--background-transparent: transparent;
--bg-primary: var(--bg-app);
--bg-secondary: var(--bg-surface);
--bg-tertiary: var(--bg-elevated);
--text-color: var(--text-primary);
--description-color: var(--text-secondary);
--placeholder-color: var(--text-muted);
--border-color: var(--border);
--border-subtle: var(--border);
--border-default: var(--border-strong);
--header-background: var(--bg-surface);
--header-actions-color: var(--text-secondary);
--main-content-background: var(--bg-app);
--button-background: transparent;
--button-border: #3c3c3c;
--icon-button-color: #a0a0a0;
--hover-background: rgba(50, 50, 50, 0.8);
--input-background: rgba(45, 45, 45, 0.8);
--input-focus-background: rgba(45, 45, 45, 0.8);
/* Focus states - neutral */
--focus-border-color: #4a4a4a;
--button-border: var(--border-strong);
--icon-button-color: var(--text-secondary);
--hover-background: var(--bg-hover);
--input-background: var(--bg-elevated);
--input-focus-background: var(--bg-elevated);
--focus-border-color: var(--accent);
--focus-box-shadow: transparent;
--scrollbar-track: var(--bg-app);
--scrollbar-thumb: var(--border-strong);
--scrollbar-thumb-hover: #444444;
--scrollbar-background: var(--bg-app);
--start-button-background: var(--accent);
--start-button-color: #ffffff;
--start-button-border: var(--accent);
--start-button-hover-background: var(--accent-hover);
--start-button-hover-border: var(--accent-hover);
--text-input-button-background: var(--accent);
--text-input-button-hover: var(--accent-hover);
--link-color: var(--accent);
--key-background: var(--bg-elevated);
--success-color: var(--success);
--warning-color: var(--warning);
--error-color: var(--danger);
--danger-color: var(--danger);
--preview-video-background: var(--bg-surface);
--preview-video-border: var(--border);
--option-label-color: var(--text-primary);
--screen-option-background: var(--bg-surface);
--screen-option-hover-background: var(--bg-elevated);
--screen-option-selected-background: var(--bg-hover);
--screen-option-text: var(--text-secondary);
/* Scrollbar */
--scrollbar-track: #1e1e1e;
--scrollbar-thumb: #3c3c3c;
--scrollbar-thumb-hover: #4a4a4a;
--scrollbar-background: #1e1e1e;
/* Legacy/misc */
--preview-video-background: #1e1e1e;
--preview-video-border: #3c3c3c;
--option-label-color: #e5e5e5;
--screen-option-background: #252526;
--screen-option-hover-background: #2d2d2d;
--screen-option-selected-background: #323232;
--screen-option-text: #a0a0a0;
/* Buttons */
--start-button-background: #ffffff;
--start-button-color: #1e1e1e;
--start-button-border: #ffffff;
--start-button-hover-background: #e0e0e0;
--start-button-hover-border: #e0e0e0;
--text-input-button-background: #ffffff;
--text-input-button-hover: #e0e0e0;
/* Links - neutral */
--link-color: #e5e5e5;
--key-background: #2d2d2d;
/* Status colors */
--success-color: #4ec9b0;
--warning-color: #dcdcaa;
--error-color: #f14c4c;
--danger-color: #f14c4c;
/* Layout-specific variables */
/* Layout-specific */
--header-padding: 8px 16px;
--header-font-size: 14px;
--header-gap: 8px;
@@ -81,48 +118,65 @@
--header-icon-padding: 6px;
--header-font-size-small: 12px;
--main-content-padding: 16px;
--main-content-margin-top: 1px;
--main-content-margin-top: 0;
--icon-size: 18px;
--border-radius: 3px;
--border-radius: var(--radius-sm);
--content-border-radius: 0;
}
/* Compact layout styles */
:root.compact-layout {
--header-padding: 6px 12px;
--header-font-size: 12px;
--header-gap: 6px;
--header-button-padding: 4px 8px;
--header-icon-padding: 4px;
--header-font-size-small: 10px;
--main-content-padding: 12px;
--main-content-margin-top: 1px;
--icon-size: 16px;
--border-radius: 3px;
--content-border-radius: 0;
html {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
border-radius: 12px;
background: transparent;
}
html,
body {
margin: 0;
padding: 0;
height: 100%;
overflow: hidden;
background: transparent;
}
body {
font-family:
'Inter',
-apple-system,
BlinkMacSystemFont,
sans-serif;
background: var(--bg-app);
color: var(--text-primary);
line-height: var(--line-height);
border-radius: 12px;
border: 1px solid var(--border);
font-family: var(--font);
font-size: var(--font-size-base);
font-weight: var(--font-weight-normal);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
::-webkit-scrollbar {
width: 6px;
height: 6px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-strong);
border-radius: 3px;
}
::-webkit-scrollbar-thumb:hover {
background: #444444;
}
cheating-daddy-app {
display: block;
width: 100%;
+292 -306
View File
@@ -1,350 +1,336 @@
if (require('electron-squirrel-startup')) {
process.exit(0);
if (require("electron-squirrel-startup")) {
process.exit(0);
}
const { app, BrowserWindow, shell, ipcMain } = require('electron');
const { createWindow, updateGlobalShortcuts } = require('./utils/window');
const { setupAIProviderIpcHandlers } = require('./utils/ai-provider-manager');
const { stopMacOSAudioCapture } = require('./utils/gemini');
const { initLogger, closeLogger, getLogPath } = require('./utils/logger');
const storage = require('./storage');
// ── Global crash handlers to prevent silent process termination ──
process.on("uncaughtException", (error) => {
console.error("[FATAL] Uncaught exception:", error);
try {
const { sendToRenderer } = require("./utils/gemini");
sendToRenderer(
"update-status",
"Fatal error: " + (error?.message || "unknown"),
);
} catch (_) {
// sendToRenderer may not be available yet
}
});
process.on("unhandledRejection", (reason) => {
console.error("[FATAL] Unhandled promise rejection:", reason);
try {
const { sendToRenderer } = require("./utils/gemini");
sendToRenderer(
"update-status",
"Unhandled error: " +
(reason instanceof Error ? reason.message : String(reason)),
);
} catch (_) {
// sendToRenderer may not be available yet
}
});
const { app, BrowserWindow, shell, ipcMain } = require("electron");
const { createWindow, updateGlobalShortcuts } = require("./utils/window");
const {
setupGeminiIpcHandlers,
stopMacOSAudioCapture,
sendToRenderer,
} = require("./utils/gemini");
const storage = require("./storage");
const geminiSessionRef = { current: null };
let mainWindow = null;
function sendToRenderer(channel, data) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send(channel, data);
}
}
function createMainWindow() {
mainWindow = createWindow(sendToRenderer, geminiSessionRef);
return mainWindow;
mainWindow = createWindow(sendToRenderer, geminiSessionRef);
return mainWindow;
}
app.whenReady().then(async () => {
// Initialize file logger first
const logPath = initLogger();
console.log('App starting, log file:', logPath);
// Initialize storage (checks version, resets if needed)
storage.initializeStorage();
// Initialize storage (checks version, resets if needed)
storage.initializeStorage();
// Trigger screen recording permission prompt on macOS if not already granted
if (process.platform === "darwin") {
const { desktopCapturer } = require("electron");
desktopCapturer.getSources({ types: ["screen"] }).catch(() => {});
}
createMainWindow();
setupGeminiIpcHandlers(geminiSessionRef);
setupStorageIpcHandlers();
setupGeneralIpcHandlers();
});
app.on("window-all-closed", () => {
stopMacOSAudioCapture();
if (process.platform !== "darwin") {
app.quit();
}
});
app.on("before-quit", () => {
stopMacOSAudioCapture();
});
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
setupAIProviderIpcHandlers(geminiSessionRef);
setupStorageIpcHandlers();
setupGeneralIpcHandlers();
// Add handler to get log path from renderer
ipcMain.handle('get-log-path', () => getLogPath());
// Add handler for renderer logs (so they go to the log file)
ipcMain.on('renderer-log', (event, { level, message }) => {
const prefix = '[RENDERER]';
if (level === 'error') console.error(prefix, message);
else if (level === 'warn') console.warn(prefix, message);
else console.log(prefix, message);
});
});
app.on('window-all-closed', () => {
stopMacOSAudioCapture();
closeLogger();
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('before-quit', () => {
stopMacOSAudioCapture();
closeLogger();
});
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createMainWindow();
}
}
});
function setupStorageIpcHandlers() {
// ============ CONFIG ============
ipcMain.handle('storage:get-config', async () => {
try {
return { success: true, data: storage.getConfig() };
} catch (error) {
console.error('Error getting config:', error);
return { success: false, error: error.message };
}
});
// ============ CONFIG ============
ipcMain.handle("storage:get-config", async () => {
try {
return { success: true, data: storage.getConfig() };
} catch (error) {
console.error("Error getting config:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-config', async (event, config) => {
try {
storage.setConfig(config);
return { success: true };
} catch (error) {
console.error('Error setting config:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:set-config", async (event, config) => {
try {
storage.setConfig(config);
return { success: true };
} catch (error) {
console.error("Error setting config:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:update-config', async (event, key, value) => {
try {
storage.updateConfig(key, value);
return { success: true };
} catch (error) {
console.error('Error updating config:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:update-config", async (event, key, value) => {
try {
storage.updateConfig(key, value);
return { success: true };
} catch (error) {
console.error("Error updating config:", error);
return { success: false, error: error.message };
}
});
// ============ CREDENTIALS ============
ipcMain.handle('storage:get-credentials', async () => {
try {
return { success: true, data: storage.getCredentials() };
} catch (error) {
console.error('Error getting credentials:', error);
return { success: false, error: error.message };
}
});
// ============ CREDENTIALS ============
ipcMain.handle("storage:get-credentials", async () => {
try {
return { success: true, data: storage.getCredentials() };
} catch (error) {
console.error("Error getting credentials:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-credentials', async (event, credentials) => {
try {
storage.setCredentials(credentials);
return { success: true };
} catch (error) {
console.error('Error setting credentials:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:set-credentials", async (event, credentials) => {
try {
storage.setCredentials(credentials);
return { success: true };
} catch (error) {
console.error("Error setting credentials:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:get-api-key', async () => {
try {
return { success: true, data: storage.getApiKey() };
} catch (error) {
console.error('Error getting API key:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:get-api-key", async () => {
try {
return { success: true, data: storage.getApiKey() };
} catch (error) {
console.error("Error getting API key:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-api-key', async (event, apiKey) => {
try {
storage.setApiKey(apiKey);
return { success: true };
} catch (error) {
console.error('Error setting API key:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:set-api-key", async (event, apiKey) => {
try {
storage.setApiKey(apiKey);
return { success: true };
} catch (error) {
console.error("Error setting API key:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:get-openai-credentials', async () => {
try {
return { success: true, data: storage.getOpenAICredentials() };
} catch (error) {
console.error('Error getting OpenAI credentials:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:get-groq-api-key", async () => {
try {
return { success: true, data: storage.getGroqApiKey() };
} catch (error) {
console.error("Error getting Groq API key:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-openai-credentials', async (event, config) => {
try {
storage.setOpenAICredentials(config);
return { success: true };
} catch (error) {
console.error('Error setting OpenAI credentials:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:set-groq-api-key", async (event, groqApiKey) => {
try {
storage.setGroqApiKey(groqApiKey);
return { success: true };
} catch (error) {
console.error("Error setting Groq API key:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:get-openai-sdk-credentials', async () => {
try {
return { success: true, data: storage.getOpenAISDKCredentials() };
} catch (error) {
console.error('Error getting OpenAI SDK credentials:', error);
return { success: false, error: error.message };
}
});
// ============ PREFERENCES ============
ipcMain.handle("storage:get-preferences", async () => {
try {
return { success: true, data: storage.getPreferences() };
} catch (error) {
console.error("Error getting preferences:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-openai-sdk-credentials', async (event, config) => {
try {
storage.setOpenAISDKCredentials(config);
return { success: true };
} catch (error) {
console.error('Error setting OpenAI SDK credentials:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:set-preferences", async (event, preferences) => {
try {
storage.setPreferences(preferences);
return { success: true };
} catch (error) {
console.error("Error setting preferences:", error);
return { success: false, error: error.message };
}
});
// ============ PREFERENCES ============
ipcMain.handle('storage:get-preferences', async () => {
try {
return { success: true, data: storage.getPreferences() };
} catch (error) {
console.error('Error getting preferences:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:update-preference", async (event, key, value) => {
try {
storage.updatePreference(key, value);
return { success: true };
} catch (error) {
console.error("Error updating preference:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-preferences', async (event, preferences) => {
try {
storage.setPreferences(preferences);
return { success: true };
} catch (error) {
console.error('Error setting preferences:', error);
return { success: false, error: error.message };
}
});
// ============ KEYBINDS ============
ipcMain.handle("storage:get-keybinds", async () => {
try {
return { success: true, data: storage.getKeybinds() };
} catch (error) {
console.error("Error getting keybinds:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:update-preference', async (event, key, value) => {
try {
storage.updatePreference(key, value);
return { success: true };
} catch (error) {
console.error('Error updating preference:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:set-keybinds", async (event, keybinds) => {
try {
storage.setKeybinds(keybinds);
return { success: true };
} catch (error) {
console.error("Error setting keybinds:", error);
return { success: false, error: error.message };
}
});
// ============ KEYBINDS ============
ipcMain.handle('storage:get-keybinds', async () => {
try {
return { success: true, data: storage.getKeybinds() };
} catch (error) {
console.error('Error getting keybinds:', error);
return { success: false, error: error.message };
}
});
// ============ HISTORY ============
ipcMain.handle("storage:get-all-sessions", async () => {
try {
return { success: true, data: storage.getAllSessions() };
} catch (error) {
console.error("Error getting sessions:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:set-keybinds', async (event, keybinds) => {
try {
storage.setKeybinds(keybinds);
return { success: true };
} catch (error) {
console.error('Error setting keybinds:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:get-session", async (event, sessionId) => {
try {
return { success: true, data: storage.getSession(sessionId) };
} catch (error) {
console.error("Error getting session:", error);
return { success: false, error: error.message };
}
});
// ============ HISTORY ============
ipcMain.handle('storage:get-all-sessions', async () => {
try {
return { success: true, data: storage.getAllSessions() };
} catch (error) {
console.error('Error getting sessions:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:save-session", async (event, sessionId, data) => {
try {
storage.saveSession(sessionId, data);
return { success: true };
} catch (error) {
console.error("Error saving session:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:get-session', async (event, sessionId) => {
try {
return { success: true, data: storage.getSession(sessionId) };
} catch (error) {
console.error('Error getting session:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:delete-session", async (event, sessionId) => {
try {
storage.deleteSession(sessionId);
return { success: true };
} catch (error) {
console.error("Error deleting session:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:save-session', async (event, sessionId, data) => {
try {
storage.saveSession(sessionId, data);
return { success: true };
} catch (error) {
console.error('Error saving session:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("storage:delete-all-sessions", async () => {
try {
storage.deleteAllSessions();
return { success: true };
} catch (error) {
console.error("Error deleting all sessions:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:delete-session', async (event, sessionId) => {
try {
storage.deleteSession(sessionId);
return { success: true };
} catch (error) {
console.error('Error deleting session:', error);
return { success: false, error: error.message };
}
});
// ============ LIMITS ============
ipcMain.handle("storage:get-today-limits", async () => {
try {
return { success: true, data: storage.getTodayLimits() };
} catch (error) {
console.error("Error getting today limits:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('storage:delete-all-sessions', async () => {
try {
storage.deleteAllSessions();
return { success: true };
} catch (error) {
console.error('Error deleting all sessions:', error);
return { success: false, error: error.message };
}
});
// ============ LIMITS ============
ipcMain.handle('storage:get-today-limits', async () => {
try {
return { success: true, data: storage.getTodayLimits() };
} catch (error) {
console.error('Error getting today limits:', error);
return { success: false, error: error.message };
}
});
// ============ CLEAR ALL ============
ipcMain.handle('storage:clear-all', async () => {
try {
storage.clearAllData();
return { success: true };
} catch (error) {
console.error('Error clearing all data:', error);
return { success: false, error: error.message };
}
});
// ============ CLEAR ALL ============
ipcMain.handle("storage:clear-all", async () => {
try {
storage.clearAllData();
return { success: true };
} catch (error) {
console.error("Error clearing all data:", error);
return { success: false, error: error.message };
}
});
}
function setupGeneralIpcHandlers() {
ipcMain.handle('get-app-version', async () => {
return app.getVersion();
});
ipcMain.handle("get-app-version", async () => {
return app.getVersion();
});
ipcMain.handle('open-logs-folder', async () => {
try {
const logPath = getLogPath();
const logsDir = require('path').dirname(logPath);
await shell.openPath(logsDir);
return { success: true, path: logsDir };
} catch (error) {
console.error('Error opening logs folder:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("quit-application", async (event) => {
try {
stopMacOSAudioCapture();
app.quit();
return { success: true };
} catch (error) {
console.error("Error quitting application:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('quit-application', async event => {
try {
stopMacOSAudioCapture();
app.quit();
return { success: true };
} catch (error) {
console.error('Error quitting application:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle("open-external", async (event, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error("Error opening external URL:", error);
return { success: false, error: error.message };
}
});
ipcMain.handle('open-external', async (event, url) => {
try {
await shell.openExternal(url);
return { success: true };
} catch (error) {
console.error('Error opening external URL:', error);
return { success: false, error: error.message };
}
});
ipcMain.on("update-keybinds", (event, newKeybinds) => {
if (mainWindow) {
// Also save to storage
storage.setKeybinds(newKeybinds);
updateGlobalShortcuts(
newKeybinds,
mainWindow,
sendToRenderer,
geminiSessionRef,
);
}
});
ipcMain.on('update-keybinds', (event, newKeybinds) => {
if (mainWindow) {
// Also save to storage
storage.setKeybinds(newKeybinds);
updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef);
}
});
// Debug logging from renderer
ipcMain.on('log-message', (event, msg) => {
console.log(msg);
});
// Debug logging from renderer
ipcMain.on("log-message", (event, msg) => {
console.log(msg);
});
}
+386 -312
View File
@@ -1,508 +1,582 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const fs = require("fs");
const path = require("path");
const os = require("os");
const CONFIG_VERSION = 1;
// Default values
const DEFAULT_CONFIG = {
configVersion: CONFIG_VERSION,
onboarded: false,
layout: 'normal'
configVersion: CONFIG_VERSION,
onboarded: false,
layout: "normal",
};
const DEFAULT_CREDENTIALS = {
apiKey: '',
// OpenAI Realtime API settings
openaiApiKey: '',
openaiBaseUrl: '',
openaiModel: 'gpt-4o-realtime-preview-2024-12-17',
// OpenAI SDK settings (for BotHub and other providers)
openaiSdkApiKey: '',
openaiSdkBaseUrl: '',
openaiSdkModel: 'gpt-4o',
openaiSdkVisionModel: 'gpt-4o',
openaiSdkWhisperModel: 'whisper-1'
apiKey: "",
groqApiKey: "",
openaiCompatibleApiKey: "",
openaiCompatibleBaseUrl: "",
openaiCompatibleModel: "",
};
const DEFAULT_PREFERENCES = {
customPrompt: '',
selectedProfile: 'interview',
selectedLanguage: 'en-US',
selectedScreenshotInterval: '5',
selectedImageQuality: 'medium',
advancedMode: false,
audioMode: 'speaker_only',
fontSize: 'medium',
backgroundTransparency: 0.8,
googleSearchEnabled: false,
aiProvider: 'gemini'
customPrompt: "",
selectedProfile: "interview",
selectedLanguage: "en-US",
selectedScreenshotInterval: "5",
selectedImageQuality: "medium",
advancedMode: false,
audioMode: "speaker_only",
fontSize: "medium",
backgroundTransparency: 0.8,
googleSearchEnabled: false,
providerMode: "local",
responseProvider: "gemini",
llmProvider: "lmstudio",
sttProvider: "nemotron-sidecar",
localLlmBaseUrl: "http://127.0.0.1:1234/v1",
localLlmModel: "",
localLlmApiKey: "",
localSttUrl: "ws://127.0.0.1:8765/v1/asr/stream",
localSttLanguage: "en-US",
ollamaHost: "http://127.0.0.1:11434",
ollamaModel: "llama3.1",
whisperModel: "Xenova/whisper-small",
whisperDevice: "", // '' = auto-detect, 'cpu' = native, 'wasm' = compatible
};
const DEFAULT_KEYBINDS = null; // null means use system defaults
const DEFAULT_LIMITS = {
data: [] // Array of { date: 'YYYY-MM-DD', flash: { count: 0 }, flashLite: { count: 0 } }
data: [], // Array of { date: 'YYYY-MM-DD', flash: { count }, flashLite: { count }, groq: { 'qwen3-32b': { chars, limit }, 'gpt-oss-120b': { chars, limit }, 'gpt-oss-20b': { chars, limit } }, gemini: { 'gemma-3-27b-it': { chars } } }
};
// Get the config directory path based on OS
function getConfigDir() {
const platform = os.platform();
let configDir;
const platform = os.platform();
let configDir;
if (platform === 'win32') {
configDir = path.join(os.homedir(), 'AppData', 'Roaming', 'cheating-daddy-config');
} else if (platform === 'darwin') {
configDir = path.join(os.homedir(), 'Library', 'Application Support', 'cheating-daddy-config');
} else {
configDir = path.join(os.homedir(), '.config', 'cheating-daddy-config');
}
if (platform === "win32") {
configDir = path.join(
os.homedir(),
"AppData",
"Roaming",
"cheating-daddy-config",
);
} else if (platform === "darwin") {
configDir = path.join(
os.homedir(),
"Library",
"Application Support",
"cheating-daddy-config",
);
} else {
configDir = path.join(os.homedir(), ".config", "cheating-daddy-config");
}
return configDir;
return configDir;
}
// File paths
function getConfigPath() {
return path.join(getConfigDir(), 'config.json');
return path.join(getConfigDir(), "config.json");
}
function getCredentialsPath() {
return path.join(getConfigDir(), 'credentials.json');
return path.join(getConfigDir(), "credentials.json");
}
function getPreferencesPath() {
return path.join(getConfigDir(), 'preferences.json');
return path.join(getConfigDir(), "preferences.json");
}
function getKeybindsPath() {
return path.join(getConfigDir(), 'keybinds.json');
return path.join(getConfigDir(), "keybinds.json");
}
function getLimitsPath() {
return path.join(getConfigDir(), 'limits.json');
return path.join(getConfigDir(), "limits.json");
}
function getHistoryDir() {
return path.join(getConfigDir(), 'history');
return path.join(getConfigDir(), "history");
}
// Helper to read JSON file safely
function readJsonFile(filePath, defaultValue) {
try {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, 'utf8');
return JSON.parse(data);
}
} catch (error) {
console.warn(`Error reading ${filePath}:`, error.message);
try {
if (fs.existsSync(filePath)) {
const data = fs.readFileSync(filePath, "utf8");
return JSON.parse(data);
}
return defaultValue;
} catch (error) {
console.warn(`Error reading ${filePath}:`, error.message);
}
return defaultValue;
}
// Helper to write JSON file safely
function writeJsonFile(filePath, data) {
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
return true;
} catch (error) {
console.error(`Error writing ${filePath}:`, error.message);
return false;
try {
const dir = path.dirname(filePath);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
return true;
} catch (error) {
console.error(`Error writing ${filePath}:`, error.message);
return false;
}
}
// Check if we need to reset (no configVersion or wrong version)
function needsReset() {
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return true;
}
const configPath = getConfigPath();
if (!fs.existsSync(configPath)) {
return true;
}
try {
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
return !config.configVersion || config.configVersion !== CONFIG_VERSION;
} catch {
return true;
}
try {
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
return !config.configVersion || config.configVersion !== CONFIG_VERSION;
} catch {
return true;
}
}
// Wipe and reinitialize the config directory
function resetConfigDir() {
const configDir = getConfigDir();
const configDir = getConfigDir();
console.log('Resetting config directory...');
console.log("Resetting config directory...");
// Remove existing directory if it exists
if (fs.existsSync(configDir)) {
fs.rmSync(configDir, { recursive: true, force: true });
}
// Remove existing directory if it exists
if (fs.existsSync(configDir)) {
fs.rmSync(configDir, { recursive: true, force: true });
}
// Create fresh directory structure
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(getHistoryDir(), { recursive: true });
// Create fresh directory structure
fs.mkdirSync(configDir, { recursive: true });
fs.mkdirSync(getHistoryDir(), { recursive: true });
// Initialize with defaults
writeJsonFile(getConfigPath(), DEFAULT_CONFIG);
writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES);
// Initialize with defaults
writeJsonFile(getConfigPath(), DEFAULT_CONFIG);
writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES);
console.log('Config directory initialized with defaults');
console.log("Config directory initialized with defaults");
}
// Initialize storage - call this on app startup
function initializeStorage() {
if (needsReset()) {
resetConfigDir();
} else {
// Ensure history directory exists
const historyDir = getHistoryDir();
if (!fs.existsSync(historyDir)) {
fs.mkdirSync(historyDir, { recursive: true });
}
if (needsReset()) {
resetConfigDir();
} else {
// Ensure history directory exists
const historyDir = getHistoryDir();
if (!fs.existsSync(historyDir)) {
fs.mkdirSync(historyDir, { recursive: true });
}
}
}
// ============ CONFIG ============
function getConfig() {
return readJsonFile(getConfigPath(), DEFAULT_CONFIG);
return readJsonFile(getConfigPath(), DEFAULT_CONFIG);
}
function setConfig(config) {
const current = getConfig();
const updated = { ...current, ...config, configVersion: CONFIG_VERSION };
return writeJsonFile(getConfigPath(), updated);
const current = getConfig();
const updated = { ...current, ...config, configVersion: CONFIG_VERSION };
return writeJsonFile(getConfigPath(), updated);
}
function updateConfig(key, value) {
const config = getConfig();
config[key] = value;
return writeJsonFile(getConfigPath(), config);
const config = getConfig();
config[key] = value;
return writeJsonFile(getConfigPath(), config);
}
// ============ CREDENTIALS ============
function getCredentials() {
return readJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
return readJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
}
function setCredentials(credentials) {
const current = getCredentials();
const updated = { ...current, ...credentials };
return writeJsonFile(getCredentialsPath(), updated);
const current = getCredentials();
const updated = { ...current, ...credentials };
return writeJsonFile(getCredentialsPath(), updated);
}
function getApiKey() {
return getCredentials().apiKey || '';
return getCredentials().apiKey || "";
}
function setApiKey(apiKey) {
return setCredentials({ apiKey });
return setCredentials({ apiKey });
}
function getOpenAICredentials() {
const creds = getCredentials();
return {
apiKey: creds.openaiApiKey || '',
baseUrl: creds.openaiBaseUrl || '',
model: creds.openaiModel || 'gpt-4o-realtime-preview-2024-12-17'
};
function getGroqApiKey() {
return getCredentials().groqApiKey || "";
}
function setOpenAICredentials(config) {
const updates = {};
if (config.apiKey !== undefined) updates.openaiApiKey = config.apiKey;
if (config.baseUrl !== undefined) updates.openaiBaseUrl = config.baseUrl;
if (config.model !== undefined) updates.openaiModel = config.model;
return setCredentials(updates);
function setGroqApiKey(groqApiKey) {
return setCredentials({ groqApiKey });
}
function getOpenAISDKCredentials() {
const creds = getCredentials();
return {
apiKey: creds.openaiSdkApiKey || '',
baseUrl: creds.openaiSdkBaseUrl || '',
model: creds.openaiSdkModel || 'gpt-4o',
visionModel: creds.openaiSdkVisionModel || 'gpt-4o',
whisperModel: creds.openaiSdkWhisperModel || 'whisper-1'
};
function getOpenAICompatibleConfig() {
const creds = getCredentials();
return {
apiKey: creds.openaiCompatibleApiKey || "",
baseUrl: creds.openaiCompatibleBaseUrl || "",
model: creds.openaiCompatibleModel || "",
};
}
function setOpenAISDKCredentials(config) {
const updates = {};
if (config.apiKey !== undefined) updates.openaiSdkApiKey = config.apiKey;
if (config.baseUrl !== undefined) updates.openaiSdkBaseUrl = config.baseUrl;
if (config.model !== undefined) updates.openaiSdkModel = config.model;
if (config.visionModel !== undefined) updates.openaiSdkVisionModel = config.visionModel;
if (config.whisperModel !== undefined) updates.openaiSdkWhisperModel = config.whisperModel;
return setCredentials(updates);
function setOpenAICompatibleConfig(apiKey, baseUrl, model) {
return setCredentials({
openaiCompatibleApiKey: apiKey,
openaiCompatibleBaseUrl: baseUrl,
openaiCompatibleModel: model,
});
}
// ============ PREFERENCES ============
function getPreferences() {
const saved = readJsonFile(getPreferencesPath(), {});
return { ...DEFAULT_PREFERENCES, ...saved };
const saved = readJsonFile(getPreferencesPath(), {});
return { ...DEFAULT_PREFERENCES, ...saved };
}
function setPreferences(preferences) {
const current = getPreferences();
const updated = { ...current, ...preferences };
return writeJsonFile(getPreferencesPath(), updated);
const current = getPreferences();
const updated = { ...current, ...preferences };
return writeJsonFile(getPreferencesPath(), updated);
}
function updatePreference(key, value) {
const preferences = getPreferences();
preferences[key] = value;
return writeJsonFile(getPreferencesPath(), preferences);
const preferences = getPreferences();
preferences[key] = value;
return writeJsonFile(getPreferencesPath(), preferences);
}
// ============ KEYBINDS ============
function getKeybinds() {
return readJsonFile(getKeybindsPath(), DEFAULT_KEYBINDS);
return readJsonFile(getKeybindsPath(), DEFAULT_KEYBINDS);
}
function setKeybinds(keybinds) {
return writeJsonFile(getKeybindsPath(), keybinds);
return writeJsonFile(getKeybindsPath(), keybinds);
}
// ============ LIMITS (Rate Limiting) ============
function getLimits() {
return readJsonFile(getLimitsPath(), DEFAULT_LIMITS);
return readJsonFile(getLimitsPath(), DEFAULT_LIMITS);
}
function setLimits(limits) {
return writeJsonFile(getLimitsPath(), limits);
return writeJsonFile(getLimitsPath(), limits);
}
function getTodayDateString() {
const now = new Date();
return now.toISOString().split('T')[0]; // YYYY-MM-DD
const now = new Date();
return now.toISOString().split("T")[0]; // YYYY-MM-DD
}
function getTodayLimits() {
const limits = getLimits();
const today = getTodayDateString();
const limits = getLimits();
const today = getTodayDateString();
// Find today's entry
const todayEntry = limits.data.find(entry => entry.date === today);
// Find today's entry
const todayEntry = limits.data.find((entry) => entry.date === today);
if (todayEntry) {
return todayEntry;
if (todayEntry) {
// ensure new fields exist
if (!todayEntry.groq) {
todayEntry.groq = {
"qwen3-32b": { chars: 0, limit: 1500000 },
"gpt-oss-120b": { chars: 0, limit: 600000 },
"gpt-oss-20b": { chars: 0, limit: 600000 },
"kimi-k2-instruct": { chars: 0, limit: 600000 },
};
}
if (!todayEntry.gemini) {
todayEntry.gemini = {
"gemma-3-27b-it": { chars: 0 },
};
}
// No entry for today - clean old entries and create new one
limits.data = limits.data.filter(entry => entry.date === today);
const newEntry = {
date: today,
flash: { count: 0 },
flashLite: { count: 0 }
};
limits.data.push(newEntry);
setLimits(limits);
return todayEntry;
}
return newEntry;
// No entry for today - clean old entries and create new one
limits.data = limits.data.filter((entry) => entry.date === today);
const newEntry = {
date: today,
flash: { count: 0 },
flashLite: { count: 0 },
groq: {
"qwen3-32b": { chars: 0, limit: 1500000 },
"gpt-oss-120b": { chars: 0, limit: 600000 },
"gpt-oss-20b": { chars: 0, limit: 600000 },
"kimi-k2-instruct": { chars: 0, limit: 600000 },
},
gemini: {
"gemma-3-27b-it": { chars: 0 },
},
};
limits.data.push(newEntry);
setLimits(limits);
return newEntry;
}
function incrementLimitCount(model) {
const limits = getLimits();
const today = getTodayDateString();
const limits = getLimits();
const today = getTodayDateString();
// Find or create today's entry
let todayEntry = limits.data.find(entry => entry.date === today);
// Find or create today's entry
let todayEntry = limits.data.find((entry) => entry.date === today);
if (!todayEntry) {
// Clean old entries and create new one
limits.data = [];
todayEntry = {
date: today,
flash: { count: 0 },
flashLite: { count: 0 }
};
limits.data.push(todayEntry);
} else {
// Clean old entries, keep only today
limits.data = limits.data.filter(entry => entry.date === today);
}
if (!todayEntry) {
// Clean old entries and create new one
limits.data = [];
todayEntry = {
date: today,
flash: { count: 0 },
flashLite: { count: 0 },
};
limits.data.push(todayEntry);
} else {
// Clean old entries, keep only today
limits.data = limits.data.filter((entry) => entry.date === today);
}
// Increment the appropriate model count
if (model === 'gemini-2.5-flash') {
todayEntry.flash.count++;
} else if (model === 'gemini-2.5-flash-lite') {
todayEntry.flashLite.count++;
}
// Increment the appropriate model count
if (model === "gemini-2.5-flash") {
todayEntry.flash.count++;
} else if (model === "gemini-2.5-flash-lite") {
todayEntry.flashLite.count++;
}
setLimits(limits);
return todayEntry;
}
function incrementCharUsage(provider, model, charCount) {
getTodayLimits();
const limits = getLimits();
const today = getTodayDateString();
const todayEntry = limits.data.find((entry) => entry.date === today);
if (todayEntry[provider] && todayEntry[provider][model]) {
todayEntry[provider][model].chars += charCount;
setLimits(limits);
return todayEntry;
}
return todayEntry;
}
function getAvailableModel() {
const todayLimits = getTodayLimits();
const todayLimits = getTodayLimits();
// RPD limits: flash = 20, flash-lite = 20
// After both exhausted, fall back to flash (for paid API users)
if (todayLimits.flash.count < 20) {
return 'gemini-2.5-flash';
} else if (todayLimits.flashLite.count < 20) {
return 'gemini-2.5-flash-lite';
}
// RPD limits: flash = 20, flash-lite = 20
// After both exhausted, fall back to flash (for paid API users)
if (todayLimits.flash.count < 20) {
return "gemini-2.5-flash";
} else if (todayLimits.flashLite.count < 20) {
return "gemini-2.5-flash-lite";
}
return 'gemini-2.5-flash'; // Default to flash for paid API users
return "gemini-2.5-flash"; // Default to flash for paid API users
}
function getModelForToday() {
const todayEntry = getTodayLimits();
const groq = todayEntry.groq;
if (groq["qwen3-32b"].chars < groq["qwen3-32b"].limit) {
return "qwen/qwen3-32b";
}
if (groq["gpt-oss-120b"].chars < groq["gpt-oss-120b"].limit) {
return "openai/gpt-oss-120b";
}
if (groq["gpt-oss-20b"].chars < groq["gpt-oss-20b"].limit) {
return "openai/gpt-oss-20b";
}
if (groq["kimi-k2-instruct"].chars < groq["kimi-k2-instruct"].limit) {
return "moonshotai/kimi-k2-instruct";
}
// All limits exhausted
return null;
}
// ============ HISTORY ============
function getSessionPath(sessionId) {
return path.join(getHistoryDir(), `${sessionId}.json`);
return path.join(getHistoryDir(), `${sessionId}.json`);
}
function saveSession(sessionId, data) {
const sessionPath = getSessionPath(sessionId);
const sessionPath = getSessionPath(sessionId);
// Load existing session to preserve metadata
const existingSession = readJsonFile(sessionPath, null);
// Load existing session to preserve metadata
const existingSession = readJsonFile(sessionPath, null);
const sessionData = {
sessionId,
createdAt: existingSession?.createdAt || parseInt(sessionId),
lastUpdated: Date.now(),
// Profile context - set once when session starts
profile: data.profile || existingSession?.profile || null,
customPrompt: data.customPrompt || existingSession?.customPrompt || null,
// Conversation data
conversationHistory: data.conversationHistory || existingSession?.conversationHistory || [],
screenAnalysisHistory: data.screenAnalysisHistory || existingSession?.screenAnalysisHistory || []
};
return writeJsonFile(sessionPath, sessionData);
const sessionData = {
sessionId,
createdAt: existingSession?.createdAt || parseInt(sessionId),
lastUpdated: Date.now(),
// Profile context - set once when session starts
profile: data.profile || existingSession?.profile || null,
customPrompt: data.customPrompt || existingSession?.customPrompt || null,
// Conversation data
conversationHistory:
data.conversationHistory || existingSession?.conversationHistory || [],
screenAnalysisHistory:
data.screenAnalysisHistory ||
existingSession?.screenAnalysisHistory ||
[],
};
return writeJsonFile(sessionPath, sessionData);
}
function getSession(sessionId) {
return readJsonFile(getSessionPath(sessionId), null);
return readJsonFile(getSessionPath(sessionId), null);
}
function getAllSessions() {
const historyDir = getHistoryDir();
const historyDir = getHistoryDir();
try {
if (!fs.existsSync(historyDir)) {
return [];
}
const files = fs.readdirSync(historyDir)
.filter(f => f.endsWith('.json'))
.sort((a, b) => {
// Sort by timestamp descending (newest first)
const tsA = parseInt(a.replace('.json', ''));
const tsB = parseInt(b.replace('.json', ''));
return tsB - tsA;
});
return files.map(file => {
const sessionId = file.replace('.json', '');
const data = readJsonFile(path.join(historyDir, file), null);
if (data) {
return {
sessionId,
createdAt: data.createdAt,
lastUpdated: data.lastUpdated,
messageCount: data.conversationHistory?.length || 0,
screenAnalysisCount: data.screenAnalysisHistory?.length || 0,
profile: data.profile || null,
customPrompt: data.customPrompt || null
};
}
return null;
}).filter(Boolean);
} catch (error) {
console.error('Error reading sessions:', error.message);
return [];
try {
if (!fs.existsSync(historyDir)) {
return [];
}
const files = fs
.readdirSync(historyDir)
.filter((f) => f.endsWith(".json"))
.sort((a, b) => {
// Sort by timestamp descending (newest first)
const tsA = parseInt(a.replace(".json", ""));
const tsB = parseInt(b.replace(".json", ""));
return tsB - tsA;
});
return files
.map((file) => {
const sessionId = file.replace(".json", "");
const data = readJsonFile(path.join(historyDir, file), null);
if (data) {
return {
sessionId,
createdAt: data.createdAt,
lastUpdated: data.lastUpdated,
messageCount: data.conversationHistory?.length || 0,
screenAnalysisCount: data.screenAnalysisHistory?.length || 0,
profile: data.profile || null,
customPrompt: data.customPrompt || null,
};
}
return null;
})
.filter(Boolean);
} catch (error) {
console.error("Error reading sessions:", error.message);
return [];
}
}
function deleteSession(sessionId) {
const sessionPath = getSessionPath(sessionId);
try {
if (fs.existsSync(sessionPath)) {
fs.unlinkSync(sessionPath);
return true;
}
} catch (error) {
console.error('Error deleting session:', error.message);
const sessionPath = getSessionPath(sessionId);
try {
if (fs.existsSync(sessionPath)) {
fs.unlinkSync(sessionPath);
return true;
}
return false;
} catch (error) {
console.error("Error deleting session:", error.message);
}
return false;
}
function deleteAllSessions() {
const historyDir = getHistoryDir();
try {
if (fs.existsSync(historyDir)) {
const files = fs.readdirSync(historyDir).filter(f => f.endsWith('.json'));
files.forEach(file => {
fs.unlinkSync(path.join(historyDir, file));
});
}
return true;
} catch (error) {
console.error('Error deleting all sessions:', error.message);
return false;
const historyDir = getHistoryDir();
try {
if (fs.existsSync(historyDir)) {
const files = fs
.readdirSync(historyDir)
.filter((f) => f.endsWith(".json"));
files.forEach((file) => {
fs.unlinkSync(path.join(historyDir, file));
});
}
return true;
} catch (error) {
console.error("Error deleting all sessions:", error.message);
return false;
}
}
// ============ CLEAR ALL DATA ============
function clearAllData() {
resetConfigDir();
return true;
resetConfigDir();
return true;
}
module.exports = {
// Initialization
initializeStorage,
getConfigDir,
// Initialization
initializeStorage,
getConfigDir,
// Config
getConfig,
setConfig,
updateConfig,
// Config
getConfig,
setConfig,
updateConfig,
// Credentials
getCredentials,
setCredentials,
getApiKey,
setApiKey,
getOpenAICredentials,
setOpenAICredentials,
getOpenAISDKCredentials,
setOpenAISDKCredentials,
// Credentials
getCredentials,
setCredentials,
getApiKey,
setApiKey,
getGroqApiKey,
setGroqApiKey,
getOpenAICompatibleConfig,
setOpenAICompatibleConfig,
// Preferences
getPreferences,
setPreferences,
updatePreference,
// Preferences
getPreferences,
setPreferences,
updatePreference,
// Keybinds
getKeybinds,
setKeybinds,
// Keybinds
getKeybinds,
setKeybinds,
// Limits (Rate Limiting)
getLimits,
setLimits,
getTodayLimits,
incrementLimitCount,
getAvailableModel,
// Limits (Rate Limiting)
getLimits,
setLimits,
getTodayLimits,
incrementLimitCount,
getAvailableModel,
incrementCharUsage,
getModelForToday,
// History
saveSession,
getSession,
getAllSessions,
deleteSession,
deleteAllSessions,
// History
saveSession,
getSession,
getAllSessions,
deleteSession,
deleteAllSessions,
// Clear all
clearAllData
// Clear all
clearAllData,
};
-453
View File
@@ -1,453 +0,0 @@
const { BrowserWindow, ipcMain } = require('electron');
const { getSystemPrompt } = require('./prompts');
const { getAvailableModel, incrementLimitCount, getApiKey, getOpenAICredentials, getOpenAISDKCredentials, getPreferences } = require('../storage');
// Import provider implementations
const geminiProvider = require('./gemini');
const openaiRealtimeProvider = require('./openai-realtime');
const openaiSdkProvider = require('./openai-sdk');
// Conversation tracking (shared across providers)
let currentSessionId = null;
let conversationHistory = [];
let screenAnalysisHistory = [];
let currentProfile = null;
let currentCustomPrompt = null;
let currentProvider = 'gemini'; // 'gemini', 'openai-realtime', or 'openai-sdk'
let providerConfig = {};
function sendToRenderer(channel, data) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send(channel, data);
}
}
function initializeNewSession(profile = null, customPrompt = null) {
currentSessionId = Date.now().toString();
conversationHistory = [];
screenAnalysisHistory = [];
currentProfile = profile;
currentCustomPrompt = customPrompt;
console.log('New conversation session started:', currentSessionId, 'profile:', profile, 'provider:', currentProvider);
if (profile) {
sendToRenderer('save-session-context', {
sessionId: currentSessionId,
profile: profile,
customPrompt: customPrompt || '',
provider: currentProvider,
});
}
}
function saveConversationTurn(transcription, aiResponse) {
if (!currentSessionId) {
initializeNewSession();
}
const conversationTurn = {
timestamp: Date.now(),
transcription: transcription.trim(),
ai_response: aiResponse.trim(),
};
conversationHistory.push(conversationTurn);
console.log('Saved conversation turn:', conversationTurn);
sendToRenderer('save-conversation-turn', {
sessionId: currentSessionId,
turn: conversationTurn,
fullHistory: conversationHistory,
});
}
function saveScreenAnalysis(prompt, response, model) {
if (!currentSessionId) {
initializeNewSession();
}
const analysisEntry = {
timestamp: Date.now(),
prompt: prompt,
response: response.trim(),
model: model,
provider: currentProvider,
};
screenAnalysisHistory.push(analysisEntry);
console.log('Saved screen analysis:', analysisEntry);
sendToRenderer('save-screen-analysis', {
sessionId: currentSessionId,
analysis: analysisEntry,
fullHistory: screenAnalysisHistory,
profile: currentProfile,
customPrompt: currentCustomPrompt,
});
}
function getCurrentSessionData() {
return {
sessionId: currentSessionId,
history: conversationHistory,
provider: currentProvider,
};
}
// Get provider configuration from storage
async function getStoredSetting(key, defaultValue) {
try {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
await new Promise(resolve => setTimeout(resolve, 100));
const value = await windows[0].webContents.executeJavaScript(`
(function() {
try {
if (typeof localStorage === 'undefined') {
return '${defaultValue}';
}
const stored = localStorage.getItem('${key}');
return stored || '${defaultValue}';
} catch (e) {
return '${defaultValue}';
}
})()
`);
return value;
}
} catch (error) {
console.error('Error getting stored setting for', key, ':', error.message);
}
return defaultValue;
}
// Initialize AI session based on selected provider
async function initializeAISession(customPrompt = '', profile = 'interview', language = 'en-US') {
// Read provider from file-based storage (preferences.json)
const prefs = getPreferences();
const provider = prefs.aiProvider || 'gemini';
currentProvider = provider;
console.log('Initializing AI session with provider:', provider);
// Check if Google Search is enabled for system prompt
const googleSearchEnabled = prefs.googleSearchEnabled ?? true;
const systemPrompt = getSystemPrompt(profile, customPrompt, googleSearchEnabled);
if (provider === 'openai-realtime') {
// Get OpenAI Realtime configuration
const creds = getOpenAICredentials();
if (!creds.apiKey) {
sendToRenderer('update-status', 'OpenAI API key not configured');
return false;
}
providerConfig = {
apiKey: creds.apiKey,
baseUrl: creds.baseUrl || null,
model: creds.model,
systemPrompt,
language,
isReconnect: false,
};
initializeNewSession(profile, customPrompt);
try {
await openaiRealtimeProvider.initializeOpenAISession(providerConfig, conversationHistory);
return true;
} catch (error) {
console.error('Failed to initialize OpenAI Realtime session:', error);
sendToRenderer('update-status', 'Failed to connect to OpenAI Realtime');
return false;
}
} else if (provider === 'openai-sdk') {
// Get OpenAI SDK configuration (for BotHub, etc.)
const creds = getOpenAISDKCredentials();
if (!creds.apiKey) {
sendToRenderer('update-status', 'OpenAI SDK API key not configured');
return false;
}
providerConfig = {
apiKey: creds.apiKey,
baseUrl: creds.baseUrl || null,
model: creds.model,
visionModel: creds.visionModel,
whisperModel: creds.whisperModel,
};
initializeNewSession(profile, customPrompt);
try {
await openaiSdkProvider.initializeOpenAISDK(providerConfig);
openaiSdkProvider.setSystemPrompt(systemPrompt);
sendToRenderer('update-status', 'Ready (OpenAI SDK)');
return true;
} catch (error) {
console.error('Failed to initialize OpenAI SDK:', error);
sendToRenderer('update-status', 'Failed to initialize OpenAI SDK: ' + error.message);
return false;
}
} else {
// Use Gemini (default)
const apiKey = getApiKey();
if (!apiKey) {
sendToRenderer('update-status', 'Gemini API key not configured');
return false;
}
const session = await geminiProvider.initializeGeminiSession(apiKey, customPrompt, profile, language);
if (session && global.geminiSessionRef) {
global.geminiSessionRef.current = session;
return true;
}
return false;
}
}
// Send audio to appropriate provider
async function sendAudioContent(data, mimeType, isSystemAudio = true) {
if (currentProvider === 'openai-realtime') {
return await openaiRealtimeProvider.sendAudioToOpenAI(data);
} else if (currentProvider === 'openai-sdk') {
// OpenAI SDK buffers audio and transcribes on flush
return await openaiSdkProvider.processAudioChunk(data, mimeType);
} else {
// Gemini
if (!global.geminiSessionRef?.current) {
return { success: false, error: 'No active Gemini session' };
}
try {
const marker = isSystemAudio ? '.' : ',';
process.stdout.write(marker);
await global.geminiSessionRef.current.sendRealtimeInput({
audio: { data, mimeType },
});
return { success: true };
} catch (error) {
console.error('Error sending audio to Gemini:', error);
return { success: false, error: error.message };
}
}
}
// Send image to appropriate provider
async function sendImageContent(data, prompt) {
if (currentProvider === 'openai-realtime') {
const creds = getOpenAICredentials();
const result = await openaiRealtimeProvider.sendImageToOpenAI(data, prompt, {
apiKey: creds.apiKey,
baseUrl: creds.baseUrl,
model: creds.model,
});
if (result.success) {
saveScreenAnalysis(prompt, result.text, result.model);
}
return result;
} else if (currentProvider === 'openai-sdk') {
const result = await openaiSdkProvider.sendImageMessage(data, prompt);
if (result.success) {
saveScreenAnalysis(prompt, result.text, result.model);
}
return result;
} else {
// Use Gemini HTTP API
const result = await geminiProvider.sendImageToGeminiHttp(data, prompt);
// Screen analysis is saved inside sendImageToGeminiHttp for Gemini
return result;
}
}
// Send text message to appropriate provider
async function sendTextMessage(text) {
if (currentProvider === 'openai-realtime') {
return await openaiRealtimeProvider.sendTextToOpenAI(text);
} else if (currentProvider === 'openai-sdk') {
const result = await openaiSdkProvider.sendTextMessage(text);
if (result.success && result.text) {
saveConversationTurn(text, result.text);
}
return result;
} else {
// Gemini
if (!global.geminiSessionRef?.current) {
return { success: false, error: 'No active Gemini session' };
}
try {
console.log('Sending text message to Gemini:', text);
await global.geminiSessionRef.current.sendRealtimeInput({ text: text.trim() });
return { success: true };
} catch (error) {
console.error('Error sending text to Gemini:', error);
return { success: false, error: error.message };
}
}
}
// Close session for appropriate provider
async function closeSession() {
try {
if (currentProvider === 'openai-realtime') {
openaiRealtimeProvider.closeOpenAISession();
} else if (currentProvider === 'openai-sdk') {
openaiSdkProvider.closeOpenAISDK();
} else {
geminiProvider.stopMacOSAudioCapture();
if (global.geminiSessionRef?.current) {
await global.geminiSessionRef.current.close();
global.geminiSessionRef.current = null;
}
}
return { success: true };
} catch (error) {
console.error('Error closing session:', error);
return { success: false, error: error.message };
}
}
// Setup IPC handlers
function setupAIProviderIpcHandlers(geminiSessionRef) {
// Store reference for Gemini
global.geminiSessionRef = geminiSessionRef;
// Listen for conversation turn save requests from providers
ipcMain.on('save-conversation-turn-data', (event, { transcription, response }) => {
saveConversationTurn(transcription, response);
});
ipcMain.handle('initialize-ai-session', async (event, customPrompt, profile, language) => {
return await initializeAISession(customPrompt, profile, language);
});
ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
return await sendAudioContent(data, mimeType, true);
});
ipcMain.handle('send-mic-audio-content', async (event, { data, mimeType }) => {
return await sendAudioContent(data, mimeType, false);
});
ipcMain.handle('send-image-content', async (event, { data, prompt }) => {
return await sendImageContent(data, prompt);
});
ipcMain.handle('send-text-message', async (event, text) => {
return await sendTextMessage(text);
});
ipcMain.handle('close-session', async event => {
return await closeSession();
});
// macOS system audio
ipcMain.handle('start-macos-audio', async event => {
if (process.platform !== 'darwin') {
return {
success: false,
error: 'macOS audio capture only available on macOS',
};
}
try {
if (currentProvider === 'gemini') {
const success = await geminiProvider.startMacOSAudioCapture(global.geminiSessionRef);
return { success };
} else if (currentProvider === 'openai-sdk') {
const success = await openaiSdkProvider.startMacOSAudioCapture();
return { success };
} else if (currentProvider === 'openai-realtime') {
// OpenAI Realtime uses WebSocket, handle differently if needed
return {
success: false,
error: 'OpenAI Realtime uses WebSocket for audio',
};
}
return {
success: false,
error: 'Unknown provider: ' + currentProvider,
};
} catch (error) {
console.error('Error starting macOS audio capture:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('stop-macos-audio', async event => {
try {
if (currentProvider === 'gemini') {
geminiProvider.stopMacOSAudioCapture();
} else if (currentProvider === 'openai-sdk') {
openaiSdkProvider.stopMacOSAudioCapture();
}
return { success: true };
} catch (error) {
console.error('Error stopping macOS audio capture:', error);
return { success: false, error: error.message };
}
});
// Session management
ipcMain.handle('get-current-session', async event => {
try {
return { success: true, data: getCurrentSessionData() };
} catch (error) {
console.error('Error getting current session:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('start-new-session', async event => {
try {
initializeNewSession();
return { success: true, sessionId: currentSessionId };
} catch (error) {
console.error('Error starting new session:', error);
return { success: false, error: error.message };
}
});
ipcMain.handle('update-google-search-setting', async (event, enabled) => {
try {
console.log('Google Search setting updated to:', enabled);
return { success: true };
} catch (error) {
console.error('Error updating Google Search setting:', error);
return { success: false, error: error.message };
}
});
// Provider switching
ipcMain.handle('switch-ai-provider', async (event, provider) => {
try {
console.log('Switching AI provider to:', provider);
currentProvider = provider;
return { success: true };
} catch (error) {
console.error('Error switching provider:', error);
return { success: false, error: error.message };
}
});
}
module.exports = {
setupAIProviderIpcHandlers,
initializeAISession,
sendAudioContent,
sendImageContent,
sendTextMessage,
closeSession,
getCurrentSessionData,
initializeNewSession,
saveConversationTurn,
};
+1264 -508
View File
File diff suppressed because it is too large Load Diff
+304
View File
@@ -0,0 +1,304 @@
const { EventEmitter } = require("events");
const WebSocket = require("ws");
const DEFAULT_LOCAL_LLM_BASE_URL = "http://127.0.0.1:1234/v1";
const DEFAULT_LOCAL_STT_URL = "ws://127.0.0.1:8765/v1/asr/stream";
const DEFAULT_LOCAL_STT_LANGUAGE = "en-US";
function normalizeOpenAiBaseUrl(baseUrl) {
const trimmed = (baseUrl || DEFAULT_LOCAL_LLM_BASE_URL)
.trim()
.replace(/\/+$/, "");
if (!trimmed) return DEFAULT_LOCAL_LLM_BASE_URL;
return trimmed.endsWith("/v1") ? trimmed : `${trimmed}/v1`;
}
function isLoopbackUrl(value) {
try {
const url = new URL(value);
const hostname = url.hostname.toLowerCase();
return (
hostname === "localhost" ||
hostname === "127.0.0.1" ||
hostname === "::1" ||
hostname === "[::1]"
);
} catch (_) {
return false;
}
}
function buildChatMessages({
systemPrompt,
history = [],
userText,
imageBase64,
}) {
const messages = [];
if (systemPrompt && systemPrompt.trim()) {
messages.push({ role: "system", content: systemPrompt.trim() });
}
for (const message of history) {
if (!message || !message.role || !message.content) continue;
if (
message.role !== "user" &&
message.role !== "assistant" &&
message.role !== "system"
)
continue;
messages.push({
role: message.role,
content: String(message.content),
});
}
const text = (userText || "").trim();
if (imageBase64) {
messages.push({
role: "user",
content: [
{ type: "text", text },
{
type: "image_url",
image_url: { url: `data:image/jpeg;base64,${imageBase64}` },
},
],
});
} else if (text) {
messages.push({ role: "user", content: text });
}
return messages;
}
function parseChatCompletionSseLine(line) {
if (!line.startsWith("data: ")) return null;
const data = line.slice(6).trim();
if (!data || data === "[DONE]") return null;
const parsed = JSON.parse(data);
return parsed.choices?.[0]?.delta?.content || "";
}
async function streamLmStudioChat({
baseUrl,
apiKey,
model,
messages,
temperature = 0.7,
maxTokens = 2048,
onToken,
}) {
if (!model || !model.trim()) {
throw new Error("LM Studio model id is required");
}
const normalizedBaseUrl = normalizeOpenAiBaseUrl(baseUrl);
const response = await fetch(`${normalizedBaseUrl}/chat/completions`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(apiKey && apiKey.trim()
? { Authorization: `Bearer ${apiKey.trim()}` }
: {}),
},
body: JSON.stringify({
model: model.trim(),
messages,
stream: true,
temperature,
max_tokens: maxTokens,
}),
});
if (!response.ok) {
const errorText = await response.text().catch(() => "");
if (response.status === 400 && /image|vision|multimodal/i.test(errorText)) {
throw new Error(
"The selected local model does not appear to support image input",
);
}
throw new Error(
`LM Studio error ${response.status}: ${errorText || response.statusText}`,
);
}
if (!response.body) {
throw new Error("LM Studio response did not include a stream body");
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let fullText = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || "";
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
let token = "";
try {
token = parseChatCompletionSseLine(trimmed) || "";
} catch (_) {
continue;
}
if (token) {
fullText += token;
if (onToken) onToken(token, fullText);
}
}
}
return fullText;
}
class NemotronSidecarClient extends EventEmitter {
constructor({
url = DEFAULT_LOCAL_STT_URL,
language = DEFAULT_LOCAL_STT_LANGUAGE,
sampleRate = 16000,
channels = 1,
encoding = "pcm_s16le",
} = {}) {
super();
this.url = url;
this.language = language;
this.sampleRate = sampleRate;
this.channels = channels;
this.encoding = encoding;
this.socket = null;
this.connected = false;
}
connect() {
if (this.connected && this.socket?.readyState === WebSocket.OPEN) {
return Promise.resolve();
}
return new Promise((resolve, reject) => {
const socket = new WebSocket(this.url);
this.socket = socket;
const cleanup = () => {
socket.removeListener("open", onOpen);
socket.removeListener("error", onErrorBeforeOpen);
};
const onOpen = () => {
cleanup();
this.connected = true;
this.emit("connected");
this._sendStart();
resolve();
};
const onErrorBeforeOpen = (error) => {
cleanup();
this.connected = false;
this.emit("error", error);
reject(error);
};
socket.once("open", onOpen);
socket.once("error", onErrorBeforeOpen);
socket.on("message", (data) => this._handleMessage(data));
socket.on("close", (code, reason) => {
this.connected = false;
this.emit("close", { code, reason: reason?.toString?.() || "" });
});
socket.on("error", (error) => {
this.connected = false;
this.emit("error", error);
});
});
}
_sendStart() {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
this.socket.send(
JSON.stringify({
type: "start",
sampleRate: this.sampleRate,
channels: this.channels,
encoding: this.encoding,
language: this.language,
}),
);
}
_handleMessage(data) {
let message;
try {
message = JSON.parse(data.toString("utf8"));
} catch (error) {
this.emit(
"error",
new Error(`Invalid ASR sidecar message: ${error.message}`),
);
return;
}
if (!message || !message.type) return;
if (message.type === "partial") {
this.emit("partial", message.text || "");
} else if (message.type === "final") {
this.emit("final", message.text || "");
} else if (message.type === "ready") {
this.emit("ready", message);
} else if (message.type === "error") {
this.emit(
"error",
new Error(message.error || message.message || "ASR sidecar error"),
);
} else {
this.emit(message.type, message);
}
}
sendAudio(pcm16kBuffer) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return false;
if (!pcm16kBuffer || pcm16kBuffer.length === 0) return false;
this.socket.send(pcm16kBuffer, { binary: true });
return true;
}
close() {
if (!this.socket) return;
try {
if (this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify({ type: "stop" }));
}
this.socket.close();
} catch (_) {
// Best-effort close.
}
this.connected = false;
this.socket = null;
}
}
module.exports = {
DEFAULT_LOCAL_LLM_BASE_URL,
DEFAULT_LOCAL_STT_LANGUAGE,
DEFAULT_LOCAL_STT_URL,
NemotronSidecarClient,
buildChatMessages,
isLoopbackUrl,
normalizeOpenAiBaseUrl,
parseChatCompletionSseLine,
streamLmStudioChat,
};
+1179
View File
File diff suppressed because it is too large Load Diff
-97
View File
@@ -1,97 +0,0 @@
const fs = require('fs');
const path = require('path');
const { app } = require('electron');
let logFile = null;
let logPath = null;
function getLogPath() {
if (logPath) return logPath;
const userDataPath = app.getPath('userData');
const logsDir = path.join(userDataPath, 'logs');
// Create logs directory if it doesn't exist
if (!fs.existsSync(logsDir)) {
fs.mkdirSync(logsDir, { recursive: true });
}
// Create log file with timestamp
const timestamp = new Date().toISOString().split('T')[0];
logPath = path.join(logsDir, `app-${timestamp}.log`);
return logPath;
}
function initLogger() {
try {
const filePath = getLogPath();
logFile = fs.createWriteStream(filePath, { flags: 'a' });
const startMsg = `\n${'='.repeat(60)}\nApp started at ${new Date().toISOString()}\nPlatform: ${process.platform}, Arch: ${process.arch}\nElectron: ${process.versions.electron}, Node: ${process.versions.node}\nPackaged: ${app.isPackaged}\n${'='.repeat(60)}\n`;
logFile.write(startMsg);
// Override console methods to also write to file
const originalLog = console.log;
const originalError = console.error;
const originalWarn = console.warn;
console.log = (...args) => {
originalLog.apply(console, args);
writeLog('INFO', args);
};
console.error = (...args) => {
originalError.apply(console, args);
writeLog('ERROR', args);
};
console.warn = (...args) => {
originalWarn.apply(console, args);
writeLog('WARN', args);
};
console.log('Logger initialized, writing to:', filePath);
return filePath;
} catch (err) {
console.error('Failed to initialize logger:', err);
return null;
}
}
function writeLog(level, args) {
if (!logFile) return;
try {
const timestamp = new Date().toISOString();
const message = args.map(arg => {
if (typeof arg === 'object') {
try {
return JSON.stringify(arg, null, 2);
} catch {
return String(arg);
}
}
return String(arg);
}).join(' ');
logFile.write(`[${timestamp}] [${level}] ${message}\n`);
} catch (err) {
// Silently fail - don't want logging errors to crash the app
}
}
function closeLogger() {
if (logFile) {
logFile.write(`\nApp closed at ${new Date().toISOString()}\n`);
logFile.end();
logFile = null;
}
}
module.exports = {
initLogger,
closeLogger,
getLogPath,
};
+177
View File
@@ -0,0 +1,177 @@
/**
* nodeDetect.js Locate the system Node.js binary.
*
* When spawning child processes that rely on native addons compiled against the
* system Node.js ABI (e.g. onnxruntime-node), we must NOT run them inside
* Electron's embedded Node.js runtime the ABI mismatch causes SIGTRAP /
* SIGSEGV crashes. This module finds the real system `node` binary so we can
* pass it as `execPath` to `child_process.fork()`.
*
* Falls back to `null` when no system Node.js is found, letting the caller
* decide on an alternative strategy (e.g. WASM backend).
*/
const { execSync } = require("child_process");
const fs = require("fs");
const path = require("path");
const os = require("os");
/** Well-known Node.js install locations per platform. */
const KNOWN_PATHS = {
darwin: [
"/usr/local/bin/node",
"/opt/homebrew/bin/node", // Apple Silicon Homebrew
path.join(os.homedir(), ".nvm/versions/node"), // nvm — needs glob
path.join(os.homedir(), ".volta/bin/node"), // Volta
path.join(os.homedir(), ".fnm/aliases/default/bin/node"), // fnm
path.join(os.homedir(), ".mise/shims/node"), // mise (rtx)
path.join(os.homedir(), ".asdf/shims/node"), // asdf
],
linux: [
"/usr/bin/node",
"/usr/local/bin/node",
path.join(os.homedir(), ".nvm/versions/node"),
path.join(os.homedir(), ".volta/bin/node"),
path.join(os.homedir(), ".fnm/aliases/default/bin/node"),
path.join(os.homedir(), ".mise/shims/node"),
path.join(os.homedir(), ".asdf/shims/node"),
],
win32: [
"C:\\Program Files\\nodejs\\node.exe",
"C:\\Program Files (x86)\\nodejs\\node.exe",
path.join(os.homedir(), "AppData", "Roaming", "nvm", "current", "node.exe"),
path.join(os.homedir(), ".volta", "bin", "node.exe"),
],
};
/**
* Find the latest nvm-installed Node.js binary on macOS / Linux.
* Returns the path to the `node` binary or null.
*/
function findNvmNode() {
const nvmDir = path.join(os.homedir(), ".nvm", "versions", "node");
try {
if (!fs.existsSync(nvmDir)) return null;
const versions = fs.readdirSync(nvmDir).filter((d) => d.startsWith("v"));
if (versions.length === 0) return null;
// Sort semver descending (rough but sufficient)
versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }));
const nodeBin = path.join(nvmDir, versions[0], "bin", "node");
if (fs.existsSync(nodeBin)) return nodeBin;
} catch (_) {
// Ignore
}
return null;
}
/**
* Attempt to resolve `node` via the system PATH using `which` (Unix) or
* `where` (Windows). Returns the path string or null.
*/
function whichNode() {
try {
const cmd = process.platform === "win32" ? "where node" : "which node";
const result = execSync(cmd, {
encoding: "utf8",
timeout: 5000,
env: {
...process.env,
// Ensure common manager shim dirs are on PATH
PATH: [
process.env.PATH || "",
"/usr/local/bin",
"/opt/homebrew/bin",
path.join(os.homedir(), ".volta", "bin"),
path.join(os.homedir(), ".fnm", "aliases", "default", "bin"),
path.join(os.homedir(), ".mise", "shims"),
path.join(os.homedir(), ".asdf", "shims"),
].join(process.platform === "win32" ? ";" : ":"),
},
stdio: ["ignore", "pipe", "ignore"],
}).trim();
// `where` on Windows may return multiple lines — take the first
const first = result.split(/\r?\n/)[0].trim();
if (first && fs.existsSync(first)) return first;
} catch (_) {
// Command failed
}
return null;
}
/**
* Check whether a given path is a real Node.js binary (not the Electron binary
* pretending to be Node via ELECTRON_RUN_AS_NODE).
*/
function isRealNode(nodePath) {
if (!nodePath) return false;
try {
const out = execSync(
`"${nodePath}" -e "process.stdout.write(String(!process.versions.electron))"`,
{
encoding: "utf8",
timeout: 5000,
env: { ...process.env, ELECTRON_RUN_AS_NODE: undefined },
stdio: ["ignore", "pipe", "ignore"],
},
).trim();
return out === "true";
} catch (_) {
return false;
}
}
/**
* Find the system Node.js binary.
*
* @returns {{ nodePath: string } | null} The absolute path to system `node`,
* or null if none found. The caller should fall back to WASM when null.
*/
function findSystemNode() {
// 1. Try `which node` / `where node` first (respects user's PATH / shims)
const fromPath = whichNode();
if (fromPath && isRealNode(fromPath)) {
return { nodePath: fromPath };
}
// 2. Try nvm (has multiple version dirs)
const fromNvm = findNvmNode();
if (fromNvm && isRealNode(fromNvm)) {
return { nodePath: fromNvm };
}
// 3. Walk the well-known paths for the current platform
const platform = process.platform;
const candidates = KNOWN_PATHS[platform] || KNOWN_PATHS.linux;
for (const candidate of candidates) {
// Skip the nvm root — already handled above
if (candidate.includes(".nvm/versions/node")) continue;
if (fs.existsSync(candidate) && isRealNode(candidate)) {
return { nodePath: candidate };
}
}
return null;
}
/** Cache so we only search once per process lifetime. */
let _cached = undefined;
/**
* Cached version of `findSystemNode()`.
* @returns {{ nodePath: string } | null}
*/
function getSystemNode() {
if (_cached === undefined) {
_cached = findSystemNode();
if (_cached) {
console.log("[nodeDetect] Found system Node.js:", _cached.nodePath);
} else {
console.warn(
"[nodeDetect] No system Node.js found — will fall back to WASM backend",
);
}
}
return _cached;
}
module.exports = { findSystemNode, getSystemNode, isRealNode };
-402
View File
@@ -1,402 +0,0 @@
const { BrowserWindow } = require('electron');
const WebSocket = require('ws');
// OpenAI Realtime API implementation
// Documentation: https://platform.openai.com/docs/api-reference/realtime
let ws = null;
let isUserClosing = false;
let sessionParams = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 3;
const RECONNECT_DELAY = 2000;
// Message buffer for accumulating responses
let messageBuffer = '';
let currentTranscription = '';
function sendToRenderer(channel, data) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send(channel, data);
}
}
function buildContextMessage(conversationHistory) {
const lastTurns = conversationHistory.slice(-20);
const validTurns = lastTurns.filter(turn => turn.transcription?.trim() && turn.ai_response?.trim());
if (validTurns.length === 0) return null;
const contextLines = validTurns.map(turn => `User: ${turn.transcription.trim()}\nAssistant: ${turn.ai_response.trim()}`);
return `Session reconnected. Here's the conversation so far:\n\n${contextLines.join('\n\n')}\n\nContinue from here.`;
}
async function initializeOpenAISession(config, conversationHistory = []) {
const { apiKey, baseUrl, systemPrompt, model, language, isReconnect } = config;
if (!isReconnect) {
sessionParams = config;
reconnectAttempts = 0;
sendToRenderer('session-initializing', true);
}
// Use custom baseURL or default OpenAI endpoint
const wsUrl = baseUrl || 'wss://api.openai.com/v1/realtime';
const fullUrl = `${wsUrl}?model=${model || 'gpt-4o-realtime-preview-2024-12-17'}`;
return new Promise((resolve, reject) => {
try {
ws = new WebSocket(fullUrl, {
headers: {
Authorization: `Bearer ${apiKey}`,
'OpenAI-Beta': 'realtime=v1',
},
});
ws.on('open', () => {
console.log('OpenAI Realtime connection established');
// Configure session
const sessionConfig = {
type: 'session.update',
session: {
modalities: ['text', 'audio'],
instructions: systemPrompt,
voice: 'alloy',
input_audio_format: 'pcm16',
output_audio_format: 'pcm16',
input_audio_transcription: {
model: 'whisper-1',
},
turn_detection: {
type: 'server_vad',
threshold: 0.5,
prefix_padding_ms: 300,
silence_duration_ms: 500,
},
temperature: 0.8,
max_response_output_tokens: 4096,
},
};
ws.send(JSON.stringify(sessionConfig));
// Restore context if reconnecting
if (isReconnect && conversationHistory.length > 0) {
const contextMessage = buildContextMessage(conversationHistory);
if (contextMessage) {
ws.send(
JSON.stringify({
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: contextMessage }],
},
})
);
ws.send(JSON.stringify({ type: 'response.create' }));
}
}
sendToRenderer('update-status', 'Connected to OpenAI');
if (!isReconnect) {
sendToRenderer('session-initializing', false);
}
resolve(ws);
});
ws.on('message', data => {
try {
const event = JSON.parse(data.toString());
handleOpenAIEvent(event);
} catch (error) {
console.error('Error parsing OpenAI message:', error);
}
});
ws.on('error', error => {
console.error('OpenAI WebSocket error:', error);
sendToRenderer('update-status', 'Error: ' + error.message);
reject(error);
});
ws.on('close', (code, reason) => {
console.log(`OpenAI WebSocket closed: ${code} - ${reason}`);
if (isUserClosing) {
isUserClosing = false;
sendToRenderer('update-status', 'Session closed');
return;
}
// Attempt reconnection
if (sessionParams && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
attemptReconnect(conversationHistory);
} else {
sendToRenderer('update-status', 'Session closed');
}
});
} catch (error) {
console.error('Failed to initialize OpenAI session:', error);
if (!isReconnect) {
sendToRenderer('session-initializing', false);
}
reject(error);
}
});
}
function handleOpenAIEvent(event) {
console.log('OpenAI event:', event.type);
switch (event.type) {
case 'session.created':
console.log('Session created:', event.session.id);
break;
case 'session.updated':
console.log('Session updated');
sendToRenderer('update-status', 'Listening...');
break;
case 'input_audio_buffer.speech_started':
console.log('Speech started');
break;
case 'input_audio_buffer.speech_stopped':
console.log('Speech stopped');
break;
case 'conversation.item.input_audio_transcription.completed':
if (event.transcript) {
currentTranscription += event.transcript;
console.log('Transcription:', event.transcript);
}
break;
case 'response.audio_transcript.delta':
if (event.delta) {
const isNewResponse = messageBuffer === '';
messageBuffer += event.delta;
sendToRenderer(isNewResponse ? 'new-response' : 'update-response', messageBuffer);
}
break;
case 'response.audio_transcript.done':
console.log('Audio transcript complete');
break;
case 'response.text.delta':
if (event.delta) {
const isNewResponse = messageBuffer === '';
messageBuffer += event.delta;
sendToRenderer(isNewResponse ? 'new-response' : 'update-response', messageBuffer);
}
break;
case 'response.done':
if (messageBuffer.trim() !== '') {
sendToRenderer('update-response', messageBuffer);
// Send conversation turn to be saved
if (currentTranscription) {
sendToRenderer('save-conversation-turn-data', {
transcription: currentTranscription,
response: messageBuffer,
});
currentTranscription = '';
}
}
messageBuffer = '';
sendToRenderer('update-status', 'Listening...');
break;
case 'error':
console.error('OpenAI error:', event.error);
sendToRenderer('update-status', 'Error: ' + event.error.message);
break;
default:
// console.log('Unhandled event type:', event.type);
break;
}
}
async function attemptReconnect(conversationHistory) {
reconnectAttempts++;
console.log(`Reconnection attempt ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS}`);
messageBuffer = '';
currentTranscription = '';
sendToRenderer('update-status', `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
await new Promise(resolve => setTimeout(resolve, RECONNECT_DELAY));
try {
const newConfig = { ...sessionParams, isReconnect: true };
ws = await initializeOpenAISession(newConfig, conversationHistory);
sendToRenderer('update-status', 'Reconnected! Listening...');
console.log('OpenAI session reconnected successfully');
return true;
} catch (error) {
console.error(`Reconnection attempt ${reconnectAttempts} failed:`, error);
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
return attemptReconnect(conversationHistory);
}
console.log('Max reconnection attempts reached');
sendToRenderer('reconnect-failed', {
message: 'Tried 3 times to reconnect to OpenAI. Check your connection and API key.',
});
sessionParams = null;
return false;
}
}
async function sendAudioToOpenAI(base64Data) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('WebSocket not connected');
return { success: false, error: 'No active connection' };
}
try {
ws.send(
JSON.stringify({
type: 'input_audio_buffer.append',
audio: base64Data,
})
);
return { success: true };
} catch (error) {
console.error('Error sending audio to OpenAI:', error);
return { success: false, error: error.message };
}
}
async function sendTextToOpenAI(text) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
console.error('WebSocket not connected');
return { success: false, error: 'No active connection' };
}
try {
// Create a conversation item with user text
ws.send(
JSON.stringify({
type: 'conversation.item.create',
item: {
type: 'message',
role: 'user',
content: [{ type: 'input_text', text: text }],
},
})
);
// Trigger response generation
ws.send(JSON.stringify({ type: 'response.create' }));
return { success: true };
} catch (error) {
console.error('Error sending text to OpenAI:', error);
return { success: false, error: error.message };
}
}
async function sendImageToOpenAI(base64Data, prompt, config) {
const { apiKey, baseUrl, model } = config;
// OpenAI doesn't support images in Realtime API yet, use standard Chat Completions
const apiEndpoint = baseUrl ? `${baseUrl.replace('wss://', 'https://').replace('/v1/realtime', '')}/v1/chat/completions` : 'https://api.openai.com/v1/chat/completions';
try {
const response = await fetch(apiEndpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${apiKey}`,
},
body: JSON.stringify({
model: model || 'gpt-4o',
messages: [
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{
type: 'image_url',
image_url: {
url: `data:image/jpeg;base64,${base64Data}`,
},
},
],
},
],
max_tokens: 4096,
stream: true,
}),
});
if (!response.ok) {
const error = await response.text();
throw new Error(`OpenAI API error: ${response.status} - ${error}`);
}
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
let isFirst = true;
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n').filter(line => line.trim().startsWith('data: '));
for (const line of lines) {
const data = line.replace('data: ', '');
if (data === '[DONE]') continue;
try {
const json = JSON.parse(data);
const content = json.choices[0]?.delta?.content;
if (content) {
fullText += content;
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
isFirst = false;
}
} catch (e) {
// Skip invalid JSON
}
}
}
return { success: true, text: fullText, model: model || 'gpt-4o' };
} catch (error) {
console.error('Error sending image to OpenAI:', error);
return { success: false, error: error.message };
}
}
function closeOpenAISession() {
isUserClosing = true;
sessionParams = null;
if (ws) {
ws.close();
ws = null;
}
}
module.exports = {
initializeOpenAISession,
sendAudioToOpenAI,
sendTextToOpenAI,
sendImageToOpenAI,
closeOpenAISession,
};
-689
View File
@@ -1,689 +0,0 @@
const { BrowserWindow } = require('electron');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { spawn } = require('child_process');
// OpenAI SDK will be loaded dynamically
let OpenAI = null;
// OpenAI SDK-based provider (for BotHub, Azure, and other OpenAI-compatible APIs)
// This uses the standard Chat Completions API with Whisper for transcription
let openaiClient = null;
let currentConfig = null;
let conversationMessages = [];
let isProcessing = false;
// macOS audio capture
let systemAudioProc = null;
let audioBuffer = Buffer.alloc(0);
let transcriptionTimer = null;
const TRANSCRIPTION_INTERVAL_MS = 3000; // Transcribe every 3 seconds
const MIN_AUDIO_DURATION_MS = 500; // Minimum audio duration to transcribe
const SAMPLE_RATE = 24000;
function sendToRenderer(channel, data) {
const windows = BrowserWindow.getAllWindows();
if (windows.length > 0) {
windows[0].webContents.send(channel, data);
}
}
async function initializeOpenAISDK(config) {
const { apiKey, baseUrl, model } = config;
if (!apiKey) {
throw new Error('OpenAI API key is required');
}
// Dynamic import for ES module
if (!OpenAI) {
const openaiModule = await import('openai');
OpenAI = openaiModule.default;
}
const clientConfig = {
apiKey: apiKey,
};
// Use custom baseURL if provided
if (baseUrl && baseUrl.trim() !== '') {
clientConfig.baseURL = baseUrl;
}
openaiClient = new OpenAI(clientConfig);
currentConfig = config;
conversationMessages = [];
console.log('OpenAI SDK initialized with baseURL:', clientConfig.baseURL || 'default');
sendToRenderer('update-status', 'Ready (OpenAI SDK)');
return true;
}
function setSystemPrompt(systemPrompt) {
// Clear conversation and set system prompt
conversationMessages = [];
if (systemPrompt) {
conversationMessages.push({
role: 'system',
content: systemPrompt,
});
}
}
// Create WAV file from raw PCM data
function createWavBuffer(pcmBuffer, sampleRate = 24000, numChannels = 1, bitsPerSample = 16) {
const byteRate = sampleRate * numChannels * (bitsPerSample / 8);
const blockAlign = numChannels * (bitsPerSample / 8);
const dataSize = pcmBuffer.length;
const headerSize = 44;
const fileSize = headerSize + dataSize - 8;
const wavBuffer = Buffer.alloc(headerSize + dataSize);
// RIFF header
wavBuffer.write('RIFF', 0);
wavBuffer.writeUInt32LE(fileSize, 4);
wavBuffer.write('WAVE', 8);
// fmt chunk
wavBuffer.write('fmt ', 12);
wavBuffer.writeUInt32LE(16, 16); // fmt chunk size
wavBuffer.writeUInt16LE(1, 20); // audio format (1 = PCM)
wavBuffer.writeUInt16LE(numChannels, 22);
wavBuffer.writeUInt32LE(sampleRate, 24);
wavBuffer.writeUInt32LE(byteRate, 28);
wavBuffer.writeUInt16LE(blockAlign, 32);
wavBuffer.writeUInt16LE(bitsPerSample, 34);
// data chunk
wavBuffer.write('data', 36);
wavBuffer.writeUInt32LE(dataSize, 40);
// Copy PCM data
pcmBuffer.copy(wavBuffer, 44);
return wavBuffer;
}
async function transcribeAudio(audioBuffer, mimeType = 'audio/wav') {
if (!openaiClient) {
throw new Error('OpenAI client not initialized');
}
try {
// Save audio buffer to temp file (OpenAI SDK requires file path)
const tempDir = os.tmpdir();
const tempFile = path.join(tempDir, `audio_${Date.now()}.wav`);
// Convert base64 to buffer if needed
let buffer = audioBuffer;
if (typeof audioBuffer === 'string') {
buffer = Buffer.from(audioBuffer, 'base64');
}
// Create proper WAV file with header
const wavBuffer = createWavBuffer(buffer, SAMPLE_RATE, 1, 16);
fs.writeFileSync(tempFile, wavBuffer);
const transcription = await openaiClient.audio.transcriptions.create({
file: fs.createReadStream(tempFile),
model: currentConfig.whisperModel || 'whisper-1',
response_format: 'text',
});
// Clean up temp file
try {
fs.unlinkSync(tempFile);
} catch (e) {
// Ignore cleanup errors
}
return transcription;
} catch (error) {
console.error('Transcription error:', error);
throw error;
}
}
async function sendTextMessage(text) {
if (!openaiClient) {
return { success: false, error: 'OpenAI client not initialized' };
}
if (isProcessing) {
return { success: false, error: 'Already processing a request' };
}
isProcessing = true;
try {
// Add user message to conversation
conversationMessages.push({
role: 'user',
content: text,
});
sendToRenderer('update-status', 'Thinking...');
const stream = await openaiClient.chat.completions.create({
model: currentConfig.model || 'gpt-4o',
messages: conversationMessages,
stream: true,
max_tokens: 4096,
});
let fullResponse = '';
let isFirst = true;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
fullResponse += content;
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullResponse);
isFirst = false;
}
}
// Add assistant response to conversation
conversationMessages.push({
role: 'assistant',
content: fullResponse,
});
sendToRenderer('update-status', 'Ready');
isProcessing = false;
return { success: true, text: fullResponse };
} catch (error) {
console.error('Chat completion error:', error);
sendToRenderer('update-status', 'Error: ' + error.message);
isProcessing = false;
return { success: false, error: error.message };
}
}
async function sendImageMessage(base64Image, prompt) {
if (!openaiClient) {
return { success: false, error: 'OpenAI client not initialized' };
}
if (isProcessing) {
return { success: false, error: 'Already processing a request' };
}
isProcessing = true;
try {
sendToRenderer('update-status', 'Analyzing image...');
const messages = [
...conversationMessages,
{
role: 'user',
content: [
{ type: 'text', text: prompt },
{
type: 'image_url',
image_url: {
url: `data:image/jpeg;base64,${base64Image}`,
},
},
],
},
];
const stream = await openaiClient.chat.completions.create({
model: currentConfig.visionModel || currentConfig.model || 'gpt-4o',
messages: messages,
stream: true,
max_tokens: 4096,
});
let fullResponse = '';
let isFirst = true;
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content;
if (content) {
fullResponse += content;
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullResponse);
isFirst = false;
}
}
// Add to conversation history (text only for follow-ups)
conversationMessages.push({
role: 'user',
content: prompt,
});
conversationMessages.push({
role: 'assistant',
content: fullResponse,
});
sendToRenderer('update-status', 'Ready');
isProcessing = false;
return { success: true, text: fullResponse, model: currentConfig.visionModel || currentConfig.model };
} catch (error) {
console.error('Vision error:', error);
sendToRenderer('update-status', 'Error: ' + error.message);
isProcessing = false;
return { success: false, error: error.message };
}
}
// Process audio chunk and get response
// This accumulates audio and transcribes when silence is detected or timer expires
let audioChunks = [];
let lastAudioTime = 0;
let firstChunkTime = 0;
const SILENCE_THRESHOLD_MS = 1500; // 1.5 seconds of silence
const MAX_BUFFER_DURATION_MS = 5000; // 5 seconds max buffering before forced transcription
let silenceCheckTimer = null;
let windowsTranscriptionTimer = null;
async function processAudioChunk(base64Audio, mimeType) {
if (!openaiClient) {
return { success: false, error: 'OpenAI client not initialized' };
}
const now = Date.now();
const buffer = Buffer.from(base64Audio, 'base64');
// Track first chunk time for duration-based flushing
if (audioChunks.length === 0) {
firstChunkTime = now;
// Start periodic transcription timer (Windows needs this)
if (!windowsTranscriptionTimer && process.platform === 'win32') {
console.log('Starting Windows periodic transcription timer...');
windowsTranscriptionTimer = setInterval(async () => {
if (audioChunks.length > 0) {
const bufferDuration = Date.now() - firstChunkTime;
if (bufferDuration >= MAX_BUFFER_DURATION_MS) {
console.log(`Periodic flush: ${bufferDuration}ms of audio buffered`);
await flushAudioAndTranscribe();
}
}
}, 2000); // Check every 2 seconds
}
}
// Add to audio buffer
audioChunks.push(buffer);
lastAudioTime = now;
// Clear existing timer
if (silenceCheckTimer) {
clearTimeout(silenceCheckTimer);
}
// Set timer to check for silence
silenceCheckTimer = setTimeout(async () => {
const silenceDuration = Date.now() - lastAudioTime;
if (silenceDuration >= SILENCE_THRESHOLD_MS && audioChunks.length > 0) {
console.log('Silence detected, flushing audio for transcription...');
await flushAudioAndTranscribe();
}
}, SILENCE_THRESHOLD_MS);
return { success: true, buffering: true };
}
async function flushAudioAndTranscribe() {
if (audioChunks.length === 0) {
return { success: true, text: '' };
}
// Clear Windows transcription timer
if (windowsTranscriptionTimer) {
clearInterval(windowsTranscriptionTimer);
windowsTranscriptionTimer = null;
}
try {
// Combine all audio chunks
const combinedBuffer = Buffer.concat(audioChunks);
const chunkCount = audioChunks.length;
audioChunks = [];
firstChunkTime = 0;
// Calculate audio duration
const bytesPerSample = 2;
const audioDurationMs = (combinedBuffer.length / bytesPerSample / SAMPLE_RATE) * 1000;
console.log(`Transcribing ${chunkCount} chunks (${audioDurationMs.toFixed(0)}ms of audio)...`);
// Transcribe
const transcription = await transcribeAudio(combinedBuffer);
if (transcription && transcription.trim()) {
console.log('Transcription result:', transcription);
// Send to chat
const response = await sendTextMessage(transcription);
return {
success: true,
transcription: transcription,
response: response.text,
};
}
return { success: true, text: '' };
} catch (error) {
console.error('Flush audio error:', error);
return { success: false, error: error.message };
}
}
function clearConversation() {
const systemMessage = conversationMessages.find(m => m.role === 'system');
conversationMessages = systemMessage ? [systemMessage] : [];
audioChunks = [];
// Clear timers
if (silenceCheckTimer) {
clearTimeout(silenceCheckTimer);
silenceCheckTimer = null;
}
if (windowsTranscriptionTimer) {
clearInterval(windowsTranscriptionTimer);
windowsTranscriptionTimer = null;
}
}
function closeOpenAISDK() {
stopMacOSAudioCapture();
openaiClient = null;
currentConfig = null;
conversationMessages = [];
audioChunks = [];
isProcessing = false;
// Clear timers
if (silenceCheckTimer) {
clearTimeout(silenceCheckTimer);
silenceCheckTimer = null;
}
if (windowsTranscriptionTimer) {
clearInterval(windowsTranscriptionTimer);
windowsTranscriptionTimer = null;
}
sendToRenderer('update-status', 'Disconnected');
}
// ============ macOS Audio Capture ============
async function killExistingSystemAudioDump() {
return new Promise(resolve => {
const { exec } = require('child_process');
exec('pkill -f SystemAudioDump', error => {
// Ignore errors (process might not exist)
setTimeout(resolve, 100);
});
});
}
function convertStereoToMono(stereoBuffer) {
const samples = stereoBuffer.length / 4;
const monoBuffer = Buffer.alloc(samples * 2);
for (let i = 0; i < samples; i++) {
const leftSample = stereoBuffer.readInt16LE(i * 4);
monoBuffer.writeInt16LE(leftSample, i * 2);
}
return monoBuffer;
}
// Calculate RMS (Root Mean Square) volume level of audio buffer
function calculateRMS(buffer) {
const samples = buffer.length / 2;
if (samples === 0) return 0;
let sumSquares = 0;
for (let i = 0; i < samples; i++) {
const sample = buffer.readInt16LE(i * 2);
sumSquares += sample * sample;
}
return Math.sqrt(sumSquares / samples);
}
// Check if audio contains speech (simple VAD based on volume threshold)
function hasSpeech(buffer, threshold = 500) {
const rms = calculateRMS(buffer);
return rms > threshold;
}
async function transcribeBufferedAudio() {
if (audioBuffer.length === 0 || isProcessing) {
return;
}
// Calculate audio duration
const bytesPerSample = 2;
const audioDurationMs = (audioBuffer.length / bytesPerSample / SAMPLE_RATE) * 1000;
if (audioDurationMs < MIN_AUDIO_DURATION_MS) {
return; // Not enough audio
}
// Check if there's actual speech in the audio (Voice Activity Detection)
if (!hasSpeech(audioBuffer)) {
// Clear buffer if it's just silence/noise
audioBuffer = Buffer.alloc(0);
return;
}
// Take current buffer and reset
const currentBuffer = audioBuffer;
audioBuffer = Buffer.alloc(0);
try {
console.log(`Transcribing ${audioDurationMs.toFixed(0)}ms of audio...`);
sendToRenderer('update-status', 'Transcribing...');
const transcription = await transcribeAudio(currentBuffer, 'audio/wav');
if (transcription && transcription.trim() && transcription.trim().length > 2) {
console.log('Transcription:', transcription);
sendToRenderer('update-status', 'Processing...');
// Send to chat
await sendTextMessage(transcription);
}
sendToRenderer('update-status', 'Listening...');
} catch (error) {
console.error('Transcription error:', error);
sendToRenderer('update-status', 'Listening...');
}
}
async function startMacOSAudioCapture() {
if (process.platform !== 'darwin') return false;
// Kill any existing SystemAudioDump processes first
await killExistingSystemAudioDump();
console.log('=== Starting macOS audio capture (OpenAI SDK) ===');
sendToRenderer('update-status', 'Starting audio capture...');
const { app } = require('electron');
const fs = require('fs');
let systemAudioPath;
if (app.isPackaged) {
systemAudioPath = path.join(process.resourcesPath, 'SystemAudioDump');
} else {
systemAudioPath = path.join(__dirname, '../assets', 'SystemAudioDump');
}
console.log('SystemAudioDump config:', {
path: systemAudioPath,
isPackaged: app.isPackaged,
resourcesPath: process.resourcesPath,
exists: fs.existsSync(systemAudioPath),
});
// Check if file exists
if (!fs.existsSync(systemAudioPath)) {
console.error('FATAL: SystemAudioDump not found at:', systemAudioPath);
sendToRenderer('update-status', 'Error: Audio binary not found');
return false;
}
// Check and fix executable permissions
try {
fs.accessSync(systemAudioPath, fs.constants.X_OK);
console.log('SystemAudioDump is executable');
} catch (err) {
console.warn('SystemAudioDump not executable, fixing permissions...');
try {
fs.chmodSync(systemAudioPath, 0o755);
console.log('Fixed executable permissions');
} catch (chmodErr) {
console.error('Failed to fix permissions:', chmodErr);
sendToRenderer('update-status', 'Error: Cannot execute audio binary');
return false;
}
}
const spawnOptions = {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
},
};
console.log('Spawning SystemAudioDump...');
systemAudioProc = spawn(systemAudioPath, [], spawnOptions);
if (!systemAudioProc.pid) {
console.error('FATAL: Failed to start SystemAudioDump - no PID');
sendToRenderer('update-status', 'Error: Audio capture failed to start');
return false;
}
console.log('SystemAudioDump started with PID:', systemAudioProc.pid);
const CHUNK_DURATION = 0.1;
const BYTES_PER_SAMPLE = 2;
const CHANNELS = 2;
const CHUNK_SIZE = SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS * CHUNK_DURATION;
let tempBuffer = Buffer.alloc(0);
let chunkCount = 0;
let firstDataReceived = false;
systemAudioProc.stdout.on('data', data => {
if (!firstDataReceived) {
firstDataReceived = true;
console.log('First audio data received! Size:', data.length);
sendToRenderer('update-status', 'Listening...');
}
tempBuffer = Buffer.concat([tempBuffer, data]);
while (tempBuffer.length >= CHUNK_SIZE) {
const chunk = tempBuffer.slice(0, CHUNK_SIZE);
tempBuffer = tempBuffer.slice(CHUNK_SIZE);
// Convert stereo to mono
const monoChunk = CHANNELS === 2 ? convertStereoToMono(chunk) : chunk;
// Add to audio buffer for transcription
audioBuffer = Buffer.concat([audioBuffer, monoChunk]);
chunkCount++;
if (chunkCount % 100 === 0) {
console.log(`Audio: ${chunkCount} chunks processed, buffer size: ${audioBuffer.length}`);
}
}
// Limit buffer size (max 30 seconds of audio)
const maxBufferSize = SAMPLE_RATE * BYTES_PER_SAMPLE * 30;
if (audioBuffer.length > maxBufferSize) {
audioBuffer = audioBuffer.slice(-maxBufferSize);
}
});
systemAudioProc.stderr.on('data', data => {
const msg = data.toString();
console.error('SystemAudioDump stderr:', msg);
if (msg.toLowerCase().includes('error')) {
sendToRenderer('update-status', 'Audio error: ' + msg.substring(0, 50));
}
});
systemAudioProc.on('close', (code, signal) => {
console.log('SystemAudioDump closed:', { code, signal, chunksProcessed: chunkCount, tempBufferSize: tempBuffer.length });
if (code !== 0 && code !== null) {
sendToRenderer('update-status', `Audio stopped (exit: ${code}, signal: ${signal})`);
}
systemAudioProc = null;
stopTranscriptionTimer();
});
systemAudioProc.on('error', err => {
console.error('SystemAudioDump spawn error:', err.message, err.stack);
sendToRenderer('update-status', 'Audio error: ' + err.message);
systemAudioProc = null;
stopTranscriptionTimer();
});
systemAudioProc.on('exit', (code, signal) => {
console.log('SystemAudioDump exit event:', { code, signal });
});
// Start periodic transcription
startTranscriptionTimer();
sendToRenderer('update-status', 'Listening...');
return true;
}
function startTranscriptionTimer() {
stopTranscriptionTimer();
transcriptionTimer = setInterval(transcribeBufferedAudio, TRANSCRIPTION_INTERVAL_MS);
}
function stopTranscriptionTimer() {
if (transcriptionTimer) {
clearInterval(transcriptionTimer);
transcriptionTimer = null;
}
}
function stopMacOSAudioCapture() {
stopTranscriptionTimer();
if (systemAudioProc) {
console.log('Stopping SystemAudioDump for OpenAI SDK...');
systemAudioProc.kill('SIGTERM');
systemAudioProc = null;
}
audioBuffer = Buffer.alloc(0);
}
module.exports = {
initializeOpenAISDK,
setSystemPrompt,
transcribeAudio,
sendTextMessage,
sendImageMessage,
processAudioChunk,
flushAudioAndTranscribe,
clearConversation,
closeOpenAISDK,
startMacOSAudioCapture,
stopMacOSAudioCapture,
};
+109 -141
View File
@@ -1,21 +1,45 @@
const profilePrompts = {
interview: {
intro: `You are an AI-powered interview assistant, designed to act as a discreet on-screen teleprompter. Your mission is to help the user excel in their job interview by providing concise, impactful, and ready-to-speak answers or key talking points. Analyze the ongoing interview dialogue and, crucially, the 'User-provided context' below.`,
formatRequirements: `**RESPONSE FORMAT REQUIREMENTS:**
const responseModeFormats = {
brief: `**RESPONSE FORMAT REQUIREMENTS:**
- Keep responses SHORT and CONCISE (1-3 sentences max)
- Use **markdown formatting** for better readability
- Use **bold** for key points and emphasis
- Use bullet points (-) for lists when appropriate
- Focus on the most essential information only`,
- Focus on the most essential information only
- EXCEPTION: If a coding/algorithm task is detected, ALWAYS provide the complete working code (see CODING TASKS below)`,
searchUsage: `**SEARCH TOOL USAGE:**
detailed: `**RESPONSE FORMAT REQUIREMENTS:**
- Provide a THOROUGH and COMPREHENSIVE response with full explanations
- Use **markdown formatting** for better readability
- Use **bold** for key points and emphasis
- Use headers (##) to organize sections when appropriate
- Use bullet points (-) for lists when appropriate
- Include relevant context, edge cases, and reasoning
- For technical topics, explain the "why" behind each point
- No length restriction be as detailed as needed to fully answer the question`,
};
const codingAwareness = `**CODING TASKS — CRITICAL INSTRUCTION:**
When the interviewer/questioner asks to solve a coding problem, implement an algorithm, debug code, do a live coding exercise, open an IDE and write code, or any task that requires a code solution:
- You MUST provide the ACTUAL COMPLETE WORKING CODE SOLUTION
- NEVER respond with meta-advice like "now you should write code" or "prepare to implement" or "think about the approach"
- NEVER say "open your IDE" or "start coding" instead, GIVE THE CODE
- In brief mode: provide 2-3 bullet approach points, then the FULL working code with comments
- In detailed mode: explain approach, time/space complexity, edge cases, then the FULL working code with comments
- Include the programming language name in the code fence (e.g. \`\`\`python, \`\`\`javascript)
- If the language is not specified, default to Python
- The code must be complete, runnable, and correct`;
const profilePrompts = {
interview: {
intro: `You are an AI-powered interview assistant, designed to act as a discreet on-screen teleprompter. Your mission is to help the user excel in their job interview by providing concise, impactful, and ready-to-speak answers or key talking points. Analyze the ongoing interview dialogue and, crucially, the 'User-provided context' below.`,
searchUsage: `**SEARCH TOOL USAGE:**
- If the interviewer mentions **recent events, news, or current trends** (anything from the last 6 months), **ALWAYS use Google search** to get up-to-date information
- If they ask about **company-specific information, recent acquisitions, funding, or leadership changes**, use Google search first
- If they mention **new technologies, frameworks, or industry developments**, search for the latest information
- After searching, provide a **concise, informed response** based on the real-time data`,
content: `Focus on delivering the most essential information the user needs. Your suggestions should be direct and immediately usable.
content: `Focus on delivering the most essential information the user needs. Your suggestions should be direct and immediately usable.
To help the user 'crack' the interview in their specific field:
1. Heavily rely on the 'User-provided context' (e.g., details about their industry, the job description, their resume, key skills, and achievements).
@@ -32,27 +56,20 @@ You: "I've been working with React for 4 years, building everything from simple
Interviewer: "Why do you want to work here?"
You: "I'm excited about this role because your company is solving real problems in the fintech space, which aligns with my interest in building products that impact people's daily lives. I've researched your tech stack and I'm particularly interested in contributing to your microservices architecture. Your focus on innovation and the opportunity to work with a talented team really appeals to me."`,
outputInstructions: `**OUTPUT INSTRUCTIONS:**
outputInstructions: `**OUTPUT INSTRUCTIONS:**
Provide only the exact words to say in **markdown format**. No coaching, no "you should" statements, no explanations - just the direct response the candidate can speak immediately. Keep it **short and impactful**.`,
},
},
sales: {
intro: `You are a sales call assistant. Your job is to provide the exact words the salesperson should say to prospects during sales calls. Give direct, ready-to-speak responses that are persuasive and professional.`,
sales: {
intro: `You are a sales call assistant. Your job is to provide the exact words the salesperson should say to prospects during sales calls. Give direct, ready-to-speak responses that are persuasive and professional.`,
formatRequirements: `**RESPONSE FORMAT REQUIREMENTS:**
- Keep responses SHORT and CONCISE (1-3 sentences max)
- Use **markdown formatting** for better readability
- Use **bold** for key points and emphasis
- Use bullet points (-) for lists when appropriate
- Focus on the most essential information only`,
searchUsage: `**SEARCH TOOL USAGE:**
searchUsage: `**SEARCH TOOL USAGE:**
- If the prospect mentions **recent industry trends, market changes, or current events**, **ALWAYS use Google search** to get up-to-date information
- If they reference **competitor information, recent funding news, or market data**, search for the latest information first
- If they ask about **new regulations, industry reports, or recent developments**, use search to provide accurate data
- After searching, provide a **concise, informed response** that demonstrates current market knowledge`,
content: `Examples:
content: `Examples:
Prospect: "Tell me about your product"
You: "Our platform helps companies like yours reduce operational costs by 30% while improving efficiency. We've worked with over 500 businesses in your industry, and they typically see ROI within the first 90 days. What specific operational challenges are you facing right now?"
@@ -63,27 +80,20 @@ You: "Three key differentiators set us apart: First, our implementation takes ju
Prospect: "I need to think about it"
You: "I completely understand this is an important decision. What specific concerns can I address for you today? Is it about implementation timeline, cost, or integration with your existing systems? I'd rather help you make an informed decision now than leave you with unanswered questions."`,
outputInstructions: `**OUTPUT INSTRUCTIONS:**
outputInstructions: `**OUTPUT INSTRUCTIONS:**
Provide only the exact words to say in **markdown format**. Be persuasive but not pushy. Focus on value and addressing objections directly. Keep responses **short and impactful**.`,
},
},
meeting: {
intro: `You are a meeting assistant. Your job is to provide the exact words to say during professional meetings, presentations, and discussions. Give direct, ready-to-speak responses that are clear and professional.`,
meeting: {
intro: `You are a meeting assistant. Your job is to provide the exact words to say during professional meetings, presentations, and discussions. Give direct, ready-to-speak responses that are clear and professional.`,
formatRequirements: `**RESPONSE FORMAT REQUIREMENTS:**
- Keep responses SHORT and CONCISE (1-3 sentences max)
- Use **markdown formatting** for better readability
- Use **bold** for key points and emphasis
- Use bullet points (-) for lists when appropriate
- Focus on the most essential information only`,
searchUsage: `**SEARCH TOOL USAGE:**
searchUsage: `**SEARCH TOOL USAGE:**
- If participants mention **recent industry news, regulatory changes, or market updates**, **ALWAYS use Google search** for current information
- If they reference **competitor activities, recent reports, or current statistics**, search for the latest data first
- If they discuss **new technologies, tools, or industry developments**, use search to provide accurate insights
- After searching, provide a **concise, informed response** that adds value to the discussion`,
content: `Examples:
content: `Examples:
Participant: "What's the status on the project?"
You: "We're currently on track to meet our deadline. We've completed 75% of the deliverables, with the remaining items scheduled for completion by Friday. The main challenge we're facing is the integration testing, but we have a plan in place to address it."
@@ -94,27 +104,20 @@ You: "Absolutely. We're currently at 80% of our allocated budget with 20% of the
Participant: "What are the next steps?"
You: "Moving forward, I'll need approval on the revised timeline by end of day today. Sarah will handle the client communication, and Mike will coordinate with the technical team. We'll have our next checkpoint on Thursday to ensure everything stays on track."`,
outputInstructions: `**OUTPUT INSTRUCTIONS:**
outputInstructions: `**OUTPUT INSTRUCTIONS:**
Provide only the exact words to say in **markdown format**. Be clear, concise, and action-oriented in your responses. Keep it **short and impactful**.`,
},
},
presentation: {
intro: `You are a presentation coach. Your job is to provide the exact words the presenter should say during presentations, pitches, and public speaking events. Give direct, ready-to-speak responses that are engaging and confident.`,
presentation: {
intro: `You are a presentation coach. Your job is to provide the exact words the presenter should say during presentations, pitches, and public speaking events. Give direct, ready-to-speak responses that are engaging and confident.`,
formatRequirements: `**RESPONSE FORMAT REQUIREMENTS:**
- Keep responses SHORT and CONCISE (1-3 sentences max)
- Use **markdown formatting** for better readability
- Use **bold** for key points and emphasis
- Use bullet points (-) for lists when appropriate
- Focus on the most essential information only`,
searchUsage: `**SEARCH TOOL USAGE:**
searchUsage: `**SEARCH TOOL USAGE:**
- If the audience asks about **recent market trends, current statistics, or latest industry data**, **ALWAYS use Google search** for up-to-date information
- If they reference **recent events, new competitors, or current market conditions**, search for the latest information first
- If they inquire about **recent studies, reports, or breaking news** in your field, use search to provide accurate data
- After searching, provide a **concise, credible response** with current facts and figures`,
content: `Examples:
content: `Examples:
Audience: "Can you explain that slide again?"
You: "Of course. This slide shows our three-year growth trajectory. The blue line represents revenue, which has grown 150% year over year. The orange bars show our customer acquisition, doubling each year. The key insight here is that our customer lifetime value has increased by 40% while acquisition costs have remained flat."
@@ -125,27 +128,20 @@ You: "Great question. Our competitive advantage comes down to three core strengt
Audience: "How do you plan to scale?"
You: "Our scaling strategy focuses on three pillars. First, we're expanding our engineering team by 200% to accelerate product development. Second, we're entering three new markets next quarter. Third, we're building strategic partnerships that will give us access to 10 million additional potential customers."`,
outputInstructions: `**OUTPUT INSTRUCTIONS:**
outputInstructions: `**OUTPUT INSTRUCTIONS:**
Provide only the exact words to say in **markdown format**. Be confident, engaging, and back up claims with specific numbers or facts when possible. Keep responses **short and impactful**.`,
},
},
negotiation: {
intro: `You are a negotiation assistant. Your job is to provide the exact words to say during business negotiations, contract discussions, and deal-making conversations. Give direct, ready-to-speak responses that are strategic and professional.`,
negotiation: {
intro: `You are a negotiation assistant. Your job is to provide the exact words to say during business negotiations, contract discussions, and deal-making conversations. Give direct, ready-to-speak responses that are strategic and professional.`,
formatRequirements: `**RESPONSE FORMAT REQUIREMENTS:**
- Keep responses SHORT and CONCISE (1-3 sentences max)
- Use **markdown formatting** for better readability
- Use **bold** for key points and emphasis
- Use bullet points (-) for lists when appropriate
- Focus on the most essential information only`,
searchUsage: `**SEARCH TOOL USAGE:**
searchUsage: `**SEARCH TOOL USAGE:**
- If they mention **recent market pricing, current industry standards, or competitor offers**, **ALWAYS use Google search** for current benchmarks
- If they reference **recent legal changes, new regulations, or market conditions**, search for the latest information first
- If they discuss **recent company news, financial performance, or industry developments**, use search to provide informed responses
- After searching, provide a **strategic, well-informed response** that leverages current market intelligence`,
content: `Examples:
content: `Examples:
Other party: "That price is too high"
You: "I understand your concern about the investment. Let's look at the value you're getting: this solution will save you $200K annually in operational costs, which means you'll break even in just 6 months. Would it help if we structured the payment terms differently, perhaps spreading it over 12 months instead of upfront?"
@@ -156,27 +152,20 @@ You: "I appreciate your directness. We want this to work for both parties. Our c
Other party: "We're considering other options"
You: "That's smart business practice. While you're evaluating alternatives, I want to ensure you have all the information. Our solution offers three unique benefits that others don't: 24/7 dedicated support, guaranteed 48-hour implementation, and a money-back guarantee if you don't see results in 90 days. How important are these factors in your decision?"`,
outputInstructions: `**OUTPUT INSTRUCTIONS:**
outputInstructions: `**OUTPUT INSTRUCTIONS:**
Provide only the exact words to say in **markdown format**. Focus on finding win-win solutions and addressing underlying concerns. Keep responses **short and impactful**.`,
},
},
exam: {
intro: `You are an exam assistant designed to help students pass tests efficiently. Your role is to provide direct, accurate answers to exam questions with minimal explanation - just enough to confirm the answer is correct.`,
exam: {
intro: `You are an exam assistant designed to help students pass tests efficiently. Your role is to provide direct, accurate answers to exam questions with minimal explanation - just enough to confirm the answer is correct.`,
formatRequirements: `**RESPONSE FORMAT REQUIREMENTS:**
- Keep responses SHORT and CONCISE (1-2 sentences max)
- Use **markdown formatting** for better readability
- Use **bold** for the answer choice/result
- Focus on the most essential information only
- Provide only brief justification for correctness`,
searchUsage: `**SEARCH TOOL USAGE:**
searchUsage: `**SEARCH TOOL USAGE:**
- If the question involves **recent information, current events, or updated facts**, **ALWAYS use Google search** for the latest data
- If they reference **specific dates, statistics, or factual information** that might be outdated, search for current information
- If they ask about **recent research, new theories, or updated methodologies**, search for the latest information
- After searching, provide **direct, accurate answers** with minimal explanation`,
content: `Focus on providing efficient exam assistance that helps students pass tests quickly.
content: `Focus on providing efficient exam assistance that helps students pass tests quickly.
**Key Principles:**
1. **Answer the question directly** - no unnecessary explanations
@@ -196,83 +185,62 @@ You: "**Question**: Which of the following is a primary color? A) Green B) Red C
Question: "Solve for x: 2x + 5 = 13"
You: "**Question**: Solve for x: 2x + 5 = 13 **Answer**: x = 4 **Why**: Subtract 5 from both sides: 2x = 8, then divide by 2: x = 4."`,
outputInstructions: `**OUTPUT INSTRUCTIONS:**
outputInstructions: `**OUTPUT INSTRUCTIONS:**
Provide direct exam answers in **markdown format**. Include the question text, the correct answer choice, and a brief justification. Focus on efficiency and accuracy. Keep responses **short and to the point**.`,
},
},
};
function buildSystemPrompt(promptParts, customPrompt = '', googleSearchEnabled = true) {
const sections = [promptParts.intro, '\n\n', promptParts.formatRequirements];
function buildSystemPrompt(
promptParts,
customPrompt = "",
googleSearchEnabled = true,
responseMode = "brief",
) {
const formatReqs =
responseModeFormats[responseMode] || responseModeFormats.brief;
const sections = [
promptParts.intro,
"\n\n",
formatReqs,
"\n\n",
codingAwareness,
];
// Only add search usage section if Google Search is enabled
if (googleSearchEnabled) {
sections.push('\n\n', promptParts.searchUsage);
}
// Only add search usage section if Google Search is enabled
if (googleSearchEnabled) {
sections.push("\n\n", promptParts.searchUsage);
}
sections.push('\n\n', promptParts.content, '\n\nUser-provided context\n-----\n', customPrompt, '\n-----\n\n', promptParts.outputInstructions);
sections.push(
"\n\n",
promptParts.content,
"\n\nUser-provided context\n-----\n",
customPrompt,
"\n-----\n\n",
promptParts.outputInstructions,
);
return sections.join('');
return sections.join("");
}
function getSystemPrompt(profile, customPrompt = '', googleSearchEnabled = true) {
const promptParts = profilePrompts[profile] || profilePrompts.interview;
return buildSystemPrompt(promptParts, customPrompt, googleSearchEnabled);
function getSystemPrompt(
profile,
customPrompt = "",
googleSearchEnabled = true,
responseMode = "brief",
) {
const promptParts = profilePrompts[profile] || profilePrompts.interview;
return buildSystemPrompt(
promptParts,
customPrompt,
googleSearchEnabled,
responseMode,
);
}
// Comprehensive prompt for Vision/Image analysis
const VISION_ANALYSIS_PROMPT = `You are an expert AI assistant analyzing a screenshot. Your task is to understand what the user needs help with and provide the most useful response.
**ANALYSIS APPROACH:**
1. First, identify what's shown on the screen (code editor, math problem, website, document, exam, etc.)
2. Determine what the user likely needs (explanation, solution, answer, debugging help, etc.)
3. Provide a direct, actionable response
**RESPONSE GUIDELINES BY CONTEXT:**
**If it's CODE (LeetCode, HackerRank, coding interview, IDE):**
- Identify the programming language and problem type
- Provide a brief explanation of the approach (2-3 bullet points max)
- Give the complete, working code solution
- Include time/space complexity if relevant
- If there's an error, explain the fix
**If it's MATH or SCIENCE:**
- Show step-by-step solution
- Use proper mathematical notation with LaTeX ($..$ for inline, $$...$$ for blocks)
- Provide the final answer clearly marked
- Include any relevant formulas used
**If it's MCQ/EXAM/QUIZ:**
- State the correct answer immediately and clearly (e.g., "**Answer: B**")
- Provide brief justification (1-2 sentences)
- If multiple questions visible, answer all of them
**If it's a DOCUMENT/ARTICLE/WEBSITE:**
- Summarize the key information
- Answer any specific questions if apparent
- Highlight important points
**If it's a FORM/APPLICATION:**
- Help fill in the required information
- Suggest appropriate responses
- Point out any issues or missing fields
**If it's an ERROR/DEBUG scenario:**
- Identify the error type and cause
- Provide the fix immediately
- Explain briefly why it occurred
**FORMAT REQUIREMENTS:**
- Use **markdown** for formatting
- Use **bold** for key answers and important points
- Use code blocks with language specification for code
- Be concise but complete - no unnecessary explanations
- No pleasantries or filler text - get straight to the answer
**CRITICAL:** Provide the complete answer. Don't ask for clarification - make reasonable assumptions and deliver value immediately.`;
module.exports = {
profilePrompts,
getSystemPrompt,
VISION_ANALYSIS_PROMPT,
profilePrompts,
responseModeFormats,
codingAwareness,
getSystemPrompt,
};
+1034 -1090
View File
File diff suppressed because it is too large Load Diff
+332
View File
@@ -0,0 +1,332 @@
/**
* Whisper Worker runs ONNX Runtime in an isolated child process.
*
* The main Electron process forks this file and communicates via IPC messages.
* If ONNX Runtime crashes (SIGSEGV/SIGABRT inside the native Metal or CPU
* execution provider), only this worker dies the main process survives and
* can respawn the worker automatically.
*
* Protocol (parent worker):
* parent worker:
* { type: 'load', modelName, cacheDir, device? }
* { type: 'transcribe', audioBase64, language? } // PCM 16-bit 16kHz as base64
* { type: 'shutdown' }
*
* worker parent:
* { type: 'load-result', success, error?, device? }
* { type: 'transcribe-result', success, text?, error? }
* { type: 'status', message }
* { type: 'ready' }
*/
// ── Crash handlers — report fatal errors before the process dies ──
process.on("uncaughtException", (err) => {
try {
send({
type: "status",
message: `[Worker] Uncaught exception: ${err.message || err}`,
});
console.error("[WhisperWorker] Uncaught exception:", err);
} catch (_) {
// Cannot communicate with parent anymore
}
process.exit(1);
});
process.on("unhandledRejection", (reason) => {
try {
send({
type: "status",
message: `[Worker] Unhandled rejection: ${reason?.message || reason}`,
});
console.error("[WhisperWorker] Unhandled rejection:", reason);
} catch (_) {
// Cannot communicate with parent anymore
}
// Don't exit — let it be caught by the pipeline's own handlers
});
let whisperPipeline = null;
/** Which ONNX backend is actually active: "cpu" | "wasm" */
let activeDevice = null;
function pcm16ToFloat32(pcm16Buffer) {
if (!pcm16Buffer || pcm16Buffer.length === 0) {
return new Float32Array(0);
}
const alignedLength =
pcm16Buffer.length % 2 === 0 ? pcm16Buffer.length : pcm16Buffer.length - 1;
const samples = alignedLength / 2;
const float32 = new Float32Array(samples);
for (let i = 0; i < samples; i++) {
float32[i] = pcm16Buffer.readInt16LE(i * 2) / 32768;
}
return float32;
}
/**
* Load the Whisper model.
*
* @param {string} modelName HuggingFace model id, e.g. "Xenova/whisper-small"
* @param {string} cacheDir Directory for cached model files
* @param {string} [device] "cpu" (onnxruntime-node) or "wasm" (onnxruntime-web).
* When "cpu" is requested we try native first and fall
* back to "wasm" on failure (ABI mismatch, etc.).
*/
async function loadModel(modelName, cacheDir, device = "cpu") {
if (whisperPipeline) {
send({ type: "load-result", success: true, device: activeDevice });
return;
}
try {
send({
type: "status",
message: "Loading Whisper model (first time may take a while)...",
});
// Validate / create cache directory
const fs = require("fs");
const path = require("path");
if (cacheDir) {
try {
if (!fs.existsSync(cacheDir)) {
fs.mkdirSync(cacheDir, { recursive: true });
console.log("[WhisperWorker] Created cache directory:", cacheDir);
}
} catch (mkdirErr) {
console.warn(
"[WhisperWorker] Cannot create cache dir:",
mkdirErr.message,
);
}
// Check for corrupted partial downloads — if an onnx file exists but
// is suspiciously small (< 1 KB), delete it so the library re-downloads.
try {
const modelDir = path.join(cacheDir, modelName.replace("/", path.sep));
if (fs.existsSync(modelDir)) {
const walk = (dir) => {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
walk(full);
} else if (
entry.name.endsWith(".onnx") &&
fs.statSync(full).size < 1024
) {
console.warn(
"[WhisperWorker] Removing likely-corrupt file:",
full,
);
fs.unlinkSync(full);
}
}
};
walk(modelDir);
}
} catch (cleanErr) {
console.warn("[WhisperWorker] Cache cleanup error:", cleanErr.message);
}
}
const { pipeline, env } = await import("@huggingface/transformers");
env.cacheDir = cacheDir;
// Attempt to load with the requested device
const devicesToTry = device === "wasm" ? ["wasm"] : ["cpu", "wasm"];
let lastError = null;
for (const dev of devicesToTry) {
try {
send({
type: "status",
message: `Loading Whisper (${dev} backend)...`,
});
console.log(
`[WhisperWorker] Trying device: ${dev}, model: ${modelName}`,
);
whisperPipeline = await pipeline(
"automatic-speech-recognition",
modelName,
{
dtype: "q8",
device: dev,
progress_callback: (progress) => {
// progress: { status, name?, file?, progress?, loaded?, total? }
if (
progress.status === "download" ||
progress.status === "progress"
) {
send({
type: "progress",
file: progress.file || progress.name || "",
progress: progress.progress ?? 0,
loaded: progress.loaded ?? 0,
total: progress.total ?? 0,
status: progress.status,
});
} else if (progress.status === "done") {
send({
type: "progress",
file: progress.file || progress.name || "",
progress: 100,
loaded: progress.total ?? 0,
total: progress.total ?? 0,
status: "done",
});
} else if (progress.status === "initiate") {
send({
type: "progress",
file: progress.file || progress.name || "",
progress: 0,
loaded: 0,
total: 0,
status: "initiate",
});
}
},
},
);
activeDevice = dev;
console.log(
`[WhisperWorker] Model loaded successfully (device: ${dev})`,
);
send({ type: "load-result", success: true, device: dev });
return;
} catch (err) {
lastError = err;
console.error(
`[WhisperWorker] Failed to load with device "${dev}":`,
err.message || err,
);
if (dev === "cpu" && devicesToTry.includes("wasm")) {
send({
type: "status",
message: `Native CPU backend failed (${err.message}). Trying WASM fallback...`,
});
}
// Reset pipeline state before retry
whisperPipeline = null;
}
}
// All devices failed
throw lastError || new Error("All ONNX backends failed");
} catch (error) {
send({ type: "load-result", success: false, error: error.message });
}
}
async function transcribe(audioBase64, language) {
if (!whisperPipeline) {
send({
type: "transcribe-result",
success: false,
error: "Whisper pipeline not loaded",
});
return;
}
try {
const pcm16Buffer = Buffer.from(audioBase64, "base64");
if (pcm16Buffer.length < 2) {
send({
type: "transcribe-result",
success: false,
error: "Audio buffer too small",
});
return;
}
// Cap at ~30 seconds (16kHz, 16-bit mono)
const maxBytes = 16000 * 2 * 30;
const audioData =
pcm16Buffer.length > maxBytes
? pcm16Buffer.slice(0, maxBytes)
: pcm16Buffer;
const float32Audio = pcm16ToFloat32(audioData);
if (float32Audio.length === 0) {
send({
type: "transcribe-result",
success: false,
error: "Empty audio after conversion",
});
return;
}
// Build pipeline options with the requested language
const pipelineOpts = {
sampling_rate: 16000,
task: "transcribe",
};
if (language && language !== "auto") {
pipelineOpts.language = language;
}
const result = await whisperPipeline(float32Audio, pipelineOpts);
const text = result.text?.trim() || "";
send({ type: "transcribe-result", success: true, text });
} catch (error) {
send({
type: "transcribe-result",
success: false,
error: error.message || String(error),
});
}
}
function send(msg) {
try {
if (process.send) {
process.send(msg);
}
} catch (_) {
// Parent may have disconnected
}
}
process.on("message", (msg) => {
switch (msg.type) {
case "load":
loadModel(msg.modelName, msg.cacheDir, msg.device).catch((err) => {
send({ type: "load-result", success: false, error: err.message });
});
break;
case "transcribe":
transcribe(msg.audioBase64, msg.language).catch((err) => {
send({ type: "transcribe-result", success: false, error: err.message });
});
break;
case "shutdown":
// Dispose the ONNX session gracefully before exiting to avoid
// native cleanup race conditions (SIGABRT on mutex destroy).
(async () => {
if (whisperPipeline) {
try {
if (typeof whisperPipeline.dispose === "function") {
await whisperPipeline.dispose();
}
} catch (_) {
// Best-effort cleanup
}
whisperPipeline = null;
}
// Small delay to let native threads wind down
setTimeout(() => process.exit(0), 200);
})();
break;
}
});
// Signal readiness to parent
send({ type: "ready" });
+361 -720
View File
File diff suppressed because it is too large Load Diff
+151
View File
@@ -0,0 +1,151 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const http = require("node:http");
const WebSocket = require("ws");
const {
buildChatMessages,
isLoopbackUrl,
NemotronSidecarClient,
normalizeOpenAiBaseUrl,
streamLmStudioChat,
} = require("../src/utils/localProviders");
test("normalizes LM Studio OpenAI-compatible base URLs without selecting models automatically", () => {
assert.equal(
normalizeOpenAiBaseUrl("http://127.0.0.1:1234"),
"http://127.0.0.1:1234/v1",
);
assert.equal(
normalizeOpenAiBaseUrl("http://127.0.0.1:1234/v1/"),
"http://127.0.0.1:1234/v1",
);
});
test("detects loopback URLs but does not reject non-local URLs", () => {
assert.equal(isLoopbackUrl("http://localhost:1234/v1"), true);
assert.equal(isLoopbackUrl("http://127.0.0.1:1234/v1"), true);
assert.equal(isLoopbackUrl("http://[::1]:1234/v1"), true);
assert.equal(isLoopbackUrl("http://192.168.1.40:1234/v1"), false);
assert.equal(isLoopbackUrl("https://example.com/v1"), false);
});
test("builds OpenAI-compatible image messages for local screenshot analysis", () => {
const messages = buildChatMessages({
systemPrompt: "Be useful.",
history: [{ role: "assistant", content: "Previous answer" }],
userText: "Analyze this screen",
imageBase64: "abc123",
});
assert.deepEqual(messages[0], { role: "system", content: "Be useful." });
assert.equal(messages[1].role, "assistant");
assert.equal(messages[2].role, "user");
assert.equal(messages[2].content[0].type, "text");
assert.equal(messages[2].content[1].type, "image_url");
assert.equal(
messages[2].content[1].image_url.url,
"data:image/jpeg;base64,abc123",
);
});
test("requires a manually configured LM Studio model id", async () => {
await assert.rejects(
() =>
streamLmStudioChat({
baseUrl: "http://127.0.0.1:1234/v1",
model: "",
messages: [{ role: "user", content: "hello" }],
}),
/model id is required/,
);
});
test("streams LM Studio chat completion tokens from an OpenAI-compatible endpoint", async () => {
let receivedBody = null;
const server = http.createServer((req, res) => {
assert.equal(req.method, "POST");
assert.equal(req.url, "/v1/chat/completions");
let raw = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
raw += chunk;
});
req.on("end", () => {
receivedBody = JSON.parse(raw);
res.writeHead(200, {
"Content-Type": "text/event-stream",
});
res.write('data: {"choices":[{"delta":{"content":"hel"}}]}\n\n');
res.write('data: {"choices":[{"delta":{"content":"lo"}}]}\n\n');
res.end("data: [DONE]\n\n");
});
});
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
const { port } = server.address();
const accumulations = [];
const fullText = await streamLmStudioChat({
baseUrl: `http://127.0.0.1:${port}/v1`,
model: "manual-gemma4",
messages: [{ role: "user", content: "hello" }],
onToken: (_token, accumulated) => accumulations.push(accumulated),
});
assert.equal(fullText, "hello");
assert.deepEqual(accumulations, ["hel", "hello"]);
assert.equal(receivedBody.model, "manual-gemma4");
assert.equal(receivedBody.stream, true);
assert.deepEqual(receivedBody.messages, [{ role: "user", content: "hello" }]);
await new Promise((resolve) => server.close(resolve));
});
test("Nemotron sidecar client sends start JSON, binary PCM, and emits final transcripts", async () => {
const server = new WebSocket.Server({ host: "127.0.0.1", port: 0 });
await new Promise((resolve) => server.once("listening", resolve));
const { port } = server.address();
const received = [];
server.on("connection", (socket) => {
socket.on("message", (data, isBinary) => {
if (isBinary) {
received.push({ isBinary, data: Buffer.from(data) });
socket.send(JSON.stringify({ type: "final", text: "hello world" }));
return;
}
received.push({ isBinary, data: JSON.parse(data.toString("utf8")) });
socket.send(JSON.stringify({ type: "ready" }));
});
});
const client = new NemotronSidecarClient({
url: `ws://127.0.0.1:${port}/v1/asr/stream`,
language: "en-US",
});
const finalPromise = new Promise((resolve) => client.once("final", resolve));
await client.connect();
client.sendAudio(Buffer.from([1, 2, 3, 4]));
assert.equal(await finalPromise, "hello world");
assert.deepEqual(received[0], {
isBinary: false,
data: {
type: "start",
sampleRate: 16000,
channels: 1,
encoding: "pcm_s16le",
language: "en-US",
},
});
assert.equal(received[1].isBinary, true);
assert.deepEqual([...received[1].data], [1, 2, 3, 4]);
client.close();
await new Promise((resolve) => server.close(resolve));
});