Compare commits
35
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a55ee10d2b | ||
|
|
8beccdf101 | ||
|
|
219f35cc04 | ||
|
|
b6560f3c6c | ||
|
|
b1d9130b50 | ||
|
|
07c39455be | ||
|
|
39fe8d948f | ||
|
|
1330af8d19 | ||
|
|
c161f251ed | ||
|
|
d111b88886 | ||
|
|
043b5d159e | ||
|
|
851edc6da1 | ||
|
|
c68a546e72 | ||
|
|
09b2530714 | ||
|
|
a9dce5bf3c | ||
|
|
31d50c9713 | ||
|
|
bbad79875c | ||
|
|
7f15b65eb1 | ||
|
|
d6dbaa3141 | ||
|
|
2ebde60dcd | ||
|
|
0d56e06724 | ||
|
|
526bc4e877 | ||
|
|
684b61755c | ||
|
|
1b74968006 | ||
|
|
4cf48ee0af | ||
|
|
494e692738 | ||
|
|
8b216bbb33 | ||
|
|
bd62cf5524 | ||
|
|
bfd76dc0c1 | ||
|
|
310b6b3fbd | ||
|
|
430895d9ab | ||
|
|
3a8d9705a2 | ||
|
|
06e178762d | ||
|
|
656e8f0932 | ||
|
|
669c019fd8 |
@@ -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 }}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
src/assets
|
||||
node_modules
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
Vendored
+24
@@ -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)"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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 real‑time 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.
|
||||
- **Non‑blocking 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
|
||||
cherry‑picked 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 low‑quality 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 dual‑stream audio capture logic for cross‑platform 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.
|
||||
- **Dual‑stream 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** – pre‑filter 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
@@ -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
|
||||
@@ -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 you’re 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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`.
|
||||
@@ -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
@@ -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,
|
||||
}),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
.build/
|
||||
build/
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Executable
+58
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+2327
-187
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,11 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- fs-xattr
|
||||
- macos-alias
|
||||
- onnxruntime-node
|
||||
- protobufjs
|
||||
- sharp
|
||||
@@ -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
@@ -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>` : ''}
|
||||
`
|
||||
: ''}
|
||||
|
||||
+1101
-610
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
+1145
-646
File diff suppressed because it is too large
Load Diff
+852
-1671
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
|
||||
+381
-519
File diff suppressed because it is too large
Load Diff
+1538
-216
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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));
|
||||
});
|
||||
Reference in New Issue
Block a user