Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ead0eecbc5 | ||
|
|
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 | ||
|
|
528dfe01a1 |
@@ -64,10 +64,11 @@ jobs:
|
|||||||
out/make/**/*.deb
|
out/make/**/*.deb
|
||||||
out/make/**/*.rpm
|
out/make/**/*.rpm
|
||||||
if-no-files-found: ignore
|
if-no-files-found: ignore
|
||||||
|
compression-level: 0
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: build
|
needs: build
|
||||||
runs-on: ubuntu-latest
|
runs-on: macos-latest
|
||||||
if: github.server_url == 'https://github.com'
|
if: github.server_url == 'https://github.com'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
|
|||||||
@@ -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,146 @@
|
|||||||
# Repo Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
This repository is a fork of [`cheating-daddy`](https://github.com/sohzm/cheating-daddy).
|
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.
|
||||||
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.
|
|
||||||
|
|
||||||
## Getting started
|
## Read first
|
||||||
|
|
||||||
Install dependencies and run the development app:
|
Before planning or implementing product work, read:
|
||||||
|
|
||||||
```
|
1. [`CONTEXT.md`](CONTEXT.md) for canonical domain language.
|
||||||
1. npm install
|
2. [`docs/product/mastermind-product-brief.md`](docs/product/mastermind-product-brief.md) for scope and non-goals.
|
||||||
2. npm start
|
3. [`docs/architecture/native-mastermind.md`](docs/architecture/native-mastermind.md) for system boundaries.
|
||||||
|
4. [`docs/architecture/system-patterns.md`](docs/architecture/system-patterns.md) and [`docs/architecture/swift-patterns.md`](docs/architecture/swift-patterns.md) for design and implementation standards.
|
||||||
|
5. [`docs/development/commenting-standard.md`](docs/development/commenting-standard.md) for documentation rules.
|
||||||
|
6. [`docs/privacy/local-first-data-contract.md`](docs/privacy/local-first-data-contract.md) for normative data rules.
|
||||||
|
7. [`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.
|
||||||
|
|
||||||
|
## Review Checklist
|
||||||
|
|
||||||
|
При проверке кода (Code Review) обязательно убедитесь в соблюдении следующих пунктов:
|
||||||
|
|
||||||
|
- [ ] Соблюдено Dependency Rule: зависимости направлены внутрь модулей.
|
||||||
|
- [ ] Все внешние I/O и межмодульные взаимодействия закрыты протоколами (Application Ports).
|
||||||
|
- [ ] Состояние инкапсулировано в `actor` или защищено Swift 6 Concurrency.
|
||||||
|
- [ ] Отсутствуют `Task.detached` без явного обоснования и управления жизненным циклом.
|
||||||
|
- [ ] Все публичные и семантически значимые декларации снабжены русским DocC.
|
||||||
|
- [ ] Новые TODO/FIXME содержат ссылку на issue.
|
||||||
|
- [ ] Не нарушен Local-First Data Contract: сырые данные не сохраняются, секреты в Keychain.
|
||||||
|
- [ ] Код соответствует нормативным паттернам из Architecture Playbook.
|
||||||
|
|
||||||
|
## 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
|
```bash
|
||||||
`.prettierrc` (four-space indentation, print width 150, semicolons and single
|
cd native/MastermindPOC
|
||||||
quotes). `src/assets` and `node_modules` are ignored via `.prettierignore`.
|
swift test
|
||||||
The project does not provide linting; `npm run lint` simply prints
|
swift build --product MastermindPOC
|
||||||
"No linting configured".
|
./scripts/build-app.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Code standards
|
For explicitly requested legacy Electron maintenance:
|
||||||
|
|
||||||
Development is gradually migrating toward a TypeScript/React codebase inspired by the
|
```bash
|
||||||
[transcriber](https://github.com/Gatecrashah/transcriber) project. Keep the following
|
npm test
|
||||||
rules in mind as new files are created:
|
```
|
||||||
|
|
||||||
- **TypeScript strict mode** – avoid `any` and prefer explicit interfaces.
|
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.
|
||||||
- **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.
|
|
||||||
|
|
||||||
## 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.
|
## Formatting
|
||||||
Follow these guidelines when working on UI code:
|
|
||||||
|
|
||||||
- **Component directory** – place generated files under `src/components/ui` and export them from that folder.
|
- Use the repository's Swift formatter configuration when one exists; otherwise follow standard Swift API Design Guidelines and existing native code style.
|
||||||
- **Add components with the CLI** – run `npx shadcn@latest add <component>`; never hand-roll components.
|
- Use four-space indentation in JavaScript and Markdown examples where indentation is semantic.
|
||||||
- **Component pattern** – use `React.forwardRef` with the `cn()` helper for class names.
|
- Use Prettier only for files it supports; do not reformat generated assets or unrelated legacy code.
|
||||||
- **Path aliases** – import modules from `src` using the `@/` prefix.
|
- Keep ADRs short and record only decisions that are hard to reverse, surprising without context, and based on a real trade-off.
|
||||||
- **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>')"`.
|
|
||||||
|
|
||||||
## Tests
|
## Upstream changes
|
||||||
|
|
||||||
No automated tests yet. When a suite is added, run `npm test` before each
|
This remains a fork of [`sohzm/cheating-daddy`](https://github.com/sohzm/cheating-daddy), but upstream Electron changes are not automatically product direction.
|
||||||
commit. Until then, at minimum ensure `npm install` and `npm start` work after
|
|
||||||
merging upstream changes.
|
|
||||||
|
|
||||||
## Merging upstream PRs
|
Before cherry-picking upstream work:
|
||||||
|
|
||||||
Pull requests from <https://github.com/sohzm/cheating-daddy> are commonly
|
1. Inspect whether it serves legacy maintenance or the native product.
|
||||||
cherry‑picked here. When merging:
|
2. Keep only reusable protocol, test, or migration value.
|
||||||
|
3. Reject stealth, anti-detection, insecure IPC, and cloud-by-default behavior.
|
||||||
1. Inspect the diff and keep commit messages short (`feat:` / `fix:` etc.).
|
4. Run the relevant Swift and legacy tests.
|
||||||
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.
|
|
||||||
|
|||||||
+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,97 @@
|
|||||||
<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]
|
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.
|
||||||
> Use latest MacOS and Windows version, older versions have limited support
|
|
||||||
|
|
||||||
> [!NOTE]
|
## Canonical context
|
||||||
> During testing it wont answer if you ask something, you need to simulate interviewer asking question, which it will answer
|
|
||||||
|
|
||||||
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 map.
|
||||||
|
- [`docs/architecture/system-patterns.md`](docs/architecture/system-patterns.md) — normative system patterns.
|
||||||
|
- [`docs/architecture/swift-patterns.md`](docs/architecture/swift-patterns.md) — Swift implementation standards.
|
||||||
|
- [`docs/development/commenting-standard.md`](docs/development/commenting-standard.md) — documentation rules.
|
||||||
|
- [`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
|
When older code or text conflicts with these documents, the canonical context wins.
|
||||||
- **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)
|
|
||||||
|
|
||||||
## Setup
|
## MVP direction
|
||||||
|
|
||||||
1. **Get a Gemini API Key**: Visit [Google AI Studio](https://aistudio.google.com/apikey)
|
- Apple Silicon and macOS 14 or newer.
|
||||||
2. **Install Dependencies**: `npm install`
|
- Swift/AppKit host with SwiftUI content where appropriate.
|
||||||
3. **Run the App**: `npm start`
|
- 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
|
## Privacy boundary
|
||||||
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
|
|
||||||
|
|
||||||
## 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
|
Window exclusion from the app's own capture is required. Exclusion from third-party capture is best effort and is never guaranteed.
|
||||||
- **Click-through**: `Ctrl/Cmd + M` - Toggle mouse events
|
|
||||||
- **Close/Back**: `Ctrl/Cmd + \` - Close window or go back
|
|
||||||
- **Send Message**: `Enter` - Send text to AI
|
|
||||||
|
|
||||||
## Audio Capture
|
## Current native capability proof
|
||||||
|
|
||||||
- **macOS**: [SystemAudioDump](https://github.com/Mohammed-Yasin-Mulla/Sound) for system audio
|
`native/MastermindPOC` currently demonstrates:
|
||||||
- **Windows**: Loopback audio capture
|
|
||||||
- **Linux**: Microphone input
|
|
||||||
|
|
||||||
## 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)
|
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`.
|
||||||
- Gemini API key
|
|
||||||
- Screen recording permissions
|
### Build and test the POC
|
||||||
- Microphone/audio permissions
|
|
||||||
|
```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,46 @@
|
|||||||
|
# ADR 0005: Strict Clean Architecture and Capability Modules
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Mastermind is a complex macOS application with multiple responsibilities: continuous capture, knowledge extraction, local graph management, and AI assistance. To ensure maintainability, testability, and clear ownership of privacy boundaries, we need a robust architectural structure.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
We adopt a **Strict Clean Architecture** organized around **Capability Modules**.
|
||||||
|
|
||||||
|
### 1. Capability Modules
|
||||||
|
|
||||||
|
The codebase is divided into stable capability boundaries:
|
||||||
|
|
||||||
|
- **AppShell**: Orchestration, lifecycle, and composition root.
|
||||||
|
- **Observation**: Collectors and raw evidence acquisition.
|
||||||
|
- **Knowledge**: The Context Graph, projection, and retrieval logic.
|
||||||
|
- **Assistant**: AI sessions, provider routing, and prompt management.
|
||||||
|
- **Trust**: Activity logging, health monitoring, and privacy enforcement.
|
||||||
|
- **Infrastructure**: Shared utilities, encryption, and low-level storage.
|
||||||
|
|
||||||
|
### 2. Strict Clean Architecture
|
||||||
|
|
||||||
|
Each module follows Clean Architecture principles:
|
||||||
|
|
||||||
|
- **Entities**: Pure domain models and logic (inner-most).
|
||||||
|
- **Use Cases/Services**: Application-specific business rules.
|
||||||
|
- **Interface Adapters**: Controllers, presenters, and gatekeepers.
|
||||||
|
- **Frameworks & Drivers**: External tools like ScreenCaptureKit, SQLite, and sidecar clients (outer-most).
|
||||||
|
|
||||||
|
### 3. Compile-Time Dependency Rule
|
||||||
|
|
||||||
|
- Dependencies flow **inwards**: outer layers depend on inner layers.
|
||||||
|
- Modules communicate through **Application Ports** (protocols).
|
||||||
|
- **Protocols are required** at module boundaries and for all I/O (adapters).
|
||||||
|
- Internal module logic does not require protocols for every internal function or value type, avoiding unnecessary boilerplate.
|
||||||
|
|
||||||
|
### 4. Enforcement
|
||||||
|
|
||||||
|
- Boundaries are enforced by separate Swift targets/modules where possible.
|
||||||
|
- The **Composition Root** (in AppShell) is the only place where concrete adapters are instantiated and injected.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- **Pros**: Clearer boundaries, easier to mock dependencies for testing, better isolation of sensitive data processing, and independent evolution of capabilities.
|
||||||
|
- **Cons**: Higher initial setup cost for new modules and mandatory boilerplate for cross-module communication.
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
# 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).
|
||||||
|
|
||||||
|
Нормативные паттерны проектирования и реализации вынесены в отдельные документы:
|
||||||
|
|
||||||
|
- [Системные паттерны](system-patterns.md) — взаимодействие подсистем, потоки данных и безопасность.
|
||||||
|
- [Swift Implementation Patterns](swift-patterns.md) — стандарты реализации на Swift, конкурентность и управление состоянием.
|
||||||
|
- [Стандарт комментирования](../development/commenting-standard.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,83 @@
|
|||||||
|
# Swift Implementation Patterns Mastermind
|
||||||
|
|
||||||
|
Этот документ описывает нормативные паттерны реализации кода на Swift.
|
||||||
|
|
||||||
|
## 1. Concurrency & State Ownership
|
||||||
|
|
||||||
|
**Problem**: Состояние гонки (race conditions) и неопределённое поведение при многопоточности.
|
||||||
|
**Rule**: Каждая stateful-возможность (Capability) или сервис принадлежат конкретному `actor`. Межмодульный обмен — только через `Sendable` неизменяемые типы (value types). `MainActor` используется исключительно для UI-проекций.
|
||||||
|
**Apply when**: При проектировании хранилищ, сервисов и вью-моделей.
|
||||||
|
**Avoid**: Использование `lock`, `DispatchQueue` для синхронизации состояния вручную; захват мутабельного состояния в замыканиях.
|
||||||
|
**Trade-offs**: Требует понимания Swift Concurrency и использования `await`.
|
||||||
|
**Verification**: Swift 6 Strict Concurrency не должен выдавать предупреждений и ошибок.
|
||||||
|
|
||||||
|
## 2. Bounded Async Streams
|
||||||
|
|
||||||
|
**Problem**: Неконтролируемое накопление событий в очередях (backpressure) приводит к утечкам памяти и задержкам.
|
||||||
|
**Rule**: Все `AsyncStream` должны иметь ограниченный буфер (bounded) и явную политику обработки переполнения (`dropOldest`, `dropNewest` или `coalesce`).
|
||||||
|
**Apply when**: Для потоков аудио-фреймов, скриншотов и событий UI.
|
||||||
|
**Avoid**: Создание неограниченных потоков событий.
|
||||||
|
**Verification**: Каждый стрим должен иметь тесты на поведение при переполнении.
|
||||||
|
|
||||||
|
## 3. Lightweight UDF (Unidirectional Data Flow)
|
||||||
|
|
||||||
|
**Problem**: Сложная двусторонняя синхронизация UI и бизнес-логики.
|
||||||
|
**Rule**: Использование однонаправленного потока данных: Immutable State → View → Intent (Action) → Service/Reducer → New State. Без обязательной зависимости от тяжелых фреймворков (TCA).
|
||||||
|
**Apply when**: В реализации Companion Island и экранов управления.
|
||||||
|
**Avoid**: Прямая мутация состояния из View; использование `Binding` для бизнес-логики.
|
||||||
|
**Verification**: View зависит только от `State` и отправляет `Intents`.
|
||||||
|
|
||||||
|
## 4. Boundary State Machines
|
||||||
|
|
||||||
|
**Problem**: Неявные переходы между состояниями (например, Collectors) приводят к трудновоспроизводимым багам.
|
||||||
|
**Rule**: Использование явных конечных автоматов (State Machines) для жизненного циклаCollectors, сессий и миграций. Недопустимые переходы должны быть невозможны на уровне типов.
|
||||||
|
**Apply when**: Управление жизненным циклом сложных компонентов.
|
||||||
|
**Avoid**: Большое количество разрозненных `Bool` флагов для описания состояния.
|
||||||
|
**Verification**: Unit-тесты покрывают матрицу переходов.
|
||||||
|
|
||||||
|
## 5. Validated Value Types
|
||||||
|
|
||||||
|
**Problem**: Проброс примитивов (String, Int) через все слои приводит к потере смысла и ошибкам валидации.
|
||||||
|
**Rule**: Использование отдельных типов-обёрток для доменных понятий (ID, Timestamp, Confidence). Проверка инвариантов происходит при создании типа.
|
||||||
|
**Apply when**: Все доменные сущности и параметры портов.
|
||||||
|
**Avoid**: Использование `String` для ID или `Double` для Confidence без обёртки.
|
||||||
|
**Verification**: Код компилируется только при передаче правильных типов; невозможны "пустые" или некорректные значения.
|
||||||
|
|
||||||
|
## 6. Manual Composition Root
|
||||||
|
|
||||||
|
**Problem**: Глобальные синглтоны и Service Locator делают зависимости неявными.
|
||||||
|
**Rule**: Использование ручного внедрения зависимостей (Constructor Injection) в единственной точке входа (Composition Root). Глобальные мутабельные синглтоны запрещены.
|
||||||
|
**Apply when**: Инициализация приложения в `AppShell`.
|
||||||
|
**Avoid**: Использование `shared` instance для бизнес-логики.
|
||||||
|
**Verification**: Все зависимости можно подменить (mock) в тестах без изменения кода модулей.
|
||||||
|
|
||||||
|
## 7. Workflow-Sized Services
|
||||||
|
|
||||||
|
**Problem**: Use cases, которые делают слишком мало (один метод) или слишком много (весь модуль).
|
||||||
|
**Rule**: Application Service должен отражать осмысленный пользовательский или системный воркфлоу (например, `AssistantSessionService`) и оркестровать несколько портов.
|
||||||
|
**Avoid**: Создание класса UseCase для каждой мелкой функции; "божественные" объекты-координаторы.
|
||||||
|
**Verification**: Сервис покрывает логически связанную группу действий.
|
||||||
|
|
||||||
|
## 8. Typed Failure States
|
||||||
|
|
||||||
|
**Problem**: Обобщённые ошибки `Swift.Error` не дают понимания, как на них реагировать.
|
||||||
|
**Rule**: Ожидаемые ошибки моделируются как типизированные состояния (Enum). Ошибки адаптеров переводятся в доменные ошибки на границе модуля.
|
||||||
|
**Apply when**: Возврат результатов из портов и сервисов.
|
||||||
|
**Avoid**: Проброс `NSError` или `URLError` в доменные слои.
|
||||||
|
**Verification**: UI может точно отобразить причину сбоя на основе типа ошибки.
|
||||||
|
|
||||||
|
## 9. Structured Task Ownership
|
||||||
|
|
||||||
|
**Problem**: Утечки задач (detached tasks) и сложности с отменой (cancellation).
|
||||||
|
**Rule**: Каждая долгоживущая `Task` принадлежит владельцу жизненного цикла и отменяется при его завершении. `Task.detached` запрещён, кроме системных воркеров.
|
||||||
|
**Apply when**: Запуск Collectors и фоновой обработки.
|
||||||
|
**Avoid**: "Fire-and-forget" задачи без сохранения ссылки на отмену.
|
||||||
|
**Verification**: Deinit объекта приводит к остановке всех запущенных им задач.
|
||||||
|
|
||||||
|
## 10. Dedicated Executors for Blocking Work
|
||||||
|
|
||||||
|
**Problem**: Блокировка потока актора или MainActor тяжелыми вычислениями.
|
||||||
|
**Rule**: Все блокирующие операции (SQLite, ML, PCM) выносятся на выделенные очереди или исполнители (Dedicated Executors/Queues). Оркестрация акторов не должна выполнять тяжелую работу.
|
||||||
|
**Apply when**: I/O, обработка медиа, криптография.
|
||||||
|
**Avoid**: Выполнение `Data(contentsOf:)` или сложных циклов на MainActor.
|
||||||
|
**Verification**: Профилирование в Instruments не показывает блокировок UI потока.
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
# Системные паттерны Mastermind
|
||||||
|
|
||||||
|
Этот документ описывает нормативные паттерны взаимодействия подсистем Mastermind.
|
||||||
|
|
||||||
|
## 1. Capability-Oriented Clean Architecture
|
||||||
|
|
||||||
|
**Problem**: Высокая связность между захватом, хранилищем и UI мешает тестированию и безопасности.
|
||||||
|
**Rule**: Каждая функциональная область (Capability) инкапсулирована в отдельный модуль с чёткими границами. Взаимодействие происходит через порты (протоколы).
|
||||||
|
**Apply when**: При добавлении новой крупной функциональности (например, новый вид захвата).
|
||||||
|
**Avoid**: Прямой импорт конкретных реализаций (Adapters) между модулями.
|
||||||
|
**Trade-offs**: Требует больше кода для инициализации (Dependency Injection).
|
||||||
|
**Verification**: Запрещены перекрёстные импорты в Swift модулях; Unit-тесты используют Mock-реализации портов.
|
||||||
|
|
||||||
|
## 2. Ports & Async Events
|
||||||
|
|
||||||
|
**Problem**: Глобальные шины событий (EventBus) делают зависимости неявными и затрудняют отладку.
|
||||||
|
**Rule**: Запросы и команды идут через явные порты (Application Ports). Факты о произошедшем передаются через типизированные асинхронные потоки событий (Typed Async Events). Глобальная шина запрещена.
|
||||||
|
**Apply when**: Для межмодульного взаимодействия.
|
||||||
|
**Avoid**: Использование `NotificationCenter` или глобальных `ObservableObject`.
|
||||||
|
**Trade-offs**: Требует явной оркестрации в Composition Root.
|
||||||
|
**Verification**: Каждый исходящий поток событий должен быть частью интерфейса порта модуля.
|
||||||
|
|
||||||
|
## 3. Observation Ledger
|
||||||
|
|
||||||
|
**Problem**: Прямая запись результатов захвата в граф знаний приводит к потере контекста и невозможности переобработки данных.
|
||||||
|
**Rule**: Collectors записывают только неизменяемые "свидетельства" (Observations) в лог (Ledger). Только Knowledge Pipeline читает этот лог.
|
||||||
|
**Apply when**: При обработке любого потока данных из источников (Sources).
|
||||||
|
**Avoid**: Прямое обновление Facts или Entities из Collectors.
|
||||||
|
**Trade-offs**: Увеличивает объём хранимых данных на диске до момента очистки (Retention).
|
||||||
|
**Verification**: База данных содержит таблицу `Observations` с Provenance.
|
||||||
|
|
||||||
|
## 4. Idempotent Projectors & Durable Checkpoints
|
||||||
|
|
||||||
|
**Problem**: Сбой во время обработки Observations может привести к дублированию или потере знаний в графе.
|
||||||
|
**Rule**: Проекторы (Projectors) читают лог Observations и обновляют граф, сохраняя контрольные точки (Checkpoints). Процесс должен быть идемпотентным.
|
||||||
|
**Apply when**: При преобразовании сырых данных в Assertions и Facts.
|
||||||
|
**Avoid**: Логика проекции, зависящая от текущего времени или внешнего состояния вне лога.
|
||||||
|
**Trade-offs**: Усложняет логику обновления графа.
|
||||||
|
**Verification**: Перезапуск проектора с одного и того же чекпоинта должен приводить к идентичному состоянию графа.
|
||||||
|
|
||||||
|
## 5. Capability-Specific Persistence Ports
|
||||||
|
|
||||||
|
**Problem**: Общие репозитории (Generic Repository<T>) скрывают специфичные требования к данным и производительности.
|
||||||
|
**Rule**: Каждый модуль определяет свои узкие порты для работы с данными (например, `AppendObservation`, `QueryContext`). Реализация за скрытым SQLite/SQLCipher адаптером.
|
||||||
|
**Avoid**: Использование общего DAO или прямого доступа к БД вне адаптера.
|
||||||
|
**Verification**: Интерфейсы портов содержат только те методы, которые реально нужны данному модулю.
|
||||||
|
|
||||||
|
## 6. Central Egress Gate & Privacy Envelopes
|
||||||
|
|
||||||
|
**Problem**: Риск случайной отправки конфиденциальных данных (PII) в облако.
|
||||||
|
**Rule**: Весь исходящий трафик к внешним провайдерам проходит через единый Egress Gate. Данные передаются в типизированных конвертах (Privacy Envelopes) с метаданными о классификации.
|
||||||
|
**Apply when**: Любая передача данных за пределы Mac (Cloud Providers).
|
||||||
|
**Avoid**: Прямые сетевые запросы из Assistant или других модулей.
|
||||||
|
**Trade-offs**: Единая точка отказа и бутылочное горлышко производительности.
|
||||||
|
**Verification**: Egress Gate блокирует любые данные без явного Provider Context Permission.
|
||||||
|
|
||||||
|
## 7. Work Scheduler & Budgets
|
||||||
|
|
||||||
|
**Problem**: Непрерывный захват и ML-обработка могут замедлять UI или разряжать батарею.
|
||||||
|
**Rule**: Центральный планировщик распределяет задачи по приоритетам и бюджетам ресурсов. Аудио и интерактив всегда выше фоновой индексации.
|
||||||
|
**Avoid**: Запуск `Task.detached` без указания приоритета и лимитов.
|
||||||
|
**Trade-offs**: Может увеличивать задержку (latency) для фоновых задач.
|
||||||
|
**Verification**: Приложение снижает активность в Low Power Mode.
|
||||||
|
|
||||||
|
## 8. Bounded Derived Caches
|
||||||
|
|
||||||
|
**Problem**: Кэширование может приводить к несогласованности данных и утечкам памяти.
|
||||||
|
**Rule**: Все кэши ограничены (bounded), принадлежат конкретным акторам (actor-owned) и могут быть полностью перестроены из Context Store.
|
||||||
|
**Apply when**: Для OCR, embeddings и UI элементов.
|
||||||
|
**Avoid**: Использование глобального `NSCache` без ограничений по времени и размеру.
|
||||||
|
**Verification**: Unit-тесты проверяют очистку кэша при достижении лимитов.
|
||||||
|
|
||||||
|
## 9. Structured Local Tracing
|
||||||
|
|
||||||
|
**Problem**: Текстовые логи бесполезны для отладки сложных распределённых процессов без передачи контента.
|
||||||
|
**Rule**: Использование структурированных спанов и событий с Correlation IDs (SourceID, ObservationID). Redacted metadata — только технические детали.
|
||||||
|
**Avoid**: Логирование распознанного текста или аудио-транскриптов.
|
||||||
|
**Verification**: Логи не содержат персональных данных пользователя, но позволяют проследить путь конкретной Observation.
|
||||||
|
|
||||||
|
## 10. Versioned Boundaries
|
||||||
|
|
||||||
|
**Problem**: Изменение формата данных (ASR, LLM, Export) ломает совместимость.
|
||||||
|
**Rule**: Все границы (Sidecars, Providers, Archives) используют типизированные DTO с версионированием и Contract Tests. Tolerant Reader обязателен.
|
||||||
|
**Avoid**: Сериализация внутренних доменных типов напрямую.
|
||||||
|
**Verification**: Наличие тестов на обратную совместимость схем.
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
# Стандарт комментирования Mastermind
|
||||||
|
|
||||||
|
Этот документ устанавливает правила документирования и комментирования кода в Swift-проекте Mastermind.
|
||||||
|
|
||||||
|
## 1. Язык и стиль
|
||||||
|
|
||||||
|
- **Язык**: Все комментарии, документация DocC и пометки TODO/FIXME пишутся на **русском языке**.
|
||||||
|
- **Стиль**: Лаконичный, технический, без лишних слов. Используйте DocC для всех семантически значимых деклараций.
|
||||||
|
|
||||||
|
## 2. Обязательный DocC
|
||||||
|
|
||||||
|
DocC (тройной слэш `///`) обязателен для следующих элементов:
|
||||||
|
|
||||||
|
- Все типы (Struct, Class, Enum, Actor, Protocol).
|
||||||
|
- Все требования протоколов.
|
||||||
|
- Все функции, методы и инициализаторы.
|
||||||
|
- Все свойства (properties), имеющие самостоятельный доменный или технический смысл.
|
||||||
|
|
||||||
|
**Исключения**:
|
||||||
|
|
||||||
|
- Локальные переменные внутри функций.
|
||||||
|
- Очевидные элементы тестовых фикстур (если их смысл понятен из названия).
|
||||||
|
- Однородные `enum cases` (можно документировать одной группой перед перечислением).
|
||||||
|
|
||||||
|
## 3. Формат DocC (Concise Semantic DocC)
|
||||||
|
|
||||||
|
**Правила**:
|
||||||
|
|
||||||
|
- Первая строка — одно предложение, описывающее роль или контракт элемента.
|
||||||
|
- Секции `- Parameters:`, `- Returns:`, `- Throws:` добавляются только если они несут дополнительную информацию.
|
||||||
|
- **Запрещено**:
|
||||||
|
- Пустые секции.
|
||||||
|
- Дословный пересказ сигнатуры (например, `/// Возвращает строку` для функции `func getString() -> String`).
|
||||||
|
- Комментарии ради комментариев.
|
||||||
|
|
||||||
|
**Пример**:
|
||||||
|
|
||||||
|
```swift
|
||||||
|
/// Обрабатывает входящий аудио-фрейм и извлекает наблюдения.
|
||||||
|
///
|
||||||
|
/// - Parameter frame: PCM данные в формате 16 кГц моно.
|
||||||
|
/// - Throws: `AudioError.invalidFormat`, если данные повреждены.
|
||||||
|
func process(frame: PCMFrame) throws { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Внутренние комментарии (Inline)
|
||||||
|
|
||||||
|
Используйте двойной слэш `//` только в следующих случаях:
|
||||||
|
|
||||||
|
- **Почему (Rationale)**: Объяснение нетривиального архитектурного решения.
|
||||||
|
- **Инварианты**: Описание условий, которые должны соблюдаться в этом блоке кода.
|
||||||
|
- **Безопасность и Concurrency**: Пояснения по поводу владения данными или специфики потоков.
|
||||||
|
- **OS Quirks**: Описание обходных путей (workarounds) для особенностей macOS/AppKit.
|
||||||
|
|
||||||
|
**Запрещено**:
|
||||||
|
|
||||||
|
- "Narrating comments" — пересказ того, что делает код (например, `// увеличиваем счетчик`).
|
||||||
|
- Закомментированный код (удаляйте его, история есть в Git).
|
||||||
|
|
||||||
|
## 5. Навигация и пометки
|
||||||
|
|
||||||
|
### MARK
|
||||||
|
|
||||||
|
Используйте `// MARK: -` для разделения больших файлов на смысловые секции.
|
||||||
|
|
||||||
|
- Группируйте методы расширений (extensions) по протоколам, которым они соответствуют.
|
||||||
|
- Не используйте MARK для одиночных методов.
|
||||||
|
|
||||||
|
### TODO и FIXME
|
||||||
|
|
||||||
|
Использование этих пометок разрешено только с указанием ссылки на задачу (issue).
|
||||||
|
|
||||||
|
- `// TODO(#123): Описание задачи и что именно нужно сделать.`
|
||||||
|
- `// FIXME(#456): Описание нарушения или риска, который нужно устранить.`
|
||||||
|
|
||||||
|
## 6. Актуальность
|
||||||
|
|
||||||
|
- Комментарий, не соответствующий коду — это дезинформация.
|
||||||
|
- При изменении контракта функции или логики типа, комментарий **обязан** быть обновлён в том же коммите.
|
||||||
|
- Устаревшие комментарии должны безжалостно удаляться.
|
||||||
@@ -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,97 @@
|
|||||||
|
# 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`.
|
||||||
|
|
||||||
|
## Architectural compliance
|
||||||
|
|
||||||
|
- [ ] Code follows **Strict Clean Architecture** with capability modules.
|
||||||
|
- [ ] Dependency Rule is enforced at compile-time between targets.
|
||||||
|
- [ ] No global EventBus; communication uses ports and typed async streams.
|
||||||
|
- [ ] Context Graph updates are performed by idempotent projectors with durable checkpoints.
|
||||||
|
- [ ] Privacy-sensitive egress is gated by a central fail-closed Egress Gate.
|
||||||
|
- [ ] Swift 6 Strict Concurrency is enabled and produces no warnings.
|
||||||
|
- [ ] All semantic declarations have Russian DocC per the **Commenting Standard**.
|
||||||
@@ -10,8 +10,6 @@
|
|||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
<key>com.apple.security.cs.disable-library-validation</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.microphone</key>
|
<key>com.apple.security.device.microphone</key>
|
||||||
|
|||||||
+34
-48
@@ -1,89 +1,75 @@
|
|||||||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
const { FusesPlugin } = require("@electron-forge/plugin-fuses");
|
||||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
const { FuseV1Options, FuseVersion } = require("@electron/fuses");
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs');
|
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
packagerConfig: {
|
packagerConfig: {
|
||||||
asar: true,
|
asar: {
|
||||||
extraResource: ['./src/assets/SystemAudioDump'],
|
unpack:
|
||||||
name: 'Cheating Daddy',
|
"**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**",
|
||||||
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();
|
|
||||||
},
|
},
|
||||||
],
|
extraResource: ["./src/assets/SystemAudioDump"],
|
||||||
|
name: "Mastermind",
|
||||||
|
icon: "src/assets/logo",
|
||||||
// use `security find-identity -v -p codesigning` to find your identity
|
// use `security find-identity -v -p codesigning` to find your identity
|
||||||
// for macos signing
|
// for macos signing
|
||||||
// Disabled for local builds - ad-hoc signing causes issues
|
// also fuck apple
|
||||||
// osxSign: {
|
// osxSign: {
|
||||||
// identity: '-', // ad-hoc signing (no Apple Developer account needed)
|
// identity: '<paste your identity here>',
|
||||||
// optionsForFile: (filePath) => {
|
// optionsForFile: (filePath) => {
|
||||||
// return {
|
// return {
|
||||||
// entitlements: 'entitlements.plist',
|
// entitlements: 'entitlements.plist',
|
||||||
// };
|
// };
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
// notarize is off - requires Apple Developer account
|
// notarize if off cuz i ran this for 6 hours and it still didnt finish
|
||||||
// osxNotarize: {
|
// osxNotarize: {
|
||||||
// appleId: 'your apple id',
|
// appleId: 'your apple id',
|
||||||
// appleIdPassword: 'app specific password',
|
// appleIdPassword: 'app specific password',
|
||||||
// teamId: 'your team id',
|
// teamId: 'your team id',
|
||||||
// },
|
// },
|
||||||
},
|
},
|
||||||
rebuildConfig: {},
|
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: [
|
makers: [
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-squirrel',
|
name: "@electron-forge/maker-squirrel",
|
||||||
config: {
|
config: {
|
||||||
name: 'cheating-daddy',
|
name: "mastermind",
|
||||||
productName: 'Cheating Daddy',
|
productName: "Mastermind",
|
||||||
shortcutName: 'Cheating Daddy',
|
shortcutName: "Mastermind",
|
||||||
createDesktopShortcut: true,
|
createDesktopShortcut: true,
|
||||||
createStartMenuShortcut: true,
|
createStartMenuShortcut: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '@electron-forge/maker-dmg',
|
name: "@electron-forge/maker-dmg",
|
||||||
platforms: ['darwin'],
|
platforms: ["darwin"],
|
||||||
config: {
|
config: {
|
||||||
name: 'CheatingDaddy',
|
format: "UDZO",
|
||||||
format: 'ULFO',
|
icon: "src/assets/logo.icns",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: '@reforged/maker-appimage',
|
name: "@reforged/maker-appimage",
|
||||||
platforms: ['linux'],
|
platforms: ["linux"],
|
||||||
config: {
|
config: {
|
||||||
options: {
|
options: {
|
||||||
name: 'Cheating Daddy',
|
name: "Mastermind",
|
||||||
productName: 'Cheating Daddy',
|
productName: "Mastermind",
|
||||||
genericName: 'AI Assistant',
|
genericName: "AI Assistant",
|
||||||
description: 'AI assistant for interviews and learning',
|
description: "AI assistant for interviews and learning",
|
||||||
categories: ['Development', 'Education'],
|
categories: ["Development", "Education"],
|
||||||
icon: 'src/assets/logo.png'
|
icon: "src/assets/logo.png",
|
||||||
}
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
{
|
{
|
||||||
name: '@electron-forge/plugin-auto-unpack-natives',
|
name: "@electron-forge/plugin-auto-unpack-natives",
|
||||||
config: {},
|
config: {},
|
||||||
},
|
},
|
||||||
// Fuses are used to enable/disable various Electron functionality
|
// Fuses are used to enable/disable various Electron functionality
|
||||||
|
|||||||
@@ -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"
|
||||||
+37
-25
@@ -1,22 +1,25 @@
|
|||||||
{
|
{
|
||||||
"name": "cheating-daddy",
|
"name": "mastermind",
|
||||||
"productName": "cheating-daddy",
|
"productName": "Mastermind",
|
||||||
"version": "0.5.10",
|
"version": "0.7.9",
|
||||||
"description": "cheating daddy",
|
"description": "Mastermind AI assistant",
|
||||||
"main": "src/index.js",
|
"main": "src/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "electron-forge start",
|
"start": "electron-forge start",
|
||||||
"package": "electron-forge package",
|
"package": "electron-forge package",
|
||||||
"make": "electron-forge make",
|
"make": "electron-forge make",
|
||||||
"publish": "electron-forge publish",
|
"publish": "electron-forge publish",
|
||||||
"lint": "echo \"No linting configured\""
|
"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": [
|
"keywords": [
|
||||||
"cheating daddy",
|
"mastermind",
|
||||||
"cheating daddy ai",
|
"mastermind ai",
|
||||||
"cheating daddy ai assistant",
|
"mastermind ai assistant",
|
||||||
"cheating daddy ai assistant for interviews",
|
"mastermind ai assistant for interviews",
|
||||||
"cheating daddy ai assistant for interviews"
|
"mastermind ai assistant for interviews"
|
||||||
],
|
],
|
||||||
"author": {
|
"author": {
|
||||||
"name": "ShiftyX1",
|
"name": "ShiftyX1",
|
||||||
@@ -24,23 +27,32 @@
|
|||||||
},
|
},
|
||||||
"license": "GPL-3.0",
|
"license": "GPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@google/genai": "^1.35.0",
|
"@google/genai": "^1.41.0",
|
||||||
|
"@huggingface/transformers": "^3.8.1",
|
||||||
"electron-squirrel-startup": "^1.0.1",
|
"electron-squirrel-startup": "^1.0.1",
|
||||||
"openai": "^6.16.0",
|
"ollama": "^0.6.3",
|
||||||
"ws": "^8.18.0"
|
"openai": "^6.22.0",
|
||||||
|
"p-retry": "^4.6.2",
|
||||||
|
"ws": "^8.19.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@electron-forge/cli": "^7.11.1",
|
"@electron-forge/cli": "^7.8.1",
|
||||||
"@electron-forge/maker-deb": "^7.11.1",
|
"@electron-forge/maker-deb": "^7.8.1",
|
||||||
"@electron-forge/maker-dmg": "^7.11.1",
|
"@electron-forge/maker-dmg": "^7.8.1",
|
||||||
"@electron-forge/maker-rpm": "^7.11.1",
|
"@electron-forge/maker-rpm": "^7.8.1",
|
||||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
"@electron-forge/maker-squirrel": "^7.8.1",
|
||||||
"@electron-forge/maker-zip": "^7.11.1",
|
"@electron-forge/maker-zip": "^7.8.1",
|
||||||
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
"@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
|
||||||
"@electron-forge/plugin-fuses": "^7.11.1",
|
"@electron-forge/plugin-fuses": "^7.8.1",
|
||||||
"@electron/fuses": "^2.0.0",
|
"@electron/fuses": "^1.8.0",
|
||||||
"@electron/osx-sign": "^2.3.0",
|
"@electron/rebuild": "^3.7.1",
|
||||||
"@reforged/maker-appimage": "^5.1.1",
|
"@reforged/maker-appimage": "^5.0.0",
|
||||||
"electron": "^39.2.7"
|
"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 {
|
export class AppHeader extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
* {
|
* {
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
font-family: var(--font);
|
||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
}
|
}
|
||||||
@@ -14,14 +14,14 @@ export class AppHeader extends LitElement {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: var(--header-padding);
|
padding: var(--header-padding);
|
||||||
background: var(--header-background);
|
background: var(--header-background);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-title {
|
.header-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: var(--header-font-size);
|
font-size: var(--header-font-size);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--text-color);
|
color: var(--text-primary);
|
||||||
-webkit-app-region: drag;
|
-webkit-app-region: drag;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,8 +39,8 @@ export class AppHeader extends LitElement {
|
|||||||
|
|
||||||
.button {
|
.button {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--text-color);
|
color: var(--text-primary);
|
||||||
border: 1px solid var(--border-color);
|
border: 1px solid var(--border);
|
||||||
padding: var(--header-button-padding);
|
padding: var(--header-button-padding);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: var(--header-font-size-small);
|
font-size: var(--header-font-size-small);
|
||||||
@@ -73,7 +73,7 @@ export class AppHeader extends LitElement {
|
|||||||
|
|
||||||
.icon-button:hover {
|
.icon-button:hover {
|
||||||
background: var(--hover-background);
|
background: var(--hover-background);
|
||||||
color: var(--text-color);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
:host([isclickthrough]) .button:hover,
|
:host([isclickthrough]) .button:hover,
|
||||||
@@ -86,7 +86,7 @@ export class AppHeader extends LitElement {
|
|||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.click-through-indicator {
|
.click-through-indicator {
|
||||||
@@ -95,7 +95,7 @@ export class AppHeader extends LitElement {
|
|||||||
background: var(--key-background);
|
background: var(--key-background);
|
||||||
padding: 2px 6px;
|
padding: 2px 6px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
font-family: var(--font-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
.update-button {
|
.update-button {
|
||||||
@@ -120,148 +120,6 @@ export class AppHeader extends LitElement {
|
|||||||
.update-button:hover {
|
.update-button:hover {
|
||||||
background: rgba(241, 76, 76, 0.1);
|
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 = {
|
static properties = {
|
||||||
@@ -276,8 +134,6 @@ export class AppHeader extends LitElement {
|
|||||||
onHideToggleClick: { type: Function },
|
onHideToggleClick: { type: Function },
|
||||||
isClickThrough: { type: Boolean, reflect: true },
|
isClickThrough: { type: Boolean, reflect: true },
|
||||||
updateAvailable: { type: Boolean },
|
updateAvailable: { type: Boolean },
|
||||||
aiProvider: { type: String },
|
|
||||||
modelInfo: { type: Object },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -294,8 +150,6 @@ export class AppHeader extends LitElement {
|
|||||||
this.isClickThrough = false;
|
this.isClickThrough = false;
|
||||||
this.updateAvailable = false;
|
this.updateAvailable = false;
|
||||||
this._timerInterval = null;
|
this._timerInterval = null;
|
||||||
this.aiProvider = 'gemini';
|
|
||||||
this.modelInfo = { model: '', visionModel: '', whisperModel: '' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
connectedCallback() {
|
||||||
@@ -388,15 +242,15 @@ export class AppHeader extends LitElement {
|
|||||||
|
|
||||||
getViewTitle() {
|
getViewTitle() {
|
||||||
const titles = {
|
const titles = {
|
||||||
onboarding: 'Welcome to Cheating Daddy',
|
onboarding: 'Welcome to Mastermind',
|
||||||
main: 'Cheating Daddy',
|
main: 'Mastermind',
|
||||||
customize: 'Customize',
|
customize: 'Customize',
|
||||||
help: 'Help & Shortcuts',
|
help: 'Help & Shortcuts',
|
||||||
history: 'Conversation History',
|
history: 'Conversation History',
|
||||||
advanced: 'Advanced Tools',
|
advanced: 'Advanced Tools',
|
||||||
assistant: 'Cheating Daddy',
|
assistant: 'Mastermind',
|
||||||
};
|
};
|
||||||
return titles[this.currentView] || 'Cheating Daddy';
|
return titles[this.currentView] || 'Mastermind';
|
||||||
}
|
}
|
||||||
|
|
||||||
getElapsedTime() {
|
getElapsedTime() {
|
||||||
@@ -417,49 +271,8 @@ export class AppHeader extends LitElement {
|
|||||||
return navigationViews.includes(this.currentView);
|
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() {
|
render() {
|
||||||
const elapsedTime = this.getElapsedTime();
|
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`
|
return html`
|
||||||
<div class="header">
|
<div class="header">
|
||||||
@@ -467,17 +280,8 @@ export class AppHeader extends LitElement {
|
|||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
${this.currentView === 'assistant'
|
${this.currentView === 'assistant'
|
||||||
? html`
|
? html`
|
||||||
${this.renderModelInfo()}
|
|
||||||
<span>${elapsedTime}</span>
|
<span>${elapsedTime}</span>
|
||||||
<div class="status-wrapper">
|
<span>${this.statusText}</span>
|
||||||
<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>
|
|
||||||
${this.isClickThrough ? html`<span class="click-through-indicator">click-through</span>` : ''}
|
${this.isClickThrough ? html`<span class="click-through-indicator">click-through</span>` : ''}
|
||||||
`
|
`
|
||||||
: ''}
|
: ''}
|
||||||
|
|||||||
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);
|
||||||
File diff suppressed because it is too large
Load Diff
+513
-1332
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);
|
||||||
@@ -1,229 +1,95 @@
|
|||||||
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||||
import { resizeLayout } from '../../utils/windowResize.js';
|
import { unifiedPageStyles } from './sharedPageStyles.js';
|
||||||
|
|
||||||
export class HelpView extends LitElement {
|
export class HelpView extends LitElement {
|
||||||
static styles = css`
|
static styles = [
|
||||||
* {
|
unifiedPageStyles,
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
css`
|
||||||
cursor: default;
|
.shortcut-grid {
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host {
|
|
||||||
display: block;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.help-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-group {
|
|
||||||
padding: 16px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-group:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.option-label {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-muted);
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.5px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
user-select: text;
|
|
||||||
cursor: text;
|
|
||||||
}
|
|
||||||
|
|
||||||
.description strong {
|
|
||||||
color: var(--text-color);
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.link {
|
|
||||||
color: var(--text-color);
|
|
||||||
text-decoration: underline;
|
|
||||||
text-underline-offset: 2px;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.keyboard-section {
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
grid-template-columns: 1fr 1fr;
|
||||||
gap: 12px;
|
gap: var(--space-sm);
|
||||||
margin-top: 8px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.keyboard-group {
|
.shortcut-row {
|
||||||
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;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 4px 0;
|
justify-content: space-between;
|
||||||
font-size: 11px;
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-description {
|
.shortcut-label {
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.shortcut-keys {
|
.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;
|
display: inline-flex;
|
||||||
align-items: center;
|
gap: 4px;
|
||||||
gap: 6px;
|
flex-wrap: wrap;
|
||||||
padding: 8px 14px;
|
justify-content: flex-end;
|
||||||
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 {
|
.key {
|
||||||
background: var(--hover-background);
|
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-steps {
|
.list {
|
||||||
counter-reset: step-counter;
|
display: grid;
|
||||||
|
gap: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-step {
|
.list-item {
|
||||||
counter-increment: step-counter;
|
padding: var(--space-sm);
|
||||||
position: relative;
|
border: 1px solid var(--border);
|
||||||
padding-left: 24px;
|
border-radius: var(--radius-sm);
|
||||||
margin-bottom: 8px;
|
|
||||||
font-size: 11px;
|
|
||||||
line-height: 1.4;
|
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-step::before {
|
.link-row {
|
||||||
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;
|
display: flex;
|
||||||
align-items: center;
|
flex-wrap: wrap;
|
||||||
justify-content: center;
|
gap: var(--space-sm);
|
||||||
font-size: 10px;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.usage-step strong {
|
.link-button {
|
||||||
color: var(--text-color);
|
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);
|
||||||
}
|
}
|
||||||
`;
|
|
||||||
|
.link-button:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(63, 125, 229, 0.14);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.shortcut-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
`,
|
||||||
|
];
|
||||||
|
|
||||||
static properties = {
|
static properties = {
|
||||||
onExternalLinkClick: { type: Function },
|
onExternalLinkClick: { type: Function },
|
||||||
@@ -249,12 +115,6 @@ export class HelpView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
// Resize window for this view
|
|
||||||
resizeLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
getDefaultKeybinds() {
|
getDefaultKeybinds() {
|
||||||
const isMac = cheatingDaddy.isMacOS || navigator.platform.includes('Mac');
|
const isMac = cheatingDaddy.isMacOS || navigator.platform.includes('Mac');
|
||||||
return {
|
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>`);
|
return keybind.split('+').map(key => html`<span class="key">${key}</span>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleExternalLinkClick(url) {
|
_open(url) {
|
||||||
this.onExternalLinkClick(url);
|
this.onExternalLinkClick(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
const isMacOS = cheatingDaddy.isMacOS || false;
|
const shortcutRows = [
|
||||||
const isLinux = cheatingDaddy.isLinux || false;
|
['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`
|
return html`
|
||||||
<div class="help-container">
|
<div class="unified-page">
|
||||||
<div class="option-group">
|
<div class="unified-wrap">
|
||||||
<div class="option-label">
|
<div class="page-title">Help</div>
|
||||||
<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="option-group">
|
<section class="surface">
|
||||||
<div class="option-label">
|
<div class="surface-title">Support</div>
|
||||||
<span>Keyboard Shortcuts</span>
|
<div class="link-row">
|
||||||
</div>
|
<button class="link-button" @click=${() => this._open('https://cheatingdaddy.com')}>Website</button>
|
||||||
<div class="keyboard-section">
|
<button class="link-button" @click=${() => this._open('https://github.com/sohzm/cheating-daddy')}>GitHub</button>
|
||||||
<div class="keyboard-group">
|
<button class="link-button" @click=${() => this._open('https://discord.gg/GCBdubnXfJ')}>Discord</button>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="keyboard-group">
|
<section class="surface">
|
||||||
<div class="keyboard-group-title">Window Control</div>
|
<div class="surface-title">Keyboard Shortcuts</div>
|
||||||
<div class="shortcut-item">
|
<div class="shortcut-grid">
|
||||||
<span class="shortcut-description">Toggle click-through mode</span>
|
${shortcutRows.map(([label, keys]) => html`
|
||||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.toggleClickThrough)}</div>
|
<div class="shortcut-row">
|
||||||
|
<span class="shortcut-label">${label}</span>
|
||||||
|
<span class="shortcut-keys">${this._formatKeybind(keys)}</span>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</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);
|
customElements.define('help-view', HelpView);
|
||||||
|
|||||||
+350
-488
@@ -1,384 +1,289 @@
|
|||||||
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||||
import { resizeLayout } from '../../utils/windowResize.js';
|
import { unifiedPageStyles } from './sharedPageStyles.js';
|
||||||
|
|
||||||
export class HistoryView extends LitElement {
|
export class HistoryView extends LitElement {
|
||||||
static styles = css`
|
static styles = [
|
||||||
* {
|
unifiedPageStyles,
|
||||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
css`
|
||||||
cursor: default;
|
.unified-page {
|
||||||
user-select: none;
|
overflow-y: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
:host {
|
.unified-wrap {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-container {
|
.search-wrap {
|
||||||
height: 100%;
|
position: relative;
|
||||||
|
max-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 10px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-wrap .control {
|
||||||
|
padding-left: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-shell {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
overflow: hidden;
|
||||||
|
flex: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sessions-list {
|
.sessions-list {
|
||||||
flex: 1;
|
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-item {
|
.session-card {
|
||||||
padding: 12px;
|
width: 100%;
|
||||||
border-bottom: 1px solid var(--border-color);
|
border: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: transparent;
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-sm) var(--space-md);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.1s ease;
|
transition: background var(--transition);
|
||||||
}
|
|
||||||
|
|
||||||
.session-item:hover {
|
|
||||||
background: var(--hover-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-item.selected {
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 4px;
|
justify-content: space-between;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-card:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-left {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-profile {
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-date {
|
.session-date {
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-time {
|
|
||||||
font-size: 11px;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
font-size: var(--font-size-xs);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-preview {
|
.session-badge {
|
||||||
font-size: 11px;
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
background: var(--bg-elevated);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 2px 8px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn {
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
line-height: 1.3;
|
padding: 0;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.conversation-view {
|
.back-btn svg {
|
||||||
flex: 1;
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-btn:hover {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-info {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-muted);
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: var(--font-size-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn:hover {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-btn.active {
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.details-scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: var(--bg-primary);
|
flex: 1;
|
||||||
padding: 12px 0;
|
min-height: 0;
|
||||||
user-select: text;
|
display: flex;
|
||||||
cursor: text;
|
flex-direction: column;
|
||||||
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm) 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.user {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-row.ai,
|
||||||
|
.message-row.screen {
|
||||||
|
justify-content: flex-start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.message {
|
.message {
|
||||||
margin-bottom: 8px;
|
max-width: 75%;
|
||||||
|
border-radius: 16px;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-left: 2px solid transparent;
|
word-break: break-word;
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.4;
|
|
||||||
background: var(--bg-secondary);
|
|
||||||
user-select: text;
|
user-select: text;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
|
font-size: var(--font-size-sm);
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-wrap: break-word;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.user {
|
.message-meta {
|
||||||
border-left-color: #3b82f6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.ai {
|
|
||||||
border-left-color: #ef4444;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
padding: 12px 12px 12px 12px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-color);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
padding: 6px 12px;
|
|
||||||
border-radius: 3px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
transition: background 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.back-button:hover {
|
|
||||||
background: var(--hover-background);
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
align-items: center;
|
|
||||||
}
|
|
||||||
|
|
||||||
.legend-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
margin-top: 4px;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot {
|
.message-row.user .message {
|
||||||
width: 8px;
|
background: var(--accent);
|
||||||
height: 2px;
|
color: var(--bg-app);
|
||||||
|
border-bottom-right-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.user {
|
.message-row.user .message-meta {
|
||||||
background-color: #3b82f6;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.ai {
|
.message-row.ai .message {
|
||||||
background-color: #ef4444;
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.legend-dot.screen {
|
.message-row.screen .message {
|
||||||
background-color: #22c55e;
|
background: var(--bg-elevated);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-bottom-left-radius: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-context {
|
.context-row {
|
||||||
padding: 8px 12px;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
background: var(--bg-tertiary);
|
|
||||||
border-radius: 4px;
|
|
||||||
font-size: 11px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-context-row {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 8px;
|
align-items: flex-start;
|
||||||
margin-bottom: 4px;
|
gap: var(--space-sm);
|
||||||
|
padding: var(--space-sm);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg-elevated);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-context-row:last-child {
|
.context-key {
|
||||||
margin-bottom: 0;
|
width: 84px;
|
||||||
}
|
|
||||||
|
|
||||||
.context-label {
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
min-width: 80px;
|
font-size: var(--font-size-xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.4px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-value {
|
.context-value {
|
||||||
color: var(--text-color);
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-size: var(--font-size-sm);
|
||||||
}
|
line-height: 1.45;
|
||||||
|
|
||||||
.custom-prompt-value {
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
word-break: break-word;
|
||||||
|
|
||||||
.view-tabs {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab:hover {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-tab.active {
|
|
||||||
color: var(--text-color);
|
|
||||||
border-bottom-color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message.screen {
|
|
||||||
border-left-color: #22c55e;
|
|
||||||
}
|
|
||||||
|
|
||||||
.analysis-meta {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state-title {
|
|
||||||
font-size: 14px;
|
|
||||||
font-weight: 500;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading {
|
|
||||||
text-align: center;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 12px;
|
|
||||||
margin-top: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-list::-webkit-scrollbar,
|
|
||||||
.conversation-view::-webkit-scrollbar {
|
|
||||||
width: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-list::-webkit-scrollbar-track,
|
|
||||||
.conversation-view::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-list::-webkit-scrollbar-thumb,
|
|
||||||
.conversation-view::-webkit-scrollbar-thumb {
|
|
||||||
background: var(--scrollbar-thumb);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sessions-list::-webkit-scrollbar-thumb:hover,
|
|
||||||
.conversation-view::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: var(--scrollbar-thumb-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tabs-container {
|
|
||||||
display: flex;
|
|
||||||
gap: 0;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab {
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
|
||||||
border: none;
|
|
||||||
padding: 8px 16px;
|
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
|
||||||
transition: color 0.1s ease;
|
|
||||||
border-bottom: 2px solid transparent;
|
|
||||||
margin-bottom: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab:hover {
|
|
||||||
color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.tab.active {
|
|
||||||
color: var(--text-color);
|
|
||||||
border-bottom-color: var(--text-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-response-item {
|
|
||||||
padding: 12px 0;
|
|
||||||
border-bottom: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-response-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-response-profile {
|
|
||||||
font-size: 11px;
|
|
||||||
font-weight: 500;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
text-transform: capitalize;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-response-date {
|
|
||||||
font-size: 10px;
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-family: 'SF Mono', Monaco, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.saved-response-content {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--text-color);
|
|
||||||
line-height: 1.4;
|
|
||||||
user-select: text;
|
user-select: text;
|
||||||
cursor: text;
|
cursor: text;
|
||||||
}
|
}
|
||||||
|
|
||||||
.delete-button {
|
.empty {
|
||||||
background: transparent;
|
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border: none;
|
font-size: var(--font-size-sm);
|
||||||
padding: 4px;
|
display: flex;
|
||||||
border-radius: 3px;
|
align-items: center;
|
||||||
cursor: pointer;
|
justify-content: center;
|
||||||
transition: all 0.1s ease;
|
min-height: 120px;
|
||||||
|
border: 1px dashed var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
`,
|
||||||
.delete-button:hover {
|
];
|
||||||
background: rgba(241, 76, 76, 0.1);
|
|
||||||
color: var(--error-color);
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
|
|
||||||
static properties = {
|
static properties = {
|
||||||
sessions: { type: Array },
|
sessions: { type: Array },
|
||||||
selectedSession: { type: Object },
|
selectedSession: { type: Object },
|
||||||
|
selectedSessionId: { type: String },
|
||||||
loading: { type: Boolean },
|
loading: { type: Boolean },
|
||||||
activeTab: { type: String },
|
activeTab: { type: String },
|
||||||
|
searchQuery: { type: String },
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.sessions = [];
|
this.sessions = [];
|
||||||
this.selectedSession = null;
|
this.selectedSession = null;
|
||||||
|
this.selectedSessionId = null;
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.activeTab = 'conversation'; // 'conversation' or 'screen'
|
this.activeTab = 'conversation';
|
||||||
|
this.searchQuery = '';
|
||||||
this.loadSessions();
|
this.loadSessions();
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback() {
|
|
||||||
super.connectedCallback();
|
|
||||||
// Resize window for this view
|
|
||||||
resizeLayout();
|
|
||||||
}
|
|
||||||
|
|
||||||
async loadSessions() {
|
async loadSessions() {
|
||||||
try {
|
try {
|
||||||
this.loading = true;
|
this.loading = true;
|
||||||
this.sessions = await cheatingDaddy.storage.getAllSessions();
|
this.sessions = await cheatingDaddy.storage.getAllSessions();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading conversation sessions:', error);
|
console.error('Error loading sessions:', error);
|
||||||
this.sessions = [];
|
this.sessions = [];
|
||||||
} finally {
|
} finally {
|
||||||
this.loading = false;
|
this.loading = false;
|
||||||
@@ -386,11 +291,13 @@ export class HistoryView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async loadSelectedSession(sessionId) {
|
async openSession(sessionId) {
|
||||||
try {
|
try {
|
||||||
const session = await cheatingDaddy.storage.getSession(sessionId);
|
const session = await cheatingDaddy.storage.getSession(sessionId);
|
||||||
if (session) {
|
if (session) {
|
||||||
this.selectedSession = session;
|
this.selectedSession = session;
|
||||||
|
this.selectedSessionId = sessionId;
|
||||||
|
this.activeTab = 'conversation';
|
||||||
this.requestUpdate();
|
this.requestUpdate();
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -398,59 +305,29 @@ export class HistoryView extends LitElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
closeSession() {
|
||||||
|
this.selectedSession = null;
|
||||||
|
this.selectedSessionId = null;
|
||||||
|
this.activeTab = 'conversation';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleSearchInput(e) {
|
||||||
|
this.searchQuery = e.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
formatDate(timestamp) {
|
formatDate(timestamp) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleDateString('en-US', {
|
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
year: 'numeric',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
formatTime(timestamp) {
|
formatTime(timestamp) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleTimeString('en-US', {
|
return date.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
formatTimestamp(timestamp) {
|
formatTimestamp(timestamp) {
|
||||||
const date = new Date(timestamp);
|
const date = new Date(timestamp);
|
||||||
return date.toLocaleString('en-US', {
|
return date.toLocaleString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||||
month: 'short',
|
|
||||||
day: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
getSessionPreview(session) {
|
|
||||||
const parts = [];
|
|
||||||
if (session.messageCount > 0) {
|
|
||||||
parts.push(`${session.messageCount} messages`);
|
|
||||||
}
|
|
||||||
if (session.screenAnalysisCount > 0) {
|
|
||||||
parts.push(`${session.screenAnalysisCount} screen analysis`);
|
|
||||||
}
|
|
||||||
if (session.profile) {
|
|
||||||
const profileNames = this.getProfileNames();
|
|
||||||
parts.push(profileNames[session.profile] || session.profile);
|
|
||||||
}
|
|
||||||
return parts.length > 0 ? parts.join(' • ') : 'Empty session';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleSessionClick(session) {
|
|
||||||
this.loadSelectedSession(session.sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
handleBackClick() {
|
|
||||||
this.selectedSession = null;
|
|
||||||
this.activeTab = 'conversation';
|
|
||||||
}
|
|
||||||
|
|
||||||
handleTabClick(tab) {
|
|
||||||
this.activeTab = tab;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getProfileNames() {
|
getProfileNames() {
|
||||||
@@ -464,183 +341,168 @@ export class HistoryView extends LitElement {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
renderSessionsList() {
|
_getProfileLabel(session) {
|
||||||
if (this.loading) {
|
if (session.profile) {
|
||||||
return html`<div class="loading">Loading conversation history...</div>`;
|
const names = this.getProfileNames();
|
||||||
|
return names[session.profile] || session.profile;
|
||||||
|
}
|
||||||
|
return 'Session';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.sessions.length === 0) {
|
getSessionPreview(session) {
|
||||||
return html`
|
const parts = [];
|
||||||
<div class="empty-state">
|
if (session.messageCount > 0) parts.push(`${session.messageCount} messages`);
|
||||||
<div class="empty-state-title">No conversations yet</div>
|
if (session.screenAnalysisCount > 0) parts.push(`${session.screenAnalysisCount} screen`);
|
||||||
<div>Start a session to see your conversation history here</div>
|
if (session.profile) {
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
|
||||||
<div class="sessions-list">
|
|
||||||
${this.sessions.map(
|
|
||||||
session => html`
|
|
||||||
<div class="session-item" @click=${() => this.handleSessionClick(session)}>
|
|
||||||
<div class="session-header">
|
|
||||||
<div class="session-date">${this.formatDate(session.createdAt)}</div>
|
|
||||||
<div class="session-time">${this.formatTime(session.createdAt)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="session-preview">${this.getSessionPreview(session)}</div>
|
|
||||||
</div>
|
|
||||||
`
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderContextContent() {
|
|
||||||
const { profile, customPrompt } = this.selectedSession;
|
|
||||||
const profileNames = this.getProfileNames();
|
const profileNames = this.getProfileNames();
|
||||||
|
parts.push(profileNames[session.profile] || session.profile);
|
||||||
if (!profile && !customPrompt) {
|
}
|
||||||
return html`<div class="empty-state">No profile context available</div>`;
|
return parts.length > 0 ? parts.join(' · ') : 'Empty session';
|
||||||
}
|
}
|
||||||
|
|
||||||
return html`
|
getFilteredSessions() {
|
||||||
<div class="session-context">
|
if (!this.searchQuery.trim()) return this.sessions;
|
||||||
${profile ? html`
|
const q = this.searchQuery.toLowerCase();
|
||||||
<div class="session-context-row">
|
return this.sessions.filter(session => {
|
||||||
<span class="context-label">Profile:</span>
|
const preview = this.getSessionPreview(session).toLowerCase();
|
||||||
<span class="context-value">${profileNames[profile] || profile}</span>
|
const date = this.formatDate(session.createdAt).toLowerCase();
|
||||||
</div>
|
return preview.includes(q) || date.includes(q);
|
||||||
` : ''}
|
});
|
||||||
${customPrompt ? html`
|
|
||||||
<div class="session-context-row">
|
|
||||||
<span class="context-label">Custom Prompt:</span>
|
|
||||||
<span class="custom-prompt-value">${customPrompt}</span>
|
|
||||||
</div>
|
|
||||||
` : ''}
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderConversationContent() {
|
collectConversation(session) {
|
||||||
const { conversationHistory } = this.selectedSession;
|
|
||||||
|
|
||||||
// Flatten the conversation turns into individual messages
|
|
||||||
const messages = [];
|
const messages = [];
|
||||||
if (conversationHistory) {
|
const history = session.conversationHistory || [];
|
||||||
conversationHistory.forEach(turn => {
|
history.forEach(turn => {
|
||||||
if (turn.transcription) {
|
if (turn.transcription) messages.push({ type: 'user', content: turn.transcription, timestamp: turn.timestamp });
|
||||||
messages.push({
|
if (turn.ai_response) messages.push({ type: 'ai', content: turn.ai_response, timestamp: turn.timestamp });
|
||||||
type: 'user',
|
|
||||||
content: turn.transcription,
|
|
||||||
timestamp: turn.timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (turn.ai_response) {
|
|
||||||
messages.push({
|
|
||||||
type: 'ai',
|
|
||||||
content: turn.ai_response,
|
|
||||||
timestamp: turn.timestamp,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (messages.length === 0) {
|
renderTabContent() {
|
||||||
return html`<div class="empty-state">No conversation data available</div>`;
|
if (!this.selectedSession) return html`<div class="empty">Select a session.</div>`;
|
||||||
}
|
|
||||||
|
|
||||||
return messages.map(message => html`<div class="message ${message.type}">${message.content}</div>`);
|
if (this.activeTab === 'conversation') {
|
||||||
}
|
const messages = this.collectConversation(this.selectedSession);
|
||||||
|
if (!messages.length) return html`<div class="empty">No conversation data.</div>`;
|
||||||
renderScreenAnalysisContent() {
|
return messages.map(msg => html`
|
||||||
const { screenAnalysisHistory } = this.selectedSession;
|
<div class="message-row ${msg.type}">
|
||||||
|
<div class="message">
|
||||||
if (!screenAnalysisHistory || screenAnalysisHistory.length === 0) {
|
<div class="message-body">${msg.content}</div>
|
||||||
return html`<div class="empty-state">No screen analysis data available</div>`;
|
<div class="message-meta">${this.formatTime(msg.timestamp)}</div>
|
||||||
}
|
</div>
|
||||||
|
</div>
|
||||||
return screenAnalysisHistory.map(analysis => html`
|
|
||||||
<div class="message screen"><div class="analysis-meta">${this.formatTimestamp(analysis.timestamp)} • ${analysis.model || 'unknown model'}</div>${analysis.response}</div>
|
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
|
|
||||||
renderConversationView() {
|
if (this.activeTab === 'screen') {
|
||||||
if (!this.selectedSession) return html``;
|
const screen = this.selectedSession.screenAnalysisHistory || [];
|
||||||
|
if (!screen.length) return html`<div class="empty">No screen analysis data.</div>`;
|
||||||
|
return screen.map(entry => html`
|
||||||
|
<div class="message-row screen">
|
||||||
|
<div class="message">
|
||||||
|
<div class="message-body">${entry.response || ''}</div>
|
||||||
|
<div class="message-meta">${this.formatTime(entry.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
const { conversationHistory, screenAnalysisHistory, profile, customPrompt } = this.selectedSession;
|
const profile = this.selectedSession.profile;
|
||||||
const hasConversation = conversationHistory && conversationHistory.length > 0;
|
const prompt = this.selectedSession.customPrompt;
|
||||||
const hasScreenAnalysis = screenAnalysisHistory && screenAnalysisHistory.length > 0;
|
if (!profile && !prompt) return html`<div class="empty">No context saved for this session.</div>`;
|
||||||
const hasContext = profile || customPrompt;
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="back-header">
|
${profile ? html`
|
||||||
<button class="back-button" @click=${this.handleBackClick}>
|
<div class="context-row">
|
||||||
<svg
|
<span class="context-key">Profile</span>
|
||||||
width="16px"
|
<span class="context-value">${this.getProfileNames()[profile] || profile}</span>
|
||||||
height="16px"
|
</div>
|
||||||
stroke-width="1.7"
|
` : ''}
|
||||||
viewBox="0 0 24 24"
|
${prompt ? html`
|
||||||
fill="none"
|
<div class="context-row">
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<span class="context-key">Prompt</span>
|
||||||
color="currentColor"
|
<span class="context-value">${prompt}</span>
|
||||||
>
|
</div>
|
||||||
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
|
` : ''}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderListView() {
|
||||||
|
const filteredSessions = this.getFilteredSessions();
|
||||||
|
return html`
|
||||||
|
<div class="page-title">History</div>
|
||||||
|
|
||||||
|
<div class="search-wrap">
|
||||||
|
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<circle cx="11" cy="11" r="8"/>
|
||||||
|
<line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||||
</svg>
|
</svg>
|
||||||
Back to Sessions
|
<input
|
||||||
|
class="control"
|
||||||
|
type="text"
|
||||||
|
placeholder="Search sessions..."
|
||||||
|
.value=${this.searchQuery}
|
||||||
|
@input=${this.handleSearchInput}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="list-shell">
|
||||||
|
<div class="sessions-list">
|
||||||
|
${this.loading ? html`<div class="empty" style="margin:var(--space-md);">Loading sessions...</div>` : ''}
|
||||||
|
${!this.loading && filteredSessions.length === 0 ? html`<div class="empty" style="margin:var(--space-md);">No matching sessions.</div>` : ''}
|
||||||
|
${!this.loading ? filteredSessions.map(session => html`
|
||||||
|
<button class="session-card" @click=${() => this.openSession(session.sessionId)}>
|
||||||
|
<div class="session-left">
|
||||||
|
<span class="session-profile">${this._getProfileLabel(session)}</span>
|
||||||
|
<span class="session-date">${this.formatDate(session.createdAt)} · ${this.formatTime(session.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
${session.messageCount > 0 ? html`<span class="session-badge">${session.messageCount}</span>` : ''}
|
||||||
</button>
|
</button>
|
||||||
<div class="legend">
|
`) : ''}
|
||||||
<div class="legend-item">
|
|
||||||
<div class="legend-dot user"></div>
|
|
||||||
<span>Them</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="legend-item">
|
</section>
|
||||||
<div class="legend-dot ai"></div>
|
`;
|
||||||
<span>Suggestion</span>
|
}
|
||||||
</div>
|
|
||||||
<div class="legend-item">
|
renderDetailView() {
|
||||||
<div class="legend-dot screen"></div>
|
const conversationCount = this.collectConversation(this.selectedSession).length;
|
||||||
<span>Screen</span>
|
const screenCount = this.selectedSession?.screenAnalysisHistory?.length || 0;
|
||||||
</div>
|
|
||||||
</div>
|
return html`
|
||||||
</div>
|
<div class="page-title">Session Detail</div>
|
||||||
<div class="view-tabs">
|
<div class="detail-top">
|
||||||
<button
|
<button class="back-btn" @click=${this.closeSession}>
|
||||||
class="view-tab ${this.activeTab === 'conversation' ? 'active' : ''}"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
@click=${() => this.handleTabClick('conversation')}
|
<polyline points="15 18 9 12 15 6"/>
|
||||||
>
|
</svg>
|
||||||
Conversation ${hasConversation ? `(${conversationHistory.length})` : ''}
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<span class="detail-info">${this._getProfileLabel(this.selectedSession)} · ${this.formatDate(this.selectedSession.createdAt)} · ${this.formatTime(this.selectedSession.createdAt)}</span>
|
||||||
class="view-tab ${this.activeTab === 'screen' ? 'active' : ''}"
|
</div>
|
||||||
@click=${() => this.handleTabClick('screen')}
|
<div class="tab-row">
|
||||||
>
|
<button class="tab-btn ${this.activeTab === 'conversation' ? 'active' : ''}" @click=${() => { this.activeTab = 'conversation'; }}>
|
||||||
Screen ${hasScreenAnalysis ? `(${screenAnalysisHistory.length})` : ''}
|
Conversation (${conversationCount})
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button class="tab-btn ${this.activeTab === 'screen' ? 'active' : ''}" @click=${() => { this.activeTab = 'screen'; }}>
|
||||||
class="view-tab ${this.activeTab === 'context' ? 'active' : ''}"
|
Screen (${screenCount})
|
||||||
@click=${() => this.handleTabClick('context')}
|
</button>
|
||||||
>
|
<button class="tab-btn ${this.activeTab === 'context' ? 'active' : ''}" @click=${() => { this.activeTab = 'context'; }}>
|
||||||
Context ${hasContext ? '' : '(empty)'}
|
Context
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="conversation-view">
|
<section class="details-scroll">
|
||||||
${this.activeTab === 'conversation'
|
${this.renderTabContent()}
|
||||||
? this.renderConversationContent()
|
</section>
|
||||||
: this.activeTab === 'screen'
|
|
||||||
? this.renderScreenAnalysisContent()
|
|
||||||
: this.renderContextContent()}
|
|
||||||
</div>
|
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
render() {
|
||||||
if (this.selectedSession) {
|
|
||||||
return html`<div class="history-container">${this.renderConversationView()}</div>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="history-container">
|
<div class="unified-page">
|
||||||
${this.renderSessionsList()}
|
<div class="unified-wrap">
|
||||||
|
${this.selectedSession ? this.renderDetailView() : this.renderListView()}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|||||||
+1496
-174
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 {
|
export class OnboardingView extends LitElement {
|
||||||
static styles = css`
|
static styles = css`
|
||||||
* {
|
* {
|
||||||
font-family:
|
font-family: var(--font);
|
||||||
'Inter',
|
|
||||||
-apple-system,
|
|
||||||
BlinkMacSystemFont,
|
|
||||||
'Segoe UI',
|
|
||||||
Roboto,
|
|
||||||
sans-serif;
|
|
||||||
cursor: default;
|
cursor: default;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -27,44 +21,20 @@ export class OnboardingView extends LitElement {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.onboarding-container {
|
.onboarding {
|
||||||
position: relative;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0a0a0a;
|
position: relative;
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
cursor: pointer;
|
border-radius: 12px;
|
||||||
transition: all 0.2s ease;
|
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||||
color: rgba(255, 255, 255, 0.6);
|
overflow: hidden;
|
||||||
|
background: #f0f0f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-button:hover {
|
canvas.aurora {
|
||||||
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 {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
@@ -73,166 +43,104 @@ export class OnboardingView extends LitElement {
|
|||||||
z-index: 0;
|
z-index: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.content-wrapper {
|
canvas.dither {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0;
|
top: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
width: 100%;
|
||||||
bottom: 60px;
|
height: 100%;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
display: flex;
|
opacity: 0.12;
|
||||||
flex-direction: column;
|
mix-blend-mode: overlay;
|
||||||
justify-content: center;
|
pointer-events: none;
|
||||||
padding: 32px 48px;
|
image-rendering: pixelated;
|
||||||
max-width: 500px;
|
|
||||||
color: #e5e5e5;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-icon {
|
.slide {
|
||||||
width: 48px;
|
position: relative;
|
||||||
height: 48px;
|
z-index: 2;
|
||||||
margin-bottom: 16px;
|
display: flex;
|
||||||
opacity: 0.9;
|
flex-direction: column;
|
||||||
display: block;
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
max-width: 400px;
|
||||||
|
padding: var(--space-xl);
|
||||||
|
gap: var(--space-md);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-title {
|
.slide-title {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 12px;
|
color: #111111;
|
||||||
color: #ffffff;
|
line-height: 1.2;
|
||||||
line-height: 1.3;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slide-content {
|
.slide-text {
|
||||||
font-size: 16px;
|
font-size: 13px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin-bottom: 24px;
|
color: #666666;
|
||||||
color: #b8b8b8;
|
|
||||||
font-weight: 400;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-textarea {
|
.context-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100px;
|
min-height: 120px;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.7);
|
||||||
color: #e5e5e5;
|
backdrop-filter: blur(8px);
|
||||||
font-size: 14px;
|
color: #111111;
|
||||||
font-family: inherit;
|
font-size: 13px;
|
||||||
|
font-family: var(--font);
|
||||||
|
line-height: 1.5;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
transition: all 0.2s ease;
|
text-align: left;
|
||||||
margin-bottom: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-textarea::placeholder {
|
.context-input::placeholder {
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: #999999;
|
||||||
font-size: 14px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.context-textarea:focus {
|
.context-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(0, 0, 0, 0.3);
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-list {
|
.actions {
|
||||||
max-width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.feature-item {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 12px;
|
gap: 8px;
|
||||||
font-size: 15px;
|
margin-top: 8px;
|
||||||
color: #b8b8b8;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feature-icon {
|
.btn-primary {
|
||||||
font-size: 16px;
|
background: #111111;
|
||||||
margin-right: 12px;
|
border: none;
|
||||||
opacity: 0.8;
|
color: #ffffff;
|
||||||
}
|
padding: 10px 32px;
|
||||||
|
border-radius: 8px;
|
||||||
.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;
|
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: opacity 0.15s;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
min-width: 36px;
|
|
||||||
min-height: 36px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button:hover {
|
.btn-primary:hover {
|
||||||
background: rgba(255, 255, 255, 0.12);
|
opacity: 0.85;
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-button:active {
|
.btn-back {
|
||||||
transform: scale(0.98);
|
background: none;
|
||||||
}
|
border: none;
|
||||||
|
color: #888888;
|
||||||
.nav-button:disabled {
|
font-size: 11px;
|
||||||
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;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dot:hover {
|
.btn-back:hover {
|
||||||
background: rgba(255, 255, 255, 0.4);
|
color: #555555;
|
||||||
}
|
|
||||||
|
|
||||||
.dot.active {
|
|
||||||
background: rgba(255, 255, 255, 0.8);
|
|
||||||
transform: scale(1.2);
|
|
||||||
}
|
}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
@@ -240,7 +148,6 @@ export class OnboardingView extends LitElement {
|
|||||||
currentSlide: { type: Number },
|
currentSlide: { type: Number },
|
||||||
contextText: { type: String },
|
contextText: { type: String },
|
||||||
onComplete: { type: Function },
|
onComplete: { type: Function },
|
||||||
onClose: { type: Function },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -248,220 +155,151 @@ export class OnboardingView extends LitElement {
|
|||||||
this.currentSlide = 0;
|
this.currentSlide = 0;
|
||||||
this.contextText = '';
|
this.contextText = '';
|
||||||
this.onComplete = () => {};
|
this.onComplete = () => {};
|
||||||
this.onClose = () => {};
|
this._animId = null;
|
||||||
this.canvas = null;
|
this._time = 0;
|
||||||
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
|
|
||||||
],
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
firstUpdated() {
|
firstUpdated() {
|
||||||
this.canvas = this.shadowRoot.querySelector('.gradient-canvas');
|
this._startAurora();
|
||||||
this.ctx = this.canvas.getContext('2d');
|
this._drawDither();
|
||||||
this.resizeCanvas();
|
|
||||||
this.startGradientAnimation();
|
|
||||||
|
|
||||||
window.addEventListener('resize', () => this.resizeCanvas());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback() {
|
disconnectedCallback() {
|
||||||
super.disconnectedCallback();
|
super.disconnectedCallback();
|
||||||
if (this.animationId) {
|
if (this._animId) cancelAnimationFrame(this._animId);
|
||||||
cancelAnimationFrame(this.animationId);
|
|
||||||
}
|
|
||||||
window.removeEventListener('resize', () => this.resizeCanvas());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resizeCanvas() {
|
_drawDither() {
|
||||||
if (!this.canvas) return;
|
const canvas = this.shadowRoot.querySelector('canvas.dither');
|
||||||
|
if (!canvas) return;
|
||||||
const rect = this.getBoundingClientRect();
|
const blockSize = 5;
|
||||||
this.canvas.width = rect.width;
|
const cols = Math.ceil(canvas.offsetWidth / blockSize);
|
||||||
this.canvas.height = rect.height;
|
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;
|
||||||
|
}
|
||||||
|
ctx.putImageData(img, 0, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
startGradientAnimation() {
|
_startAurora() {
|
||||||
if (!this.ctx) return;
|
const canvas = this.shadowRoot.querySelector('canvas.aurora');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
const animate = timestamp => {
|
const scale = 0.35;
|
||||||
this.drawGradient(timestamp);
|
const resize = () => {
|
||||||
this.animationId = requestAnimationFrame(animate);
|
canvas.width = Math.floor(canvas.offsetWidth * scale);
|
||||||
|
canvas.height = Math.floor(canvas.offsetHeight * scale);
|
||||||
|
};
|
||||||
|
resize();
|
||||||
|
|
||||||
|
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 },
|
||||||
|
|
||||||
|
{ 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);
|
draw();
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
handleContextInput(e) {
|
handleContextInput(e) {
|
||||||
this.contextText = e.target.value;
|
this.contextText = e.target.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
async handleClose() {
|
|
||||||
if (window.require) {
|
|
||||||
const { ipcRenderer } = window.require('electron');
|
|
||||||
await ipcRenderer.invoke('quit-application');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async completeOnboarding() {
|
async completeOnboarding() {
|
||||||
if (this.contextText.trim()) {
|
if (this.contextText.trim()) {
|
||||||
await cheatingDaddy.storage.updatePreference('customPrompt', this.contextText.trim());
|
await cheatingDaddy.storage.updatePreference('customPrompt', this.contextText.trim());
|
||||||
@@ -470,120 +308,43 @@ export class OnboardingView extends LitElement {
|
|||||||
this.onComplete();
|
this.onComplete();
|
||||||
}
|
}
|
||||||
|
|
||||||
getSlideContent() {
|
renderSlide() {
|
||||||
const slides = [
|
if (this.currentSlide === 0) {
|
||||||
{
|
return html`
|
||||||
icon: 'assets/onboarding/welcome.svg',
|
<div class="slide">
|
||||||
title: 'Welcome to Cheating Daddy',
|
<div class="slide-title">Mastermind</div>
|
||||||
content:
|
<div class="slide-text">Real-time AI that listens, watches, and helps during interviews, meetings, and exams.</div>
|
||||||
'Your AI assistant that listens and watches, then provides intelligent suggestions automatically during interviews and meetings.',
|
<div class="actions">
|
||||||
},
|
<button class="btn-primary" @click=${() => { this.currentSlide = 1; }}>Continue</button>
|
||||||
{
|
</div>
|
||||||
icon: 'assets/onboarding/security.svg',
|
</div>
|
||||||
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.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
return slides[this.currentSlide];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
render() {
|
|
||||||
const slide = this.getSlideContent();
|
|
||||||
|
|
||||||
return html`
|
return html`
|
||||||
<div class="onboarding-container">
|
<div class="slide">
|
||||||
<button class="close-button" @click=${this.handleClose} title="Close">
|
<div class="slide-title">Add context</div>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
<div class="slide-text">Paste your resume or any info the AI should know. You can skip this and add it later.</div>
|
||||||
<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
|
<textarea
|
||||||
class="context-textarea"
|
class="context-input"
|
||||||
placeholder="Paste your resume, job description, or any relevant context here..."
|
placeholder="Resume, job description, notes..."
|
||||||
.value=${this.contextText}
|
.value=${this.contextText}
|
||||||
@input=${this.handleContextInput}
|
@input=${this.handleContextInput}
|
||||||
></textarea>
|
></textarea>
|
||||||
`
|
<div class="actions">
|
||||||
: ''}
|
<button class="btn-primary" @click=${this.completeOnboarding}>Get Started</button>
|
||||||
${slide.showFeatures
|
<button class="btn-back" @click=${() => { this.currentSlide = 0; }}>Back</button>
|
||||||
? 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>
|
||||||
`
|
`;
|
||||||
: ''}
|
|
||||||
</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}>
|
render() {
|
||||||
${this.currentSlide === 4
|
return html`
|
||||||
? 'Get Started'
|
<div class="onboarding">
|
||||||
: html`
|
<canvas class="aurora"></canvas>
|
||||||
<svg width="16px" height="16px" stroke-width="2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<canvas class="dither"></canvas>
|
||||||
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
${this.renderSlide()}
|
||||||
</svg>
|
|
||||||
`}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
<title>Screen and Audio Capture</title>
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
/* Backgrounds - with default 0.8 transparency */
|
/* Backgrounds */
|
||||||
--background-transparent: transparent;
|
--bg-app: #0A0A0A;
|
||||||
--bg-primary: rgba(30, 30, 30, 0.8);
|
--bg-surface: #111111;
|
||||||
--bg-secondary: rgba(37, 37, 38, 0.8);
|
--bg-elevated: #191919;
|
||||||
--bg-tertiary: rgba(45, 45, 45, 0.8);
|
--bg-hover: #1F1F1F;
|
||||||
--bg-hover: rgba(50, 50, 50, 0.8);
|
|
||||||
|
|
||||||
/* Text */
|
/* Text */
|
||||||
--text-color: #e5e5e5;
|
--text-primary: #F5F5F5;
|
||||||
--text-secondary: #a0a0a0;
|
--text-secondary: #999999;
|
||||||
--text-muted: #6b6b6b;
|
--text-muted: #555555;
|
||||||
--description-color: #a0a0a0;
|
|
||||||
--placeholder-color: #6b6b6b;
|
|
||||||
|
|
||||||
/* Borders */
|
/* Borders & Lines */
|
||||||
--border-color: #3c3c3c;
|
--border: #222222;
|
||||||
--border-subtle: #3c3c3c;
|
--border-strong: #333333;
|
||||||
--border-default: #4a4a4a;
|
|
||||||
|
|
||||||
/* Component backgrounds - with default 0.8 transparency */
|
/* Accent */
|
||||||
--header-background: rgba(30, 30, 30, 0.8);
|
--accent: #3B82F6;
|
||||||
--header-actions-color: #a0a0a0;
|
--accent-hover: #2563EB;
|
||||||
--main-content-background: rgba(30, 30, 30, 0.8);
|
|
||||||
|
/* 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-background: transparent;
|
||||||
--button-border: #3c3c3c;
|
--button-border: var(--border-strong);
|
||||||
--icon-button-color: #a0a0a0;
|
--icon-button-color: var(--text-secondary);
|
||||||
--hover-background: rgba(50, 50, 50, 0.8);
|
--hover-background: var(--bg-hover);
|
||||||
--input-background: rgba(45, 45, 45, 0.8);
|
--input-background: var(--bg-elevated);
|
||||||
--input-focus-background: rgba(45, 45, 45, 0.8);
|
--input-focus-background: var(--bg-elevated);
|
||||||
|
--focus-border-color: var(--accent);
|
||||||
/* Focus states - neutral */
|
|
||||||
--focus-border-color: #4a4a4a;
|
|
||||||
--focus-box-shadow: transparent;
|
--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 */
|
/* Layout-specific */
|
||||||
--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 */
|
|
||||||
--header-padding: 8px 16px;
|
--header-padding: 8px 16px;
|
||||||
--header-font-size: 14px;
|
--header-font-size: 14px;
|
||||||
--header-gap: 8px;
|
--header-gap: 8px;
|
||||||
@@ -81,48 +118,65 @@
|
|||||||
--header-icon-padding: 6px;
|
--header-icon-padding: 6px;
|
||||||
--header-font-size-small: 12px;
|
--header-font-size-small: 12px;
|
||||||
--main-content-padding: 16px;
|
--main-content-padding: 16px;
|
||||||
--main-content-margin-top: 1px;
|
--main-content-margin-top: 0;
|
||||||
--icon-size: 18px;
|
--icon-size: 18px;
|
||||||
--border-radius: 3px;
|
--border-radius: var(--radius-sm);
|
||||||
--content-border-radius: 0;
|
--content-border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact layout styles */
|
html {
|
||||||
:root.compact-layout {
|
margin: 0;
|
||||||
--header-padding: 6px 12px;
|
padding: 0;
|
||||||
--header-font-size: 12px;
|
height: 100%;
|
||||||
--header-gap: 6px;
|
overflow: hidden;
|
||||||
--header-button-padding: 4px 8px;
|
border-radius: 12px;
|
||||||
--header-icon-padding: 4px;
|
background: transparent;
|
||||||
--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,
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: transparent;
|
background: var(--bg-app);
|
||||||
}
|
color: var(--text-primary);
|
||||||
|
line-height: var(--line-height);
|
||||||
body {
|
border-radius: 12px;
|
||||||
font-family:
|
border: 1px solid var(--border);
|
||||||
'Inter',
|
font-family: var(--font);
|
||||||
-apple-system,
|
font-size: var(--font-size-base);
|
||||||
BlinkMacSystemFont,
|
font-weight: var(--font-weight-normal);
|
||||||
sans-serif;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
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 {
|
cheating-daddy-app {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
|||||||
+105
-119
@@ -1,68 +1,80 @@
|
|||||||
if (require('electron-squirrel-startup')) {
|
if (require("electron-squirrel-startup")) {
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { app, BrowserWindow, shell, ipcMain } = require('electron');
|
// ── Global crash handlers to prevent silent process termination ──
|
||||||
const { createWindow, updateGlobalShortcuts } = require('./utils/window');
|
process.on("uncaughtException", (error) => {
|
||||||
const { setupAIProviderIpcHandlers } = require('./utils/ai-provider-manager');
|
console.error("[FATAL] Uncaught exception:", error);
|
||||||
const { stopMacOSAudioCapture } = require('./utils/gemini');
|
try {
|
||||||
const { initLogger, closeLogger, getLogPath } = require('./utils/logger');
|
const { sendToRenderer } = require("./utils/gemini");
|
||||||
const storage = require('./storage');
|
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 };
|
const geminiSessionRef = { current: null };
|
||||||
let mainWindow = null;
|
let mainWindow = null;
|
||||||
|
|
||||||
function sendToRenderer(channel, data) {
|
|
||||||
const windows = BrowserWindow.getAllWindows();
|
|
||||||
if (windows.length > 0) {
|
|
||||||
windows[0].webContents.send(channel, data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMainWindow() {
|
function createMainWindow() {
|
||||||
mainWindow = createWindow(sendToRenderer, geminiSessionRef);
|
mainWindow = createWindow(sendToRenderer, geminiSessionRef);
|
||||||
return mainWindow;
|
return mainWindow;
|
||||||
}
|
}
|
||||||
|
|
||||||
app.whenReady().then(async () => {
|
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)
|
// Initialize storage (checks version, resets if needed)
|
||||||
storage.initializeStorage();
|
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();
|
createMainWindow();
|
||||||
setupAIProviderIpcHandlers(geminiSessionRef);
|
setupGeminiIpcHandlers(geminiSessionRef);
|
||||||
setupStorageIpcHandlers();
|
setupStorageIpcHandlers();
|
||||||
setupGeneralIpcHandlers();
|
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', () => {
|
app.on("window-all-closed", () => {
|
||||||
stopMacOSAudioCapture();
|
stopMacOSAudioCapture();
|
||||||
closeLogger();
|
if (process.platform !== "darwin") {
|
||||||
if (process.platform !== 'darwin') {
|
|
||||||
app.quit();
|
app.quit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('before-quit', () => {
|
app.on("before-quit", () => {
|
||||||
stopMacOSAudioCapture();
|
stopMacOSAudioCapture();
|
||||||
closeLogger();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.on('activate', () => {
|
app.on("activate", () => {
|
||||||
if (BrowserWindow.getAllWindows().length === 0) {
|
if (BrowserWindow.getAllWindows().length === 0) {
|
||||||
createMainWindow();
|
createMainWindow();
|
||||||
}
|
}
|
||||||
@@ -70,281 +82,255 @@ app.on('activate', () => {
|
|||||||
|
|
||||||
function setupStorageIpcHandlers() {
|
function setupStorageIpcHandlers() {
|
||||||
// ============ CONFIG ============
|
// ============ CONFIG ============
|
||||||
ipcMain.handle('storage:get-config', async () => {
|
ipcMain.handle("storage:get-config", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getConfig() };
|
return { success: true, data: storage.getConfig() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting config:', error);
|
console.error("Error getting config:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:set-config', async (event, config) => {
|
ipcMain.handle("storage:set-config", async (event, config) => {
|
||||||
try {
|
try {
|
||||||
storage.setConfig(config);
|
storage.setConfig(config);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting config:', error);
|
console.error("Error setting config:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:update-config', async (event, key, value) => {
|
ipcMain.handle("storage:update-config", async (event, key, value) => {
|
||||||
try {
|
try {
|
||||||
storage.updateConfig(key, value);
|
storage.updateConfig(key, value);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating config:', error);
|
console.error("Error updating config:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ CREDENTIALS ============
|
// ============ CREDENTIALS ============
|
||||||
ipcMain.handle('storage:get-credentials', async () => {
|
ipcMain.handle("storage:get-credentials", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getCredentials() };
|
return { success: true, data: storage.getCredentials() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting credentials:', error);
|
console.error("Error getting credentials:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:set-credentials', async (event, credentials) => {
|
ipcMain.handle("storage:set-credentials", async (event, credentials) => {
|
||||||
try {
|
try {
|
||||||
storage.setCredentials(credentials);
|
storage.setCredentials(credentials);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting credentials:', error);
|
console.error("Error setting credentials:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:get-api-key', async () => {
|
ipcMain.handle("storage:get-api-key", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getApiKey() };
|
return { success: true, data: storage.getApiKey() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting API key:', error);
|
console.error("Error getting API key:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:set-api-key', async (event, apiKey) => {
|
ipcMain.handle("storage:set-api-key", async (event, apiKey) => {
|
||||||
try {
|
try {
|
||||||
storage.setApiKey(apiKey);
|
storage.setApiKey(apiKey);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting API key:', error);
|
console.error("Error setting API key:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:get-openai-credentials', async () => {
|
ipcMain.handle("storage:get-groq-api-key", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getOpenAICredentials() };
|
return { success: true, data: storage.getGroqApiKey() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting OpenAI credentials:', error);
|
console.error("Error getting Groq API key:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:set-openai-credentials', async (event, config) => {
|
ipcMain.handle("storage:set-groq-api-key", async (event, groqApiKey) => {
|
||||||
try {
|
try {
|
||||||
storage.setOpenAICredentials(config);
|
storage.setGroqApiKey(groqApiKey);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting OpenAI credentials:', 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 };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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 };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ PREFERENCES ============
|
// ============ PREFERENCES ============
|
||||||
ipcMain.handle('storage:get-preferences', async () => {
|
ipcMain.handle("storage:get-preferences", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getPreferences() };
|
return { success: true, data: storage.getPreferences() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting preferences:', error);
|
console.error("Error getting preferences:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:set-preferences', async (event, preferences) => {
|
ipcMain.handle("storage:set-preferences", async (event, preferences) => {
|
||||||
try {
|
try {
|
||||||
storage.setPreferences(preferences);
|
storage.setPreferences(preferences);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting preferences:', error);
|
console.error("Error setting preferences:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:update-preference', async (event, key, value) => {
|
ipcMain.handle("storage:update-preference", async (event, key, value) => {
|
||||||
try {
|
try {
|
||||||
storage.updatePreference(key, value);
|
storage.updatePreference(key, value);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating preference:', error);
|
console.error("Error updating preference:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ KEYBINDS ============
|
// ============ KEYBINDS ============
|
||||||
ipcMain.handle('storage:get-keybinds', async () => {
|
ipcMain.handle("storage:get-keybinds", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getKeybinds() };
|
return { success: true, data: storage.getKeybinds() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting keybinds:', error);
|
console.error("Error getting keybinds:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:set-keybinds', async (event, keybinds) => {
|
ipcMain.handle("storage:set-keybinds", async (event, keybinds) => {
|
||||||
try {
|
try {
|
||||||
storage.setKeybinds(keybinds);
|
storage.setKeybinds(keybinds);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error setting keybinds:', error);
|
console.error("Error setting keybinds:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ HISTORY ============
|
// ============ HISTORY ============
|
||||||
ipcMain.handle('storage:get-all-sessions', async () => {
|
ipcMain.handle("storage:get-all-sessions", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getAllSessions() };
|
return { success: true, data: storage.getAllSessions() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting sessions:', error);
|
console.error("Error getting sessions:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:get-session', async (event, sessionId) => {
|
ipcMain.handle("storage:get-session", async (event, sessionId) => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getSession(sessionId) };
|
return { success: true, data: storage.getSession(sessionId) };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting session:', error);
|
console.error("Error getting session:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:save-session', async (event, sessionId, data) => {
|
ipcMain.handle("storage:save-session", async (event, sessionId, data) => {
|
||||||
try {
|
try {
|
||||||
storage.saveSession(sessionId, data);
|
storage.saveSession(sessionId, data);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving session:', error);
|
console.error("Error saving session:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:delete-session', async (event, sessionId) => {
|
ipcMain.handle("storage:delete-session", async (event, sessionId) => {
|
||||||
try {
|
try {
|
||||||
storage.deleteSession(sessionId);
|
storage.deleteSession(sessionId);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting session:', error);
|
console.error("Error deleting session:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('storage:delete-all-sessions', async () => {
|
ipcMain.handle("storage:delete-all-sessions", async () => {
|
||||||
try {
|
try {
|
||||||
storage.deleteAllSessions();
|
storage.deleteAllSessions();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting all sessions:', error);
|
console.error("Error deleting all sessions:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ LIMITS ============
|
// ============ LIMITS ============
|
||||||
ipcMain.handle('storage:get-today-limits', async () => {
|
ipcMain.handle("storage:get-today-limits", async () => {
|
||||||
try {
|
try {
|
||||||
return { success: true, data: storage.getTodayLimits() };
|
return { success: true, data: storage.getTodayLimits() };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error getting today limits:', error);
|
console.error("Error getting today limits:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ CLEAR ALL ============
|
// ============ CLEAR ALL ============
|
||||||
ipcMain.handle('storage:clear-all', async () => {
|
ipcMain.handle("storage:clear-all", async () => {
|
||||||
try {
|
try {
|
||||||
storage.clearAllData();
|
storage.clearAllData();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error clearing all data:', error);
|
console.error("Error clearing all data:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupGeneralIpcHandlers() {
|
function setupGeneralIpcHandlers() {
|
||||||
ipcMain.handle('get-app-version', async () => {
|
ipcMain.handle("get-app-version", async () => {
|
||||||
return app.getVersion();
|
return app.getVersion();
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-logs-folder', async () => {
|
ipcMain.handle("quit-application", async (event) => {
|
||||||
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 {
|
try {
|
||||||
stopMacOSAudioCapture();
|
stopMacOSAudioCapture();
|
||||||
app.quit();
|
app.quit();
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error quitting application:', error);
|
console.error("Error quitting application:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('open-external', async (event, url) => {
|
ipcMain.handle("open-external", async (event, url) => {
|
||||||
try {
|
try {
|
||||||
await shell.openExternal(url);
|
await shell.openExternal(url);
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error opening external URL:', error);
|
console.error("Error opening external URL:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-keybinds', (event, newKeybinds) => {
|
ipcMain.on("update-keybinds", (event, newKeybinds) => {
|
||||||
if (mainWindow) {
|
if (mainWindow) {
|
||||||
// Also save to storage
|
// Also save to storage
|
||||||
storage.setKeybinds(newKeybinds);
|
storage.setKeybinds(newKeybinds);
|
||||||
updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef);
|
updateGlobalShortcuts(
|
||||||
|
newKeybinds,
|
||||||
|
mainWindow,
|
||||||
|
sendToRenderer,
|
||||||
|
geminiSessionRef,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Debug logging from renderer
|
// Debug logging from renderer
|
||||||
ipcMain.on('log-message', (event, msg) => {
|
ipcMain.on("log-message", (event, msg) => {
|
||||||
console.log(msg);
|
console.log(msg);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+178
-104
@@ -1,6 +1,6 @@
|
|||||||
const fs = require('fs');
|
const fs = require("fs");
|
||||||
const path = require('path');
|
const path = require("path");
|
||||||
const os = require('os');
|
const os = require("os");
|
||||||
|
|
||||||
const CONFIG_VERSION = 1;
|
const CONFIG_VERSION = 1;
|
||||||
|
|
||||||
@@ -8,41 +8,47 @@ const CONFIG_VERSION = 1;
|
|||||||
const DEFAULT_CONFIG = {
|
const DEFAULT_CONFIG = {
|
||||||
configVersion: CONFIG_VERSION,
|
configVersion: CONFIG_VERSION,
|
||||||
onboarded: false,
|
onboarded: false,
|
||||||
layout: 'normal'
|
layout: "normal",
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_CREDENTIALS = {
|
const DEFAULT_CREDENTIALS = {
|
||||||
apiKey: '',
|
apiKey: "",
|
||||||
// OpenAI Realtime API settings
|
groqApiKey: "",
|
||||||
openaiApiKey: '',
|
openaiCompatibleApiKey: "",
|
||||||
openaiBaseUrl: '',
|
openaiCompatibleBaseUrl: "",
|
||||||
openaiModel: 'gpt-4o-realtime-preview-2024-12-17',
|
openaiCompatibleModel: "",
|
||||||
// OpenAI SDK settings (for BotHub and other providers)
|
|
||||||
openaiSdkApiKey: '',
|
|
||||||
openaiSdkBaseUrl: '',
|
|
||||||
openaiSdkModel: 'gpt-4o',
|
|
||||||
openaiSdkVisionModel: 'gpt-4o',
|
|
||||||
openaiSdkWhisperModel: 'whisper-1'
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_PREFERENCES = {
|
const DEFAULT_PREFERENCES = {
|
||||||
customPrompt: '',
|
customPrompt: "",
|
||||||
selectedProfile: 'interview',
|
selectedProfile: "interview",
|
||||||
selectedLanguage: 'en-US',
|
selectedLanguage: "en-US",
|
||||||
selectedScreenshotInterval: '5',
|
selectedScreenshotInterval: "5",
|
||||||
selectedImageQuality: 'medium',
|
selectedImageQuality: "medium",
|
||||||
advancedMode: false,
|
advancedMode: false,
|
||||||
audioMode: 'speaker_only',
|
audioMode: "speaker_only",
|
||||||
fontSize: 'medium',
|
fontSize: "medium",
|
||||||
backgroundTransparency: 0.8,
|
backgroundTransparency: 0.8,
|
||||||
googleSearchEnabled: false,
|
googleSearchEnabled: false,
|
||||||
aiProvider: 'gemini'
|
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_KEYBINDS = null; // null means use system defaults
|
||||||
|
|
||||||
const DEFAULT_LIMITS = {
|
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
|
// Get the config directory path based on OS
|
||||||
@@ -50,12 +56,22 @@ function getConfigDir() {
|
|||||||
const platform = os.platform();
|
const platform = os.platform();
|
||||||
let configDir;
|
let configDir;
|
||||||
|
|
||||||
if (platform === 'win32') {
|
if (platform === "win32") {
|
||||||
configDir = path.join(os.homedir(), 'AppData', 'Roaming', 'cheating-daddy-config');
|
configDir = path.join(
|
||||||
} else if (platform === 'darwin') {
|
os.homedir(),
|
||||||
configDir = path.join(os.homedir(), 'Library', 'Application Support', 'cheating-daddy-config');
|
"AppData",
|
||||||
|
"Roaming",
|
||||||
|
"cheating-daddy-config",
|
||||||
|
);
|
||||||
|
} else if (platform === "darwin") {
|
||||||
|
configDir = path.join(
|
||||||
|
os.homedir(),
|
||||||
|
"Library",
|
||||||
|
"Application Support",
|
||||||
|
"cheating-daddy-config",
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
configDir = path.join(os.homedir(), '.config', 'cheating-daddy-config');
|
configDir = path.join(os.homedir(), ".config", "cheating-daddy-config");
|
||||||
}
|
}
|
||||||
|
|
||||||
return configDir;
|
return configDir;
|
||||||
@@ -63,34 +79,34 @@ function getConfigDir() {
|
|||||||
|
|
||||||
// File paths
|
// File paths
|
||||||
function getConfigPath() {
|
function getConfigPath() {
|
||||||
return path.join(getConfigDir(), 'config.json');
|
return path.join(getConfigDir(), "config.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCredentialsPath() {
|
function getCredentialsPath() {
|
||||||
return path.join(getConfigDir(), 'credentials.json');
|
return path.join(getConfigDir(), "credentials.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPreferencesPath() {
|
function getPreferencesPath() {
|
||||||
return path.join(getConfigDir(), 'preferences.json');
|
return path.join(getConfigDir(), "preferences.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getKeybindsPath() {
|
function getKeybindsPath() {
|
||||||
return path.join(getConfigDir(), 'keybinds.json');
|
return path.join(getConfigDir(), "keybinds.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLimitsPath() {
|
function getLimitsPath() {
|
||||||
return path.join(getConfigDir(), 'limits.json');
|
return path.join(getConfigDir(), "limits.json");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getHistoryDir() {
|
function getHistoryDir() {
|
||||||
return path.join(getConfigDir(), 'history');
|
return path.join(getConfigDir(), "history");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper to read JSON file safely
|
// Helper to read JSON file safely
|
||||||
function readJsonFile(filePath, defaultValue) {
|
function readJsonFile(filePath, defaultValue) {
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(filePath)) {
|
if (fs.existsSync(filePath)) {
|
||||||
const data = fs.readFileSync(filePath, 'utf8');
|
const data = fs.readFileSync(filePath, "utf8");
|
||||||
return JSON.parse(data);
|
return JSON.parse(data);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -106,7 +122,7 @@ function writeJsonFile(filePath, data) {
|
|||||||
if (!fs.existsSync(dir)) {
|
if (!fs.existsSync(dir)) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8');
|
fs.writeFileSync(filePath, JSON.stringify(data, null, 2), "utf8");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error writing ${filePath}:`, error.message);
|
console.error(`Error writing ${filePath}:`, error.message);
|
||||||
@@ -122,7 +138,7 @@ function needsReset() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
const config = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
||||||
return !config.configVersion || config.configVersion !== CONFIG_VERSION;
|
return !config.configVersion || config.configVersion !== CONFIG_VERSION;
|
||||||
} catch {
|
} catch {
|
||||||
return true;
|
return true;
|
||||||
@@ -133,7 +149,7 @@ function needsReset() {
|
|||||||
function resetConfigDir() {
|
function resetConfigDir() {
|
||||||
const configDir = getConfigDir();
|
const configDir = getConfigDir();
|
||||||
|
|
||||||
console.log('Resetting config directory...');
|
console.log("Resetting config directory...");
|
||||||
|
|
||||||
// Remove existing directory if it exists
|
// Remove existing directory if it exists
|
||||||
if (fs.existsSync(configDir)) {
|
if (fs.existsSync(configDir)) {
|
||||||
@@ -149,7 +165,7 @@ function resetConfigDir() {
|
|||||||
writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
|
writeJsonFile(getCredentialsPath(), DEFAULT_CREDENTIALS);
|
||||||
writeJsonFile(getPreferencesPath(), DEFAULT_PREFERENCES);
|
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
|
// Initialize storage - call this on app startup
|
||||||
@@ -196,49 +212,36 @@ function setCredentials(credentials) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getApiKey() {
|
function getApiKey() {
|
||||||
return getCredentials().apiKey || '';
|
return getCredentials().apiKey || "";
|
||||||
}
|
}
|
||||||
|
|
||||||
function setApiKey(apiKey) {
|
function setApiKey(apiKey) {
|
||||||
return setCredentials({ apiKey });
|
return setCredentials({ apiKey });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getOpenAICredentials() {
|
function getGroqApiKey() {
|
||||||
|
return getCredentials().groqApiKey || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function setGroqApiKey(groqApiKey) {
|
||||||
|
return setCredentials({ groqApiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOpenAICompatibleConfig() {
|
||||||
const creds = getCredentials();
|
const creds = getCredentials();
|
||||||
return {
|
return {
|
||||||
apiKey: creds.openaiApiKey || '',
|
apiKey: creds.openaiCompatibleApiKey || "",
|
||||||
baseUrl: creds.openaiBaseUrl || '',
|
baseUrl: creds.openaiCompatibleBaseUrl || "",
|
||||||
model: creds.openaiModel || 'gpt-4o-realtime-preview-2024-12-17'
|
model: creds.openaiCompatibleModel || "",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOpenAICredentials(config) {
|
function setOpenAICompatibleConfig(apiKey, baseUrl, model) {
|
||||||
const updates = {};
|
return setCredentials({
|
||||||
if (config.apiKey !== undefined) updates.openaiApiKey = config.apiKey;
|
openaiCompatibleApiKey: apiKey,
|
||||||
if (config.baseUrl !== undefined) updates.openaiBaseUrl = config.baseUrl;
|
openaiCompatibleBaseUrl: baseUrl,
|
||||||
if (config.model !== undefined) updates.openaiModel = config.model;
|
openaiCompatibleModel: model,
|
||||||
return setCredentials(updates);
|
});
|
||||||
}
|
|
||||||
|
|
||||||
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 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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============ PREFERENCES ============
|
// ============ PREFERENCES ============
|
||||||
@@ -282,7 +285,7 @@ function setLimits(limits) {
|
|||||||
|
|
||||||
function getTodayDateString() {
|
function getTodayDateString() {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
return now.toISOString().split('T')[0]; // YYYY-MM-DD
|
return now.toISOString().split("T")[0]; // YYYY-MM-DD
|
||||||
}
|
}
|
||||||
|
|
||||||
function getTodayLimits() {
|
function getTodayLimits() {
|
||||||
@@ -290,18 +293,42 @@ function getTodayLimits() {
|
|||||||
const today = getTodayDateString();
|
const today = getTodayDateString();
|
||||||
|
|
||||||
// Find today's entry
|
// Find today's entry
|
||||||
const todayEntry = limits.data.find(entry => entry.date === today);
|
const todayEntry = limits.data.find((entry) => entry.date === today);
|
||||||
|
|
||||||
if (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 },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
setLimits(limits);
|
||||||
return todayEntry;
|
return todayEntry;
|
||||||
}
|
}
|
||||||
|
|
||||||
// No entry for today - clean old entries and create new one
|
// No entry for today - clean old entries and create new one
|
||||||
limits.data = limits.data.filter(entry => entry.date === today);
|
limits.data = limits.data.filter((entry) => entry.date === today);
|
||||||
const newEntry = {
|
const newEntry = {
|
||||||
date: today,
|
date: today,
|
||||||
flash: { count: 0 },
|
flash: { count: 0 },
|
||||||
flashLite: { 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);
|
limits.data.push(newEntry);
|
||||||
setLimits(limits);
|
setLimits(limits);
|
||||||
@@ -314,7 +341,7 @@ function incrementLimitCount(model) {
|
|||||||
const today = getTodayDateString();
|
const today = getTodayDateString();
|
||||||
|
|
||||||
// Find or create today's entry
|
// Find or create today's entry
|
||||||
let todayEntry = limits.data.find(entry => entry.date === today);
|
let todayEntry = limits.data.find((entry) => entry.date === today);
|
||||||
|
|
||||||
if (!todayEntry) {
|
if (!todayEntry) {
|
||||||
// Clean old entries and create new one
|
// Clean old entries and create new one
|
||||||
@@ -322,18 +349,18 @@ function incrementLimitCount(model) {
|
|||||||
todayEntry = {
|
todayEntry = {
|
||||||
date: today,
|
date: today,
|
||||||
flash: { count: 0 },
|
flash: { count: 0 },
|
||||||
flashLite: { count: 0 }
|
flashLite: { count: 0 },
|
||||||
};
|
};
|
||||||
limits.data.push(todayEntry);
|
limits.data.push(todayEntry);
|
||||||
} else {
|
} else {
|
||||||
// Clean old entries, keep only today
|
// Clean old entries, keep only today
|
||||||
limits.data = limits.data.filter(entry => entry.date === today);
|
limits.data = limits.data.filter((entry) => entry.date === today);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Increment the appropriate model count
|
// Increment the appropriate model count
|
||||||
if (model === 'gemini-2.5-flash') {
|
if (model === "gemini-2.5-flash") {
|
||||||
todayEntry.flash.count++;
|
todayEntry.flash.count++;
|
||||||
} else if (model === 'gemini-2.5-flash-lite') {
|
} else if (model === "gemini-2.5-flash-lite") {
|
||||||
todayEntry.flashLite.count++;
|
todayEntry.flashLite.count++;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,18 +368,54 @@ function incrementLimitCount(model) {
|
|||||||
return todayEntry;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
function getAvailableModel() {
|
function getAvailableModel() {
|
||||||
const todayLimits = getTodayLimits();
|
const todayLimits = getTodayLimits();
|
||||||
|
|
||||||
// RPD limits: flash = 20, flash-lite = 20
|
// RPD limits: flash = 20, flash-lite = 20
|
||||||
// After both exhausted, fall back to flash (for paid API users)
|
// After both exhausted, fall back to flash (for paid API users)
|
||||||
if (todayLimits.flash.count < 20) {
|
if (todayLimits.flash.count < 20) {
|
||||||
return 'gemini-2.5-flash';
|
return "gemini-2.5-flash";
|
||||||
} else if (todayLimits.flashLite.count < 20) {
|
} else if (todayLimits.flashLite.count < 20) {
|
||||||
return 'gemini-2.5-flash-lite';
|
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 ============
|
// ============ HISTORY ============
|
||||||
@@ -375,8 +438,12 @@ function saveSession(sessionId, data) {
|
|||||||
profile: data.profile || existingSession?.profile || null,
|
profile: data.profile || existingSession?.profile || null,
|
||||||
customPrompt: data.customPrompt || existingSession?.customPrompt || null,
|
customPrompt: data.customPrompt || existingSession?.customPrompt || null,
|
||||||
// Conversation data
|
// Conversation data
|
||||||
conversationHistory: data.conversationHistory || existingSession?.conversationHistory || [],
|
conversationHistory:
|
||||||
screenAnalysisHistory: data.screenAnalysisHistory || existingSession?.screenAnalysisHistory || []
|
data.conversationHistory || existingSession?.conversationHistory || [],
|
||||||
|
screenAnalysisHistory:
|
||||||
|
data.screenAnalysisHistory ||
|
||||||
|
existingSession?.screenAnalysisHistory ||
|
||||||
|
[],
|
||||||
};
|
};
|
||||||
return writeJsonFile(sessionPath, sessionData);
|
return writeJsonFile(sessionPath, sessionData);
|
||||||
}
|
}
|
||||||
@@ -393,17 +460,19 @@ function getAllSessions() {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const files = fs.readdirSync(historyDir)
|
const files = fs
|
||||||
.filter(f => f.endsWith('.json'))
|
.readdirSync(historyDir)
|
||||||
|
.filter((f) => f.endsWith(".json"))
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
// Sort by timestamp descending (newest first)
|
// Sort by timestamp descending (newest first)
|
||||||
const tsA = parseInt(a.replace('.json', ''));
|
const tsA = parseInt(a.replace(".json", ""));
|
||||||
const tsB = parseInt(b.replace('.json', ''));
|
const tsB = parseInt(b.replace(".json", ""));
|
||||||
return tsB - tsA;
|
return tsB - tsA;
|
||||||
});
|
});
|
||||||
|
|
||||||
return files.map(file => {
|
return files
|
||||||
const sessionId = file.replace('.json', '');
|
.map((file) => {
|
||||||
|
const sessionId = file.replace(".json", "");
|
||||||
const data = readJsonFile(path.join(historyDir, file), null);
|
const data = readJsonFile(path.join(historyDir, file), null);
|
||||||
if (data) {
|
if (data) {
|
||||||
return {
|
return {
|
||||||
@@ -413,13 +482,14 @@ function getAllSessions() {
|
|||||||
messageCount: data.conversationHistory?.length || 0,
|
messageCount: data.conversationHistory?.length || 0,
|
||||||
screenAnalysisCount: data.screenAnalysisHistory?.length || 0,
|
screenAnalysisCount: data.screenAnalysisHistory?.length || 0,
|
||||||
profile: data.profile || null,
|
profile: data.profile || null,
|
||||||
customPrompt: data.customPrompt || null
|
customPrompt: data.customPrompt || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}).filter(Boolean);
|
})
|
||||||
|
.filter(Boolean);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error reading sessions:', error.message);
|
console.error("Error reading sessions:", error.message);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -432,7 +502,7 @@ function deleteSession(sessionId) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting session:', error.message);
|
console.error("Error deleting session:", error.message);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -441,14 +511,16 @@ function deleteAllSessions() {
|
|||||||
const historyDir = getHistoryDir();
|
const historyDir = getHistoryDir();
|
||||||
try {
|
try {
|
||||||
if (fs.existsSync(historyDir)) {
|
if (fs.existsSync(historyDir)) {
|
||||||
const files = fs.readdirSync(historyDir).filter(f => f.endsWith('.json'));
|
const files = fs
|
||||||
files.forEach(file => {
|
.readdirSync(historyDir)
|
||||||
|
.filter((f) => f.endsWith(".json"));
|
||||||
|
files.forEach((file) => {
|
||||||
fs.unlinkSync(path.join(historyDir, file));
|
fs.unlinkSync(path.join(historyDir, file));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting all sessions:', error.message);
|
console.error("Error deleting all sessions:", error.message);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,10 +547,10 @@ module.exports = {
|
|||||||
setCredentials,
|
setCredentials,
|
||||||
getApiKey,
|
getApiKey,
|
||||||
setApiKey,
|
setApiKey,
|
||||||
getOpenAICredentials,
|
getGroqApiKey,
|
||||||
setOpenAICredentials,
|
setGroqApiKey,
|
||||||
getOpenAISDKCredentials,
|
getOpenAICompatibleConfig,
|
||||||
setOpenAISDKCredentials,
|
setOpenAICompatibleConfig,
|
||||||
|
|
||||||
// Preferences
|
// Preferences
|
||||||
getPreferences,
|
getPreferences,
|
||||||
@@ -495,6 +567,8 @@ module.exports = {
|
|||||||
getTodayLimits,
|
getTodayLimits,
|
||||||
incrementLimitCount,
|
incrementLimitCount,
|
||||||
getAvailableModel,
|
getAvailableModel,
|
||||||
|
incrementCharUsage,
|
||||||
|
getModelForToday,
|
||||||
|
|
||||||
// History
|
// History
|
||||||
saveSession,
|
saveSession,
|
||||||
@@ -504,5 +578,5 @@ module.exports = {
|
|||||||
deleteAllSessions,
|
deleteAllSessions,
|
||||||
|
|
||||||
// Clear all
|
// Clear all
|
||||||
clearAllData
|
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,
|
|
||||||
};
|
|
||||||
+937
-181
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,631 +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
|
|
||||||
let audioChunks = [];
|
|
||||||
let lastAudioTime = 0;
|
|
||||||
const SILENCE_THRESHOLD_MS = 1500; // 1.5 seconds of silence
|
|
||||||
let silenceCheckTimer = 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');
|
|
||||||
|
|
||||||
// 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: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Combine all audio chunks
|
|
||||||
const combinedBuffer = Buffer.concat(audioChunks);
|
|
||||||
audioChunks = [];
|
|
||||||
|
|
||||||
// Transcribe
|
|
||||||
const transcription = await transcribeAudio(combinedBuffer);
|
|
||||||
|
|
||||||
if (transcription && transcription.trim()) {
|
|
||||||
// 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 = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeOpenAISDK() {
|
|
||||||
stopMacOSAudioCapture();
|
|
||||||
openaiClient = null;
|
|
||||||
currentConfig = null;
|
|
||||||
conversationMessages = [];
|
|
||||||
audioChunks = [];
|
|
||||||
isProcessing = false;
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
+69
-101
@@ -1,13 +1,37 @@
|
|||||||
const profilePrompts = {
|
const responseModeFormats = {
|
||||||
interview: {
|
brief: `**RESPONSE FORMAT REQUIREMENTS:**
|
||||||
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:**
|
|
||||||
- Keep responses SHORT and CONCISE (1-3 sentences max)
|
- Keep responses SHORT and CONCISE (1-3 sentences max)
|
||||||
- Use **markdown formatting** for better readability
|
- Use **markdown formatting** for better readability
|
||||||
- Use **bold** for key points and emphasis
|
- Use **bold** for key points and emphasis
|
||||||
- Use bullet points (-) for lists when appropriate
|
- 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)`,
|
||||||
|
|
||||||
|
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:**
|
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 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
|
||||||
@@ -39,13 +63,6 @@ Provide only the exact words to say in **markdown format**. No coaching, no "you
|
|||||||
sales: {
|
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.`,
|
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 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 reference **competitor information, recent funding news, or market data**, search for the latest information first
|
||||||
@@ -70,13 +87,6 @@ Provide only the exact words to say in **markdown format**. Be persuasive but no
|
|||||||
meeting: {
|
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.`,
|
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 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 reference **competitor activities, recent reports, or current statistics**, search for the latest data first
|
||||||
@@ -101,13 +111,6 @@ Provide only the exact words to say in **markdown format**. Be clear, concise, a
|
|||||||
presentation: {
|
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.`,
|
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 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 reference **recent events, new competitors, or current market conditions**, search for the latest information first
|
||||||
@@ -132,13 +135,6 @@ Provide only the exact words to say in **markdown format**. Be confident, engagi
|
|||||||
negotiation: {
|
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.`,
|
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 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 reference **recent legal changes, new regulations, or market conditions**, search for the latest information first
|
||||||
@@ -163,13 +159,6 @@ Provide only the exact words to say in **markdown format**. Focus on finding win
|
|||||||
exam: {
|
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.`,
|
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 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 reference **specific dates, statistics, or factual information** that might be outdated, search for current information
|
||||||
@@ -201,78 +190,57 @@ Provide direct exam answers in **markdown format**. Include the question text, t
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function buildSystemPrompt(promptParts, customPrompt = '', googleSearchEnabled = true) {
|
function buildSystemPrompt(
|
||||||
const sections = [promptParts.intro, '\n\n', promptParts.formatRequirements];
|
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
|
// Only add search usage section if Google Search is enabled
|
||||||
if (googleSearchEnabled) {
|
if (googleSearchEnabled) {
|
||||||
sections.push('\n\n', promptParts.searchUsage);
|
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) {
|
function getSystemPrompt(
|
||||||
|
profile,
|
||||||
|
customPrompt = "",
|
||||||
|
googleSearchEnabled = true,
|
||||||
|
responseMode = "brief",
|
||||||
|
) {
|
||||||
const promptParts = profilePrompts[profile] || profilePrompts.interview;
|
const promptParts = profilePrompts[profile] || profilePrompts.interview;
|
||||||
return buildSystemPrompt(promptParts, customPrompt, googleSearchEnabled);
|
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 = {
|
module.exports = {
|
||||||
profilePrompts,
|
profilePrompts,
|
||||||
|
responseModeFormats,
|
||||||
|
codingAwareness,
|
||||||
getSystemPrompt,
|
getSystemPrompt,
|
||||||
VISION_ANALYSIS_PROMPT,
|
|
||||||
};
|
};
|
||||||
|
|||||||
+490
-546
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" });
|
||||||
+151
-510
@@ -1,13 +1,8 @@
|
|||||||
const { BrowserWindow, globalShortcut, ipcMain, screen } = require('electron');
|
const { BrowserWindow, globalShortcut, ipcMain, screen } = require("electron");
|
||||||
const path = require('node:path');
|
const path = require("node:path");
|
||||||
const fs = require('node:fs');
|
const storage = require("../storage");
|
||||||
const os = require('os');
|
|
||||||
const storage = require('../storage');
|
|
||||||
|
|
||||||
let mouseEventsIgnored = false;
|
let mouseEventsIgnored = false;
|
||||||
let windowResizing = false;
|
|
||||||
let resizeAnimation = null;
|
|
||||||
const RESIZE_ANIMATION_DURATION = 500; // milliseconds
|
|
||||||
|
|
||||||
function createWindow(sendToRenderer, geminiSessionRef) {
|
function createWindow(sendToRenderer, geminiSessionRef) {
|
||||||
// Get layout preference (default to 'normal')
|
// Get layout preference (default to 'normal')
|
||||||
@@ -25,84 +20,42 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
|||||||
nodeIntegration: true,
|
nodeIntegration: true,
|
||||||
contextIsolation: false, // TODO: change to true
|
contextIsolation: false, // TODO: change to true
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
enableBlinkFeatures: 'GetDisplayMedia',
|
enableBlinkFeatures: "GetDisplayMedia",
|
||||||
webSecurity: true,
|
webSecurity: true,
|
||||||
allowRunningInsecureContent: false,
|
allowRunningInsecureContent: false,
|
||||||
},
|
},
|
||||||
backgroundColor: '#00000000',
|
backgroundColor: "#00000000",
|
||||||
});
|
});
|
||||||
|
|
||||||
const { session, desktopCapturer } = require('electron');
|
const { session, desktopCapturer } = require("electron");
|
||||||
|
|
||||||
// Store selected source for Windows custom picker
|
|
||||||
let selectedSourceId = null;
|
|
||||||
|
|
||||||
// Setup display media handler based on platform
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
// macOS: Use native system picker
|
|
||||||
session.defaultSession.setDisplayMediaRequestHandler(
|
session.defaultSession.setDisplayMediaRequestHandler(
|
||||||
(request, callback) => {
|
(request, callback) => {
|
||||||
desktopCapturer.getSources({ types: ['screen'] }).then(sources => {
|
desktopCapturer.getSources({ types: ["screen"] }).then((sources) => {
|
||||||
callback({ video: sources[0], audio: 'loopback' });
|
callback({ video: sources[0], audio: "loopback" });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
{ useSystemPicker: true }
|
{ useSystemPicker: true },
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Windows/Linux: Use selected source from custom picker
|
|
||||||
session.defaultSession.setDisplayMediaRequestHandler(async (request, callback) => {
|
|
||||||
try {
|
|
||||||
const sources = await desktopCapturer.getSources({
|
|
||||||
types: ['screen', 'window'],
|
|
||||||
thumbnailSize: { width: 0, height: 0 },
|
|
||||||
});
|
|
||||||
|
|
||||||
// Find the selected source or use first screen
|
|
||||||
let source = sources[0];
|
|
||||||
if (selectedSourceId) {
|
|
||||||
const found = sources.find(s => s.id === selectedSourceId);
|
|
||||||
if (found) source = found;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (source) {
|
|
||||||
callback({ video: source, audio: 'loopback' });
|
|
||||||
} else {
|
|
||||||
callback({});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error in display media handler:', error);
|
|
||||||
callback({});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// IPC handler to set selected source
|
|
||||||
ipcMain.handle('set-selected-source', async (event, sourceId) => {
|
|
||||||
selectedSourceId = sourceId;
|
|
||||||
return { success: true };
|
|
||||||
});
|
|
||||||
|
|
||||||
mainWindow.setResizable(false);
|
mainWindow.setResizable(false);
|
||||||
mainWindow.setContentProtection(true);
|
mainWindow.setContentProtection(true);
|
||||||
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
mainWindow.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
||||||
|
|
||||||
// Hide from Windows taskbar
|
// Hide from Windows taskbar
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === "win32") {
|
||||||
try {
|
try {
|
||||||
mainWindow.setSkipTaskbar(true);
|
mainWindow.setSkipTaskbar(true);
|
||||||
console.log('Hidden from Windows taskbar');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not hide from taskbar:', error.message);
|
console.warn("Could not hide from taskbar:", error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide from Mission Control on macOS
|
// Hide from Mission Control on macOS
|
||||||
if (process.platform === 'darwin') {
|
if (process.platform === "darwin") {
|
||||||
try {
|
try {
|
||||||
mainWindow.setHiddenInMissionControl(true);
|
mainWindow.setHiddenInMissionControl(true);
|
||||||
console.log('Hidden from macOS Mission Control');
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Could not hide from Mission Control:', error.message);
|
console.warn("Could not hide from Mission Control:", error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,14 +66,14 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
|||||||
const y = 0;
|
const y = 0;
|
||||||
mainWindow.setPosition(x, y);
|
mainWindow.setPosition(x, y);
|
||||||
|
|
||||||
if (process.platform === 'win32') {
|
if (process.platform === "win32") {
|
||||||
mainWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
mainWindow.setAlwaysOnTop(true, "screen-saver", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
mainWindow.loadFile(path.join(__dirname, '../index.html'));
|
mainWindow.loadFile(path.join(__dirname, "../index.html"));
|
||||||
|
|
||||||
// After window is created, initialize keybinds
|
// After window is created, initialize keybinds
|
||||||
mainWindow.webContents.once('dom-ready', () => {
|
mainWindow.webContents.once("dom-ready", () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const defaultKeybinds = getDefaultKeybinds();
|
const defaultKeybinds = getDefaultKeybinds();
|
||||||
let keybinds = defaultKeybinds;
|
let keybinds = defaultKeybinds;
|
||||||
@@ -131,7 +84,12 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
|||||||
keybinds = { ...defaultKeybinds, ...savedKeybinds };
|
keybinds = { ...defaultKeybinds, ...savedKeybinds };
|
||||||
}
|
}
|
||||||
|
|
||||||
updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessionRef);
|
updateGlobalShortcuts(
|
||||||
|
keybinds,
|
||||||
|
mainWindow,
|
||||||
|
sendToRenderer,
|
||||||
|
geminiSessionRef,
|
||||||
|
);
|
||||||
}, 150);
|
}, 150);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -141,25 +99,31 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultKeybinds() {
|
function getDefaultKeybinds() {
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === "darwin";
|
||||||
return {
|
return {
|
||||||
moveUp: isMac ? 'Alt+Up' : 'Ctrl+Up',
|
moveUp: isMac ? "Alt+Up" : "Ctrl+Up",
|
||||||
moveDown: isMac ? 'Alt+Down' : 'Ctrl+Down',
|
moveDown: isMac ? "Alt+Down" : "Ctrl+Down",
|
||||||
moveLeft: isMac ? 'Alt+Left' : 'Ctrl+Left',
|
moveLeft: isMac ? "Alt+Left" : "Ctrl+Left",
|
||||||
moveRight: isMac ? 'Alt+Right' : 'Ctrl+Right',
|
moveRight: isMac ? "Alt+Right" : "Ctrl+Right",
|
||||||
toggleVisibility: isMac ? 'Cmd+\\' : 'Ctrl+\\',
|
toggleVisibility: isMac ? "Cmd+\\" : "Ctrl+\\",
|
||||||
toggleClickThrough: isMac ? 'Cmd+M' : 'Ctrl+M',
|
toggleClickThrough: isMac ? "Cmd+M" : "Ctrl+M",
|
||||||
nextStep: isMac ? 'Cmd+Enter' : 'Ctrl+Enter',
|
nextStep: isMac ? "Cmd+Enter" : "Ctrl+Enter",
|
||||||
previousResponse: isMac ? 'Cmd+[' : 'Ctrl+[',
|
previousResponse: isMac ? "Cmd+[" : "Ctrl+[",
|
||||||
nextResponse: isMac ? 'Cmd+]' : 'Ctrl+]',
|
nextResponse: isMac ? "Cmd+]" : "Ctrl+]",
|
||||||
scrollUp: isMac ? 'Cmd+Shift+Up' : 'Ctrl+Shift+Up',
|
scrollUp: isMac ? "Cmd+Shift+Up" : "Ctrl+Shift+Up",
|
||||||
scrollDown: isMac ? 'Cmd+Shift+Down' : 'Ctrl+Shift+Down',
|
scrollDown: isMac ? "Cmd+Shift+Down" : "Ctrl+Shift+Down",
|
||||||
emergencyErase: isMac ? 'Cmd+Shift+E' : 'Ctrl+Shift+E',
|
expandResponse: isMac ? "Cmd+E" : "Ctrl+E",
|
||||||
|
emergencyErase: isMac ? "Cmd+Shift+E" : "Ctrl+Shift+E",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessionRef) {
|
function updateGlobalShortcuts(
|
||||||
console.log('Updating global shortcuts with:', keybinds);
|
keybinds,
|
||||||
|
mainWindow,
|
||||||
|
sendToRenderer,
|
||||||
|
geminiSessionRef,
|
||||||
|
) {
|
||||||
|
console.log("Updating global shortcuts with:", keybinds);
|
||||||
|
|
||||||
// Unregister all existing shortcuts
|
// Unregister all existing shortcuts
|
||||||
globalShortcut.unregisterAll();
|
globalShortcut.unregisterAll();
|
||||||
@@ -193,7 +157,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Register each movement shortcut
|
// Register each movement shortcut
|
||||||
Object.keys(movementActions).forEach(action => {
|
Object.keys(movementActions).forEach((action) => {
|
||||||
const keybind = keybinds[action];
|
const keybind = keybinds[action];
|
||||||
if (keybind) {
|
if (keybind) {
|
||||||
try {
|
try {
|
||||||
@@ -217,7 +181,10 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
});
|
});
|
||||||
console.log(`Registered toggleVisibility: ${keybinds.toggleVisibility}`);
|
console.log(`Registered toggleVisibility: ${keybinds.toggleVisibility}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register toggleVisibility (${keybinds.toggleVisibility}):`, error);
|
console.error(
|
||||||
|
`Failed to register toggleVisibility (${keybinds.toggleVisibility}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,16 +195,24 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
mouseEventsIgnored = !mouseEventsIgnored;
|
mouseEventsIgnored = !mouseEventsIgnored;
|
||||||
if (mouseEventsIgnored) {
|
if (mouseEventsIgnored) {
|
||||||
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
mainWindow.setIgnoreMouseEvents(true, { forward: true });
|
||||||
console.log('Mouse events ignored');
|
console.log("Mouse events ignored");
|
||||||
} else {
|
} else {
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
console.log('Mouse events enabled');
|
console.log("Mouse events enabled");
|
||||||
}
|
}
|
||||||
mainWindow.webContents.send('click-through-toggled', mouseEventsIgnored);
|
mainWindow.webContents.send(
|
||||||
|
"click-through-toggled",
|
||||||
|
mouseEventsIgnored,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
console.log(`Registered toggleClickThrough: ${keybinds.toggleClickThrough}`);
|
console.log(
|
||||||
|
`Registered toggleClickThrough: ${keybinds.toggleClickThrough}`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register toggleClickThrough (${keybinds.toggleClickThrough}):`, error);
|
console.error(
|
||||||
|
`Failed to register toggleClickThrough (${keybinds.toggleClickThrough}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -245,23 +220,26 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
if (keybinds.nextStep) {
|
if (keybinds.nextStep) {
|
||||||
try {
|
try {
|
||||||
globalShortcut.register(keybinds.nextStep, async () => {
|
globalShortcut.register(keybinds.nextStep, async () => {
|
||||||
console.log('Next step shortcut triggered');
|
console.log("Next step shortcut triggered");
|
||||||
try {
|
try {
|
||||||
// Determine the shortcut key format
|
// Determine the shortcut key format
|
||||||
const isMac = process.platform === 'darwin';
|
const isMac = process.platform === "darwin";
|
||||||
const shortcutKey = isMac ? 'cmd+enter' : 'ctrl+enter';
|
const shortcutKey = isMac ? "cmd+enter" : "ctrl+enter";
|
||||||
|
|
||||||
// Use the new handleShortcut function
|
// Use the new handleShortcut function
|
||||||
mainWindow.webContents.executeJavaScript(`
|
mainWindow.webContents.executeJavaScript(`
|
||||||
cheatingDaddy.handleShortcut('${shortcutKey}');
|
cheatingDaddy.handleShortcut('${shortcutKey}');
|
||||||
`);
|
`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error handling next step shortcut:', error);
|
console.error("Error handling next step shortcut:", error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`Registered nextStep: ${keybinds.nextStep}`);
|
console.log(`Registered nextStep: ${keybinds.nextStep}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register nextStep (${keybinds.nextStep}):`, error);
|
console.error(
|
||||||
|
`Failed to register nextStep (${keybinds.nextStep}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,12 +247,15 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
if (keybinds.previousResponse) {
|
if (keybinds.previousResponse) {
|
||||||
try {
|
try {
|
||||||
globalShortcut.register(keybinds.previousResponse, () => {
|
globalShortcut.register(keybinds.previousResponse, () => {
|
||||||
console.log('Previous response shortcut triggered');
|
console.log("Previous response shortcut triggered");
|
||||||
sendToRenderer('navigate-previous-response');
|
sendToRenderer("navigate-previous-response");
|
||||||
});
|
});
|
||||||
console.log(`Registered previousResponse: ${keybinds.previousResponse}`);
|
console.log(`Registered previousResponse: ${keybinds.previousResponse}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register previousResponse (${keybinds.previousResponse}):`, error);
|
console.error(
|
||||||
|
`Failed to register previousResponse (${keybinds.previousResponse}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,12 +263,15 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
if (keybinds.nextResponse) {
|
if (keybinds.nextResponse) {
|
||||||
try {
|
try {
|
||||||
globalShortcut.register(keybinds.nextResponse, () => {
|
globalShortcut.register(keybinds.nextResponse, () => {
|
||||||
console.log('Next response shortcut triggered');
|
console.log("Next response shortcut triggered");
|
||||||
sendToRenderer('navigate-next-response');
|
sendToRenderer("navigate-next-response");
|
||||||
});
|
});
|
||||||
console.log(`Registered nextResponse: ${keybinds.nextResponse}`);
|
console.log(`Registered nextResponse: ${keybinds.nextResponse}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register nextResponse (${keybinds.nextResponse}):`, error);
|
console.error(
|
||||||
|
`Failed to register nextResponse (${keybinds.nextResponse}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -295,12 +279,15 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
if (keybinds.scrollUp) {
|
if (keybinds.scrollUp) {
|
||||||
try {
|
try {
|
||||||
globalShortcut.register(keybinds.scrollUp, () => {
|
globalShortcut.register(keybinds.scrollUp, () => {
|
||||||
console.log('Scroll up shortcut triggered');
|
console.log("Scroll up shortcut triggered");
|
||||||
sendToRenderer('scroll-response-up');
|
sendToRenderer("scroll-response-up");
|
||||||
});
|
});
|
||||||
console.log(`Registered scrollUp: ${keybinds.scrollUp}`);
|
console.log(`Registered scrollUp: ${keybinds.scrollUp}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register scrollUp (${keybinds.scrollUp}):`, error);
|
console.error(
|
||||||
|
`Failed to register scrollUp (${keybinds.scrollUp}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,12 +295,31 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
if (keybinds.scrollDown) {
|
if (keybinds.scrollDown) {
|
||||||
try {
|
try {
|
||||||
globalShortcut.register(keybinds.scrollDown, () => {
|
globalShortcut.register(keybinds.scrollDown, () => {
|
||||||
console.log('Scroll down shortcut triggered');
|
console.log("Scroll down shortcut triggered");
|
||||||
sendToRenderer('scroll-response-down');
|
sendToRenderer("scroll-response-down");
|
||||||
});
|
});
|
||||||
console.log(`Registered scrollDown: ${keybinds.scrollDown}`);
|
console.log(`Registered scrollDown: ${keybinds.scrollDown}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register scrollDown (${keybinds.scrollDown}):`, error);
|
console.error(
|
||||||
|
`Failed to register scrollDown (${keybinds.scrollDown}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register expand response shortcut
|
||||||
|
if (keybinds.expandResponse) {
|
||||||
|
try {
|
||||||
|
globalShortcut.register(keybinds.expandResponse, () => {
|
||||||
|
console.log("Expand response shortcut triggered");
|
||||||
|
sendToRenderer("expand-response");
|
||||||
|
});
|
||||||
|
console.log(`Registered expandResponse: ${keybinds.expandResponse}`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
`Failed to register expandResponse (${keybinds.expandResponse}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -321,7 +327,7 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
if (keybinds.emergencyErase) {
|
if (keybinds.emergencyErase) {
|
||||||
try {
|
try {
|
||||||
globalShortcut.register(keybinds.emergencyErase, () => {
|
globalShortcut.register(keybinds.emergencyErase, () => {
|
||||||
console.log('Emergency Erase triggered!');
|
console.log("Emergency Erase triggered!");
|
||||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||||
mainWindow.hide();
|
mainWindow.hide();
|
||||||
|
|
||||||
@@ -330,44 +336,70 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
|||||||
geminiSessionRef.current = null;
|
geminiSessionRef.current = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
sendToRenderer('clear-sensitive-data');
|
sendToRenderer("clear-sensitive-data");
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const { app } = require('electron');
|
const { app } = require("electron");
|
||||||
app.quit();
|
app.quit();
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
console.log(`Registered emergencyErase: ${keybinds.emergencyErase}`);
|
console.log(`Registered emergencyErase: ${keybinds.emergencyErase}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Failed to register emergencyErase (${keybinds.emergencyErase}):`, error);
|
console.error(
|
||||||
|
`Failed to register emergencyErase (${keybinds.emergencyErase}):`,
|
||||||
|
error,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setupWindowIpcHandlers(mainWindow, sendToRenderer, geminiSessionRef) {
|
function setupWindowIpcHandlers(mainWindow, sendToRenderer, geminiSessionRef) {
|
||||||
ipcMain.on('view-changed', (event, view) => {
|
ipcMain.on("view-changed", (event, view) => {
|
||||||
if (view !== 'assistant' && !mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
|
const primaryDisplay = screen.getPrimaryDisplay();
|
||||||
|
const { width: screenWidth } = primaryDisplay.workAreaSize;
|
||||||
|
|
||||||
|
if (view === "assistant") {
|
||||||
|
// Shrink window for live view
|
||||||
|
const liveWidth = 850;
|
||||||
|
const liveHeight = 400;
|
||||||
|
const x = Math.floor((screenWidth - liveWidth) / 2);
|
||||||
|
mainWindow.setSize(liveWidth, liveHeight);
|
||||||
|
mainWindow.setPosition(x, 0);
|
||||||
|
} else {
|
||||||
|
// Restore full size
|
||||||
|
const fullWidth = 1100;
|
||||||
|
const fullHeight = 800;
|
||||||
|
const x = Math.floor((screenWidth - fullWidth) / 2);
|
||||||
|
mainWindow.setSize(fullWidth, fullHeight);
|
||||||
|
mainWindow.setPosition(x, 0);
|
||||||
mainWindow.setIgnoreMouseEvents(false);
|
mainWindow.setIgnoreMouseEvents(false);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('window-minimize', () => {
|
ipcMain.handle("window-minimize", () => {
|
||||||
if (!mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
mainWindow.minimize();
|
mainWindow.minimize();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.on('update-keybinds', (event, newKeybinds) => {
|
ipcMain.on("update-keybinds", (event, newKeybinds) => {
|
||||||
if (!mainWindow.isDestroyed()) {
|
if (!mainWindow.isDestroyed()) {
|
||||||
updateGlobalShortcuts(newKeybinds, mainWindow, sendToRenderer, geminiSessionRef);
|
updateGlobalShortcuts(
|
||||||
|
newKeybinds,
|
||||||
|
mainWindow,
|
||||||
|
sendToRenderer,
|
||||||
|
geminiSessionRef,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
ipcMain.handle('toggle-window-visibility', async event => {
|
ipcMain.handle("toggle-window-visibility", async (event) => {
|
||||||
try {
|
try {
|
||||||
if (mainWindow.isDestroyed()) {
|
if (mainWindow.isDestroyed()) {
|
||||||
return { success: false, error: 'Window has been destroyed' };
|
return { success: false, error: "Window has been destroyed" };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mainWindow.isVisible()) {
|
if (mainWindow.isVisible()) {
|
||||||
@@ -377,406 +409,15 @@ function setupWindowIpcHandlers(mainWindow, sendToRenderer, geminiSessionRef) {
|
|||||||
}
|
}
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error toggling window visibility:', error);
|
console.error("Error toggling window visibility:", error);
|
||||||
return { success: false, error: error.message };
|
return { success: false, error: error.message };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function animateWindowResize(mainWindow, targetWidth, targetHeight, layoutMode) {
|
ipcMain.handle("update-sizes", async (event) => {
|
||||||
return new Promise(resolve => {
|
// With the sidebar layout, the window size is user-controlled.
|
||||||
// Check if window is destroyed before starting animation
|
// This handler is kept for compatibility but is a no-op now.
|
||||||
if (mainWindow.isDestroyed()) {
|
|
||||||
console.log('Cannot animate resize: window has been destroyed');
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear any existing animation
|
|
||||||
if (resizeAnimation) {
|
|
||||||
clearInterval(resizeAnimation);
|
|
||||||
resizeAnimation = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [startWidth, startHeight] = mainWindow.getSize();
|
|
||||||
|
|
||||||
// If already at target size, no need to animate
|
|
||||||
if (startWidth === targetWidth && startHeight === targetHeight) {
|
|
||||||
console.log(`Window already at target size for ${layoutMode} mode`);
|
|
||||||
resolve();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Starting animated resize from ${startWidth}x${startHeight} to ${targetWidth}x${targetHeight}`);
|
|
||||||
|
|
||||||
windowResizing = true;
|
|
||||||
mainWindow.setResizable(true);
|
|
||||||
|
|
||||||
const frameRate = 60; // 60 FPS
|
|
||||||
const totalFrames = Math.floor(RESIZE_ANIMATION_DURATION / (1000 / frameRate));
|
|
||||||
let currentFrame = 0;
|
|
||||||
|
|
||||||
const widthDiff = targetWidth - startWidth;
|
|
||||||
const heightDiff = targetHeight - startHeight;
|
|
||||||
|
|
||||||
resizeAnimation = setInterval(() => {
|
|
||||||
currentFrame++;
|
|
||||||
const progress = currentFrame / totalFrames;
|
|
||||||
|
|
||||||
// Use easing function (ease-out)
|
|
||||||
const easedProgress = 1 - Math.pow(1 - progress, 3);
|
|
||||||
|
|
||||||
const currentWidth = Math.round(startWidth + widthDiff * easedProgress);
|
|
||||||
const currentHeight = Math.round(startHeight + heightDiff * easedProgress);
|
|
||||||
|
|
||||||
if (!mainWindow || mainWindow.isDestroyed()) {
|
|
||||||
clearInterval(resizeAnimation);
|
|
||||||
resizeAnimation = null;
|
|
||||||
windowResizing = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
mainWindow.setSize(currentWidth, currentHeight);
|
|
||||||
|
|
||||||
// Re-center the window during animation
|
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
|
||||||
const { width: screenWidth } = primaryDisplay.workAreaSize;
|
|
||||||
const x = Math.floor((screenWidth - currentWidth) / 2);
|
|
||||||
const y = 0;
|
|
||||||
mainWindow.setPosition(x, y);
|
|
||||||
|
|
||||||
if (currentFrame >= totalFrames) {
|
|
||||||
clearInterval(resizeAnimation);
|
|
||||||
resizeAnimation = null;
|
|
||||||
windowResizing = false;
|
|
||||||
|
|
||||||
// Check if window is still valid before final operations
|
|
||||||
if (!mainWindow.isDestroyed()) {
|
|
||||||
mainWindow.setResizable(false);
|
|
||||||
|
|
||||||
// Ensure final size is exact
|
|
||||||
mainWindow.setSize(targetWidth, targetHeight);
|
|
||||||
const finalX = Math.floor((screenWidth - targetWidth) / 2);
|
|
||||||
mainWindow.setPosition(finalX, 0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`Animation complete: ${targetWidth}x${targetHeight}`);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
}, 1000 / frameRate);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ipcMain.handle('update-sizes', async event => {
|
|
||||||
try {
|
|
||||||
if (mainWindow.isDestroyed()) {
|
|
||||||
return { success: false, error: 'Window has been destroyed' };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get current view and layout mode from renderer
|
|
||||||
let viewName, layoutMode;
|
|
||||||
try {
|
|
||||||
viewName = await event.sender.executeJavaScript('cheatingDaddy.getCurrentView()');
|
|
||||||
layoutMode = await event.sender.executeJavaScript('cheatingDaddy.getLayoutMode()');
|
|
||||||
} catch (error) {
|
|
||||||
console.warn('Failed to get view/layout from renderer, using defaults:', error);
|
|
||||||
viewName = 'main';
|
|
||||||
layoutMode = 'normal';
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('Size update requested for view:', viewName, 'layout:', layoutMode);
|
|
||||||
|
|
||||||
let targetWidth, targetHeight;
|
|
||||||
|
|
||||||
// Determine base size from layout mode
|
|
||||||
const baseWidth = layoutMode === 'compact' ? 700 : 900;
|
|
||||||
const baseHeight = layoutMode === 'compact' ? 500 : 600;
|
|
||||||
|
|
||||||
// Adjust height based on view
|
|
||||||
switch (viewName) {
|
|
||||||
case 'main':
|
|
||||||
targetWidth = baseWidth;
|
|
||||||
targetHeight = layoutMode === 'compact' ? 320 : 400;
|
|
||||||
break;
|
|
||||||
case 'customize':
|
|
||||||
case 'settings':
|
|
||||||
targetWidth = baseWidth;
|
|
||||||
targetHeight = layoutMode === 'compact' ? 700 : 800;
|
|
||||||
break;
|
|
||||||
case 'help':
|
|
||||||
targetWidth = baseWidth;
|
|
||||||
targetHeight = layoutMode === 'compact' ? 650 : 750;
|
|
||||||
break;
|
|
||||||
case 'history':
|
|
||||||
targetWidth = baseWidth;
|
|
||||||
targetHeight = layoutMode === 'compact' ? 650 : 750;
|
|
||||||
break;
|
|
||||||
case 'assistant':
|
|
||||||
case 'onboarding':
|
|
||||||
default:
|
|
||||||
targetWidth = baseWidth;
|
|
||||||
targetHeight = baseHeight;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [currentWidth, currentHeight] = mainWindow.getSize();
|
|
||||||
console.log('Current window size:', currentWidth, 'x', currentHeight);
|
|
||||||
|
|
||||||
// If currently resizing, the animation will start from current position
|
|
||||||
if (windowResizing) {
|
|
||||||
console.log('Interrupting current resize animation');
|
|
||||||
}
|
|
||||||
|
|
||||||
await animateWindowResize(mainWindow, targetWidth, targetHeight, `${viewName} view (${layoutMode})`);
|
|
||||||
|
|
||||||
return { success: true };
|
return { success: true };
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating sizes:', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Region selection window for capturing areas outside the main window
|
|
||||||
let regionSelectionWindow = null;
|
|
||||||
|
|
||||||
ipcMain.handle('start-region-selection', async (event, { screenshotDataUrl }) => {
|
|
||||||
try {
|
|
||||||
// Hide main window first
|
|
||||||
const wasVisible = mainWindow.isVisible();
|
|
||||||
if (wasVisible) {
|
|
||||||
mainWindow.hide();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Small delay to ensure window is hidden
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
|
||||||
|
|
||||||
// Get all displays to cover all screens
|
|
||||||
const displays = screen.getAllDisplays();
|
|
||||||
const primaryDisplay = screen.getPrimaryDisplay();
|
|
||||||
|
|
||||||
// Calculate bounds that cover all displays
|
|
||||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
|
||||||
displays.forEach(display => {
|
|
||||||
minX = Math.min(minX, display.bounds.x);
|
|
||||||
minY = Math.min(minY, display.bounds.y);
|
|
||||||
maxX = Math.max(maxX, display.bounds.x + display.bounds.width);
|
|
||||||
maxY = Math.max(maxY, display.bounds.y + display.bounds.height);
|
|
||||||
});
|
|
||||||
|
|
||||||
const totalWidth = maxX - minX;
|
|
||||||
const totalHeight = maxY - minY;
|
|
||||||
|
|
||||||
// Create fullscreen transparent window for selection
|
|
||||||
regionSelectionWindow = new BrowserWindow({
|
|
||||||
x: minX,
|
|
||||||
y: minY,
|
|
||||||
width: totalWidth,
|
|
||||||
height: totalHeight,
|
|
||||||
frame: false,
|
|
||||||
transparent: true,
|
|
||||||
alwaysOnTop: true,
|
|
||||||
skipTaskbar: true,
|
|
||||||
resizable: false,
|
|
||||||
movable: false,
|
|
||||||
hasShadow: false,
|
|
||||||
// Hide from screen capture/sharing
|
|
||||||
...(process.platform === 'darwin' ? { type: 'panel' } : {}),
|
|
||||||
webPreferences: {
|
|
||||||
nodeIntegration: true,
|
|
||||||
contextIsolation: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Hide window content from screen capture (macOS)
|
|
||||||
if (process.platform === 'darwin') {
|
|
||||||
regionSelectionWindow.setContentProtection(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
regionSelectionWindow.setAlwaysOnTop(true, 'screen-saver', 1);
|
|
||||||
|
|
||||||
// Create HTML content for selection overlay
|
|
||||||
const htmlContent = `
|
|
||||||
<!DOCTYPE html>
|
|
||||||
<html>
|
|
||||||
<head>
|
|
||||||
<style>
|
|
||||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
||||||
body {
|
|
||||||
width: 100vw;
|
|
||||||
height: 100vh;
|
|
||||||
cursor: crosshair;
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
#screenshot {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
object-fit: cover;
|
|
||||||
}
|
|
||||||
#overlay {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
background: rgba(0, 0, 0, 0.3);
|
|
||||||
}
|
|
||||||
#selection {
|
|
||||||
position: absolute;
|
|
||||||
border: 2px dashed #fff;
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5);
|
|
||||||
display: none;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
#hint {
|
|
||||||
position: fixed;
|
|
||||||
top: 20px;
|
|
||||||
left: 50%;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
background: rgba(0, 0, 0, 0.8);
|
|
||||||
color: white;
|
|
||||||
padding: 12px 24px;
|
|
||||||
border-radius: 8px;
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
||||||
font-size: 14px;
|
|
||||||
z-index: 10000;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<img id="screenshot" src="${screenshotDataUrl}" />
|
|
||||||
<div id="overlay"></div>
|
|
||||||
<div id="selection"></div>
|
|
||||||
<div id="hint">Click and drag to select region • ESC to cancel</div>
|
|
||||||
<script>
|
|
||||||
const { ipcRenderer } = require('electron');
|
|
||||||
const selection = document.getElementById('selection');
|
|
||||||
const overlay = document.getElementById('overlay');
|
|
||||||
let isSelecting = false;
|
|
||||||
let startX = 0, startY = 0;
|
|
||||||
|
|
||||||
document.addEventListener('mousedown', (e) => {
|
|
||||||
if (e.button !== 0) return;
|
|
||||||
isSelecting = true;
|
|
||||||
startX = e.clientX;
|
|
||||||
startY = e.clientY;
|
|
||||||
selection.style.display = 'block';
|
|
||||||
selection.style.left = startX + 'px';
|
|
||||||
selection.style.top = startY + 'px';
|
|
||||||
selection.style.width = '0px';
|
|
||||||
selection.style.height = '0px';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mousemove', (e) => {
|
|
||||||
if (!isSelecting) return;
|
|
||||||
const currentX = e.clientX;
|
|
||||||
const currentY = e.clientY;
|
|
||||||
const left = Math.min(startX, currentX);
|
|
||||||
const top = Math.min(startY, currentY);
|
|
||||||
const width = Math.abs(currentX - startX);
|
|
||||||
const height = Math.abs(currentY - startY);
|
|
||||||
selection.style.left = left + 'px';
|
|
||||||
selection.style.top = top + 'px';
|
|
||||||
selection.style.width = width + 'px';
|
|
||||||
selection.style.height = height + 'px';
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('mouseup', (e) => {
|
|
||||||
if (!isSelecting) return;
|
|
||||||
isSelecting = false;
|
|
||||||
const rect = {
|
|
||||||
left: parseInt(selection.style.left),
|
|
||||||
top: parseInt(selection.style.top),
|
|
||||||
width: parseInt(selection.style.width),
|
|
||||||
height: parseInt(selection.style.height)
|
|
||||||
};
|
|
||||||
if (rect.width > 10 && rect.height > 10) {
|
|
||||||
ipcRenderer.send('region-selected', rect);
|
|
||||||
} else {
|
|
||||||
ipcRenderer.send('region-selection-cancelled');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', (e) => {
|
|
||||||
if (e.key === 'Escape') {
|
|
||||||
ipcRenderer.send('region-selection-cancelled');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>
|
|
||||||
`;
|
|
||||||
|
|
||||||
regionSelectionWindow.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(htmlContent)}`);
|
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
|
||||||
ipcMain.once('region-selected', (event, rect) => {
|
|
||||||
if (regionSelectionWindow && !regionSelectionWindow.isDestroyed()) {
|
|
||||||
regionSelectionWindow.close();
|
|
||||||
regionSelectionWindow = null;
|
|
||||||
}
|
|
||||||
if (wasVisible) {
|
|
||||||
mainWindow.showInactive();
|
|
||||||
}
|
|
||||||
resolve({ success: true, rect });
|
|
||||||
});
|
|
||||||
|
|
||||||
ipcMain.once('region-selection-cancelled', () => {
|
|
||||||
if (regionSelectionWindow && !regionSelectionWindow.isDestroyed()) {
|
|
||||||
regionSelectionWindow.close();
|
|
||||||
regionSelectionWindow = null;
|
|
||||||
}
|
|
||||||
if (wasVisible) {
|
|
||||||
mainWindow.showInactive();
|
|
||||||
}
|
|
||||||
resolve({ success: false, cancelled: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Also handle window close
|
|
||||||
regionSelectionWindow.on('closed', () => {
|
|
||||||
regionSelectionWindow = null;
|
|
||||||
if (wasVisible && !mainWindow.isDestroyed()) {
|
|
||||||
mainWindow.showInactive();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error starting region selection:', error);
|
|
||||||
if (regionSelectionWindow && !regionSelectionWindow.isDestroyed()) {
|
|
||||||
regionSelectionWindow.close();
|
|
||||||
regionSelectionWindow = null;
|
|
||||||
}
|
|
||||||
if (!mainWindow.isDestroyed()) {
|
|
||||||
mainWindow.showInactive();
|
|
||||||
}
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Get available screen sources for picker
|
|
||||||
ipcMain.handle('get-screen-sources', async () => {
|
|
||||||
try {
|
|
||||||
const { desktopCapturer } = require('electron');
|
|
||||||
const sources = await desktopCapturer.getSources({
|
|
||||||
types: ['screen', 'window'],
|
|
||||||
thumbnailSize: { width: 150, height: 150 },
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
sources: sources.map(source => ({
|
|
||||||
id: source.id,
|
|
||||||
name: source.name,
|
|
||||||
thumbnail: source.thumbnail.toDataURL(),
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error getting screen sources:', error);
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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