Compare commits
16
Commits
v0.5.2
...
bd62cf5524
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bd62cf5524 | ||
|
|
bfd76dc0c1 | ||
|
|
310b6b3fbd | ||
|
|
3a8d9705a2 | ||
|
|
06e178762d | ||
|
|
656e8f0932 | ||
|
|
669c019fd8 | ||
|
|
528dfe01a1 | ||
|
|
fb6c8e3fc0 | ||
|
|
76d6fc2749 | ||
|
|
2f013b4751 | ||
|
|
66dce4415a | ||
|
|
6460349fc7 | ||
|
|
6926c27f20 | ||
|
|
cbf82f9317 | ||
|
|
bf9ec15912 |
@@ -1,100 +1,100 @@
|
||||
name: Build and Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
push:
|
||||
tags:
|
||||
- 'v*.*.*'
|
||||
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
jobs:
|
||||
build:
|
||||
if: github.server_url == 'https://github.com'
|
||||
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin
|
||||
arch: x64
|
||||
- os: macos-latest
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
arch: x64
|
||||
- os: windows-latest
|
||||
platform: win32
|
||||
arch: x64
|
||||
build:
|
||||
if: github.server_url == 'https://github.com'
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
include:
|
||||
- os: macos-latest
|
||||
platform: darwin
|
||||
arch: x64
|
||||
- os: macos-latest
|
||||
platform: darwin
|
||||
arch: arm64
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
arch: x64
|
||||
- os: windows-latest
|
||||
platform: win32
|
||||
arch: x64
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 9
|
||||
|
||||
- name: Build for ${{ matrix.platform }}-${{ matrix.arch }}
|
||||
run: pnpm run make -- --arch=${{ matrix.arch }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
out/make/**/*.dmg
|
||||
out/make/**/*.zip
|
||||
out/make/**/*.exe
|
||||
out/make/**/*.AppImage
|
||||
out/make/**/*.deb
|
||||
out/make/**/*.rpm
|
||||
if-no-files-found: ignore
|
||||
- name: Build for ${{ matrix.platform }}-${{ matrix.arch }}
|
||||
run: pnpm run make -- --arch=${{ matrix.arch }}
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.server_url == 'https://github.com'
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: release-${{ matrix.platform }}-${{ matrix.arch }}
|
||||
path: |
|
||||
out/make/**/*.dmg
|
||||
out/make/**/*.zip
|
||||
out/make/**/*.exe
|
||||
out/make/**/*.AppImage
|
||||
out/make/**/*.deb
|
||||
out/make/**/*.rpm
|
||||
if-no-files-found: ignore
|
||||
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
release:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
if: github.server_url == 'https://github.com'
|
||||
|
||||
- name: List artifacts
|
||||
run: find artifacts -type f | head -50
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*.dmg
|
||||
artifacts/**/*.zip
|
||||
artifacts/**/*.exe
|
||||
artifacts/**/*.AppImage
|
||||
artifacts/**/*.deb
|
||||
artifacts/**/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
steps:
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: release-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: List artifacts
|
||||
run: find artifacts -type f | head -50
|
||||
|
||||
- name: Create Draft Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
draft: true
|
||||
generate_release_notes: true
|
||||
files: |
|
||||
artifacts/**/*.dmg
|
||||
artifacts/**/*.zip
|
||||
artifacts/**/*.exe
|
||||
artifacts/**/*.AppImage
|
||||
artifacts/**/*.deb
|
||||
artifacts/**/*.rpm
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
src/assets
|
||||
node_modules
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"semi": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 150,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"bracketSpacing": true,
|
||||
"arrowParens": "avoid",
|
||||
"endOfLine": "lf"
|
||||
}
|
||||
@@ -1,10 +1,5 @@
|
||||
<img width="1299" height="424" alt="cd (1)" src="https://github.com/user-attachments/assets/b25fff4d-043d-4f38-9985-f832ae0d0f6e" />
|
||||
|
||||
## Recall.ai - API for desktop recording
|
||||
|
||||
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.
|
||||
|
||||
This project is sponsored by Recall.ai.
|
||||
<!-- <img width="1299" height="424" alt="cd (1)" src="https://github.com/user-attachments/assets/b25fff4d-043d-4f38-9985-f832ae0d0f6e" /> -->
|
||||
# Mastermind
|
||||
|
||||
---
|
||||
|
||||
@@ -14,33 +9,36 @@ This project is sponsored by Recall.ai.
|
||||
> [!NOTE]
|
||||
> 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.
|
||||
A real-time AI assistant that provides contextual help during video calls, interviews, presentations, and meetings using screen capture and audio analysis. It is fork of [Cheating Daddy](https://github.com/sohzm/cheating-daddy) project.
|
||||
|
||||
## Features
|
||||
|
||||
- **Live AI Assistance**: Real-time help powered by Google Gemini 2.0 Flash Live
|
||||
- **Live AI Assistance**: Real-time help powered by Gemini API / OpenAI SDK / OpenAI Realtime API, so you can choose which one you want to use
|
||||
- **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
|
||||
- **Transparent Overlay**: Always-on-top window that can be positioned anywhere, if something goes wrong you can hide it without stoping session and losing context!
|
||||
- **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
|
||||
|
||||
1. **Get a Gemini API Key**: Visit [Google AI Studio](https://aistudio.google.com/apikey)
|
||||
2. **Install Dependencies**: `npm install`
|
||||
3. **Run the App**: `npm start`
|
||||
1. **Get a API Key**: Visit [Google AI Studio](https://aistudio.google.com/apikey) or [OpenAI](https://platform.openai.com/docs/api-reference) or any other OpenAI-compatible API!
|
||||
2. **Install Dependencies**: `pnpm install`
|
||||
3. **Run the App**: `pnpm start`
|
||||
|
||||
## Usage
|
||||
|
||||
1. Enter your Gemini API key in the main window
|
||||
1. Enter your API key in the main window, select provider and model you want to use in preferences
|
||||
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
|
||||
3. Click "Start Session" to begin, if you want to use push-to-talk mode, you can enable it in preferences
|
||||
4. Position the window using keyboard shortcuts, or use your mouse to move it
|
||||
5. The AI will provide real-time assistance based on your screen and system audio/microphone input, you can also send text messages to AI by pressing Enter
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
> [!NOTE]
|
||||
> All keyboard shortcuts are customizable in settings. You can check some default shortcuts below.
|
||||
|
||||
- **Window Movement**: `Ctrl/Cmd + Arrow Keys` - Move window
|
||||
- **Click-through**: `Ctrl/Cmd + M` - Toggle mouse events
|
||||
- **Close/Back**: `Ctrl/Cmd + \` - Close window or go back
|
||||
@@ -48,13 +46,13 @@ A real-time AI assistant that provides contextual help during video calls, inter
|
||||
|
||||
## Audio Capture
|
||||
|
||||
- **macOS**: [SystemAudioDump](https://github.com/Mohammed-Yasin-Mulla/Sound) for system audio
|
||||
- **Windows**: Loopback audio capture
|
||||
- **macOS**: [SystemAudioDump](https://github.com/Mohammed-Yasin-Mulla/Sound) for system audio capture, you can use microphone input as well
|
||||
- **Windows**: Loopback audio capture, you can use microphone input as well
|
||||
- **Linux**: Microphone input
|
||||
|
||||
## Requirements
|
||||
|
||||
- Electron-compatible OS (macOS, Windows, Linux)
|
||||
- Gemini API key
|
||||
- AI Provider API key
|
||||
- Screen recording permissions
|
||||
- Microphone/audio permissions
|
||||
|
||||
@@ -10,8 +10,6 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
|
||||
+13
-32
@@ -1,45 +1,26 @@
|
||||
const { FusesPlugin } = require('@electron-forge/plugin-fuses');
|
||||
const { FuseV1Options, FuseVersion } = require('@electron/fuses');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
module.exports = {
|
||||
packagerConfig: {
|
||||
asar: true,
|
||||
asar: {
|
||||
unpack: '**/{onnxruntime-node,onnxruntime-common,@huggingface/transformers,sharp,@img}/**',
|
||||
},
|
||||
extraResource: ['./src/assets/SystemAudioDump'],
|
||||
name: 'Cheating Daddy',
|
||||
icon: 'src/assets/logo',
|
||||
// Fix executable permissions after packaging
|
||||
afterCopy: [
|
||||
(buildPath, electronVersion, platform, arch, callback) => {
|
||||
if (platform === 'darwin') {
|
||||
const systemAudioDump = path.join(buildPath, '..', 'Resources', 'SystemAudioDump');
|
||||
if (fs.existsSync(systemAudioDump)) {
|
||||
try {
|
||||
fs.chmodSync(systemAudioDump, 0o755);
|
||||
console.log('✓ Set executable permissions for SystemAudioDump');
|
||||
} catch (err) {
|
||||
console.error('✗ Failed to set permissions:', err.message);
|
||||
}
|
||||
} else {
|
||||
console.warn('SystemAudioDump not found at:', systemAudioDump);
|
||||
}
|
||||
}
|
||||
callback();
|
||||
},
|
||||
],
|
||||
// use `security find-identity -v -p codesigning` to find your identity
|
||||
// for macos signing
|
||||
// Use ad-hoc signing with entitlements for local development
|
||||
osxSign: {
|
||||
identity: '-', // ad-hoc signing (no Apple Developer account needed)
|
||||
optionsForFile: (filePath) => {
|
||||
return {
|
||||
entitlements: 'entitlements.plist',
|
||||
};
|
||||
},
|
||||
},
|
||||
// notarize is off - requires Apple Developer account
|
||||
// also fuck apple
|
||||
// osxSign: {
|
||||
// identity: '<paste your identity here>',
|
||||
// optionsForFile: (filePath) => {
|
||||
// return {
|
||||
// entitlements: 'entitlements.plist',
|
||||
// };
|
||||
// },
|
||||
// },
|
||||
// notarize if off cuz i ran this for 6 hours and it still didnt finish
|
||||
// osxNotarize: {
|
||||
// appleId: 'your apple id',
|
||||
// appleIdPassword: 'app specific password',
|
||||
|
||||
+22
-16
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "cheating-daddy",
|
||||
"productName": "cheating-daddy",
|
||||
"version": "0.5.2",
|
||||
"version": "0.7.0",
|
||||
"description": "cheating daddy",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
@@ -24,23 +24,29 @@
|
||||
},
|
||||
"license": "GPL-3.0",
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.35.0",
|
||||
"@google/genai": "^1.41.0",
|
||||
"@huggingface/transformers": "^3.8.1",
|
||||
"electron-squirrel-startup": "^1.0.1",
|
||||
"openai": "^6.16.0",
|
||||
"ws": "^8.18.0"
|
||||
"ollama": "^0.6.3",
|
||||
"p-retry": "^4.6.2",
|
||||
"ws": "^8.19.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@electron-forge/cli": "^7.11.1",
|
||||
"@electron-forge/maker-deb": "^7.11.1",
|
||||
"@electron-forge/maker-dmg": "^7.11.1",
|
||||
"@electron-forge/maker-rpm": "^7.11.1",
|
||||
"@electron-forge/maker-squirrel": "^7.11.1",
|
||||
"@electron-forge/maker-zip": "^7.11.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.11.1",
|
||||
"@electron-forge/plugin-fuses": "^7.11.1",
|
||||
"@electron/fuses": "^2.0.0",
|
||||
"@electron/osx-sign": "^2.3.0",
|
||||
"@reforged/maker-appimage": "^5.1.1",
|
||||
"electron": "^39.2.7"
|
||||
"@electron-forge/cli": "^7.8.1",
|
||||
"@electron-forge/maker-deb": "^7.8.1",
|
||||
"@electron-forge/maker-dmg": "^7.8.1",
|
||||
"@electron-forge/maker-rpm": "^7.8.1",
|
||||
"@electron-forge/maker-squirrel": "^7.8.1",
|
||||
"@electron-forge/maker-zip": "^7.8.1",
|
||||
"@electron-forge/plugin-auto-unpack-natives": "^7.8.1",
|
||||
"@electron-forge/plugin-fuses": "^7.8.1",
|
||||
"@electron/fuses": "^1.8.0",
|
||||
"@reforged/maker-appimage": "^5.0.0",
|
||||
"electron": "^30.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"p-retry": "4.6.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+660
-196
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,8 @@
|
||||
onlyBuiltDependencies:
|
||||
- electron
|
||||
- electron-winstaller
|
||||
- fs-xattr
|
||||
- macos-alias
|
||||
- onnxruntime-node
|
||||
- protobufjs
|
||||
- sharp
|
||||
@@ -3,7 +3,7 @@ import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||
export class AppHeader extends LitElement {
|
||||
static styles = css`
|
||||
* {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
@@ -14,14 +14,14 @@ export class AppHeader extends LitElement {
|
||||
align-items: center;
|
||||
padding: var(--header-padding);
|
||||
background: var(--header-background);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.header-title {
|
||||
flex: 1;
|
||||
font-size: var(--header-font-size);
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
color: var(--text-primary);
|
||||
-webkit-app-region: drag;
|
||||
}
|
||||
|
||||
@@ -39,8 +39,8 @@ export class AppHeader extends LitElement {
|
||||
|
||||
.button {
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--header-button-padding);
|
||||
border-radius: 3px;
|
||||
font-size: var(--header-font-size-small);
|
||||
@@ -73,7 +73,7 @@ export class AppHeader extends LitElement {
|
||||
|
||||
.icon-button:hover {
|
||||
background: var(--hover-background);
|
||||
color: var(--text-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
:host([isclickthrough]) .button:hover,
|
||||
@@ -86,7 +86,7 @@ export class AppHeader extends LitElement {
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 11px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.click-through-indicator {
|
||||
@@ -95,7 +95,7 @@ export class AppHeader extends LitElement {
|
||||
background: var(--key-background);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.update-button {
|
||||
@@ -120,148 +120,6 @@ export class AppHeader extends LitElement {
|
||||
.update-button:hover {
|
||||
background: rgba(241, 76, 76, 0.1);
|
||||
}
|
||||
|
||||
.status-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: var(--header-font-size-small);
|
||||
color: var(--text-secondary);
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.status-text.error {
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
.status-tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: var(--tooltip-bg, #1a1a1a);
|
||||
color: var(--tooltip-text, #ffffff);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
max-width: 300px;
|
||||
word-wrap: break-word;
|
||||
white-space: normal;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.status-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 16px;
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: var(--tooltip-bg, #1a1a1a);
|
||||
}
|
||||
|
||||
.status-wrapper:hover .status-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.status-tooltip .tooltip-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.6;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.status-tooltip .tooltip-content {
|
||||
color: #f14c4c;
|
||||
}
|
||||
|
||||
.model-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.model-badge {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
background: var(--key-background);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
max-width: 100px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.model-badge-wrapper {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.model-badge-wrapper .model-tooltip {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
margin-top: 8px;
|
||||
background: var(--tooltip-bg, #1a1a1a);
|
||||
color: var(--tooltip-text, #ffffff);
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition: opacity 0.15s ease, visibility 0.15s ease;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.model-badge-wrapper .model-tooltip::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 16px;
|
||||
border: 6px solid transparent;
|
||||
border-bottom-color: var(--tooltip-bg, #1a1a1a);
|
||||
}
|
||||
|
||||
.model-badge-wrapper:hover .model-tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.model-tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.model-tooltip-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.model-tooltip-label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.model-tooltip-value {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
`;
|
||||
|
||||
static properties = {
|
||||
@@ -276,8 +134,6 @@ export class AppHeader extends LitElement {
|
||||
onHideToggleClick: { type: Function },
|
||||
isClickThrough: { type: Boolean, reflect: true },
|
||||
updateAvailable: { type: Boolean },
|
||||
aiProvider: { type: String },
|
||||
modelInfo: { type: Object },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -294,8 +150,6 @@ export class AppHeader extends LitElement {
|
||||
this.isClickThrough = false;
|
||||
this.updateAvailable = false;
|
||||
this._timerInterval = null;
|
||||
this.aiProvider = 'gemini';
|
||||
this.modelInfo = { model: '', visionModel: '', whisperModel: '' };
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
@@ -417,49 +271,8 @@ export class AppHeader extends LitElement {
|
||||
return navigationViews.includes(this.currentView);
|
||||
}
|
||||
|
||||
getProviderDisplayName() {
|
||||
const names = {
|
||||
'gemini': 'Gemini',
|
||||
'openai-realtime': 'OpenAI Realtime',
|
||||
'openai-sdk': 'OpenAI SDK'
|
||||
};
|
||||
return names[this.aiProvider] || this.aiProvider;
|
||||
}
|
||||
|
||||
renderModelInfo() {
|
||||
// Only show model info for OpenAI SDK provider
|
||||
if (this.aiProvider !== 'openai-sdk' || !this.modelInfo) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const { model, visionModel, whisperModel } = this.modelInfo;
|
||||
|
||||
// Show a compact badge with tooltip for model details
|
||||
return html`
|
||||
<div class="model-badge-wrapper">
|
||||
<span class="model-badge" title="Models">${model || 'gpt-4o'}</span>
|
||||
<div class="model-tooltip">
|
||||
<div class="model-tooltip-row">
|
||||
<span class="model-tooltip-label">Text</span>
|
||||
<span class="model-tooltip-value">${model || 'gpt-4o'}</span>
|
||||
</div>
|
||||
<div class="model-tooltip-row">
|
||||
<span class="model-tooltip-label">Vision</span>
|
||||
<span class="model-tooltip-value">${visionModel || 'gpt-4o'}</span>
|
||||
</div>
|
||||
<div class="model-tooltip-row">
|
||||
<span class="model-tooltip-label">Speech</span>
|
||||
<span class="model-tooltip-value">${whisperModel || 'whisper-1'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const elapsedTime = this.getElapsedTime();
|
||||
const isError = this.statusText && (this.statusText.toLowerCase().includes('error') || this.statusText.toLowerCase().includes('failed'));
|
||||
const shortStatus = isError ? 'Error' : this.statusText;
|
||||
|
||||
return html`
|
||||
<div class="header">
|
||||
@@ -467,17 +280,8 @@ export class AppHeader extends LitElement {
|
||||
<div class="header-actions">
|
||||
${this.currentView === 'assistant'
|
||||
? html`
|
||||
${this.renderModelInfo()}
|
||||
<span>${elapsedTime}</span>
|
||||
<div class="status-wrapper">
|
||||
<span class="status-text ${isError ? 'error' : ''}">${shortStatus}</span>
|
||||
${isError ? html`
|
||||
<div class="status-tooltip">
|
||||
<div class="tooltip-label">Error Details</div>
|
||||
<div class="tooltip-content">${this.statusText}</div>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
<span>${this.statusText}</span>
|
||||
${this.isClickThrough ? html`<span class="click-through-indicator">click-through</span>` : ''}
|
||||
`
|
||||
: ''}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,143 @@
|
||||
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 = 'byok';
|
||||
this._loadFromStorage();
|
||||
}
|
||||
|
||||
async _loadFromStorage() {
|
||||
try {
|
||||
const prefs = await cheatingDaddy.storage.getPreferences();
|
||||
this._context = prefs.customPrompt || '';
|
||||
this._providerMode = prefs.providerMode || 'byok';
|
||||
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 (Ollama)</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);
|
||||
@@ -9,20 +9,23 @@ export class AssistantView extends LitElement {
|
||||
}
|
||||
|
||||
* {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* ── Response area ── */
|
||||
|
||||
.response-container {
|
||||
height: calc(100% - 50px);
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
font-size: var(--response-font-size, 16px);
|
||||
line-height: 1.6;
|
||||
background: var(--bg-primary);
|
||||
padding: 12px;
|
||||
font-size: var(--response-font-size, 15px);
|
||||
line-height: var(--line-height);
|
||||
background: var(--bg-app);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
scroll-behavior: smooth;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.response-container * {
|
||||
@@ -34,12 +37,12 @@ export class AssistantView extends LitElement {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Word display (no animation) */
|
||||
.response-container [data-word] {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
/* Markdown styling */
|
||||
/* ── Markdown ── */
|
||||
|
||||
.response-container h1,
|
||||
.response-container h2,
|
||||
.response-container h3,
|
||||
@@ -47,27 +50,27 @@ export class AssistantView extends LitElement {
|
||||
.response-container h5,
|
||||
.response-container h6 {
|
||||
margin: 1em 0 0.5em 0;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.response-container h1 { font-size: 1.6em; }
|
||||
.response-container h2 { font-size: 1.4em; }
|
||||
.response-container h3 { font-size: 1.2em; }
|
||||
.response-container h4 { font-size: 1.1em; }
|
||||
.response-container h5 { font-size: 1em; }
|
||||
.response-container h6 { font-size: 0.9em; }
|
||||
.response-container h1 { font-size: 1.5em; }
|
||||
.response-container h2 { font-size: 1.3em; }
|
||||
.response-container h3 { font-size: 1.15em; }
|
||||
.response-container h4 { font-size: 1.05em; }
|
||||
.response-container h5,
|
||||
.response-container h6 { font-size: 1em; }
|
||||
|
||||
.response-container p {
|
||||
margin: 0.6em 0;
|
||||
color: var(--text-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.response-container ul,
|
||||
.response-container ol {
|
||||
margin: 0.6em 0;
|
||||
padding-left: 1.5em;
|
||||
color: var(--text-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.response-container li {
|
||||
@@ -77,23 +80,24 @@ export class AssistantView extends LitElement {
|
||||
.response-container blockquote {
|
||||
margin: 0.8em 0;
|
||||
padding: 0.5em 1em;
|
||||
border-left: 2px solid var(--border-default);
|
||||
background: var(--bg-secondary);
|
||||
border-left: 2px solid var(--border-strong);
|
||||
background: var(--bg-surface);
|
||||
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
||||
}
|
||||
|
||||
.response-container code {
|
||||
background: var(--bg-tertiary);
|
||||
background: var(--bg-elevated);
|
||||
padding: 0.15em 0.4em;
|
||||
border-radius: 3px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
border-radius: var(--radius-sm);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.response-container pre {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
padding: 12px;
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-md);
|
||||
overflow-x: auto;
|
||||
margin: 0.8em 0;
|
||||
}
|
||||
@@ -104,19 +108,19 @@ export class AssistantView extends LitElement {
|
||||
}
|
||||
|
||||
.response-container a {
|
||||
color: var(--text-color);
|
||||
color: var(--accent);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.response-container strong,
|
||||
.response-container b {
|
||||
font-weight: 600;
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.response-container hr {
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color);
|
||||
border-top: 1px solid var(--border);
|
||||
margin: 1.5em 0;
|
||||
}
|
||||
|
||||
@@ -128,18 +132,18 @@ export class AssistantView extends LitElement {
|
||||
|
||||
.response-container th,
|
||||
.response-container td {
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: var(--space-sm);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.response-container th {
|
||||
background: var(--bg-secondary);
|
||||
font-weight: 600;
|
||||
background: var(--bg-surface);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
|
||||
.response-container::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.response-container::-webkit-scrollbar-track {
|
||||
@@ -147,206 +151,152 @@ export class AssistantView extends LitElement {
|
||||
}
|
||||
|
||||
.response-container::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: 4px;
|
||||
background: var(--border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.response-container::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
background: #444444;
|
||||
}
|
||||
|
||||
.text-input-container {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
/* ── Response navigation strip ── */
|
||||
|
||||
.text-input-container input {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
color: var(--text-color);
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
padding: 8px 4px;
|
||||
border-radius: 0;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.text-input-container input:focus {
|
||||
outline: none;
|
||||
border-bottom-color: var(--text-color);
|
||||
}
|
||||
|
||||
.text-input-container input::placeholder {
|
||||
color: var(--placeholder-color);
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: none;
|
||||
padding: 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
.response-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all 0.1s ease;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border-top: 1px solid var(--border);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: var(--hover-background);
|
||||
color: var(--text-color);
|
||||
.nav-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
padding: var(--space-xs);
|
||||
border-radius: var(--radius-sm);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color var(--transition);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
opacity: 0.3;
|
||||
.nav-btn:hover:not(:disabled) {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.nav-button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
stroke: currentColor;
|
||||
.nav-btn:disabled {
|
||||
opacity: 0.25;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.nav-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.response-counter {
|
||||
font-size: 11px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
white-space: nowrap;
|
||||
min-width: 50px;
|
||||
font-family: var(--font-mono);
|
||||
min-width: 40px;
|
||||
text-align: center;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
.screen-answer-btn {
|
||||
/* ── Bottom input bar ── */
|
||||
|
||||
.input-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
background: var(--btn-primary-bg, #ffffff);
|
||||
color: var(--btn-primary-text, #000000);
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-md);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.input-bar-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 100px;
|
||||
padding: 0 var(--space-md);
|
||||
height: 32px;
|
||||
transition: border-color var(--transition);
|
||||
}
|
||||
|
||||
.input-bar-inner:focus-within {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input-bar-inner input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
color: var(--text-primary);
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font);
|
||||
height: 100%;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.input-bar-inner input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.analyze-btn {
|
||||
position: relative;
|
||||
background: var(--bg-elevated);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-mono);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.screen-answer-btn:hover {
|
||||
background: var(--btn-primary-hover, #f0f0f0);
|
||||
}
|
||||
|
||||
.screen-answer-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
padding: var(--space-xs) var(--space-md);
|
||||
border-radius: 100px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: border-color 0.4s ease, background var(--transition);
|
||||
flex-shrink: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.screen-answer-btn .usage-count {
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
.analyze-btn:hover:not(.analyzing) {
|
||||
border-color: var(--accent);
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.screen-answer-btn-wrapper {
|
||||
.analyze-btn.analyzing {
|
||||
cursor: default;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.analyze-btn-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: opacity 0.4s ease;
|
||||
z-index: 1;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.screen-answer-btn-wrapper .tooltip {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 8px;
|
||||
background: var(--tooltip-bg, #1a1a1a);
|
||||
color: var(--tooltip-text, #ffffff);
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
.analyze-btn.analyzing .analyze-btn-content {
|
||||
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: 100;
|
||||
}
|
||||
|
||||
.screen-answer-btn-wrapper .tooltip::after {
|
||||
content: '';
|
||||
.analyze-canvas {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 16px;
|
||||
border: 6px solid transparent;
|
||||
border-top-color: var(--tooltip-bg, #1a1a1a);
|
||||
}
|
||||
|
||||
.screen-answer-btn-wrapper:hover .tooltip {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.tooltip-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.tooltip-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.tooltip-label {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.tooltip-value {
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
}
|
||||
|
||||
.tooltip-note {
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 1px solid rgba(255,255,255,0.1);
|
||||
opacity: 0.5;
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.capture-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.region-select-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 6px 10px;
|
||||
border-radius: 20px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.region-select-btn:hover {
|
||||
background: var(--hover-background);
|
||||
color: var(--text-color);
|
||||
border-color: var(--text-color);
|
||||
}
|
||||
|
||||
.region-select-btn svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.region-select-btn span {
|
||||
margin-left: 4px;
|
||||
inset: -1px;
|
||||
width: calc(100% + 2px);
|
||||
height: calc(100% + 2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -356,9 +306,7 @@ export class AssistantView extends LitElement {
|
||||
selectedProfile: { type: String },
|
||||
onSendText: { type: Function },
|
||||
shouldAnimateResponse: { type: Boolean },
|
||||
flashCount: { type: Number },
|
||||
flashLiteCount: { type: Number },
|
||||
aiProvider: { type: String },
|
||||
isAnalyzing: { type: Boolean, state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -367,9 +315,8 @@ export class AssistantView extends LitElement {
|
||||
this.currentResponseIndex = -1;
|
||||
this.selectedProfile = 'interview';
|
||||
this.onSendText = () => {};
|
||||
this.flashCount = 0;
|
||||
this.flashLiteCount = 0;
|
||||
this.aiProvider = 'gemini';
|
||||
this.isAnalyzing = false;
|
||||
this._animFrame = null;
|
||||
}
|
||||
|
||||
getProfileNames() {
|
||||
@@ -387,29 +334,26 @@ export class AssistantView extends LitElement {
|
||||
const profileNames = this.getProfileNames();
|
||||
return this.responses.length > 0 && this.currentResponseIndex >= 0
|
||||
? this.responses[this.currentResponseIndex]
|
||||
: `Hey, Im listening to your ${profileNames[this.selectedProfile] || 'session'}?`;
|
||||
: `Listening to your ${profileNames[this.selectedProfile] || 'session'}...`;
|
||||
}
|
||||
|
||||
renderMarkdown(content) {
|
||||
// Check if marked is available
|
||||
if (typeof window !== 'undefined' && window.marked) {
|
||||
try {
|
||||
// Configure marked for better security and formatting
|
||||
window.marked.setOptions({
|
||||
breaks: true,
|
||||
gfm: true,
|
||||
sanitize: false, // We trust the AI responses
|
||||
sanitize: false,
|
||||
});
|
||||
let rendered = window.marked.parse(content);
|
||||
rendered = this.wrapWordsInSpans(rendered);
|
||||
return rendered;
|
||||
} catch (error) {
|
||||
console.warn('Error parsing markdown:', error);
|
||||
return content; // Fallback to plain text
|
||||
return content;
|
||||
}
|
||||
}
|
||||
console.log('Marked not available, using plain text');
|
||||
return content; // Fallback if marked is not available
|
||||
return content;
|
||||
}
|
||||
|
||||
wrapWordsInSpans(html) {
|
||||
@@ -440,10 +384,6 @@ export class AssistantView extends LitElement {
|
||||
return doc.body.innerHTML;
|
||||
}
|
||||
|
||||
getResponseCounter() {
|
||||
return this.responses.length > 0 ? `${this.currentResponseIndex + 1}/${this.responses.length}` : '';
|
||||
}
|
||||
|
||||
navigateToPreviousResponse() {
|
||||
if (this.currentResponseIndex > 0) {
|
||||
this.currentResponseIndex--;
|
||||
@@ -471,7 +411,7 @@ export class AssistantView extends LitElement {
|
||||
scrollResponseUp() {
|
||||
const container = this.shadowRoot.querySelector('.response-container');
|
||||
if (container) {
|
||||
const scrollAmount = container.clientHeight * 0.3; // Scroll 30% of container height
|
||||
const scrollAmount = container.clientHeight * 0.3;
|
||||
container.scrollTop = Math.max(0, container.scrollTop - scrollAmount);
|
||||
}
|
||||
}
|
||||
@@ -479,7 +419,7 @@ export class AssistantView extends LitElement {
|
||||
scrollResponseDown() {
|
||||
const container = this.shadowRoot.querySelector('.response-container');
|
||||
if (container) {
|
||||
const scrollAmount = container.clientHeight * 0.3; // Scroll 30% of container height
|
||||
const scrollAmount = container.clientHeight * 0.3;
|
||||
container.scrollTop = Math.min(container.scrollHeight - container.clientHeight, container.scrollTop + scrollAmount);
|
||||
}
|
||||
}
|
||||
@@ -487,32 +427,13 @@ export class AssistantView extends LitElement {
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
// Load limits on mount
|
||||
this.loadLimits();
|
||||
|
||||
// Set up IPC listeners for keyboard shortcuts
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
|
||||
this.handlePreviousResponse = () => {
|
||||
console.log('Received navigate-previous-response message');
|
||||
this.navigateToPreviousResponse();
|
||||
};
|
||||
|
||||
this.handleNextResponse = () => {
|
||||
console.log('Received navigate-next-response message');
|
||||
this.navigateToNextResponse();
|
||||
};
|
||||
|
||||
this.handleScrollUp = () => {
|
||||
console.log('Received scroll-response-up message');
|
||||
this.scrollResponseUp();
|
||||
};
|
||||
|
||||
this.handleScrollDown = () => {
|
||||
console.log('Received scroll-response-down message');
|
||||
this.scrollResponseDown();
|
||||
};
|
||||
this.handlePreviousResponse = () => this.navigateToPreviousResponse();
|
||||
this.handleNextResponse = () => this.navigateToNextResponse();
|
||||
this.handleScrollUp = () => this.scrollResponseUp();
|
||||
this.handleScrollDown = () => this.scrollResponseDown();
|
||||
|
||||
ipcRenderer.on('navigate-previous-response', this.handlePreviousResponse);
|
||||
ipcRenderer.on('navigate-next-response', this.handleNextResponse);
|
||||
@@ -523,22 +444,14 @@ export class AssistantView extends LitElement {
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._stopWaveformAnimation();
|
||||
|
||||
// Clean up IPC listeners
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
if (this.handlePreviousResponse) {
|
||||
ipcRenderer.removeListener('navigate-previous-response', this.handlePreviousResponse);
|
||||
}
|
||||
if (this.handleNextResponse) {
|
||||
ipcRenderer.removeListener('navigate-next-response', this.handleNextResponse);
|
||||
}
|
||||
if (this.handleScrollUp) {
|
||||
ipcRenderer.removeListener('scroll-response-up', this.handleScrollUp);
|
||||
}
|
||||
if (this.handleScrollDown) {
|
||||
ipcRenderer.removeListener('scroll-response-down', this.handleScrollDown);
|
||||
}
|
||||
if (this.handlePreviousResponse) ipcRenderer.removeListener('navigate-previous-response', this.handlePreviousResponse);
|
||||
if (this.handleNextResponse) ipcRenderer.removeListener('navigate-next-response', this.handleNextResponse);
|
||||
if (this.handleScrollUp) ipcRenderer.removeListener('scroll-response-up', this.handleScrollUp);
|
||||
if (this.handleScrollDown) ipcRenderer.removeListener('scroll-response-down', this.handleScrollDown);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -546,7 +459,7 @@ export class AssistantView extends LitElement {
|
||||
const textInput = this.shadowRoot.querySelector('#textInput');
|
||||
if (textInput && textInput.value.trim()) {
|
||||
const message = textInput.value.trim();
|
||||
textInput.value = ''; // Clear input
|
||||
textInput.value = '';
|
||||
await this.onSendText(message);
|
||||
}
|
||||
}
|
||||
@@ -558,35 +471,149 @@ export class AssistantView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
async loadLimits() {
|
||||
if (window.cheatingDaddy?.storage?.getTodayLimits) {
|
||||
const limits = await window.cheatingDaddy.storage.getTodayLimits();
|
||||
this.flashCount = limits.flash?.count || 0;
|
||||
this.flashLiteCount = limits.flashLite?.count || 0;
|
||||
}
|
||||
}
|
||||
|
||||
getTotalUsed() {
|
||||
return this.flashCount + this.flashLiteCount;
|
||||
}
|
||||
|
||||
getTotalAvailable() {
|
||||
return 40; // 20 flash + 20 flash-lite
|
||||
}
|
||||
|
||||
async handleScreenAnswer() {
|
||||
if (this.isAnalyzing) return;
|
||||
if (window.captureManualScreenshot) {
|
||||
this.isAnalyzing = true;
|
||||
this._responseCountWhenStarted = this.responses.length;
|
||||
window.captureManualScreenshot();
|
||||
// Reload limits after a short delay to catch the update
|
||||
setTimeout(() => this.loadLimits(), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
handleRegionSelect() {
|
||||
if (window.startRegionSelection) {
|
||||
window.startRegionSelection();
|
||||
// Reload limits after a short delay to catch the update
|
||||
setTimeout(() => this.loadLimits(), 1000);
|
||||
_startWaveformAnimation() {
|
||||
const canvas = this.shadowRoot.querySelector('.analyze-canvas');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
canvas.width = rect.width * dpr;
|
||||
canvas.height = rect.height * dpr;
|
||||
ctx.scale(dpr, dpr);
|
||||
|
||||
const dangerColor = getComputedStyle(this).getPropertyValue('--danger').trim() || '#EF4444';
|
||||
const startTime = performance.now();
|
||||
const FADE_IN = 0.5; // seconds
|
||||
const PARTICLE_SPREAD = 4; // px inward from border
|
||||
const PARTICLE_COUNT = 250;
|
||||
|
||||
// Pill perimeter helpers
|
||||
const w = rect.width;
|
||||
const h = rect.height;
|
||||
const r = h / 2; // pill radius = half height
|
||||
const straightLen = w - 2 * r;
|
||||
const arcLen = Math.PI * r;
|
||||
const perimeter = 2 * straightLen + 2 * arcLen;
|
||||
|
||||
// Given a distance along the perimeter, return {x, y, nx, ny} (position + inward normal)
|
||||
const pointOnPerimeter = (d) => {
|
||||
d = ((d % perimeter) + perimeter) % perimeter;
|
||||
// Top straight: left to right
|
||||
if (d < straightLen) {
|
||||
return { x: r + d, y: 0, nx: 0, ny: 1 };
|
||||
}
|
||||
d -= straightLen;
|
||||
// Right arc
|
||||
if (d < arcLen) {
|
||||
const angle = -Math.PI / 2 + (d / arcLen) * Math.PI;
|
||||
return {
|
||||
x: w - r + Math.cos(angle) * r,
|
||||
y: r + Math.sin(angle) * r,
|
||||
nx: -Math.cos(angle),
|
||||
ny: -Math.sin(angle),
|
||||
};
|
||||
}
|
||||
d -= arcLen;
|
||||
// Bottom straight: right to left
|
||||
if (d < straightLen) {
|
||||
return { x: w - r - d, y: h, nx: 0, ny: -1 };
|
||||
}
|
||||
d -= straightLen;
|
||||
// Left arc
|
||||
const angle = Math.PI / 2 + (d / arcLen) * Math.PI;
|
||||
return {
|
||||
x: r + Math.cos(angle) * r,
|
||||
y: r + Math.sin(angle) * r,
|
||||
nx: -Math.cos(angle),
|
||||
ny: -Math.sin(angle),
|
||||
};
|
||||
};
|
||||
|
||||
// Pre-seed random offsets for stable particles
|
||||
const seeds = [];
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
seeds.push({ pos: Math.random(), drift: Math.random(), depthSeed: Math.random() });
|
||||
}
|
||||
|
||||
const draw = (now) => {
|
||||
const elapsed = (now - startTime) / 1000;
|
||||
const fade = Math.min(1, elapsed / FADE_IN);
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// ── Particle border ──
|
||||
ctx.fillStyle = dangerColor;
|
||||
for (let i = 0; i < PARTICLE_COUNT; i++) {
|
||||
const s = seeds[i];
|
||||
const along = (s.pos + s.drift * elapsed * 0.03) * perimeter;
|
||||
const depth = s.depthSeed * PARTICLE_SPREAD;
|
||||
const density = 1 - depth / PARTICLE_SPREAD;
|
||||
|
||||
if (Math.random() > density) continue;
|
||||
|
||||
const p = pointOnPerimeter(along);
|
||||
const px = p.x + p.nx * depth;
|
||||
const py = p.y + p.ny * depth;
|
||||
const size = 0.8 + density * 0.6;
|
||||
|
||||
ctx.globalAlpha = fade * density * 0.85;
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// ── Waveform ──
|
||||
const midY = h / 2;
|
||||
const waves = [
|
||||
{ freq: 3, amp: 0.35, speed: 2.5, opacity: 0.9, width: 1.8 },
|
||||
{ freq: 5, amp: 0.2, speed: 3.5, opacity: 0.5, width: 1.2 },
|
||||
{ freq: 7, amp: 0.12, speed: 5, opacity: 0.3, width: 0.8 },
|
||||
];
|
||||
|
||||
for (const wave of waves) {
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = dangerColor;
|
||||
ctx.globalAlpha = wave.opacity * fade;
|
||||
ctx.lineWidth = wave.width;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
for (let x = 0; x <= w; x++) {
|
||||
const norm = x / w;
|
||||
const envelope = Math.sin(norm * Math.PI);
|
||||
const y = midY + Math.sin(norm * Math.PI * 2 * wave.freq + elapsed * wave.speed) * (midY * wave.amp) * envelope;
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
this._animFrame = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
this._animFrame = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
_stopWaveformAnimation() {
|
||||
if (this._animFrame) {
|
||||
cancelAnimationFrame(this._animFrame);
|
||||
this._animFrame = null;
|
||||
}
|
||||
const canvas = this.shadowRoot.querySelector('.analyze-canvas');
|
||||
if (canvas) {
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -609,79 +636,74 @@ export class AssistantView extends LitElement {
|
||||
if (changedProperties.has('responses') || changedProperties.has('currentResponseIndex')) {
|
||||
this.updateResponseContent();
|
||||
}
|
||||
|
||||
if (changedProperties.has('isAnalyzing')) {
|
||||
if (this.isAnalyzing) {
|
||||
this._startWaveformAnimation();
|
||||
} else {
|
||||
this._stopWaveformAnimation();
|
||||
}
|
||||
}
|
||||
|
||||
if (changedProperties.has('responses') && this.isAnalyzing) {
|
||||
if (this.responses.length > this._responseCountWhenStarted) {
|
||||
this.isAnalyzing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateResponseContent() {
|
||||
console.log('updateResponseContent called');
|
||||
const container = this.shadowRoot.querySelector('#responseContainer');
|
||||
if (container) {
|
||||
const currentResponse = this.getCurrentResponse();
|
||||
console.log('Current response:', currentResponse);
|
||||
const renderedResponse = this.renderMarkdown(currentResponse);
|
||||
console.log('Rendered response:', renderedResponse);
|
||||
container.innerHTML = renderedResponse;
|
||||
// Show all words immediately (no animation)
|
||||
if (this.shouldAnimateResponse) {
|
||||
this.dispatchEvent(new CustomEvent('response-animation-complete', { bubbles: true, composed: true }));
|
||||
}
|
||||
} else {
|
||||
console.log('Response container not found');
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const responseCounter = this.getResponseCounter();
|
||||
const hasMultipleResponses = this.responses.length > 1;
|
||||
|
||||
return html`
|
||||
<div class="response-container" id="responseContainer"></div>
|
||||
|
||||
<div class="text-input-container">
|
||||
<button class="nav-button" @click=${this.navigateToPreviousResponse} ?disabled=${this.currentResponseIndex <= 0}>
|
||||
<svg width="24px" height="24px" stroke-width="1.7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
${this.responses.length > 0 ? html`<span class="response-counter">${responseCounter}</span>` : ''}
|
||||
|
||||
<button class="nav-button" @click=${this.navigateToNextResponse} ?disabled=${this.currentResponseIndex >= this.responses.length - 1}>
|
||||
<svg width="24px" height="24px" stroke-width="1.7" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<input type="text" id="textInput" placeholder="Type a message to the AI..." @keydown=${this.handleTextKeydown} />
|
||||
|
||||
<div class="capture-buttons">
|
||||
<button class="region-select-btn" @click=${this.handleRegionSelect} title="Select region to analyze (like Win+Shift+S)">
|
||||
${hasMultipleResponses ? html`
|
||||
<div class="response-nav">
|
||||
<button class="nav-btn" @click=${this.navigateToPreviousResponse} ?disabled=${this.currentResponseIndex <= 0} title="Previous response">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M4.25 2A2.25 2.25 0 0 0 2 4.25v2.5A.75.75 0 0 0 3.5 6.75v-2.5a.75.75 0 0 1 .75-.75h2.5A.75.75 0 0 0 6.75 2h-2.5Zm9.5 0a.75.75 0 0 0 0 1.5h2.5a.75.75 0 0 1 .75.75v2.5a.75.75 0 0 0 1.5 0v-2.5A2.25 2.25 0 0 0 16.25 2h-2.5ZM3.5 13.25a.75.75 0 0 0-1.5 0v2.5A2.25 2.25 0 0 0 4.25 18h2.5a.75.75 0 0 0 0-1.5h-2.5a.75.75 0 0 1-.75-.75v-2.5Zm13.5 0a.75.75 0 0 0 1.5 0v2.5A2.25 2.25 0 0 1 16.25 18h-2.5a.75.75 0 0 1 0-1.5h2.5a.75.75 0 0 0 .75-.75v-2.5Z" clip-rule="evenodd" />
|
||||
<path fill-rule="evenodd" d="M11.78 5.22a.75.75 0 0 1 0 1.06L8.06 10l3.72 3.72a.75.75 0 1 1-1.06 1.06l-4.25-4.25a.75.75 0 0 1 0-1.06l4.25-4.25a.75.75 0 0 1 1.06 0Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
<span class="response-counter">${this.currentResponseIndex + 1} of ${this.responses.length}</span>
|
||||
<button class="nav-btn" @click=${this.navigateToNextResponse} ?disabled=${this.currentResponseIndex >= this.responses.length - 1} title="Next response">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M8.22 5.22a.75.75 0 0 1 1.06 0l4.25 4.25a.75.75 0 0 1 0 1.06l-4.25 4.25a.75.75 0 0 1-1.06-1.06L11.94 10 8.22 6.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>Select region</span>
|
||||
</button>
|
||||
<div class="screen-answer-btn-wrapper">
|
||||
${this.aiProvider === 'gemini' ? html`
|
||||
<div class="tooltip">
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">Flash</span>
|
||||
<span class="tooltip-value">${this.flashCount}/20</span>
|
||||
</div>
|
||||
<div class="tooltip-row">
|
||||
<span class="tooltip-label">Flash Lite</span>
|
||||
<span class="tooltip-value">${this.flashLiteCount}/20</span>
|
||||
</div>
|
||||
<div class="tooltip-note">Resets every 24 hours</div>
|
||||
</div>
|
||||
` : ''}
|
||||
<button class="screen-answer-btn" @click=${this.handleScreenAnswer}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M15.98 1.804a1 1 0 0 0-1.96 0l-.24 1.192a1 1 0 0 1-.784.785l-1.192.238a1 1 0 0 0 0 1.962l1.192.238a1 1 0 0 1 .785.785l.238 1.192a1 1 0 0 0 1.962 0l.238-1.192a1 1 0 0 1 .785-.785l1.192-.238a1 1 0 0 0 0-1.962l-1.192-.238a1 1 0 0 1-.785-.785l-.238-1.192ZM6.949 5.684a1 1 0 0 0-1.898 0l-.683 2.051a1 1 0 0 1-.633.633l-2.051.683a1 1 0 0 0 0 1.898l2.051.684a1 1 0 0 1 .633.632l.683 2.051a1 1 0 0 0 1.898 0l.683-2.051a1 1 0 0 1 .633-.633l2.051-.683a1 1 0 0 0 0-1.898l-2.051-.683a1 1 0 0 1-.633-.633L6.95 5.684ZM13.949 13.684a1 1 0 0 0-1.898 0l-.184.551a1 1 0 0 1-.632.633l-.551.183a1 1 0 0 0 0 1.898l.551.183a1 1 0 0 1 .633.633l.183.551a1 1 0 0 0 1.898 0l.184-.551a1 1 0 0 1 .632-.633l.551-.183a1 1 0 0 0 0-1.898l-.551-.184a1 1 0 0 1-.633-.632l-.183-.551Z" />
|
||||
</svg>
|
||||
<span>Full screen</span>
|
||||
${this.aiProvider === 'gemini' ? html`<span class="usage-count">(${this.getTotalUsed()}/${this.getTotalAvailable()})</span>` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
` : ''}
|
||||
|
||||
<div class="input-bar">
|
||||
<div class="input-bar-inner">
|
||||
<input
|
||||
type="text"
|
||||
id="textInput"
|
||||
placeholder="Type a message..."
|
||||
@keydown=${this.handleTextKeydown}
|
||||
/>
|
||||
</div>
|
||||
<button class="analyze-btn ${this.isAnalyzing ? 'analyzing' : ''}" @click=${this.handleScreenAnswer}>
|
||||
<canvas class="analyze-canvas"></canvas>
|
||||
<span class="analyze-btn-content">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24">
|
||||
<path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 3v7h6l-8 11v-7H5z" />
|
||||
</svg>
|
||||
Analyze Screen
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
+422
-1375
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,237 @@
|
||||
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||
import { unifiedPageStyles } from './sharedPageStyles.js';
|
||||
|
||||
export class FeedbackView extends LitElement {
|
||||
static styles = [
|
||||
unifiedPageStyles,
|
||||
css`
|
||||
.feedback-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.feedback-input {
|
||||
width: 100%;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
font-family: var(--font);
|
||||
}
|
||||
|
||||
.feedback-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.feedback-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
textarea.feedback-input {
|
||||
min-height: 140px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
input.feedback-input {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.feedback-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.feedback-submit {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--accent);
|
||||
color: var(--btn-primary-text, #fff);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feedback-submit:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.feedback-submit:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.feedback-status {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.feedback-status.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.feedback-status.error {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.attach-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.attach-info input[type="checkbox"] {
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
static properties = {
|
||||
_feedbackText: { state: true },
|
||||
_feedbackEmail: { state: true },
|
||||
_feedbackStatus: { state: true },
|
||||
_feedbackSending: { state: true },
|
||||
_attachInfo: { state: true },
|
||||
_version: { state: true },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this._feedbackText = '';
|
||||
this._feedbackEmail = '';
|
||||
this._feedbackStatus = '';
|
||||
this._feedbackSending = false;
|
||||
this._attachInfo = true;
|
||||
this._version = '';
|
||||
this._loadVersion();
|
||||
}
|
||||
|
||||
async _loadVersion() {
|
||||
try {
|
||||
this._version = await cheatingDaddy.getVersion();
|
||||
this.requestUpdate();
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
_getOS() {
|
||||
const p = navigator.platform || '';
|
||||
if (p.includes('Mac')) return 'macOS';
|
||||
if (p.includes('Win')) return 'Windows';
|
||||
if (p.includes('Linux')) return 'Linux';
|
||||
return p;
|
||||
}
|
||||
|
||||
async _submitFeedback() {
|
||||
const text = this._feedbackText.trim();
|
||||
if (!text || this._feedbackSending) return;
|
||||
|
||||
let content = text;
|
||||
if (this._attachInfo) {
|
||||
content += `\n\nsent from ${this._getOS()} version ${this._version}`;
|
||||
}
|
||||
|
||||
if (content.length > 2000) {
|
||||
this._feedbackStatus = 'error:Max 2000 characters';
|
||||
this.requestUpdate();
|
||||
return;
|
||||
}
|
||||
|
||||
this._feedbackSending = true;
|
||||
this._feedbackStatus = '';
|
||||
this.requestUpdate();
|
||||
|
||||
try {
|
||||
const body = { feedback: content };
|
||||
if (this._feedbackEmail.trim()) {
|
||||
body.email = this._feedbackEmail.trim();
|
||||
}
|
||||
|
||||
const res = await fetch('https://api.cheatingdaddy.com/api/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
this._feedbackText = '';
|
||||
this._feedbackEmail = '';
|
||||
this._feedbackStatus = 'success:Feedback sent, thank you!';
|
||||
} else if (res.status === 429) {
|
||||
this._feedbackStatus = 'error:Please wait a few minutes before sending again';
|
||||
} else {
|
||||
this._feedbackStatus = 'error:Failed to send feedback';
|
||||
}
|
||||
} catch (e) {
|
||||
this._feedbackStatus = 'error:Could not connect to server';
|
||||
}
|
||||
|
||||
this._feedbackSending = false;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div class="unified-page">
|
||||
<div class="unified-wrap">
|
||||
<div class="page-title">Feedback</div>
|
||||
|
||||
<section class="surface">
|
||||
<div class="feedback-form">
|
||||
<textarea
|
||||
class="feedback-input"
|
||||
placeholder="Bug reports, feature requests, anything..."
|
||||
.value=${this._feedbackText}
|
||||
@input=${e => { this._feedbackText = e.target.value; }}
|
||||
maxlength="2000"
|
||||
></textarea>
|
||||
<input
|
||||
class="feedback-input"
|
||||
type="email"
|
||||
placeholder="Email (optional)"
|
||||
.value=${this._feedbackEmail}
|
||||
@input=${e => { this._feedbackEmail = e.target.value; }}
|
||||
/>
|
||||
<label class="attach-info">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${this._attachInfo}
|
||||
@change=${e => { this._attachInfo = e.target.checked; }}
|
||||
/>
|
||||
Attach OS and app version
|
||||
</label>
|
||||
<div class="feedback-row">
|
||||
<button
|
||||
class="feedback-submit"
|
||||
@click=${() => this._submitFeedback()}
|
||||
?disabled=${!this._feedbackText.trim() || this._feedbackSending}
|
||||
>
|
||||
${this._feedbackSending ? 'Sending...' : 'Send Feedback'}
|
||||
</button>
|
||||
${this._feedbackStatus ? html`
|
||||
<span class="feedback-status ${this._feedbackStatus.split(':')[0]}">
|
||||
${this._feedbackStatus.split(':').slice(1).join(':')}
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('feedback-view', FeedbackView);
|
||||
+113
-417
@@ -1,229 +1,95 @@
|
||||
import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||
import { resizeLayout } from '../../utils/windowResize.js';
|
||||
import { unifiedPageStyles } from './sharedPageStyles.js';
|
||||
|
||||
export class HelpView extends LitElement {
|
||||
static styles = css`
|
||||
* {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
static styles = [
|
||||
unifiedPageStyles,
|
||||
css`
|
||||
.shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0;
|
||||
}
|
||||
.shortcut-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-sm);
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.help-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.shortcut-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
}
|
||||
|
||||
.option-group {
|
||||
padding: 16px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
.shortcut-keys {
|
||||
display: inline-flex;
|
||||
gap: 4px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.option-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
.key {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 2px 6px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-surface);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.option-label {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.description {
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
user-select: text;
|
||||
cursor: text;
|
||||
}
|
||||
.list-item {
|
||||
padding: var(--space-sm);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.45;
|
||||
background: var(--bg-elevated);
|
||||
}
|
||||
|
||||
.description strong {
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
.link-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.link {
|
||||
color: var(--text-color);
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.link-button {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: border-color var(--transition), color var(--transition), background var(--transition);
|
||||
}
|
||||
|
||||
.key {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-color);
|
||||
border: 1px solid var(--border-color);
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
font-family: 'SF Mono', Monaco, monospace;
|
||||
font-weight: 500;
|
||||
margin: 0 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link-button:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--accent);
|
||||
background: rgba(63, 125, 229, 0.14);
|
||||
}
|
||||
|
||||
.keyboard-section {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
@media (max-width: 820px) {
|
||||
.shortcut-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.keyboard-group {
|
||||
padding: 10px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.keyboard-group:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.keyboard-group-title {
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.shortcut-description {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.shortcut-keys {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.profiles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.profile-item {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.profile-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.profile-name {
|
||||
font-weight: 500;
|
||||
font-size: 12px;
|
||||
color: var(--text-color);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.profile-description {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.community-links {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.community-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 10px;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 3px;
|
||||
color: var(--text-color);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition: background 0.1s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.community-link:hover {
|
||||
background: var(--hover-background);
|
||||
}
|
||||
|
||||
.community-link svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.open-logs-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 14px;
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-color);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.open-logs-btn:hover {
|
||||
background: var(--hover-background);
|
||||
}
|
||||
|
||||
.usage-steps {
|
||||
counter-reset: step-counter;
|
||||
}
|
||||
|
||||
.usage-step {
|
||||
counter-increment: step-counter;
|
||||
position: relative;
|
||||
padding-left: 24px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.usage-step::before {
|
||||
content: counter(step-counter);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-color);
|
||||
border-radius: 3px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.usage-step strong {
|
||||
color: var(--text-color);
|
||||
}
|
||||
`;
|
||||
`,
|
||||
];
|
||||
|
||||
static properties = {
|
||||
onExternalLinkClick: { type: Function },
|
||||
@@ -249,12 +115,6 @@ export class HelpView extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
// Resize window for this view
|
||||
resizeLayout();
|
||||
}
|
||||
|
||||
getDefaultKeybinds() {
|
||||
const isMac = cheatingDaddy.isMacOS || navigator.platform.includes('Mac');
|
||||
return {
|
||||
@@ -272,222 +132,58 @@ export class HelpView extends LitElement {
|
||||
};
|
||||
}
|
||||
|
||||
formatKeybind(keybind) {
|
||||
_formatKeybind(keybind) {
|
||||
return keybind.split('+').map(key => html`<span class="key">${key}</span>`);
|
||||
}
|
||||
|
||||
handleExternalLinkClick(url) {
|
||||
_open(url) {
|
||||
this.onExternalLinkClick(url);
|
||||
}
|
||||
|
||||
render() {
|
||||
const isMacOS = cheatingDaddy.isMacOS || false;
|
||||
const isLinux = cheatingDaddy.isLinux || false;
|
||||
const shortcutRows = [
|
||||
['Move Window Up', this.keybinds.moveUp],
|
||||
['Move Window Down', this.keybinds.moveDown],
|
||||
['Move Window Left', this.keybinds.moveLeft],
|
||||
['Move Window Right', this.keybinds.moveRight],
|
||||
['Toggle Visibility', this.keybinds.toggleVisibility],
|
||||
['Toggle Click-through', this.keybinds.toggleClickThrough],
|
||||
['Ask Next Step', this.keybinds.nextStep],
|
||||
['Previous Response', this.keybinds.previousResponse],
|
||||
['Next Response', this.keybinds.nextResponse],
|
||||
['Scroll Response Up', this.keybinds.scrollUp],
|
||||
['Scroll Response Down', this.keybinds.scrollDown],
|
||||
];
|
||||
|
||||
return html`
|
||||
<div class="help-container">
|
||||
<div class="option-group">
|
||||
<div class="option-label">
|
||||
<span>Community & Support</span>
|
||||
</div>
|
||||
<div class="community-links">
|
||||
<div class="community-link" @click=${() => this.handleExternalLinkClick('https://cheatingdaddy.com')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M14 11.9976C14 9.5059 11.683 7 8.85714 7C8.52241 7 7.41904 7.00001 7.14286 7.00001C4.30254 7.00001 2 9.23752 2 11.9976C2 14.376 3.70973 16.3664 6 16.8714C6.36756 16.9525 6.75006 16.9952 7.14286 16.9952"></path>
|
||||
<path d="M10 11.9976C10 14.4893 12.317 16.9952 15.1429 16.9952C15.4776 16.9952 16.581 16.9952 16.8571 16.9952C19.6975 16.9952 22 14.7577 22 11.9976C22 9.6192 20.2903 7.62884 18 7.12383C17.6324 7.04278 17.2499 6.99999 16.8571 6.99999"></path>
|
||||
</svg>
|
||||
Website
|
||||
</div>
|
||||
<div class="community-link" @click=${() => this.handleExternalLinkClick('https://github.com/sohzm/cheating-daddy')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M16 22.0268V19.1568C16.0375 18.68 15.9731 18.2006 15.811 17.7506C15.6489 17.3006 15.3929 16.8902 15.06 16.5468C18.2 16.1968 21.5 15.0068 21.5 9.54679C21.4997 8.15062 20.9627 6.80799 20 5.79679C20.4558 4.5753 20.4236 3.22514 19.91 2.02679C19.91 2.02679 18.73 1.67679 16 3.50679C13.708 2.88561 11.292 2.88561 8.99999 3.50679C6.26999 1.67679 5.08999 2.02679 5.08999 2.02679C4.57636 3.22514 4.54413 4.5753 4.99999 5.79679C4.03011 6.81549 3.49251 8.17026 3.49999 9.57679C3.49999 14.9968 6.79998 16.1868 9.93998 16.5768C9.61098 16.9168 9.35725 17.3222 9.19529 17.7667C9.03334 18.2112 8.96679 18.6849 8.99999 19.1568V22.0268"></path>
|
||||
<path d="M9 20.0267C6 20.9999 3.5 20.0267 2 17.0267"></path>
|
||||
</svg>
|
||||
GitHub
|
||||
</div>
|
||||
<div class="community-link" @click=${() => this.handleExternalLinkClick('https://discord.gg/GCBdubnXfJ')}>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M5.5 16C10.5 18.5 13.5 18.5 18.5 16"></path>
|
||||
<path d="M15.5 17.5L16.5 19.5C16.5 19.5 20.6713 18.1717 22 16C22 15 22.5301 7.85339 19 5.5C17.5 4.5 15 4 15 4L14 6H12"></path>
|
||||
<path d="M8.52832 17.5L7.52832 19.5C7.52832 19.5 3.35699 18.1717 2.02832 16C2.02832 15 1.49823 7.85339 5.02832 5.5C6.52832 4.5 9.02832 4 9.02832 4L10.0283 6H12.0283"></path>
|
||||
<path d="M8.5 14C7.67157 14 7 13.1046 7 12C7 10.8954 7.67157 10 8.5 10C9.32843 10 10 10.8954 10 12C10 13.1046 9.32843 14 8.5 14Z"></path>
|
||||
<path d="M15.5 14C14.6716 14 14 13.1046 14 12C14 10.8954 14.6716 10 15.5 10C16.3284 10 17 10.8954 17 12C17 13.1046 16.3284 14 15.5 14Z"></path>
|
||||
</svg>
|
||||
Discord
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="unified-page">
|
||||
<div class="unified-wrap">
|
||||
<div class="page-title">Help</div>
|
||||
|
||||
<div class="option-group">
|
||||
<div class="option-label">
|
||||
<span>Keyboard Shortcuts</span>
|
||||
</div>
|
||||
<div class="keyboard-section">
|
||||
<div class="keyboard-group">
|
||||
<div class="keyboard-group-title">Window Movement</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Move window up</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveUp)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Move window down</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveDown)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Move window left</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveLeft)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Move window right</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.moveRight)}</div>
|
||||
</div>
|
||||
<section class="surface">
|
||||
<div class="surface-title">Support</div>
|
||||
<div class="link-row">
|
||||
<button class="link-button" @click=${() => this._open('https://cheatingdaddy.com')}>Website</button>
|
||||
<button class="link-button" @click=${() => this._open('https://github.com/sohzm/cheating-daddy')}>GitHub</button>
|
||||
<button class="link-button" @click=${() => this._open('https://discord.gg/GCBdubnXfJ')}>Discord</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="keyboard-group">
|
||||
<div class="keyboard-group-title">Window Control</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Toggle click-through mode</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.toggleClickThrough)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Toggle window visibility</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.toggleVisibility)}</div>
|
||||
</div>
|
||||
<section class="surface">
|
||||
<div class="surface-title">Keyboard Shortcuts</div>
|
||||
<div class="shortcut-grid">
|
||||
${shortcutRows.map(([label, keys]) => html`
|
||||
<div class="shortcut-row">
|
||||
<span class="shortcut-label">${label}</span>
|
||||
<span class="shortcut-keys">${this._formatKeybind(keys)}</span>
|
||||
</div>
|
||||
`)}
|
||||
</div>
|
||||
|
||||
<div class="keyboard-group">
|
||||
<div class="keyboard-group-title">AI Actions</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Take screenshot and ask for next step</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.nextStep)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-group">
|
||||
<div class="keyboard-group-title">Response Navigation</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Previous response</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.previousResponse)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Next response</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.nextResponse)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Scroll response up</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.scrollUp)}</div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Scroll response down</span>
|
||||
<div class="shortcut-keys">${this.formatKeybind(this.keybinds.scrollDown)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="keyboard-group">
|
||||
<div class="keyboard-group-title">Text Input</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">Send message to AI</span>
|
||||
<div class="shortcut-keys"><span class="key">Enter</span></div>
|
||||
</div>
|
||||
<div class="shortcut-item">
|
||||
<span class="shortcut-description">New line in text input</span>
|
||||
<div class="shortcut-keys"><span class="key">Shift</span><span class="key">Enter</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="description" style="margin-top: 12px; text-align: center;">
|
||||
You can customize these shortcuts in Settings.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option-group">
|
||||
<div class="option-label">
|
||||
<span>How to Use</span>
|
||||
</div>
|
||||
<div class="usage-steps">
|
||||
<div class="usage-step"><strong>Start a Session:</strong> Enter your Gemini API key and click "Start Session"</div>
|
||||
<div class="usage-step"><strong>Customize:</strong> Choose your profile and language in the settings</div>
|
||||
<div class="usage-step">
|
||||
<strong>Position Window:</strong> Use keyboard shortcuts to move the window to your desired location
|
||||
</div>
|
||||
<div class="usage-step">
|
||||
<strong>Click-through Mode:</strong> Use ${this.formatKeybind(this.keybinds.toggleClickThrough)} to make the window
|
||||
click-through
|
||||
</div>
|
||||
<div class="usage-step"><strong>Get AI Help:</strong> The AI will analyze your screen and audio to provide assistance</div>
|
||||
<div class="usage-step"><strong>Text Messages:</strong> Type questions or requests to the AI using the text input</div>
|
||||
<div class="usage-step">
|
||||
<strong>Navigate Responses:</strong> Use ${this.formatKeybind(this.keybinds.previousResponse)} and
|
||||
${this.formatKeybind(this.keybinds.nextResponse)} to browse through AI responses
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option-group">
|
||||
<div class="option-label">
|
||||
<span>Supported Profiles</span>
|
||||
</div>
|
||||
<div class="profiles-grid">
|
||||
<div class="profile-item">
|
||||
<div class="profile-name">Job Interview</div>
|
||||
<div class="profile-description">Get help with interview questions and responses</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<div class="profile-name">Sales Call</div>
|
||||
<div class="profile-description">Assistance with sales conversations and objection handling</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<div class="profile-name">Business Meeting</div>
|
||||
<div class="profile-description">Support for professional meetings and discussions</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<div class="profile-name">Presentation</div>
|
||||
<div class="profile-description">Help with presentations and public speaking</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<div class="profile-name">Negotiation</div>
|
||||
<div class="profile-description">Guidance for business negotiations and deals</div>
|
||||
</div>
|
||||
<div class="profile-item">
|
||||
<div class="profile-name">Exam Assistant</div>
|
||||
<div class="profile-description">Academic assistance for test-taking and exam questions</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="option-group">
|
||||
<div class="option-label">
|
||||
<span>Audio Input</span>
|
||||
</div>
|
||||
<div class="description">The AI listens to conversations and provides contextual assistance based on what it hears.</div>
|
||||
</div>
|
||||
|
||||
<div class="option-group">
|
||||
<div class="option-label">
|
||||
<span>Troubleshooting</span>
|
||||
</div>
|
||||
<div class="description" style="margin-bottom: 12px;">
|
||||
If you're experiencing issues with audio capture or other features, check the application logs for diagnostic information.
|
||||
</div>
|
||||
<button class="open-logs-btn" @click=${this.openLogsFolder}>
|
||||
📁 Open Logs Folder
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async openLogsFolder() {
|
||||
try {
|
||||
const { ipcRenderer } = require('electron');
|
||||
const result = await ipcRenderer.invoke('open-logs-folder');
|
||||
if (!result.success) {
|
||||
console.error('Failed to open logs folder:', result.error);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error opening logs folder:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('help-view', HelpView);
|
||||
|
||||
+381
-519
File diff suppressed because it is too large
Load Diff
+910
-173
File diff suppressed because it is too large
Load Diff
@@ -3,13 +3,7 @@ import { html, css, LitElement } from '../../assets/lit-core-2.7.4.min.js';
|
||||
export class OnboardingView extends LitElement {
|
||||
static styles = css`
|
||||
* {
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
sans-serif;
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
margin: 0;
|
||||
@@ -27,44 +21,20 @@ export class OnboardingView extends LitElement {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.onboarding-container {
|
||||
position: relative;
|
||||
.onboarding {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #0a0a0a;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.08);
|
||||
overflow: hidden;
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
}
|
||||
|
||||
.close-button svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.gradient-canvas {
|
||||
canvas.aurora {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
@@ -73,166 +43,104 @@ export class OnboardingView extends LitElement {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
canvas.dither {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 60px;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 32px 48px;
|
||||
max-width: 500px;
|
||||
color: #e5e5e5;
|
||||
overflow: hidden;
|
||||
opacity: 0.12;
|
||||
mix-blend-mode: overlay;
|
||||
pointer-events: none;
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
.slide-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: 16px;
|
||||
opacity: 0.9;
|
||||
display: block;
|
||||
.slide {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
max-width: 400px;
|
||||
padding: var(--space-xl);
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.slide-title {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 12px;
|
||||
color: #ffffff;
|
||||
line-height: 1.3;
|
||||
color: #111111;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.slide-content {
|
||||
font-size: 16px;
|
||||
.slide-text {
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 24px;
|
||||
color: #b8b8b8;
|
||||
font-weight: 400;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.context-textarea {
|
||||
.context-input {
|
||||
width: 100%;
|
||||
height: 100px;
|
||||
padding: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-height: 120px;
|
||||
padding: 12px;
|
||||
border: 1px solid rgba(0, 0, 0, 0.12);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #e5e5e5;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
backdrop-filter: blur(8px);
|
||||
color: #111111;
|
||||
font-size: 13px;
|
||||
font-family: var(--font);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
transition: all 0.2s ease;
|
||||
margin-bottom: 24px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.context-textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 14px;
|
||||
.context-input::placeholder {
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.context-textarea:focus {
|
||||
.context-input:focus {
|
||||
outline: none;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.feature-list {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 12px;
|
||||
font-size: 15px;
|
||||
color: #b8b8b8;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.feature-icon {
|
||||
font-size: 16px;
|
||||
margin-right: 12px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.navigation {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 24px;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||
height: 60px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
color: #e5e5e5;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
.btn-primary {
|
||||
background: #111111;
|
||||
border: none;
|
||||
color: #ffffff;
|
||||
padding: 10px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 36px;
|
||||
min-height: 36px;
|
||||
transition: opacity 0.15s;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
.btn-primary:hover {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.nav-button:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.nav-button:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.nav-button:disabled:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.progress-dots {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
transition: all 0.2s ease;
|
||||
.btn-back {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888888;
|
||||
font-size: 11px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
|
||||
.dot:hover {
|
||||
background: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.dot.active {
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
transform: scale(1.2);
|
||||
.btn-back:hover {
|
||||
color: #555555;
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -240,7 +148,6 @@ export class OnboardingView extends LitElement {
|
||||
currentSlide: { type: Number },
|
||||
contextText: { type: String },
|
||||
onComplete: { type: Function },
|
||||
onClose: { type: Function },
|
||||
};
|
||||
|
||||
constructor() {
|
||||
@@ -248,220 +155,151 @@ export class OnboardingView extends LitElement {
|
||||
this.currentSlide = 0;
|
||||
this.contextText = '';
|
||||
this.onComplete = () => {};
|
||||
this.onClose = () => {};
|
||||
this.canvas = null;
|
||||
this.ctx = null;
|
||||
this.animationId = null;
|
||||
|
||||
// Transition properties
|
||||
this.isTransitioning = false;
|
||||
this.transitionStartTime = 0;
|
||||
this.transitionDuration = 800; // 800ms fade duration
|
||||
this.previousColorScheme = null;
|
||||
|
||||
// Subtle dark color schemes for each slide
|
||||
this.colorSchemes = [
|
||||
// Slide 1 - Welcome (Very dark purple/gray)
|
||||
[
|
||||
[25, 25, 35], // Dark gray-purple
|
||||
[20, 20, 30], // Darker gray
|
||||
[30, 25, 40], // Slightly purple
|
||||
[15, 15, 25], // Very dark
|
||||
[35, 30, 45], // Muted purple
|
||||
[10, 10, 20], // Almost black
|
||||
],
|
||||
// Slide 2 - Privacy (Dark blue-gray)
|
||||
[
|
||||
[20, 25, 35], // Dark blue-gray
|
||||
[15, 20, 30], // Darker blue-gray
|
||||
[25, 30, 40], // Slightly blue
|
||||
[10, 15, 25], // Very dark blue
|
||||
[30, 35, 45], // Muted blue
|
||||
[5, 10, 20], // Almost black
|
||||
],
|
||||
// Slide 3 - Context (Dark neutral)
|
||||
[
|
||||
[25, 25, 25], // Neutral dark
|
||||
[20, 20, 20], // Darker neutral
|
||||
[30, 30, 30], // Light dark
|
||||
[15, 15, 15], // Very dark
|
||||
[35, 35, 35], // Lighter dark
|
||||
[10, 10, 10], // Almost black
|
||||
],
|
||||
// Slide 4 - Features (Dark green-gray)
|
||||
[
|
||||
[20, 30, 25], // Dark green-gray
|
||||
[15, 25, 20], // Darker green-gray
|
||||
[25, 35, 30], // Slightly green
|
||||
[10, 20, 15], // Very dark green
|
||||
[30, 40, 35], // Muted green
|
||||
[5, 15, 10], // Almost black
|
||||
],
|
||||
// Slide 5 - Complete (Dark warm gray)
|
||||
[
|
||||
[30, 25, 20], // Dark warm gray
|
||||
[25, 20, 15], // Darker warm
|
||||
[35, 30, 25], // Slightly warm
|
||||
[20, 15, 10], // Very dark warm
|
||||
[40, 35, 30], // Muted warm
|
||||
[15, 10, 5], // Almost black
|
||||
],
|
||||
];
|
||||
this._animId = null;
|
||||
this._time = 0;
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
this.canvas = this.shadowRoot.querySelector('.gradient-canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.resizeCanvas();
|
||||
this.startGradientAnimation();
|
||||
|
||||
window.addEventListener('resize', () => this.resizeCanvas());
|
||||
this._startAurora();
|
||||
this._drawDither();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.animationId) {
|
||||
cancelAnimationFrame(this.animationId);
|
||||
if (this._animId) cancelAnimationFrame(this._animId);
|
||||
}
|
||||
|
||||
_drawDither() {
|
||||
const canvas = this.shadowRoot.querySelector('canvas.dither');
|
||||
if (!canvas) return;
|
||||
const blockSize = 5;
|
||||
const cols = Math.ceil(canvas.offsetWidth / blockSize);
|
||||
const rows = Math.ceil(canvas.offsetHeight / blockSize);
|
||||
canvas.width = cols;
|
||||
canvas.height = rows;
|
||||
const ctx = canvas.getContext('2d');
|
||||
const img = ctx.createImageData(cols, rows);
|
||||
for (let i = 0; i < img.data.length; i += 4) {
|
||||
const v = Math.random() > 0.5 ? 255 : 0;
|
||||
img.data[i] = v;
|
||||
img.data[i + 1] = v;
|
||||
img.data[i + 2] = v;
|
||||
img.data[i + 3] = 255;
|
||||
}
|
||||
window.removeEventListener('resize', () => this.resizeCanvas());
|
||||
ctx.putImageData(img, 0, 0);
|
||||
}
|
||||
|
||||
resizeCanvas() {
|
||||
if (!this.canvas) return;
|
||||
_startAurora() {
|
||||
const canvas = this.shadowRoot.querySelector('canvas.aurora');
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const rect = this.getBoundingClientRect();
|
||||
this.canvas.width = rect.width;
|
||||
this.canvas.height = rect.height;
|
||||
}
|
||||
const scale = 0.35;
|
||||
const resize = () => {
|
||||
canvas.width = Math.floor(canvas.offsetWidth * scale);
|
||||
canvas.height = Math.floor(canvas.offsetHeight * scale);
|
||||
};
|
||||
resize();
|
||||
|
||||
startGradientAnimation() {
|
||||
if (!this.ctx) return;
|
||||
const blobs = [
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 1.0 },
|
||||
{ ox: 0.22, oy: 0.1, r: 0.85 },
|
||||
{ ox: 0.11, oy: 0.05, r: 0.5 },
|
||||
], color: [180, 200, 230], x: 0.15, y: 0.2, vx: 0.35, vy: 0.25, phase: 0 },
|
||||
|
||||
const animate = timestamp => {
|
||||
this.drawGradient(timestamp);
|
||||
this.animationId = requestAnimationFrame(animate);
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.95 },
|
||||
{ ox: 0.18, oy: -0.08, r: 0.75 },
|
||||
{ ox: 0.09, oy: -0.04, r: 0.4 },
|
||||
], color: [190, 180, 220], x: 0.75, y: 0.2, vx: -0.3, vy: 0.35, phase: 1.2 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.9 },
|
||||
{ ox: 0.24, oy: 0.12, r: 0.9 },
|
||||
{ ox: 0.12, oy: 0.06, r: 0.35 },
|
||||
], color: [210, 195, 215], x: 0.5, y: 0.65, vx: 0.25, vy: -0.3, phase: 2.4 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.8 },
|
||||
{ ox: -0.15, oy: 0.18, r: 0.7 },
|
||||
{ ox: -0.07, oy: 0.09, r: 0.45 },
|
||||
], color: [175, 210, 210], x: 0.1, y: 0.75, vx: 0.4, vy: 0.2, phase: 3.6 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.75 },
|
||||
{ ox: 0.12, oy: -0.15, r: 0.65 },
|
||||
{ ox: 0.06, oy: -0.07, r: 0.35 },
|
||||
], color: [220, 210, 195], x: 0.85, y: 0.55, vx: -0.28, vy: -0.32, phase: 4.8 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.95 },
|
||||
{ ox: -0.2, oy: -0.12, r: 0.75 },
|
||||
{ ox: -0.1, oy: -0.06, r: 0.4 },
|
||||
], color: [170, 190, 225], x: 0.6, y: 0.1, vx: -0.2, vy: 0.38, phase: 6.0 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.85 },
|
||||
{ ox: 0.17, oy: 0.15, r: 0.75 },
|
||||
{ ox: 0.08, oy: 0.07, r: 0.35 },
|
||||
], color: [200, 190, 220], x: 0.35, y: 0.4, vx: 0.32, vy: -0.22, phase: 7.2 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.75 },
|
||||
{ ox: -0.13, oy: 0.18, r: 0.65 },
|
||||
{ ox: -0.06, oy: 0.1, r: 0.4 },
|
||||
], color: [215, 205, 200], x: 0.9, y: 0.85, vx: -0.35, vy: -0.25, phase: 8.4 },
|
||||
|
||||
{ parts: [
|
||||
{ ox: 0, oy: 0, r: 0.7 },
|
||||
{ ox: 0.16, oy: -0.1, r: 0.6 },
|
||||
{ ox: 0.08, oy: -0.05, r: 0.35 },
|
||||
], color: [185, 210, 205], x: 0.45, y: 0.9, vx: 0.22, vy: -0.4, phase: 9.6 },
|
||||
];
|
||||
|
||||
const baseRadius = 0.32;
|
||||
|
||||
const draw = () => {
|
||||
this._time += 0.012;
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
const dim = Math.min(w, h);
|
||||
|
||||
ctx.fillStyle = '#f0f0f0';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
for (const blob of blobs) {
|
||||
const t = this._time;
|
||||
const cx = (blob.x + Math.sin(t * blob.vx + blob.phase) * 0.22) * w;
|
||||
const cy = (blob.y + Math.cos(t * blob.vy + blob.phase * 0.7) * 0.22) * h;
|
||||
|
||||
for (const part of blob.parts) {
|
||||
const wobble = Math.sin(t * 2.5 + part.ox * 25 + blob.phase) * 0.02;
|
||||
const px = cx + (part.ox + wobble) * dim;
|
||||
const py = cy + (part.oy + wobble * 0.7) * dim;
|
||||
const pr = part.r * baseRadius * dim;
|
||||
|
||||
const grad = ctx.createRadialGradient(px, py, 0, px, py, pr);
|
||||
grad.addColorStop(0, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0.55)`);
|
||||
grad.addColorStop(0.4, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0.3)`);
|
||||
grad.addColorStop(0.7, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0.1)`);
|
||||
grad.addColorStop(1, `rgba(${blob.color[0]}, ${blob.color[1]}, ${blob.color[2]}, 0)`);
|
||||
|
||||
ctx.fillStyle = grad;
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
this._animId = requestAnimationFrame(draw);
|
||||
};
|
||||
|
||||
animate(0);
|
||||
}
|
||||
|
||||
drawGradient(timestamp) {
|
||||
if (!this.ctx || !this.canvas) return;
|
||||
|
||||
const { width, height } = this.canvas;
|
||||
let colors = this.colorSchemes[this.currentSlide];
|
||||
|
||||
// Handle color scheme transitions
|
||||
if (this.isTransitioning && this.previousColorScheme) {
|
||||
const elapsed = timestamp - this.transitionStartTime;
|
||||
const progress = Math.min(elapsed / this.transitionDuration, 1);
|
||||
|
||||
// Use easing function for smoother transition
|
||||
const easedProgress = this.easeInOutCubic(progress);
|
||||
|
||||
colors = this.interpolateColorSchemes(this.previousColorScheme, this.colorSchemes[this.currentSlide], easedProgress);
|
||||
|
||||
// End transition when complete
|
||||
if (progress >= 1) {
|
||||
this.isTransitioning = false;
|
||||
this.previousColorScheme = null;
|
||||
}
|
||||
}
|
||||
|
||||
const time = timestamp * 0.0005; // Much slower animation
|
||||
|
||||
// Create moving gradient with subtle flow
|
||||
const flowX = Math.sin(time * 0.7) * width * 0.3;
|
||||
const flowY = Math.cos(time * 0.5) * height * 0.2;
|
||||
|
||||
const gradient = this.ctx.createLinearGradient(flowX, flowY, width + flowX * 0.5, height + flowY * 0.5);
|
||||
|
||||
// Very subtle color variations with movement
|
||||
colors.forEach((color, index) => {
|
||||
const offset = index / (colors.length - 1);
|
||||
const wave = Math.sin(time + index * 0.3) * 0.05; // Very subtle wave
|
||||
|
||||
const r = Math.max(0, Math.min(255, color[0] + wave * 5));
|
||||
const g = Math.max(0, Math.min(255, color[1] + wave * 5));
|
||||
const b = Math.max(0, Math.min(255, color[2] + wave * 5));
|
||||
|
||||
gradient.addColorStop(offset, `rgb(${r}, ${g}, ${b})`);
|
||||
});
|
||||
|
||||
// Fill with moving gradient
|
||||
this.ctx.fillStyle = gradient;
|
||||
this.ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Add a second layer with radial gradient for more depth
|
||||
const centerX = width * 0.5 + Math.sin(time * 0.3) * width * 0.15;
|
||||
const centerY = height * 0.5 + Math.cos(time * 0.4) * height * 0.1;
|
||||
const radius = Math.max(width, height) * 0.8;
|
||||
|
||||
const radialGradient = this.ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, radius);
|
||||
|
||||
// Very subtle radial overlay
|
||||
radialGradient.addColorStop(0, `rgba(${colors[0][0] + 10}, ${colors[0][1] + 10}, ${colors[0][2] + 10}, 0.1)`);
|
||||
radialGradient.addColorStop(0.5, `rgba(${colors[2][0]}, ${colors[2][1]}, ${colors[2][2]}, 0.05)`);
|
||||
radialGradient.addColorStop(
|
||||
1,
|
||||
`rgba(${colors[colors.length - 1][0]}, ${colors[colors.length - 1][1]}, ${colors[colors.length - 1][2]}, 0.03)`
|
||||
);
|
||||
|
||||
this.ctx.globalCompositeOperation = 'overlay';
|
||||
this.ctx.fillStyle = radialGradient;
|
||||
this.ctx.fillRect(0, 0, width, height);
|
||||
this.ctx.globalCompositeOperation = 'source-over';
|
||||
}
|
||||
|
||||
nextSlide() {
|
||||
if (this.currentSlide < 4) {
|
||||
this.startColorTransition(this.currentSlide + 1);
|
||||
} else {
|
||||
this.completeOnboarding();
|
||||
}
|
||||
}
|
||||
|
||||
prevSlide() {
|
||||
if (this.currentSlide > 0) {
|
||||
this.startColorTransition(this.currentSlide - 1);
|
||||
}
|
||||
}
|
||||
|
||||
startColorTransition(newSlide) {
|
||||
this.previousColorScheme = [...this.colorSchemes[this.currentSlide]];
|
||||
this.currentSlide = newSlide;
|
||||
this.isTransitioning = true;
|
||||
this.transitionStartTime = performance.now();
|
||||
}
|
||||
|
||||
// Interpolate between two color schemes
|
||||
interpolateColorSchemes(scheme1, scheme2, progress) {
|
||||
return scheme1.map((color1, index) => {
|
||||
const color2 = scheme2[index];
|
||||
return [
|
||||
color1[0] + (color2[0] - color1[0]) * progress,
|
||||
color1[1] + (color2[1] - color1[1]) * progress,
|
||||
color1[2] + (color2[2] - color1[2]) * progress,
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
// Easing function for smooth transitions
|
||||
easeInOutCubic(t) {
|
||||
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
draw();
|
||||
}
|
||||
|
||||
handleContextInput(e) {
|
||||
this.contextText = e.target.value;
|
||||
}
|
||||
|
||||
async handleClose() {
|
||||
if (window.require) {
|
||||
const { ipcRenderer } = window.require('electron');
|
||||
await ipcRenderer.invoke('quit-application');
|
||||
}
|
||||
}
|
||||
|
||||
async completeOnboarding() {
|
||||
if (this.contextText.trim()) {
|
||||
await cheatingDaddy.storage.updatePreference('customPrompt', this.contextText.trim());
|
||||
@@ -470,120 +308,43 @@ export class OnboardingView extends LitElement {
|
||||
this.onComplete();
|
||||
}
|
||||
|
||||
getSlideContent() {
|
||||
const slides = [
|
||||
{
|
||||
icon: 'assets/onboarding/welcome.svg',
|
||||
title: 'Welcome to Cheating Daddy',
|
||||
content:
|
||||
'Your AI assistant that listens and watches, then provides intelligent suggestions automatically during interviews and meetings.',
|
||||
},
|
||||
{
|
||||
icon: 'assets/onboarding/security.svg',
|
||||
title: 'Completely Private',
|
||||
content: 'Invisible to screen sharing apps and recording software. Your secret advantage stays completely hidden from others.',
|
||||
},
|
||||
{
|
||||
icon: 'assets/onboarding/context.svg',
|
||||
title: 'Add Your Context',
|
||||
content: 'Share relevant information to help the AI provide better, more personalized assistance.',
|
||||
showTextarea: true,
|
||||
},
|
||||
{
|
||||
icon: 'assets/onboarding/customize.svg',
|
||||
title: 'Additional Features',
|
||||
content: '',
|
||||
showFeatures: true,
|
||||
},
|
||||
{
|
||||
icon: 'assets/onboarding/ready.svg',
|
||||
title: 'Ready to Go',
|
||||
content: 'Add your Gemini API key in settings and start getting AI-powered assistance in real-time.',
|
||||
},
|
||||
];
|
||||
renderSlide() {
|
||||
if (this.currentSlide === 0) {
|
||||
return html`
|
||||
<div class="slide">
|
||||
<div class="slide-title">Cheating Daddy</div>
|
||||
<div class="slide-text">Real-time AI that listens, watches, and helps during interviews, meetings, and exams.</div>
|
||||
<div class="actions">
|
||||
<button class="btn-primary" @click=${() => { this.currentSlide = 1; }}>Continue</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return slides[this.currentSlide];
|
||||
return html`
|
||||
<div class="slide">
|
||||
<div class="slide-title">Add context</div>
|
||||
<div class="slide-text">Paste your resume or any info the AI should know. You can skip this and add it later.</div>
|
||||
<textarea
|
||||
class="context-input"
|
||||
placeholder="Resume, job description, notes..."
|
||||
.value=${this.contextText}
|
||||
@input=${this.handleContextInput}
|
||||
></textarea>
|
||||
<div class="actions">
|
||||
<button class="btn-primary" @click=${this.completeOnboarding}>Get Started</button>
|
||||
<button class="btn-back" @click=${() => { this.currentSlide = 0; }}>Back</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
const slide = this.getSlideContent();
|
||||
|
||||
return html`
|
||||
<div class="onboarding-container">
|
||||
<button class="close-button" @click=${this.handleClose} title="Close">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path d="M6.28 5.22a.75.75 0 0 0-1.06 1.06L8.94 10l-3.72 3.72a.75.75 0 1 0 1.06 1.06L10 11.06l3.72 3.72a.75.75 0 1 0 1.06-1.06L11.06 10l3.72-3.72a.75.75 0 0 0-1.06-1.06L10 8.94 6.28 5.22Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<canvas class="gradient-canvas"></canvas>
|
||||
|
||||
<div class="content-wrapper">
|
||||
<img class="slide-icon" src="${slide.icon}" alt="${slide.title} icon" />
|
||||
<div class="slide-title">${slide.title}</div>
|
||||
<div class="slide-content">${slide.content}</div>
|
||||
|
||||
${slide.showTextarea
|
||||
? html`
|
||||
<textarea
|
||||
class="context-textarea"
|
||||
placeholder="Paste your resume, job description, or any relevant context here..."
|
||||
.value=${this.contextText}
|
||||
@input=${this.handleContextInput}
|
||||
></textarea>
|
||||
`
|
||||
: ''}
|
||||
${slide.showFeatures
|
||||
? html`
|
||||
<div class="feature-list">
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">-</span>
|
||||
Customize AI behavior and responses
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">-</span>
|
||||
Review conversation history
|
||||
</div>
|
||||
<div class="feature-item">
|
||||
<span class="feature-icon">-</span>
|
||||
Adjust capture settings and intervals
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ''}
|
||||
</div>
|
||||
|
||||
<div class="navigation">
|
||||
<button class="nav-button" @click=${this.prevSlide} ?disabled=${this.currentSlide === 0}>
|
||||
<svg width="16px" height="16px" stroke-width="2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 6L9 12L15 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="progress-dots">
|
||||
${[0, 1, 2, 3, 4].map(
|
||||
index => html`
|
||||
<div
|
||||
class="dot ${index === this.currentSlide ? 'active' : ''}"
|
||||
@click=${() => {
|
||||
if (index !== this.currentSlide) {
|
||||
this.startColorTransition(index);
|
||||
}
|
||||
}}
|
||||
></div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button class="nav-button" @click=${this.nextSlide}>
|
||||
${this.currentSlide === 4
|
||||
? 'Get Started'
|
||||
: html`
|
||||
<svg width="16px" height="16px" stroke-width="2" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 6L15 12L9 18" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"></path>
|
||||
</svg>
|
||||
`}
|
||||
</button>
|
||||
</div>
|
||||
<div class="onboarding">
|
||||
<canvas class="aurora"></canvas>
|
||||
<canvas class="dither"></canvas>
|
||||
${this.renderSlide()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { css } from '../../assets/lit-core-2.7.4.min.js';
|
||||
|
||||
export const unifiedPageStyles = css`
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
font-family: var(--font);
|
||||
cursor: default;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.unified-page {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
padding: var(--space-lg);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
.unified-wrap {
|
||||
width: 100%;
|
||||
max-width: 1160px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-xl);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.surface {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg-surface);
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.surface-title {
|
||||
color: var(--text-primary);
|
||||
font-size: var(--font-size-md);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.surface-subtitle {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.form-group.vertical {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-xs);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.control {
|
||||
width: 200px;
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 8px 12px;
|
||||
font-size: var(--font-size-sm);
|
||||
transition: border-color var(--transition), box-shadow var(--transition);
|
||||
}
|
||||
|
||||
.control:hover:not(:focus) {
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.control:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
select.control {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b6b6b' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 8px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 12px;
|
||||
padding-right: 28px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
textarea.control {
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
resize: vertical;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-elevated);
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.pill {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 999px;
|
||||
padding: 2px 8px;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.unified-page {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
}
|
||||
`;
|
||||
+141
-87
@@ -5,75 +5,112 @@
|
||||
<title>Screen and Audio Capture</title>
|
||||
<style>
|
||||
:root {
|
||||
/* Backgrounds - with default 0.8 transparency */
|
||||
--background-transparent: transparent;
|
||||
--bg-primary: rgba(30, 30, 30, 0.8);
|
||||
--bg-secondary: rgba(37, 37, 38, 0.8);
|
||||
--bg-tertiary: rgba(45, 45, 45, 0.8);
|
||||
--bg-hover: rgba(50, 50, 50, 0.8);
|
||||
/* Backgrounds */
|
||||
--bg-app: #0A0A0A;
|
||||
--bg-surface: #111111;
|
||||
--bg-elevated: #191919;
|
||||
--bg-hover: #1F1F1F;
|
||||
|
||||
/* Text */
|
||||
--text-color: #e5e5e5;
|
||||
--text-secondary: #a0a0a0;
|
||||
--text-muted: #6b6b6b;
|
||||
--description-color: #a0a0a0;
|
||||
--placeholder-color: #6b6b6b;
|
||||
--text-primary: #F5F5F5;
|
||||
--text-secondary: #999999;
|
||||
--text-muted: #555555;
|
||||
|
||||
/* Borders */
|
||||
--border-color: #3c3c3c;
|
||||
--border-subtle: #3c3c3c;
|
||||
--border-default: #4a4a4a;
|
||||
/* Borders & Lines */
|
||||
--border: #222222;
|
||||
--border-strong: #333333;
|
||||
|
||||
/* Component backgrounds - with default 0.8 transparency */
|
||||
--header-background: rgba(30, 30, 30, 0.8);
|
||||
--header-actions-color: #a0a0a0;
|
||||
--main-content-background: rgba(30, 30, 30, 0.8);
|
||||
/* Accent */
|
||||
--accent: #3B82F6;
|
||||
--accent-hover: #2563EB;
|
||||
|
||||
/* Status */
|
||||
--success: #22C55E;
|
||||
--warning: #D4A017;
|
||||
--danger: #EF4444;
|
||||
|
||||
/* Typography */
|
||||
--font: 'Inter', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
|
||||
--font-mono: 'SF Mono', 'Menlo', 'Monaco', 'Consolas', monospace;
|
||||
--font-size-xs: 11px;
|
||||
--font-size-sm: 13px;
|
||||
--font-size-base: 14px;
|
||||
--font-size-lg: 16px;
|
||||
--font-size-xl: 20px;
|
||||
--font-size-2xl: 28px;
|
||||
--font-weight-normal: 400;
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-semibold: 600;
|
||||
--line-height: 1.6;
|
||||
|
||||
/* Spacing */
|
||||
--space-xs: 4px;
|
||||
--space-sm: 8px;
|
||||
--space-md: 16px;
|
||||
--space-lg: 24px;
|
||||
--space-xl: 40px;
|
||||
--space-2xl: 64px;
|
||||
|
||||
/* Radius */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 8px;
|
||||
--radius-lg: 12px;
|
||||
|
||||
/* Transitions */
|
||||
--transition: 150ms ease;
|
||||
|
||||
/* Sidebar */
|
||||
--sidebar-width: 220px;
|
||||
--sidebar-width-collapsed: 60px;
|
||||
|
||||
/* Legacy compatibility — mapped to new tokens */
|
||||
--background-transparent: transparent;
|
||||
--bg-primary: var(--bg-app);
|
||||
--bg-secondary: var(--bg-surface);
|
||||
--bg-tertiary: var(--bg-elevated);
|
||||
--text-color: var(--text-primary);
|
||||
--description-color: var(--text-secondary);
|
||||
--placeholder-color: var(--text-muted);
|
||||
--border-color: var(--border);
|
||||
--border-subtle: var(--border);
|
||||
--border-default: var(--border-strong);
|
||||
--header-background: var(--bg-surface);
|
||||
--header-actions-color: var(--text-secondary);
|
||||
--main-content-background: var(--bg-app);
|
||||
--button-background: transparent;
|
||||
--button-border: #3c3c3c;
|
||||
--icon-button-color: #a0a0a0;
|
||||
--hover-background: rgba(50, 50, 50, 0.8);
|
||||
--input-background: rgba(45, 45, 45, 0.8);
|
||||
--input-focus-background: rgba(45, 45, 45, 0.8);
|
||||
|
||||
/* Focus states - neutral */
|
||||
--focus-border-color: #4a4a4a;
|
||||
--button-border: var(--border-strong);
|
||||
--icon-button-color: var(--text-secondary);
|
||||
--hover-background: var(--bg-hover);
|
||||
--input-background: var(--bg-elevated);
|
||||
--input-focus-background: var(--bg-elevated);
|
||||
--focus-border-color: var(--accent);
|
||||
--focus-box-shadow: transparent;
|
||||
--scrollbar-track: var(--bg-app);
|
||||
--scrollbar-thumb: var(--border-strong);
|
||||
--scrollbar-thumb-hover: #444444;
|
||||
--scrollbar-background: var(--bg-app);
|
||||
--start-button-background: var(--accent);
|
||||
--start-button-color: #ffffff;
|
||||
--start-button-border: var(--accent);
|
||||
--start-button-hover-background: var(--accent-hover);
|
||||
--start-button-hover-border: var(--accent-hover);
|
||||
--text-input-button-background: var(--accent);
|
||||
--text-input-button-hover: var(--accent-hover);
|
||||
--link-color: var(--accent);
|
||||
--key-background: var(--bg-elevated);
|
||||
--success-color: var(--success);
|
||||
--warning-color: var(--warning);
|
||||
--error-color: var(--danger);
|
||||
--danger-color: var(--danger);
|
||||
--preview-video-background: var(--bg-surface);
|
||||
--preview-video-border: var(--border);
|
||||
--option-label-color: var(--text-primary);
|
||||
--screen-option-background: var(--bg-surface);
|
||||
--screen-option-hover-background: var(--bg-elevated);
|
||||
--screen-option-selected-background: var(--bg-hover);
|
||||
--screen-option-text: var(--text-secondary);
|
||||
|
||||
/* Scrollbar */
|
||||
--scrollbar-track: #1e1e1e;
|
||||
--scrollbar-thumb: #3c3c3c;
|
||||
--scrollbar-thumb-hover: #4a4a4a;
|
||||
--scrollbar-background: #1e1e1e;
|
||||
|
||||
/* Legacy/misc */
|
||||
--preview-video-background: #1e1e1e;
|
||||
--preview-video-border: #3c3c3c;
|
||||
--option-label-color: #e5e5e5;
|
||||
--screen-option-background: #252526;
|
||||
--screen-option-hover-background: #2d2d2d;
|
||||
--screen-option-selected-background: #323232;
|
||||
--screen-option-text: #a0a0a0;
|
||||
|
||||
/* Buttons */
|
||||
--start-button-background: #ffffff;
|
||||
--start-button-color: #1e1e1e;
|
||||
--start-button-border: #ffffff;
|
||||
--start-button-hover-background: #e0e0e0;
|
||||
--start-button-hover-border: #e0e0e0;
|
||||
--text-input-button-background: #ffffff;
|
||||
--text-input-button-hover: #e0e0e0;
|
||||
|
||||
/* Links - neutral */
|
||||
--link-color: #e5e5e5;
|
||||
--key-background: #2d2d2d;
|
||||
|
||||
/* Status colors */
|
||||
--success-color: #4ec9b0;
|
||||
--warning-color: #dcdcaa;
|
||||
--error-color: #f14c4c;
|
||||
--danger-color: #f14c4c;
|
||||
|
||||
/* Layout-specific variables */
|
||||
/* Layout-specific */
|
||||
--header-padding: 8px 16px;
|
||||
--header-font-size: 14px;
|
||||
--header-gap: 8px;
|
||||
@@ -81,48 +118,65 @@
|
||||
--header-icon-padding: 6px;
|
||||
--header-font-size-small: 12px;
|
||||
--main-content-padding: 16px;
|
||||
--main-content-margin-top: 1px;
|
||||
--main-content-margin-top: 0;
|
||||
--icon-size: 18px;
|
||||
--border-radius: 3px;
|
||||
--border-radius: var(--radius-sm);
|
||||
--content-border-radius: 0;
|
||||
}
|
||||
|
||||
/* Compact layout styles */
|
||||
:root.compact-layout {
|
||||
--header-padding: 6px 12px;
|
||||
--header-font-size: 12px;
|
||||
--header-gap: 6px;
|
||||
--header-button-padding: 4px 8px;
|
||||
--header-icon-padding: 4px;
|
||||
--header-font-size-small: 10px;
|
||||
--main-content-padding: 12px;
|
||||
--main-content-margin-top: 1px;
|
||||
--icon-size: 16px;
|
||||
--border-radius: 3px;
|
||||
--content-border-radius: 0;
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'Inter',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
background: var(--bg-app);
|
||||
color: var(--text-primary);
|
||||
line-height: var(--line-height);
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--border);
|
||||
font-family: var(--font);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-normal);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: var(--border-strong);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #444444;
|
||||
}
|
||||
|
||||
cheating-daddy-app {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
+14
-65
@@ -4,54 +4,35 @@ if (require('electron-squirrel-startup')) {
|
||||
|
||||
const { app, BrowserWindow, shell, ipcMain } = require('electron');
|
||||
const { createWindow, updateGlobalShortcuts } = require('./utils/window');
|
||||
const { setupAIProviderIpcHandlers } = require('./utils/ai-provider-manager');
|
||||
const { stopMacOSAudioCapture } = require('./utils/gemini');
|
||||
const { initLogger, closeLogger, getLogPath } = require('./utils/logger');
|
||||
const { setupGeminiIpcHandlers, stopMacOSAudioCapture, sendToRenderer } = require('./utils/gemini');
|
||||
const storage = require('./storage');
|
||||
|
||||
const geminiSessionRef = { current: null };
|
||||
let mainWindow = null;
|
||||
|
||||
function sendToRenderer(channel, data) {
|
||||
const windows = BrowserWindow.getAllWindows();
|
||||
if (windows.length > 0) {
|
||||
windows[0].webContents.send(channel, data);
|
||||
}
|
||||
}
|
||||
|
||||
function createMainWindow() {
|
||||
mainWindow = createWindow(sendToRenderer, geminiSessionRef);
|
||||
return mainWindow;
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// Initialize file logger first
|
||||
const logPath = initLogger();
|
||||
console.log('App starting, log file:', logPath);
|
||||
|
||||
// Initialize storage (checks version, resets if needed)
|
||||
storage.initializeStorage();
|
||||
|
||||
// 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();
|
||||
setupAIProviderIpcHandlers(geminiSessionRef);
|
||||
setupGeminiIpcHandlers(geminiSessionRef);
|
||||
setupStorageIpcHandlers();
|
||||
setupGeneralIpcHandlers();
|
||||
|
||||
// Add handler to get log path from renderer
|
||||
ipcMain.handle('get-log-path', () => getLogPath());
|
||||
|
||||
// Add handler for renderer logs (so they go to the log file)
|
||||
ipcMain.on('renderer-log', (event, { level, message }) => {
|
||||
const prefix = '[RENDERER]';
|
||||
if (level === 'error') console.error(prefix, message);
|
||||
else if (level === 'warn') console.warn(prefix, message);
|
||||
else console.log(prefix, message);
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopMacOSAudioCapture();
|
||||
closeLogger();
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
@@ -59,7 +40,6 @@ app.on('window-all-closed', () => {
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopMacOSAudioCapture();
|
||||
closeLogger();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
@@ -138,40 +118,21 @@ function setupStorageIpcHandlers() {
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('storage:get-openai-credentials', async () => {
|
||||
ipcMain.handle('storage:get-groq-api-key', async () => {
|
||||
try {
|
||||
return { success: true, data: storage.getOpenAICredentials() };
|
||||
return { success: true, data: storage.getGroqApiKey() };
|
||||
} catch (error) {
|
||||
console.error('Error getting OpenAI credentials:', error);
|
||||
console.error('Error getting Groq API key:', error);
|
||||
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 {
|
||||
storage.setOpenAICredentials(config);
|
||||
storage.setGroqApiKey(groqApiKey);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting OpenAI credentials:', 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);
|
||||
console.error('Error setting Groq API key:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
@@ -302,18 +263,6 @@ function setupGeneralIpcHandlers() {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
ipcMain.handle('open-logs-folder', async () => {
|
||||
try {
|
||||
const logPath = getLogPath();
|
||||
const logsDir = require('path').dirname(logPath);
|
||||
await shell.openPath(logsDir);
|
||||
return { success: true, path: logsDir };
|
||||
} catch (error) {
|
||||
console.error('Error opening logs folder:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('quit-application', async event => {
|
||||
try {
|
||||
stopMacOSAudioCapture();
|
||||
|
||||
+94
-48
@@ -13,16 +13,10 @@ const DEFAULT_CONFIG = {
|
||||
|
||||
const DEFAULT_CREDENTIALS = {
|
||||
apiKey: '',
|
||||
// OpenAI Realtime API settings
|
||||
openaiApiKey: '',
|
||||
openaiBaseUrl: '',
|
||||
openaiModel: 'gpt-4o-realtime-preview-2024-12-17',
|
||||
// OpenAI SDK settings (for BotHub and other providers)
|
||||
openaiSdkApiKey: '',
|
||||
openaiSdkBaseUrl: '',
|
||||
openaiSdkModel: 'gpt-4o',
|
||||
openaiSdkVisionModel: 'gpt-4o',
|
||||
openaiSdkWhisperModel: 'whisper-1'
|
||||
groqApiKey: '',
|
||||
openaiCompatibleApiKey: '',
|
||||
openaiCompatibleBaseUrl: '',
|
||||
openaiCompatibleModel: ''
|
||||
};
|
||||
|
||||
const DEFAULT_PREFERENCES = {
|
||||
@@ -36,13 +30,16 @@ const DEFAULT_PREFERENCES = {
|
||||
fontSize: 'medium',
|
||||
backgroundTransparency: 0.8,
|
||||
googleSearchEnabled: false,
|
||||
aiProvider: 'gemini'
|
||||
responseProvider: 'gemini',
|
||||
ollamaHost: 'http://127.0.0.1:11434',
|
||||
ollamaModel: 'llama3.1',
|
||||
whisperModel: 'Xenova/whisper-small',
|
||||
};
|
||||
|
||||
const DEFAULT_KEYBINDS = null; // null means use system defaults
|
||||
|
||||
const DEFAULT_LIMITS = {
|
||||
data: [] // Array of { date: 'YYYY-MM-DD', flash: { count: 0 }, flashLite: { count: 0 } }
|
||||
data: [] // Array of { date: 'YYYY-MM-DD', flash: { count }, flashLite: { count }, groq: { 'qwen3-32b': { chars, limit }, 'gpt-oss-120b': { chars, limit }, 'gpt-oss-20b': { chars, limit } }, gemini: { 'gemma-3-27b-it': { chars } } }
|
||||
};
|
||||
|
||||
// Get the config directory path based on OS
|
||||
@@ -203,42 +200,29 @@ function setApiKey(apiKey) {
|
||||
return setCredentials({ apiKey });
|
||||
}
|
||||
|
||||
function getOpenAICredentials() {
|
||||
function getGroqApiKey() {
|
||||
return getCredentials().groqApiKey || '';
|
||||
}
|
||||
|
||||
function setGroqApiKey(groqApiKey) {
|
||||
return setCredentials({ groqApiKey });
|
||||
}
|
||||
|
||||
function getOpenAICompatibleConfig() {
|
||||
const creds = getCredentials();
|
||||
return {
|
||||
apiKey: creds.openaiApiKey || '',
|
||||
baseUrl: creds.openaiBaseUrl || '',
|
||||
model: creds.openaiModel || 'gpt-4o-realtime-preview-2024-12-17'
|
||||
apiKey: creds.openaiCompatibleApiKey || '',
|
||||
baseUrl: creds.openaiCompatibleBaseUrl || '',
|
||||
model: creds.openaiCompatibleModel || ''
|
||||
};
|
||||
}
|
||||
|
||||
function setOpenAICredentials(config) {
|
||||
const updates = {};
|
||||
if (config.apiKey !== undefined) updates.openaiApiKey = config.apiKey;
|
||||
if (config.baseUrl !== undefined) updates.openaiBaseUrl = config.baseUrl;
|
||||
if (config.model !== undefined) updates.openaiModel = config.model;
|
||||
return setCredentials(updates);
|
||||
}
|
||||
|
||||
function 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);
|
||||
function setOpenAICompatibleConfig(apiKey, baseUrl, model) {
|
||||
return setCredentials({
|
||||
openaiCompatibleApiKey: apiKey,
|
||||
openaiCompatibleBaseUrl: baseUrl,
|
||||
openaiCompatibleModel: model
|
||||
});
|
||||
}
|
||||
|
||||
// ============ PREFERENCES ============
|
||||
@@ -293,6 +277,21 @@ function getTodayLimits() {
|
||||
const todayEntry = limits.data.find(entry => entry.date === today);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -301,7 +300,16 @@ function getTodayLimits() {
|
||||
const newEntry = {
|
||||
date: today,
|
||||
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);
|
||||
setLimits(limits);
|
||||
@@ -341,6 +349,21 @@ function incrementLimitCount(model) {
|
||||
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() {
|
||||
const todayLimits = getTodayLimits();
|
||||
|
||||
@@ -355,6 +378,27 @@ function getAvailableModel() {
|
||||
return 'gemini-2.5-flash'; // Default to flash for paid API users
|
||||
}
|
||||
|
||||
function getModelForToday() {
|
||||
const todayEntry = getTodayLimits();
|
||||
const groq = todayEntry.groq;
|
||||
|
||||
if (groq['qwen3-32b'].chars < groq['qwen3-32b'].limit) {
|
||||
return 'qwen/qwen3-32b';
|
||||
}
|
||||
if (groq['gpt-oss-120b'].chars < groq['gpt-oss-120b'].limit) {
|
||||
return 'openai/gpt-oss-120b';
|
||||
}
|
||||
if (groq['gpt-oss-20b'].chars < groq['gpt-oss-20b'].limit) {
|
||||
return 'openai/gpt-oss-20b';
|
||||
}
|
||||
if (groq['kimi-k2-instruct'].chars < groq['kimi-k2-instruct'].limit) {
|
||||
return 'moonshotai/kimi-k2-instruct';
|
||||
}
|
||||
|
||||
// All limits exhausted
|
||||
return null;
|
||||
}
|
||||
|
||||
// ============ HISTORY ============
|
||||
|
||||
function getSessionPath(sessionId) {
|
||||
@@ -475,10 +519,10 @@ module.exports = {
|
||||
setCredentials,
|
||||
getApiKey,
|
||||
setApiKey,
|
||||
getOpenAICredentials,
|
||||
setOpenAICredentials,
|
||||
getOpenAISDKCredentials,
|
||||
setOpenAISDKCredentials,
|
||||
getGroqApiKey,
|
||||
setGroqApiKey,
|
||||
getOpenAICompatibleConfig,
|
||||
setOpenAICompatibleConfig,
|
||||
|
||||
// Preferences
|
||||
getPreferences,
|
||||
@@ -495,6 +539,8 @@ module.exports = {
|
||||
getTodayLimits,
|
||||
incrementLimitCount,
|
||||
getAvailableModel,
|
||||
incrementCharUsage,
|
||||
getModelForToday,
|
||||
|
||||
// History
|
||||
saveSession,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
+694
-87
@@ -3,7 +3,23 @@ const { BrowserWindow, ipcMain } = require('electron');
|
||||
const { spawn } = require('child_process');
|
||||
const { saveDebugAudio } = require('../audioUtils');
|
||||
const { getSystemPrompt } = require('./prompts');
|
||||
const { getAvailableModel, incrementLimitCount, getApiKey } = require('../storage');
|
||||
const { getAvailableModel, incrementLimitCount, getApiKey, getGroqApiKey, getOpenAICompatibleConfig, incrementCharUsage, getModelForToday } = require('../storage');
|
||||
|
||||
// Lazy-loaded to avoid circular dependency (localai.js imports from gemini.js)
|
||||
let _localai = null;
|
||||
function getLocalAi() {
|
||||
if (!_localai) _localai = require('./localai');
|
||||
return _localai;
|
||||
}
|
||||
|
||||
// Provider mode: 'byok' or 'local'
|
||||
let currentProviderMode = 'byok';
|
||||
|
||||
// Response provider: 'gemini', 'groq', or 'openai-compatible'
|
||||
let currentResponseProvider = 'gemini';
|
||||
|
||||
// Groq conversation history for context
|
||||
let groqConversationHistory = [];
|
||||
|
||||
// Conversation tracking variables
|
||||
let currentSessionId = null;
|
||||
@@ -13,6 +29,7 @@ let screenAnalysisHistory = [];
|
||||
let currentProfile = null;
|
||||
let currentCustomPrompt = null;
|
||||
let isInitializingSession = false;
|
||||
let currentSystemPrompt = null;
|
||||
|
||||
function formatSpeakerResults(results) {
|
||||
let text = '';
|
||||
@@ -31,6 +48,7 @@ module.exports.formatSpeakerResults = formatSpeakerResults;
|
||||
let systemAudioProc = null;
|
||||
let messageBuffer = '';
|
||||
|
||||
|
||||
// Reconnection variables
|
||||
let isUserClosing = false;
|
||||
let sessionParams = null;
|
||||
@@ -65,6 +83,7 @@ function initializeNewSession(profile = null, customPrompt = null) {
|
||||
currentTranscription = '';
|
||||
conversationHistory = [];
|
||||
screenAnalysisHistory = [];
|
||||
groqConversationHistory = [];
|
||||
currentProfile = profile;
|
||||
currentCustomPrompt = customPrompt;
|
||||
console.log('New conversation session started:', currentSessionId, 'profile:', profile);
|
||||
@@ -183,6 +202,363 @@ async function getStoredSetting(key, defaultValue) {
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// helper to check if groq has been configured
|
||||
function hasGroqKey() {
|
||||
const key = getGroqApiKey();
|
||||
return key && key.trim() != ''
|
||||
}
|
||||
|
||||
// helper to check if OpenAI-compatible API has been configured
|
||||
function hasOpenAICompatibleConfig() {
|
||||
const config = getOpenAICompatibleConfig();
|
||||
return config.apiKey && config.apiKey.trim() !== '' &&
|
||||
config.baseUrl && config.baseUrl.trim() !== '' &&
|
||||
config.model && config.model.trim() !== '';
|
||||
}
|
||||
|
||||
function trimConversationHistoryForGemma(history, maxChars=42000) {
|
||||
if(!history || history.length === 0) return [];
|
||||
let totalChars = 0;
|
||||
const trimmed = [];
|
||||
|
||||
for(let i = history.length - 1; i >= 0; i--) {
|
||||
const turn = history[i];
|
||||
const turnChars = (turn.content || '').length;
|
||||
|
||||
if(totalChars + turnChars > maxChars) break;
|
||||
totalChars += turnChars;
|
||||
trimmed.unshift(turn);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function stripThinkingTags(text) {
|
||||
return text.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
}
|
||||
|
||||
async function sendToGroq(transcription) {
|
||||
const groqApiKey = getGroqApiKey();
|
||||
if (!groqApiKey) {
|
||||
console.log('No Groq API key configured, skipping Groq response');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transcription || transcription.trim() === '') {
|
||||
console.log('Empty transcription, skipping Groq');
|
||||
return;
|
||||
}
|
||||
|
||||
const modelToUse = getModelForToday();
|
||||
if (!modelToUse) {
|
||||
console.log('All Groq daily limits exhausted');
|
||||
sendToRenderer('update-status', 'Groq limits reached for today');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Sending to Groq (${modelToUse}):`, transcription.substring(0, 100) + '...');
|
||||
|
||||
groqConversationHistory.push({
|
||||
role: 'user',
|
||||
content: transcription.trim()
|
||||
});
|
||||
|
||||
if (groqConversationHistory.length > 20) {
|
||||
groqConversationHistory = groqConversationHistory.slice(-20);
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.groq.com/openai/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${groqApiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelToUse,
|
||||
messages: [
|
||||
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
|
||||
...groqConversationHistory
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
max_tokens: 1024
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('Groq API error:', response.status, errorText);
|
||||
sendToRenderer('update-status', `Groq error: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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, { stream: true });
|
||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const json = JSON.parse(data);
|
||||
const token = json.choices?.[0]?.delta?.content || '';
|
||||
if (token) {
|
||||
fullText += token;
|
||||
const displayText = stripThinkingTags(fullText);
|
||||
if (displayText) {
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', displayText);
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
} catch (parseError) {
|
||||
// Skip invalid JSON chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cleanedResponse = stripThinkingTags(fullText);
|
||||
const modelKey = modelToUse.split('/').pop();
|
||||
|
||||
const systemPromptChars = (currentSystemPrompt || 'You are a helpful assistant.').length;
|
||||
const historyChars = groqConversationHistory.reduce((sum, msg) => sum + (msg.content || '').length, 0);
|
||||
const inputChars = systemPromptChars + historyChars;
|
||||
const outputChars = cleanedResponse.length;
|
||||
|
||||
incrementCharUsage('groq', modelKey, inputChars + outputChars);
|
||||
|
||||
if (cleanedResponse) {
|
||||
groqConversationHistory.push({
|
||||
role: 'assistant',
|
||||
content: cleanedResponse
|
||||
});
|
||||
|
||||
saveConversationTurn(transcription, cleanedResponse);
|
||||
}
|
||||
|
||||
console.log(`Groq response completed (${modelToUse})`);
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error calling Groq API:', error);
|
||||
sendToRenderer('update-status', 'Groq error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToOpenAICompatible(transcription) {
|
||||
const config = getOpenAICompatibleConfig();
|
||||
|
||||
if (!config.apiKey || !config.baseUrl || !config.model) {
|
||||
console.log('OpenAI-compatible API not fully configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transcription || transcription.trim() === '') {
|
||||
console.log('Empty transcription, skipping OpenAI-compatible API');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Sending to OpenAI-compatible API (${config.model}):`, transcription.substring(0, 100) + '...');
|
||||
|
||||
groqConversationHistory.push({
|
||||
role: 'user',
|
||||
content: transcription.trim()
|
||||
});
|
||||
|
||||
if (groqConversationHistory.length > 20) {
|
||||
groqConversationHistory = groqConversationHistory.slice(-20);
|
||||
}
|
||||
|
||||
try {
|
||||
// Ensure baseUrl ends with /v1/chat/completions or contains the full endpoint
|
||||
let apiUrl = config.baseUrl.trim();
|
||||
if (!apiUrl.includes('/chat/completions')) {
|
||||
// Remove trailing slash if present
|
||||
apiUrl = apiUrl.replace(/\/$/, '');
|
||||
// Add OpenAI-compatible endpoint path
|
||||
apiUrl = `${apiUrl}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
console.log(`Using OpenAI-compatible endpoint: ${apiUrl}`);
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${config.apiKey}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: config.model,
|
||||
messages: [
|
||||
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
|
||||
...groqConversationHistory
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.7,
|
||||
max_tokens: 2048
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error('OpenAI-compatible API error:', response.status, errorText);
|
||||
sendToRenderer('update-status', `OpenAI API error: ${response.status}`);
|
||||
return;
|
||||
}
|
||||
|
||||
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, { stream: true });
|
||||
const lines = chunk.split('\n').filter(line => line.trim() !== '');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') continue;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
|
||||
if (content) {
|
||||
fullText += content;
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||
isFirst = false;
|
||||
}
|
||||
} catch (e) {
|
||||
// Ignore JSON parse errors from partial chunks
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up <think> tags if present (for DeepSeek-style reasoning models)
|
||||
const cleanText = stripThinkingTags(fullText);
|
||||
if (cleanText !== fullText) {
|
||||
sendToRenderer('update-response', cleanText);
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
groqConversationHistory.push({
|
||||
role: 'assistant',
|
||||
content: fullText.trim()
|
||||
});
|
||||
|
||||
if (groqConversationHistory.length > 40) {
|
||||
groqConversationHistory = groqConversationHistory.slice(-40);
|
||||
}
|
||||
|
||||
saveConversationTurn(transcription, fullText);
|
||||
}
|
||||
|
||||
console.log(`OpenAI-compatible API response completed (${config.model})`);
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error calling OpenAI-compatible API:', error);
|
||||
sendToRenderer('update-status', 'OpenAI API error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToGemma(transcription) {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
console.log('No Gemini API key configured');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!transcription || transcription.trim() === '') {
|
||||
console.log('Empty transcription, skipping Gemma');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Sending to Gemma:', transcription.substring(0, 100) + '...');
|
||||
|
||||
groqConversationHistory.push({
|
||||
role: 'user',
|
||||
content: transcription.trim()
|
||||
});
|
||||
|
||||
const trimmedHistory = trimConversationHistoryForGemma(groqConversationHistory, 42000);
|
||||
|
||||
try {
|
||||
const ai = new GoogleGenAI({ apiKey: apiKey });
|
||||
|
||||
const messages = trimmedHistory.map(msg => ({
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }]
|
||||
}));
|
||||
|
||||
const systemPrompt = currentSystemPrompt || 'You are a helpful assistant.';
|
||||
const messagesWithSystem = [
|
||||
{ role: 'user', parts: [{ text: systemPrompt }] },
|
||||
{ role: 'model', parts: [{ text: 'Understood. I will follow these instructions.' }] },
|
||||
...messages
|
||||
];
|
||||
|
||||
const response = await ai.models.generateContentStream({
|
||||
model: 'gemma-3-27b-it',
|
||||
contents: messagesWithSystem,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let isFirst = true;
|
||||
|
||||
for await (const chunk of response) {
|
||||
const chunkText = chunk.text;
|
||||
if (chunkText) {
|
||||
fullText += chunkText;
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
|
||||
const systemPromptChars = (currentSystemPrompt || 'You are a helpful assistant.').length;
|
||||
const historyChars = trimmedHistory.reduce((sum, msg) => sum + (msg.content || '').length, 0);
|
||||
const inputChars = systemPromptChars + historyChars;
|
||||
const outputChars = fullText.length;
|
||||
|
||||
incrementCharUsage('gemini', 'gemma-3-27b-it', inputChars + outputChars);
|
||||
|
||||
if (fullText.trim()) {
|
||||
groqConversationHistory.push({
|
||||
role: 'assistant',
|
||||
content: fullText.trim()
|
||||
});
|
||||
|
||||
if (groqConversationHistory.length > 40) {
|
||||
groqConversationHistory = groqConversationHistory.slice(-40);
|
||||
}
|
||||
|
||||
saveConversationTurn(transcription, fullText);
|
||||
}
|
||||
|
||||
console.log('Gemma response completed');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error calling Gemma API:', error);
|
||||
sendToRenderer('update-status', 'Gemma error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'interview', language = 'en-US', isReconnect = false) {
|
||||
if (isInitializingSession) {
|
||||
console.log('Session initialization already in progress');
|
||||
@@ -199,6 +575,14 @@ async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'int
|
||||
sessionParams = { apiKey, customPrompt, profile, language };
|
||||
reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
// Load response provider preference
|
||||
if (!isReconnect) {
|
||||
const { getPreferences } = require('../storage');
|
||||
const prefs = getPreferences();
|
||||
currentResponseProvider = prefs.responseProvider || 'gemini';
|
||||
console.log('🔧 Response provider set to:', currentResponseProvider);
|
||||
}
|
||||
|
||||
const client = new GoogleGenAI({
|
||||
vertexai: false,
|
||||
@@ -211,6 +595,7 @@ async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'int
|
||||
const googleSearchEnabled = enabledTools.some(tool => tool.googleSearch);
|
||||
|
||||
const systemPrompt = getSystemPrompt(profile, customPrompt, googleSearchEnabled);
|
||||
currentSystemPrompt = systemPrompt; // Store for Groq
|
||||
|
||||
// Initialize new conversation session only on first connect
|
||||
if (!isReconnect) {
|
||||
@@ -229,45 +614,59 @@ async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'int
|
||||
|
||||
// Handle input transcription (what was spoken)
|
||||
if (message.serverContent?.inputTranscription?.results) {
|
||||
currentTranscription += formatSpeakerResults(message.serverContent.inputTranscription.results);
|
||||
const transcribed = formatSpeakerResults(message.serverContent.inputTranscription.results);
|
||||
console.log('Got transcription (results):', transcribed);
|
||||
currentTranscription += transcribed;
|
||||
} else if (message.serverContent?.inputTranscription?.text) {
|
||||
const text = message.serverContent.inputTranscription.text;
|
||||
if (text.trim() !== '') {
|
||||
console.log('Got transcription (text):', text);
|
||||
currentTranscription += text;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle AI model response via output transcription (native audio model)
|
||||
if (message.serverContent?.outputTranscription?.text) {
|
||||
const text = message.serverContent.outputTranscription.text;
|
||||
if (text.trim() === '') return; // Ignore empty transcriptions
|
||||
const isNewResponse = messageBuffer === '';
|
||||
messageBuffer += text;
|
||||
sendToRenderer(isNewResponse ? 'new-response' : 'update-response', messageBuffer);
|
||||
}
|
||||
// DISABLED: Gemini's outputTranscription - using Groq for faster responses instead
|
||||
// if (message.serverContent?.outputTranscription?.text) { ... }
|
||||
|
||||
if (message.serverContent?.generationComplete) {
|
||||
// Only send/save if there's actual content
|
||||
if (messageBuffer.trim() !== '') {
|
||||
sendToRenderer('update-response', messageBuffer);
|
||||
|
||||
// Save conversation turn when we have both transcription and AI response
|
||||
if (currentTranscription) {
|
||||
saveConversationTurn(currentTranscription, messageBuffer);
|
||||
currentTranscription = ''; // Reset for next turn
|
||||
console.log('✅ Generation complete. Current transcription:', `"${currentTranscription}"`);
|
||||
if (currentTranscription.trim() !== '') {
|
||||
// Use explicit user choice for response provider
|
||||
if (currentResponseProvider === 'openai-compatible') {
|
||||
if (hasOpenAICompatibleConfig()) {
|
||||
console.log('📤 Sending to OpenAI-compatible API (user selected)');
|
||||
sendToOpenAICompatible(currentTranscription);
|
||||
} else {
|
||||
console.log('⚠️ OpenAI-compatible selected but not configured, falling back to Gemini');
|
||||
sendToGemma(currentTranscription);
|
||||
}
|
||||
} else if (currentResponseProvider === 'groq') {
|
||||
if (hasGroqKey()) {
|
||||
console.log('📤 Sending to Groq (user selected)');
|
||||
sendToGroq(currentTranscription);
|
||||
} else {
|
||||
console.log('⚠️ Groq selected but not configured, falling back to Gemini');
|
||||
sendToGemma(currentTranscription);
|
||||
}
|
||||
} else {
|
||||
console.log('📤 Sending to Gemini (user selected)');
|
||||
sendToGemma(currentTranscription);
|
||||
}
|
||||
currentTranscription = '';
|
||||
} else {
|
||||
console.log('⚠️ Transcription is empty, not sending to LLM');
|
||||
}
|
||||
messageBuffer = '';
|
||||
}
|
||||
|
||||
if (message.serverContent?.turnComplete) {
|
||||
console.log('Turn complete');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
}
|
||||
},
|
||||
onerror: function (e) {
|
||||
const errorMsg = e?.message || e?.error?.message || 'Session error occurred';
|
||||
console.log('Session error:', errorMsg, e);
|
||||
sendToRenderer('update-status', 'Error: ' + errorMsg);
|
||||
console.log('Session error:', e.message);
|
||||
sendToRenderer('update-status', 'Error: ' + e.message);
|
||||
},
|
||||
onclose: function (e) {
|
||||
console.log('Session closed:', e.reason);
|
||||
@@ -289,15 +688,10 @@ async function initializeGeminiSession(apiKey, customPrompt = '', profile = 'int
|
||||
},
|
||||
config: {
|
||||
responseModalities: [Modality.AUDIO],
|
||||
proactivity: { proactiveAudio: true },
|
||||
proactivity: { proactiveAudio: false },
|
||||
outputAudioTranscription: {},
|
||||
inputAudioTranscription: {},
|
||||
tools: enabledTools,
|
||||
// Enable speaker diarization
|
||||
inputAudioTranscription: {
|
||||
enableSpeakerDiarization: true,
|
||||
minSpeakerCount: 2,
|
||||
maxSpeakerCount: 2,
|
||||
},
|
||||
contextWindowCompression: { slidingWindow: {} },
|
||||
speechConfig: { languageCode: language },
|
||||
systemInstruction: {
|
||||
@@ -328,6 +722,7 @@ async function attemptReconnect() {
|
||||
// Clear stale buffers
|
||||
messageBuffer = '';
|
||||
currentTranscription = '';
|
||||
// Don't reset groqConversationHistory to preserve context across reconnects
|
||||
|
||||
sendToRenderer('update-status', `Reconnecting... (${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`);
|
||||
|
||||
@@ -418,12 +813,10 @@ async function startMacOSAudioCapture(geminiSessionRef) {
|
||||
// Kill any existing SystemAudioDump processes first
|
||||
await killExistingSystemAudioDump();
|
||||
|
||||
console.log('=== Starting macOS audio capture ===');
|
||||
sendToRenderer('update-status', 'Starting audio capture...');
|
||||
console.log('Starting macOS audio capture with SystemAudioDump...');
|
||||
|
||||
const { app } = require('electron');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let systemAudioPath;
|
||||
if (app.isPackaged) {
|
||||
@@ -432,35 +825,7 @@ async function startMacOSAudioCapture(geminiSessionRef) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
console.log('SystemAudioDump path:', systemAudioPath);
|
||||
|
||||
const spawnOptions = {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
@@ -469,12 +834,10 @@ async function startMacOSAudioCapture(geminiSessionRef) {
|
||||
},
|
||||
};
|
||||
|
||||
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');
|
||||
console.error('Failed to start SystemAudioDump');
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -487,16 +850,8 @@ async function startMacOSAudioCapture(geminiSessionRef) {
|
||||
const CHUNK_SIZE = SAMPLE_RATE * BYTES_PER_SAMPLE * CHANNELS * CHUNK_DURATION;
|
||||
|
||||
let audioBuffer = 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...');
|
||||
}
|
||||
|
||||
audioBuffer = Buffer.concat([audioBuffer, data]);
|
||||
|
||||
while (audioBuffer.length >= CHUNK_SIZE) {
|
||||
@@ -504,12 +859,12 @@ async function startMacOSAudioCapture(geminiSessionRef) {
|
||||
audioBuffer = audioBuffer.slice(CHUNK_SIZE);
|
||||
|
||||
const monoChunk = CHANNELS === 2 ? convertStereoToMono(chunk) : chunk;
|
||||
const base64Data = monoChunk.toString('base64');
|
||||
sendAudioToGemini(base64Data, geminiSessionRef);
|
||||
|
||||
chunkCount++;
|
||||
if (chunkCount % 100 === 0) {
|
||||
console.log(`Audio: ${chunkCount} chunks processed`);
|
||||
if (currentProviderMode === 'local') {
|
||||
getLocalAi().processLocalAudio(monoChunk);
|
||||
} else {
|
||||
const base64Data = monoChunk.toString('base64');
|
||||
sendAudioToGemini(base64Data, geminiSessionRef);
|
||||
}
|
||||
|
||||
if (process.env.DEBUG_AUDIO) {
|
||||
@@ -525,24 +880,16 @@ async function startMacOSAudioCapture(geminiSessionRef) {
|
||||
});
|
||||
|
||||
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));
|
||||
}
|
||||
console.error('SystemAudioDump stderr:', data.toString());
|
||||
});
|
||||
|
||||
systemAudioProc.on('close', code => {
|
||||
console.log('SystemAudioDump closed with code:', code, 'chunks processed:', chunkCount);
|
||||
if (code !== 0 && code !== null) {
|
||||
sendToRenderer('update-status', `Audio stopped (exit: ${code})`);
|
||||
}
|
||||
console.log('SystemAudioDump process closed with code:', code);
|
||||
systemAudioProc = null;
|
||||
});
|
||||
|
||||
systemAudioProc.on('error', err => {
|
||||
console.error('SystemAudioDump spawn error:', err.message);
|
||||
sendToRenderer('update-status', 'Audio error: ' + err.message);
|
||||
console.error('SystemAudioDump process error:', err);
|
||||
systemAudioProc = null;
|
||||
});
|
||||
|
||||
@@ -641,6 +988,263 @@ async function sendImageToGeminiHttp(base64Data, prompt) {
|
||||
}
|
||||
}
|
||||
|
||||
function setupGeminiIpcHandlers(geminiSessionRef) {
|
||||
// Store the geminiSessionRef globally for reconnection access
|
||||
global.geminiSessionRef = geminiSessionRef;
|
||||
|
||||
ipcMain.handle('initialize-gemini', async (event, apiKey, customPrompt, profile = 'interview', language = 'en-US') => {
|
||||
currentProviderMode = 'byok';
|
||||
const session = await initializeGeminiSession(apiKey, customPrompt, profile, language);
|
||||
if (session) {
|
||||
geminiSessionRef.current = session;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
ipcMain.handle('initialize-local', async (event, ollamaHost, ollamaModel, whisperModel, profile, customPrompt) => {
|
||||
currentProviderMode = 'local';
|
||||
const success = await getLocalAi().initializeLocalSession(ollamaHost, ollamaModel, whisperModel, profile, customPrompt);
|
||||
if (!success) {
|
||||
currentProviderMode = 'byok';
|
||||
}
|
||||
return success;
|
||||
});
|
||||
|
||||
ipcMain.handle('send-audio-content', async (event, { data, mimeType }) => {
|
||||
if (currentProviderMode === 'local') {
|
||||
try {
|
||||
const pcmBuffer = Buffer.from(data, 'base64');
|
||||
getLocalAi().processLocalAudio(pcmBuffer);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending local audio:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
if (!geminiSessionRef.current) return { success: false, error: 'No active Gemini session' };
|
||||
try {
|
||||
process.stdout.write('.');
|
||||
await geminiSessionRef.current.sendRealtimeInput({
|
||||
audio: { data: data, mimeType: mimeType },
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending system audio:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Handle microphone audio on a separate channel
|
||||
ipcMain.handle('send-mic-audio-content', async (event, { data, mimeType }) => {
|
||||
if (currentProviderMode === 'local') {
|
||||
try {
|
||||
const pcmBuffer = Buffer.from(data, 'base64');
|
||||
getLocalAi().processLocalAudio(pcmBuffer);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending local mic audio:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
if (!geminiSessionRef.current) return { success: false, error: 'No active Gemini session' };
|
||||
try {
|
||||
process.stdout.write(',');
|
||||
await geminiSessionRef.current.sendRealtimeInput({
|
||||
audio: { data: data, mimeType: mimeType },
|
||||
});
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending mic audio:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('send-image-content', async (event, { data, prompt }) => {
|
||||
try {
|
||||
if (!data || typeof data !== 'string') {
|
||||
console.error('Invalid image data received');
|
||||
return { success: false, error: 'Invalid image data' };
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(data, 'base64');
|
||||
|
||||
if (buffer.length < 1000) {
|
||||
console.error(`Image buffer too small: ${buffer.length} bytes`);
|
||||
return { success: false, error: 'Image buffer too small' };
|
||||
}
|
||||
|
||||
process.stdout.write('!');
|
||||
|
||||
if (currentProviderMode === 'local') {
|
||||
const result = await getLocalAi().sendLocalImage(data, prompt);
|
||||
return result;
|
||||
}
|
||||
|
||||
// Use HTTP API instead of realtime session
|
||||
const result = await sendImageToGeminiHttp(data, prompt);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Error sending image:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('send-text-message', async (event, text) => {
|
||||
if (!text || typeof text !== 'string' || text.trim().length === 0) {
|
||||
return { success: false, error: 'Invalid text message' };
|
||||
}
|
||||
|
||||
if (currentProviderMode === 'local') {
|
||||
try {
|
||||
console.log('Sending text to local Ollama:', text);
|
||||
return await getLocalAi().sendLocalText(text.trim());
|
||||
} catch (error) {
|
||||
console.error('Error sending local text:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (!geminiSessionRef.current) return { success: false, error: 'No active Gemini session' };
|
||||
|
||||
try {
|
||||
console.log('Sending text message:', text);
|
||||
|
||||
// Use explicit user choice for response provider
|
||||
if (currentResponseProvider === 'openai-compatible') {
|
||||
if (hasOpenAICompatibleConfig()) {
|
||||
sendToOpenAICompatible(text.trim());
|
||||
} else {
|
||||
sendToGemma(text.trim());
|
||||
}
|
||||
} else if (currentResponseProvider === 'groq') {
|
||||
if (hasGroqKey()) {
|
||||
sendToGroq(text.trim());
|
||||
} else {
|
||||
sendToGemma(text.trim());
|
||||
}
|
||||
} else {
|
||||
sendToGemma(text.trim());
|
||||
}
|
||||
|
||||
await geminiSessionRef.current.sendRealtimeInput({ text: text.trim() });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error sending text:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('start-macos-audio', async event => {
|
||||
if (process.platform !== 'darwin') {
|
||||
return {
|
||||
success: false,
|
||||
error: 'macOS audio capture only available on macOS',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await startMacOSAudioCapture(geminiSessionRef);
|
||||
return { success };
|
||||
} catch (error) {
|
||||
console.error('Error starting macOS audio capture:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('stop-macos-audio', async event => {
|
||||
try {
|
||||
stopMacOSAudioCapture();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error stopping macOS audio capture:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('close-session', async event => {
|
||||
try {
|
||||
stopMacOSAudioCapture();
|
||||
|
||||
if (currentProviderMode === 'local') {
|
||||
getLocalAi().closeLocalSession();
|
||||
currentProviderMode = 'byok';
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
// Set flag to prevent reconnection attempts
|
||||
isUserClosing = true;
|
||||
sessionParams = null;
|
||||
|
||||
// Cleanup session
|
||||
if (geminiSessionRef.current) {
|
||||
await geminiSessionRef.current.close();
|
||||
geminiSessionRef.current = null;
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error closing session:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// Conversation history IPC handlers
|
||||
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);
|
||||
// The setting is already saved in localStorage by the renderer
|
||||
// This is just for logging/confirmation
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error updating Google Search setting:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
// OpenAI-compatible API configuration handlers
|
||||
ipcMain.handle('set-openai-compatible-config', async (event, apiKey, baseUrl, model) => {
|
||||
try {
|
||||
const { setOpenAICompatibleConfig } = require('../storage');
|
||||
setOpenAICompatibleConfig(apiKey, baseUrl, model);
|
||||
console.log('OpenAI-compatible config saved:', { baseUrl, model: model.substring(0, 30) });
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('Error setting OpenAI-compatible config:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('get-openai-compatible-config', async (event) => {
|
||||
try {
|
||||
const config = getOpenAICompatibleConfig();
|
||||
return { success: true, config };
|
||||
} catch (error) {
|
||||
console.error('Error getting OpenAI-compatible config:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeGeminiSession,
|
||||
getEnabledTools,
|
||||
@@ -655,5 +1259,8 @@ module.exports = {
|
||||
stopMacOSAudioCapture,
|
||||
sendAudioToGemini,
|
||||
sendImageToGeminiHttp,
|
||||
setupGeminiIpcHandlers,
|
||||
formatSpeakerResults,
|
||||
hasOpenAICompatibleConfig,
|
||||
sendToOpenAICompatible,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,437 @@
|
||||
const { Ollama } = require('ollama');
|
||||
const { getSystemPrompt } = require('./prompts');
|
||||
const { sendToRenderer, initializeNewSession, saveConversationTurn } = require('./gemini');
|
||||
|
||||
// ── State ──
|
||||
|
||||
let ollamaClient = null;
|
||||
let ollamaModel = null;
|
||||
let whisperPipeline = null;
|
||||
let isWhisperLoading = false;
|
||||
let localConversationHistory = [];
|
||||
let currentSystemPrompt = null;
|
||||
let isLocalActive = false;
|
||||
|
||||
// VAD state
|
||||
let isSpeaking = false;
|
||||
let speechBuffers = [];
|
||||
let silenceFrameCount = 0;
|
||||
let speechFrameCount = 0;
|
||||
|
||||
// VAD configuration
|
||||
const VAD_MODES = {
|
||||
NORMAL: { energyThreshold: 0.01, speechFramesRequired: 3, silenceFramesRequired: 30 },
|
||||
LOW_BITRATE: { energyThreshold: 0.008, speechFramesRequired: 4, silenceFramesRequired: 35 },
|
||||
AGGRESSIVE: { energyThreshold: 0.015, speechFramesRequired: 2, silenceFramesRequired: 20 },
|
||||
VERY_AGGRESSIVE: { energyThreshold: 0.02, speechFramesRequired: 2, silenceFramesRequired: 15 },
|
||||
};
|
||||
let vadConfig = VAD_MODES.VERY_AGGRESSIVE;
|
||||
|
||||
// Audio resampling buffer
|
||||
let resampleRemainder = Buffer.alloc(0);
|
||||
|
||||
// ── Audio Resampling (24kHz → 16kHz) ──
|
||||
|
||||
function resample24kTo16k(inputBuffer) {
|
||||
// Combine with any leftover samples from previous call
|
||||
const combined = Buffer.concat([resampleRemainder, inputBuffer]);
|
||||
const inputSamples = Math.floor(combined.length / 2); // 16-bit = 2 bytes per sample
|
||||
// Ratio: 16000/24000 = 2/3, so for every 3 input samples we produce 2 output samples
|
||||
const outputSamples = Math.floor((inputSamples * 2) / 3);
|
||||
const outputBuffer = Buffer.alloc(outputSamples * 2);
|
||||
|
||||
for (let i = 0; i < outputSamples; i++) {
|
||||
// Map output sample index to input position
|
||||
const srcPos = (i * 3) / 2;
|
||||
const srcIndex = Math.floor(srcPos);
|
||||
const frac = srcPos - srcIndex;
|
||||
|
||||
const s0 = combined.readInt16LE(srcIndex * 2);
|
||||
const s1 = srcIndex + 1 < inputSamples ? combined.readInt16LE((srcIndex + 1) * 2) : s0;
|
||||
const interpolated = Math.round(s0 + frac * (s1 - s0));
|
||||
outputBuffer.writeInt16LE(Math.max(-32768, Math.min(32767, interpolated)), i * 2);
|
||||
}
|
||||
|
||||
// Store remainder for next call
|
||||
const consumedInputSamples = Math.ceil((outputSamples * 3) / 2);
|
||||
const remainderStart = consumedInputSamples * 2;
|
||||
resampleRemainder = remainderStart < combined.length ? combined.slice(remainderStart) : Buffer.alloc(0);
|
||||
|
||||
return outputBuffer;
|
||||
}
|
||||
|
||||
// ── VAD (Voice Activity Detection) ──
|
||||
|
||||
function calculateRMS(pcm16Buffer) {
|
||||
const samples = pcm16Buffer.length / 2;
|
||||
if (samples === 0) return 0;
|
||||
let sumSquares = 0;
|
||||
for (let i = 0; i < samples; i++) {
|
||||
const sample = pcm16Buffer.readInt16LE(i * 2) / 32768;
|
||||
sumSquares += sample * sample;
|
||||
}
|
||||
return Math.sqrt(sumSquares / samples);
|
||||
}
|
||||
|
||||
function processVAD(pcm16kBuffer) {
|
||||
const rms = calculateRMS(pcm16kBuffer);
|
||||
const isVoice = rms > vadConfig.energyThreshold;
|
||||
|
||||
if (isVoice) {
|
||||
speechFrameCount++;
|
||||
silenceFrameCount = 0;
|
||||
|
||||
if (!isSpeaking && speechFrameCount >= vadConfig.speechFramesRequired) {
|
||||
isSpeaking = true;
|
||||
speechBuffers = [];
|
||||
console.log('[LocalAI] Speech started (RMS:', rms.toFixed(4), ')');
|
||||
sendToRenderer('update-status', 'Listening... (speech detected)');
|
||||
}
|
||||
} else {
|
||||
silenceFrameCount++;
|
||||
speechFrameCount = 0;
|
||||
|
||||
if (isSpeaking && silenceFrameCount >= vadConfig.silenceFramesRequired) {
|
||||
isSpeaking = false;
|
||||
console.log('[LocalAI] Speech ended, accumulated', speechBuffers.length, 'chunks');
|
||||
sendToRenderer('update-status', 'Transcribing...');
|
||||
|
||||
// Trigger transcription with accumulated audio
|
||||
const audioData = Buffer.concat(speechBuffers);
|
||||
speechBuffers = [];
|
||||
handleSpeechEnd(audioData);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Accumulate audio during speech
|
||||
if (isSpeaking) {
|
||||
speechBuffers.push(Buffer.from(pcm16kBuffer));
|
||||
}
|
||||
}
|
||||
|
||||
// ── Whisper Transcription ──
|
||||
|
||||
async function loadWhisperPipeline(modelName) {
|
||||
if (whisperPipeline) return whisperPipeline;
|
||||
if (isWhisperLoading) return null;
|
||||
|
||||
isWhisperLoading = true;
|
||||
console.log('[LocalAI] Loading Whisper model:', modelName);
|
||||
sendToRenderer('whisper-downloading', true);
|
||||
sendToRenderer('update-status', 'Loading Whisper model (first time may take a while)...');
|
||||
|
||||
try {
|
||||
// Dynamic import for ESM module
|
||||
const { pipeline, env } = await import('@huggingface/transformers');
|
||||
// Cache models outside the asar archive so ONNX runtime can load them
|
||||
const { app } = require('electron');
|
||||
const path = require('path');
|
||||
env.cacheDir = path.join(app.getPath('userData'), 'whisper-models');
|
||||
whisperPipeline = await pipeline('automatic-speech-recognition', modelName, {
|
||||
dtype: 'q8',
|
||||
device: 'auto',
|
||||
});
|
||||
console.log('[LocalAI] Whisper model loaded successfully');
|
||||
sendToRenderer('whisper-downloading', false);
|
||||
isWhisperLoading = false;
|
||||
return whisperPipeline;
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Failed to load Whisper model:', error);
|
||||
sendToRenderer('whisper-downloading', false);
|
||||
sendToRenderer('update-status', 'Failed to load Whisper model: ' + error.message);
|
||||
isWhisperLoading = false;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pcm16ToFloat32(pcm16Buffer) {
|
||||
const samples = pcm16Buffer.length / 2;
|
||||
const float32 = new Float32Array(samples);
|
||||
for (let i = 0; i < samples; i++) {
|
||||
float32[i] = pcm16Buffer.readInt16LE(i * 2) / 32768;
|
||||
}
|
||||
return float32;
|
||||
}
|
||||
|
||||
async function transcribeAudio(pcm16kBuffer) {
|
||||
if (!whisperPipeline) {
|
||||
console.error('[LocalAI] Whisper pipeline not loaded');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const float32Audio = pcm16ToFloat32(pcm16kBuffer);
|
||||
|
||||
// Whisper expects audio at 16kHz which is what we have
|
||||
const result = await whisperPipeline(float32Audio, {
|
||||
sampling_rate: 16000,
|
||||
language: 'en',
|
||||
task: 'transcribe',
|
||||
});
|
||||
|
||||
const text = result.text?.trim();
|
||||
console.log('[LocalAI] Transcription:', text);
|
||||
return text;
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Transcription error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Speech End Handler ──
|
||||
|
||||
async function handleSpeechEnd(audioData) {
|
||||
if (!isLocalActive) return;
|
||||
|
||||
// Minimum audio length check (~0.5 seconds at 16kHz, 16-bit)
|
||||
if (audioData.length < 16000) {
|
||||
console.log('[LocalAI] Audio too short, skipping');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
return;
|
||||
}
|
||||
|
||||
const transcription = await transcribeAudio(audioData);
|
||||
|
||||
if (!transcription || transcription.trim() === '' || transcription.trim().length < 2) {
|
||||
console.log('[LocalAI] Empty transcription, skipping');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
return;
|
||||
}
|
||||
|
||||
sendToRenderer('update-status', 'Generating response...');
|
||||
await sendToOllama(transcription);
|
||||
}
|
||||
|
||||
// ── Ollama Chat ──
|
||||
|
||||
async function sendToOllama(transcription) {
|
||||
if (!ollamaClient || !ollamaModel) {
|
||||
console.error('[LocalAI] Ollama not configured');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[LocalAI] Sending to Ollama:', transcription.substring(0, 100) + '...');
|
||||
|
||||
localConversationHistory.push({
|
||||
role: 'user',
|
||||
content: transcription.trim(),
|
||||
});
|
||||
|
||||
// Keep history manageable
|
||||
if (localConversationHistory.length > 20) {
|
||||
localConversationHistory = localConversationHistory.slice(-20);
|
||||
}
|
||||
|
||||
try {
|
||||
const messages = [
|
||||
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
|
||||
...localConversationHistory,
|
||||
];
|
||||
|
||||
const response = await ollamaClient.chat({
|
||||
model: ollamaModel,
|
||||
messages,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let isFirst = true;
|
||||
|
||||
for await (const part of response) {
|
||||
const token = part.message?.content || '';
|
||||
if (token) {
|
||||
fullText += token;
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
localConversationHistory.push({
|
||||
role: 'assistant',
|
||||
content: fullText.trim(),
|
||||
});
|
||||
|
||||
saveConversationTurn(transcription, fullText);
|
||||
}
|
||||
|
||||
console.log('[LocalAI] Ollama response completed');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Ollama error:', error);
|
||||
sendToRenderer('update-status', 'Ollama error: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ──
|
||||
|
||||
async function initializeLocalSession(ollamaHost, model, whisperModel, profile, customPrompt) {
|
||||
console.log('[LocalAI] Initializing local session:', { ollamaHost, model, whisperModel, profile });
|
||||
|
||||
sendToRenderer('session-initializing', true);
|
||||
|
||||
try {
|
||||
// Setup system prompt
|
||||
currentSystemPrompt = getSystemPrompt(profile, customPrompt, false);
|
||||
|
||||
// Initialize Ollama client
|
||||
ollamaClient = new Ollama({ host: ollamaHost });
|
||||
ollamaModel = model;
|
||||
|
||||
// Test Ollama connection
|
||||
try {
|
||||
await ollamaClient.list();
|
||||
console.log('[LocalAI] Ollama connection verified');
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Cannot connect to Ollama at', ollamaHost, ':', error.message);
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer('update-status', 'Cannot connect to Ollama at ' + ollamaHost);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Load Whisper model
|
||||
const pipeline = await loadWhisperPipeline(whisperModel);
|
||||
if (!pipeline) {
|
||||
sendToRenderer('session-initializing', false);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Reset VAD state
|
||||
isSpeaking = false;
|
||||
speechBuffers = [];
|
||||
silenceFrameCount = 0;
|
||||
speechFrameCount = 0;
|
||||
resampleRemainder = Buffer.alloc(0);
|
||||
localConversationHistory = [];
|
||||
|
||||
// Initialize conversation session
|
||||
initializeNewSession(profile, customPrompt);
|
||||
|
||||
isLocalActive = true;
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer('update-status', 'Local AI ready - Listening...');
|
||||
|
||||
console.log('[LocalAI] Session initialized successfully');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Initialization error:', error);
|
||||
sendToRenderer('session-initializing', false);
|
||||
sendToRenderer('update-status', 'Local AI error: ' + error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function processLocalAudio(monoChunk24k) {
|
||||
if (!isLocalActive) return;
|
||||
|
||||
// Resample from 24kHz to 16kHz
|
||||
const pcm16k = resample24kTo16k(monoChunk24k);
|
||||
if (pcm16k.length > 0) {
|
||||
processVAD(pcm16k);
|
||||
}
|
||||
}
|
||||
|
||||
function closeLocalSession() {
|
||||
console.log('[LocalAI] Closing local session');
|
||||
isLocalActive = false;
|
||||
isSpeaking = false;
|
||||
speechBuffers = [];
|
||||
silenceFrameCount = 0;
|
||||
speechFrameCount = 0;
|
||||
resampleRemainder = Buffer.alloc(0);
|
||||
localConversationHistory = [];
|
||||
ollamaClient = null;
|
||||
ollamaModel = null;
|
||||
currentSystemPrompt = null;
|
||||
// Note: whisperPipeline is kept loaded to avoid reloading on next session
|
||||
}
|
||||
|
||||
function isLocalSessionActive() {
|
||||
return isLocalActive;
|
||||
}
|
||||
|
||||
// ── Send text directly to Ollama (for manual text input) ──
|
||||
|
||||
async function sendLocalText(text) {
|
||||
if (!isLocalActive || !ollamaClient) {
|
||||
return { success: false, error: 'No active local session' };
|
||||
}
|
||||
|
||||
try {
|
||||
await sendToOllama(text);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
async function sendLocalImage(base64Data, prompt) {
|
||||
if (!isLocalActive || !ollamaClient) {
|
||||
return { success: false, error: 'No active local session' };
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[LocalAI] Sending image to Ollama');
|
||||
sendToRenderer('update-status', 'Analyzing image...');
|
||||
|
||||
const userMessage = {
|
||||
role: 'user',
|
||||
content: prompt,
|
||||
images: [base64Data],
|
||||
};
|
||||
|
||||
// Store text-only version in history
|
||||
localConversationHistory.push({ role: 'user', content: prompt });
|
||||
|
||||
if (localConversationHistory.length > 20) {
|
||||
localConversationHistory = localConversationHistory.slice(-20);
|
||||
}
|
||||
|
||||
const messages = [
|
||||
{ role: 'system', content: currentSystemPrompt || 'You are a helpful assistant.' },
|
||||
...localConversationHistory.slice(0, -1),
|
||||
userMessage,
|
||||
];
|
||||
|
||||
const response = await ollamaClient.chat({
|
||||
model: ollamaModel,
|
||||
messages,
|
||||
stream: true,
|
||||
});
|
||||
|
||||
let fullText = '';
|
||||
let isFirst = true;
|
||||
|
||||
for await (const part of response) {
|
||||
const token = part.message?.content || '';
|
||||
if (token) {
|
||||
fullText += token;
|
||||
sendToRenderer(isFirst ? 'new-response' : 'update-response', fullText);
|
||||
isFirst = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (fullText.trim()) {
|
||||
localConversationHistory.push({ role: 'assistant', content: fullText.trim() });
|
||||
saveConversationTurn(prompt, fullText);
|
||||
}
|
||||
|
||||
console.log('[LocalAI] Image response completed');
|
||||
sendToRenderer('update-status', 'Listening...');
|
||||
return { success: true, text: fullText, model: ollamaModel };
|
||||
} catch (error) {
|
||||
console.error('[LocalAI] Image error:', error);
|
||||
sendToRenderer('update-status', 'Ollama error: ' + error.message);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
initializeLocalSession,
|
||||
processLocalAudio,
|
||||
closeLocalSession,
|
||||
isLocalSessionActive,
|
||||
sendLocalText,
|
||||
sendLocalImage,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,618 +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
|
||||
|
||||
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;
|
||||
|
||||
// Check for silence (no new audio for SILENCE_THRESHOLD_MS)
|
||||
// This is a simple approach - in production you'd want proper VAD
|
||||
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,
|
||||
};
|
||||
@@ -219,60 +219,7 @@ function getSystemPrompt(profile, customPrompt = '', googleSearchEnabled = true)
|
||||
return buildSystemPrompt(promptParts, customPrompt, googleSearchEnabled);
|
||||
}
|
||||
|
||||
// Comprehensive prompt for Vision/Image analysis
|
||||
const VISION_ANALYSIS_PROMPT = `You are an expert AI assistant analyzing a screenshot. Your task is to understand what the user needs help with and provide the most useful response.
|
||||
|
||||
**ANALYSIS APPROACH:**
|
||||
1. First, identify what's shown on the screen (code editor, math problem, website, document, exam, etc.)
|
||||
2. Determine what the user likely needs (explanation, solution, answer, debugging help, etc.)
|
||||
3. Provide a direct, actionable response
|
||||
|
||||
**RESPONSE GUIDELINES BY CONTEXT:**
|
||||
|
||||
**If it's CODE (LeetCode, HackerRank, coding interview, IDE):**
|
||||
- Identify the programming language and problem type
|
||||
- Provide a brief explanation of the approach (2-3 bullet points max)
|
||||
- Give the complete, working code solution
|
||||
- Include time/space complexity if relevant
|
||||
- If there's an error, explain the fix
|
||||
|
||||
**If it's MATH or SCIENCE:**
|
||||
- Show step-by-step solution
|
||||
- Use proper mathematical notation with LaTeX ($..$ for inline, $$...$$ for blocks)
|
||||
- Provide the final answer clearly marked
|
||||
- Include any relevant formulas used
|
||||
|
||||
**If it's MCQ/EXAM/QUIZ:**
|
||||
- State the correct answer immediately and clearly (e.g., "**Answer: B**")
|
||||
- Provide brief justification (1-2 sentences)
|
||||
- If multiple questions visible, answer all of them
|
||||
|
||||
**If it's a DOCUMENT/ARTICLE/WEBSITE:**
|
||||
- Summarize the key information
|
||||
- Answer any specific questions if apparent
|
||||
- Highlight important points
|
||||
|
||||
**If it's a FORM/APPLICATION:**
|
||||
- Help fill in the required information
|
||||
- Suggest appropriate responses
|
||||
- Point out any issues or missing fields
|
||||
|
||||
**If it's an ERROR/DEBUG scenario:**
|
||||
- Identify the error type and cause
|
||||
- Provide the fix immediately
|
||||
- Explain briefly why it occurred
|
||||
|
||||
**FORMAT REQUIREMENTS:**
|
||||
- Use **markdown** for formatting
|
||||
- Use **bold** for key answers and important points
|
||||
- Use code blocks with language specification for code
|
||||
- Be concise but complete - no unnecessary explanations
|
||||
- No pleasantries or filler text - get straight to the answer
|
||||
|
||||
**CRITICAL:** Provide the complete answer. Don't ask for clarification - make reasonable assumptions and deliver value immediately.`;
|
||||
|
||||
module.exports = {
|
||||
profilePrompts,
|
||||
getSystemPrompt,
|
||||
VISION_ANALYSIS_PROMPT,
|
||||
};
|
||||
|
||||
+162
-370
@@ -18,23 +18,6 @@ let currentImageQuality = 'medium'; // Store current image quality for manual sc
|
||||
|
||||
const isLinux = process.platform === 'linux';
|
||||
const isMacOS = process.platform === 'darwin';
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
// Send logs to main process for file logging
|
||||
function logToMain(level, ...args) {
|
||||
const message = args.map(arg => {
|
||||
if (typeof arg === 'object') {
|
||||
try { return JSON.stringify(arg); } catch { return String(arg); }
|
||||
}
|
||||
return String(arg);
|
||||
}).join(' ');
|
||||
ipcRenderer.send('renderer-log', { level, message });
|
||||
|
||||
// Also log to console
|
||||
if (level === 'error') console.error(...args);
|
||||
else if (level === 'warn') console.warn(...args);
|
||||
else console.log(...args);
|
||||
}
|
||||
|
||||
// ============ STORAGE API ============
|
||||
// Wrapper for IPC-based storage access
|
||||
@@ -66,19 +49,19 @@ const storage = {
|
||||
async setApiKey(apiKey) {
|
||||
return ipcRenderer.invoke('storage:set-api-key', apiKey);
|
||||
},
|
||||
async getOpenAICredentials() {
|
||||
const result = await ipcRenderer.invoke('storage:get-openai-credentials');
|
||||
return result.success ? result.data : {};
|
||||
async getGroqApiKey() {
|
||||
const result = await ipcRenderer.invoke('storage:get-groq-api-key');
|
||||
return result.success ? result.data : '';
|
||||
},
|
||||
async setOpenAICredentials(config) {
|
||||
return ipcRenderer.invoke('storage:set-openai-credentials', config);
|
||||
async setGroqApiKey(groqApiKey) {
|
||||
return ipcRenderer.invoke('storage:set-groq-api-key', groqApiKey);
|
||||
},
|
||||
async getOpenAISDKCredentials() {
|
||||
const result = await ipcRenderer.invoke('storage:get-openai-sdk-credentials');
|
||||
return result.success ? result.data : {};
|
||||
async getOpenAICompatibleConfig() {
|
||||
const result = await ipcRenderer.invoke('get-openai-compatible-config');
|
||||
return result.success ? result.config : { apiKey: '', baseUrl: '', model: '' };
|
||||
},
|
||||
async setOpenAISDKCredentials(config) {
|
||||
return ipcRenderer.invoke('storage:set-openai-sdk-credentials', config);
|
||||
async setOpenAICompatibleConfig(apiKey, baseUrl, model) {
|
||||
return ipcRenderer.invoke('set-openai-compatible-config', apiKey, baseUrl, model);
|
||||
},
|
||||
|
||||
// Preferences
|
||||
@@ -165,12 +148,32 @@ function arrayBufferToBase64(buffer) {
|
||||
}
|
||||
|
||||
async function initializeGemini(profile = 'interview', language = 'en-US') {
|
||||
const apiKey = await storage.getApiKey();
|
||||
if (apiKey) {
|
||||
const prefs = await storage.getPreferences();
|
||||
const success = await ipcRenderer.invoke('initialize-gemini', apiKey, prefs.customPrompt || '', profile, language);
|
||||
if (success) {
|
||||
cheatingDaddy.setStatus('Live');
|
||||
} else {
|
||||
cheatingDaddy.setStatus('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function initializeLocal(profile = 'interview') {
|
||||
const prefs = await storage.getPreferences();
|
||||
const success = await ipcRenderer.invoke('initialize-ai-session', prefs.customPrompt || '', profile, language);
|
||||
const ollamaHost = prefs.ollamaHost || 'http://127.0.0.1:11434';
|
||||
const ollamaModel = prefs.ollamaModel || 'llama3.1';
|
||||
const whisperModel = prefs.whisperModel || 'Xenova/whisper-small';
|
||||
const customPrompt = prefs.customPrompt || '';
|
||||
|
||||
const success = await ipcRenderer.invoke('initialize-local', ollamaHost, ollamaModel, whisperModel, profile, customPrompt);
|
||||
if (success) {
|
||||
cheatingDaddy.setStatus('Live');
|
||||
cheatingDaddy.setStatus('Local AI Live');
|
||||
return true;
|
||||
} else {
|
||||
cheatingDaddy.setStatus('Error: Failed to initialize AI session Gemini');
|
||||
cheatingDaddy.setStatus('error');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -295,9 +298,6 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
|
||||
console.log('Linux capture started - system audio:', mediaStream.getAudioTracks().length > 0, 'microphone mode:', audioMode);
|
||||
} else {
|
||||
// Windows - use display media with loopback for system audio
|
||||
logToMain('info', '=== Starting Windows audio capture ===');
|
||||
cheatingDaddy.setStatus('Requesting screen & audio...');
|
||||
|
||||
mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: {
|
||||
frameRate: 1,
|
||||
@@ -313,29 +313,10 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
|
||||
},
|
||||
});
|
||||
|
||||
const audioTracks = mediaStream.getAudioTracks();
|
||||
const videoTracks = mediaStream.getVideoTracks();
|
||||
|
||||
logToMain('info', 'Windows capture result:', {
|
||||
hasVideo: videoTracks.length > 0,
|
||||
hasAudio: audioTracks.length > 0,
|
||||
audioTrackInfo: audioTracks.map(t => ({
|
||||
label: t.label,
|
||||
enabled: t.enabled,
|
||||
muted: t.muted,
|
||||
readyState: t.readyState,
|
||||
settings: t.getSettings()
|
||||
})),
|
||||
});
|
||||
console.log('Windows capture started with loopback audio');
|
||||
|
||||
if (audioTracks.length === 0) {
|
||||
logToMain('warn', 'WARNING: No audio tracks! User must check "Share audio" in screen picker dialog');
|
||||
cheatingDaddy.setStatus('Warning: No audio - enable "Share audio" checkbox');
|
||||
} else {
|
||||
logToMain('info', 'Audio track acquired, setting up processing...');
|
||||
// Setup audio processing for Windows loopback audio only
|
||||
setupWindowsLoopbackProcessing();
|
||||
}
|
||||
// Setup audio processing for Windows loopback audio only
|
||||
setupWindowsLoopbackProcessing();
|
||||
|
||||
if (audioMode === 'mic_only' || audioMode === 'both') {
|
||||
let micStream = null;
|
||||
@@ -368,21 +349,7 @@ async function startCapture(screenshotIntervalSeconds = 5, imageQuality = 'mediu
|
||||
console.log('Manual mode enabled - screenshots will be captured on demand only');
|
||||
} catch (err) {
|
||||
console.error('Error starting capture:', err);
|
||||
|
||||
// Provide more helpful error messages based on error type
|
||||
let errorMessage = err.message || 'Failed to start capture';
|
||||
|
||||
if (errorMessage.toLowerCase().includes('timeout')) {
|
||||
errorMessage = 'Screen capture timed out. Please try again and select a screen quickly.';
|
||||
} else if (errorMessage.toLowerCase().includes('permission') || errorMessage.toLowerCase().includes('denied')) {
|
||||
errorMessage = 'Screen capture permission denied. Please grant screen recording permission in System Settings.';
|
||||
} else if (errorMessage.toLowerCase().includes('not found') || errorMessage.toLowerCase().includes('no sources')) {
|
||||
errorMessage = 'No screen sources found. Please ensure a display is connected.';
|
||||
} else if (errorMessage.toLowerCase().includes('aborted') || errorMessage.toLowerCase().includes('cancel')) {
|
||||
errorMessage = 'Screen selection was cancelled. Please try again.';
|
||||
}
|
||||
|
||||
cheatingDaddy.setStatus('Error: ' + errorMessage);
|
||||
cheatingDaddy.setStatus('error');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -451,73 +418,32 @@ function setupLinuxSystemAudioProcessing() {
|
||||
|
||||
function setupWindowsLoopbackProcessing() {
|
||||
// Setup audio processing for Windows loopback audio only
|
||||
logToMain('info', 'Setting up Windows loopback audio processing...');
|
||||
|
||||
try {
|
||||
audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
|
||||
logToMain('info', 'AudioContext created:', {
|
||||
state: audioContext.state,
|
||||
sampleRate: audioContext.sampleRate,
|
||||
});
|
||||
|
||||
// Resume AudioContext if suspended (Chrome policy)
|
||||
if (audioContext.state === 'suspended') {
|
||||
logToMain('warn', 'AudioContext suspended, attempting resume...');
|
||||
audioContext.resume().then(() => {
|
||||
logToMain('info', 'AudioContext resumed successfully');
|
||||
}).catch(err => {
|
||||
logToMain('error', 'Failed to resume AudioContext:', err.message);
|
||||
audioContext = new AudioContext({ sampleRate: SAMPLE_RATE });
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
|
||||
|
||||
let audioBuffer = [];
|
||||
const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
|
||||
|
||||
audioProcessor.onaudioprocess = async e => {
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
audioBuffer.push(...inputData);
|
||||
|
||||
// Process audio in chunks
|
||||
while (audioBuffer.length >= samplesPerChunk) {
|
||||
const chunk = audioBuffer.splice(0, samplesPerChunk);
|
||||
const pcmData16 = convertFloat32ToInt16(chunk);
|
||||
const base64Data = arrayBufferToBase64(pcmData16.buffer);
|
||||
|
||||
await ipcRenderer.invoke('send-audio-content', {
|
||||
data: base64Data,
|
||||
mimeType: 'audio/pcm;rate=24000',
|
||||
});
|
||||
}
|
||||
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
audioProcessor = audioContext.createScriptProcessor(BUFFER_SIZE, 1, 1);
|
||||
};
|
||||
|
||||
let audioBuffer = [];
|
||||
const samplesPerChunk = SAMPLE_RATE * AUDIO_CHUNK_DURATION;
|
||||
let chunkCount = 0;
|
||||
let totalSamples = 0;
|
||||
|
||||
audioProcessor.onaudioprocess = async e => {
|
||||
const inputData = e.inputBuffer.getChannelData(0);
|
||||
audioBuffer.push(...inputData);
|
||||
totalSamples += inputData.length;
|
||||
|
||||
// Process audio in chunks
|
||||
while (audioBuffer.length >= samplesPerChunk) {
|
||||
const chunk = audioBuffer.splice(0, samplesPerChunk);
|
||||
const pcmData16 = convertFloat32ToInt16(chunk);
|
||||
const base64Data = arrayBufferToBase64(pcmData16.buffer);
|
||||
|
||||
await ipcRenderer.invoke('send-audio-content', {
|
||||
data: base64Data,
|
||||
mimeType: 'audio/pcm;rate=24000',
|
||||
});
|
||||
|
||||
chunkCount++;
|
||||
|
||||
// Log progress every 100 chunks (~10 seconds)
|
||||
if (chunkCount === 1) {
|
||||
logToMain('info', 'First audio chunk sent to AI');
|
||||
cheatingDaddy.setStatus('Listening...');
|
||||
} else if (chunkCount % 100 === 0) {
|
||||
// Calculate max amplitude to check if we're getting real audio
|
||||
const maxAmp = Math.max(...chunk.map(Math.abs));
|
||||
logToMain('info', `Audio progress: ${chunkCount} chunks, maxAmplitude: ${maxAmp.toFixed(4)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(audioProcessor);
|
||||
audioProcessor.connect(audioContext.destination);
|
||||
|
||||
logToMain('info', 'Windows audio processing pipeline connected');
|
||||
|
||||
} catch (err) {
|
||||
logToMain('error', 'Error setting up Windows audio:', err.message, err.stack);
|
||||
cheatingDaddy.setStatus('Audio error: ' + err.message);
|
||||
}
|
||||
source.connect(audioProcessor);
|
||||
audioProcessor.connect(audioContext.destination);
|
||||
}
|
||||
|
||||
async function captureScreenshot(imageQuality = 'medium', isManual = false) {
|
||||
@@ -612,193 +538,10 @@ async function captureScreenshot(imageQuality = 'medium', isManual = false) {
|
||||
);
|
||||
}
|
||||
|
||||
const MANUAL_SCREENSHOT_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.`;
|
||||
|
||||
// ============ REGION SELECTION ============
|
||||
// Uses a separate fullscreen window to allow selection outside the app window
|
||||
|
||||
async function startRegionSelection() {
|
||||
console.log('Starting region selection...');
|
||||
|
||||
if (!mediaStream) {
|
||||
console.error('No media stream available. Please start capture first.');
|
||||
cheatingDaddy?.addNewResponse('Please start screen capture first before selecting a region.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure video is ready
|
||||
if (!hiddenVideo) {
|
||||
hiddenVideo = document.createElement('video');
|
||||
hiddenVideo.srcObject = mediaStream;
|
||||
hiddenVideo.muted = true;
|
||||
hiddenVideo.playsInline = true;
|
||||
await hiddenVideo.play();
|
||||
|
||||
await new Promise(resolve => {
|
||||
if (hiddenVideo.readyState >= 2) return resolve();
|
||||
hiddenVideo.onloadedmetadata = () => resolve();
|
||||
});
|
||||
|
||||
// Initialize canvas
|
||||
offscreenCanvas = document.createElement('canvas');
|
||||
offscreenCanvas.width = hiddenVideo.videoWidth;
|
||||
offscreenCanvas.height = hiddenVideo.videoHeight;
|
||||
offscreenContext = offscreenCanvas.getContext('2d');
|
||||
}
|
||||
|
||||
if (hiddenVideo.readyState < 2) {
|
||||
console.warn('Video not ready yet');
|
||||
return;
|
||||
}
|
||||
|
||||
// Capture current screen to show in selection window
|
||||
offscreenContext.drawImage(hiddenVideo, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
const screenshotDataUrl = offscreenCanvas.toDataURL('image/jpeg', 0.9);
|
||||
|
||||
// Request main process to create selection window
|
||||
const result = await ipcRenderer.invoke('start-region-selection', { screenshotDataUrl });
|
||||
|
||||
if (result.success && result.rect) {
|
||||
// Capture the selected region from the screenshot
|
||||
await captureRegionFromScreenshot(result.rect, screenshotDataUrl);
|
||||
} else if (result.cancelled) {
|
||||
console.log('Region selection cancelled');
|
||||
} else if (result.error) {
|
||||
console.error('Region selection error:', result.error);
|
||||
}
|
||||
}
|
||||
|
||||
async function captureRegionFromScreenshot(rect, screenshotDataUrl) {
|
||||
console.log('Capturing region from screenshot:', rect);
|
||||
|
||||
// Load the screenshot into an image
|
||||
const img = new Image();
|
||||
img.src = screenshotDataUrl;
|
||||
|
||||
await new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
|
||||
// Calculate scale factor (screenshot might have different resolution than display)
|
||||
// The selection coordinates are in screen pixels, we need to map to image pixels
|
||||
const scaleX = img.naturalWidth / window.screen.width;
|
||||
const scaleY = img.naturalHeight / window.screen.height;
|
||||
|
||||
// Scale the selection rectangle
|
||||
const scaledRect = {
|
||||
left: Math.round(rect.left * scaleX),
|
||||
top: Math.round(rect.top * scaleY),
|
||||
width: Math.round(rect.width * scaleX),
|
||||
height: Math.round(rect.height * scaleY),
|
||||
};
|
||||
|
||||
// Create canvas for the cropped region
|
||||
const cropCanvas = document.createElement('canvas');
|
||||
cropCanvas.width = scaledRect.width;
|
||||
cropCanvas.height = scaledRect.height;
|
||||
const cropContext = cropCanvas.getContext('2d');
|
||||
|
||||
// Draw only the selected region
|
||||
cropContext.drawImage(
|
||||
img,
|
||||
scaledRect.left,
|
||||
scaledRect.top,
|
||||
scaledRect.width,
|
||||
scaledRect.height,
|
||||
0,
|
||||
0,
|
||||
scaledRect.width,
|
||||
scaledRect.height
|
||||
);
|
||||
|
||||
// Convert to blob and send
|
||||
cropCanvas.toBlob(
|
||||
async blob => {
|
||||
if (!blob) {
|
||||
console.error('Failed to create blob from cropped region');
|
||||
return;
|
||||
}
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = async () => {
|
||||
const base64data = reader.result.split(',')[1];
|
||||
|
||||
if (!base64data || base64data.length < 100) {
|
||||
console.error('Invalid base64 data generated');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await ipcRenderer.invoke('send-image-content', {
|
||||
data: base64data,
|
||||
prompt: MANUAL_SCREENSHOT_PROMPT,
|
||||
});
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Region capture response completed from ${result.model}`);
|
||||
} else {
|
||||
console.error('Failed to get region capture response:', result.error);
|
||||
cheatingDaddy.addNewResponse(`Error: ${result.error}`);
|
||||
}
|
||||
};
|
||||
reader.readAsDataURL(blob);
|
||||
},
|
||||
'image/jpeg',
|
||||
0.9
|
||||
);
|
||||
}
|
||||
|
||||
// Expose to global scope
|
||||
window.startRegionSelection = startRegionSelection;
|
||||
const MANUAL_SCREENSHOT_PROMPT = `Help me on this page, give me the answer no bs, complete answer.
|
||||
So if its a code question, give me the approach in few bullet points, then the entire code. Also if theres anything else i need to know, tell me.
|
||||
If its a question about the website, give me the answer no bs, complete answer.
|
||||
If its a mcq question, give me the answer no bs, complete answer.`;
|
||||
|
||||
async function captureManualScreenshot(imageQuality = null) {
|
||||
console.log('Manual screenshot triggered');
|
||||
@@ -835,21 +578,33 @@ async function captureManualScreenshot(imageQuality = null) {
|
||||
return;
|
||||
}
|
||||
|
||||
offscreenContext.drawImage(hiddenVideo, 0, 0, offscreenCanvas.width, offscreenCanvas.height);
|
||||
// Downscale to max 1280px wide for faster transfer — vision models don't need 4K
|
||||
const MAX_WIDTH = 1280;
|
||||
const srcW = hiddenVideo.videoWidth;
|
||||
const srcH = hiddenVideo.videoHeight;
|
||||
let destW = srcW;
|
||||
let destH = srcH;
|
||||
if (srcW > MAX_WIDTH) {
|
||||
destW = MAX_WIDTH;
|
||||
destH = Math.round(srcH * (MAX_WIDTH / srcW));
|
||||
}
|
||||
offscreenCanvas.width = destW;
|
||||
offscreenCanvas.height = destH;
|
||||
offscreenContext.drawImage(hiddenVideo, 0, 0, destW, destH);
|
||||
|
||||
let qualityValue;
|
||||
switch (quality) {
|
||||
case 'high':
|
||||
qualityValue = 0.9;
|
||||
qualityValue = 0.85;
|
||||
break;
|
||||
case 'medium':
|
||||
qualityValue = 0.7;
|
||||
qualityValue = 0.6;
|
||||
break;
|
||||
case 'low':
|
||||
qualityValue = 0.5;
|
||||
qualityValue = 0.4;
|
||||
break;
|
||||
default:
|
||||
qualityValue = 0.7;
|
||||
qualityValue = 0.6;
|
||||
}
|
||||
|
||||
offscreenCanvas.toBlob(
|
||||
@@ -868,6 +623,8 @@ async function captureManualScreenshot(imageQuality = null) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`Sending image: ${destW}x${destH}, ~${Math.round(base64data.length / 1024)}KB`);
|
||||
|
||||
// Send image with prompt to HTTP API (response streams via IPC events)
|
||||
const result = await ipcRenderer.invoke('send-image-content', {
|
||||
data: base64data,
|
||||
@@ -1020,9 +777,9 @@ const cheatingDaddyApp = document.querySelector('cheating-daddy-app');
|
||||
const theme = {
|
||||
themes: {
|
||||
dark: {
|
||||
background: '#1e1e1e',
|
||||
background: '#101010',
|
||||
text: '#e0e0e0', textSecondary: '#a0a0a0', textMuted: '#6b6b6b',
|
||||
border: '#333333', accent: '#ffffff',
|
||||
border: '#2a2a2a', accent: '#ffffff',
|
||||
btnPrimaryBg: '#ffffff', btnPrimaryText: '#000000', btnPrimaryHover: '#e0e0e0',
|
||||
tooltipBg: '#1a1a1a', tooltipText: '#ffffff',
|
||||
keyBg: 'rgba(255,255,255,0.1)'
|
||||
@@ -1051,30 +808,46 @@ const theme = {
|
||||
tooltipBg: '#5c4b37', tooltipText: '#f4ecd8',
|
||||
keyBg: 'rgba(92,75,55,0.15)'
|
||||
},
|
||||
nord: {
|
||||
background: '#2e3440',
|
||||
text: '#eceff4', textSecondary: '#d8dee9', textMuted: '#4c566a',
|
||||
border: '#3b4252', accent: '#88c0d0',
|
||||
btnPrimaryBg: '#88c0d0', btnPrimaryText: '#2e3440', btnPrimaryHover: '#8fbcbb',
|
||||
tooltipBg: '#3b4252', tooltipText: '#eceff4',
|
||||
keyBg: 'rgba(136,192,208,0.15)'
|
||||
catppuccin: {
|
||||
background: '#1e1e2e',
|
||||
text: '#cdd6f4', textSecondary: '#a6adc8', textMuted: '#585b70',
|
||||
border: '#313244', accent: '#cba6f7',
|
||||
btnPrimaryBg: '#cba6f7', btnPrimaryText: '#1e1e2e', btnPrimaryHover: '#b4befe',
|
||||
tooltipBg: '#313244', tooltipText: '#cdd6f4',
|
||||
keyBg: 'rgba(203,166,247,0.12)'
|
||||
},
|
||||
dracula: {
|
||||
background: '#282a36',
|
||||
text: '#f8f8f2', textSecondary: '#bd93f9', textMuted: '#6272a4',
|
||||
border: '#44475a', accent: '#ff79c6',
|
||||
btnPrimaryBg: '#ff79c6', btnPrimaryText: '#282a36', btnPrimaryHover: '#ff92d0',
|
||||
tooltipBg: '#44475a', tooltipText: '#f8f8f2',
|
||||
keyBg: 'rgba(255,121,198,0.15)'
|
||||
gruvbox: {
|
||||
background: '#1d2021',
|
||||
text: '#ebdbb2', textSecondary: '#a89984', textMuted: '#665c54',
|
||||
border: '#3c3836', accent: '#fe8019',
|
||||
btnPrimaryBg: '#fe8019', btnPrimaryText: '#1d2021', btnPrimaryHover: '#fabd2f',
|
||||
tooltipBg: '#3c3836', tooltipText: '#ebdbb2',
|
||||
keyBg: 'rgba(254,128,25,0.12)'
|
||||
},
|
||||
rosepine: {
|
||||
background: '#191724',
|
||||
text: '#e0def4', textSecondary: '#908caa', textMuted: '#6e6a86',
|
||||
border: '#26233a', accent: '#ebbcba',
|
||||
btnPrimaryBg: '#ebbcba', btnPrimaryText: '#191724', btnPrimaryHover: '#f6c177',
|
||||
tooltipBg: '#26233a', tooltipText: '#e0def4',
|
||||
keyBg: 'rgba(235,188,186,0.12)'
|
||||
},
|
||||
solarized: {
|
||||
background: '#002b36',
|
||||
text: '#93a1a1', textSecondary: '#839496', textMuted: '#586e75',
|
||||
border: '#073642', accent: '#2aa198',
|
||||
btnPrimaryBg: '#2aa198', btnPrimaryText: '#002b36', btnPrimaryHover: '#268bd2',
|
||||
tooltipBg: '#073642', tooltipText: '#93a1a1',
|
||||
keyBg: 'rgba(42,161,152,0.12)'
|
||||
},
|
||||
tokyonight: {
|
||||
background: '#1a1b26',
|
||||
text: '#c0caf5', textSecondary: '#9aa5ce', textMuted: '#565f89',
|
||||
border: '#292e42', accent: '#7aa2f7',
|
||||
btnPrimaryBg: '#7aa2f7', btnPrimaryText: '#1a1b26', btnPrimaryHover: '#bb9af7',
|
||||
tooltipBg: '#292e42', tooltipText: '#c0caf5',
|
||||
keyBg: 'rgba(122,162,247,0.12)'
|
||||
},
|
||||
abyss: {
|
||||
background: '#0a0a0a',
|
||||
text: '#d4d4d4', textSecondary: '#808080', textMuted: '#505050',
|
||||
border: '#1a1a1a', accent: '#ffffff',
|
||||
btnPrimaryBg: '#ffffff', btnPrimaryText: '#0a0a0a', btnPrimaryHover: '#d4d4d4',
|
||||
tooltipBg: '#141414', tooltipText: '#d4d4d4',
|
||||
keyBg: 'rgba(255,255,255,0.08)'
|
||||
}
|
||||
},
|
||||
|
||||
current: 'dark',
|
||||
@@ -1089,9 +862,11 @@ const theme = {
|
||||
light: 'Light',
|
||||
midnight: 'Midnight Blue',
|
||||
sepia: 'Sepia',
|
||||
nord: 'Nord',
|
||||
dracula: 'Dracula',
|
||||
abyss: 'Abyss'
|
||||
catppuccin: 'Catppuccin Mocha',
|
||||
gruvbox: 'Gruvbox Dark',
|
||||
rosepine: 'Ros\u00e9 Pine',
|
||||
solarized: 'Solarized Dark',
|
||||
tokyonight: 'Tokyo Night'
|
||||
};
|
||||
return Object.keys(this.themes).map(key => ({
|
||||
value: key,
|
||||
@@ -1133,20 +908,31 @@ const theme = {
|
||||
const isLight = (baseRgb.r + baseRgb.g + baseRgb.b) / 3 > 128;
|
||||
const adjust = isLight ? this.darkenColor.bind(this) : this.lightenColor.bind(this);
|
||||
|
||||
const secondary = adjust(baseRgb, 7);
|
||||
const tertiary = adjust(baseRgb, 15);
|
||||
const hover = adjust(baseRgb, 20);
|
||||
const secondary = adjust(baseRgb, 10);
|
||||
const tertiary = adjust(baseRgb, 22);
|
||||
const hover = adjust(baseRgb, 28);
|
||||
|
||||
root.style.setProperty('--header-background', `rgba(${baseRgb.r}, ${baseRgb.g}, ${baseRgb.b}, ${alpha})`);
|
||||
root.style.setProperty('--main-content-background', `rgba(${baseRgb.r}, ${baseRgb.g}, ${baseRgb.b}, ${alpha})`);
|
||||
root.style.setProperty('--bg-primary', `rgba(${baseRgb.r}, ${baseRgb.g}, ${baseRgb.b}, ${alpha})`);
|
||||
root.style.setProperty('--bg-secondary', `rgba(${secondary.r}, ${secondary.g}, ${secondary.b}, ${alpha})`);
|
||||
root.style.setProperty('--bg-tertiary', `rgba(${tertiary.r}, ${tertiary.g}, ${tertiary.b}, ${alpha})`);
|
||||
root.style.setProperty('--bg-hover', `rgba(${hover.r}, ${hover.g}, ${hover.b}, ${alpha})`);
|
||||
root.style.setProperty('--input-background', `rgba(${tertiary.r}, ${tertiary.g}, ${tertiary.b}, ${alpha})`);
|
||||
root.style.setProperty('--input-focus-background', `rgba(${tertiary.r}, ${tertiary.g}, ${tertiary.b}, ${alpha})`);
|
||||
root.style.setProperty('--hover-background', `rgba(${hover.r}, ${hover.g}, ${hover.b}, ${alpha})`);
|
||||
root.style.setProperty('--scrollbar-background', `rgba(${baseRgb.r}, ${baseRgb.g}, ${baseRgb.b}, ${alpha})`);
|
||||
const bgBase = `rgba(${baseRgb.r}, ${baseRgb.g}, ${baseRgb.b}, ${alpha})`;
|
||||
const bgSurface = `rgba(${secondary.r}, ${secondary.g}, ${secondary.b}, ${alpha})`;
|
||||
const bgElevated = `rgba(${tertiary.r}, ${tertiary.g}, ${tertiary.b}, ${alpha})`;
|
||||
const bgHover = `rgba(${hover.r}, ${hover.g}, ${hover.b}, ${alpha})`;
|
||||
|
||||
// New design tokens (used by components)
|
||||
root.style.setProperty('--bg-app', bgBase);
|
||||
root.style.setProperty('--bg-surface', bgSurface);
|
||||
root.style.setProperty('--bg-elevated', bgElevated);
|
||||
root.style.setProperty('--bg-hover', bgHover);
|
||||
|
||||
// Legacy aliases
|
||||
root.style.setProperty('--header-background', bgBase);
|
||||
root.style.setProperty('--main-content-background', bgBase);
|
||||
root.style.setProperty('--bg-primary', bgBase);
|
||||
root.style.setProperty('--bg-secondary', bgSurface);
|
||||
root.style.setProperty('--bg-tertiary', bgElevated);
|
||||
root.style.setProperty('--input-background', bgElevated);
|
||||
root.style.setProperty('--input-focus-background', bgElevated);
|
||||
root.style.setProperty('--hover-background', bgHover);
|
||||
root.style.setProperty('--scrollbar-background', bgBase);
|
||||
},
|
||||
|
||||
apply(themeName, alpha = 0.8) {
|
||||
@@ -1154,14 +940,19 @@ const theme = {
|
||||
this.current = themeName;
|
||||
const root = document.documentElement;
|
||||
|
||||
// Text colors
|
||||
root.style.setProperty('--text-color', colors.text);
|
||||
// New design tokens (used by components)
|
||||
root.style.setProperty('--text-primary', colors.text);
|
||||
root.style.setProperty('--text-secondary', colors.textSecondary);
|
||||
root.style.setProperty('--text-muted', colors.textMuted);
|
||||
// Border colors
|
||||
root.style.setProperty('--border', colors.border);
|
||||
root.style.setProperty('--border-strong', colors.accent);
|
||||
root.style.setProperty('--accent', colors.btnPrimaryBg);
|
||||
root.style.setProperty('--accent-hover', colors.btnPrimaryHover);
|
||||
|
||||
// Legacy aliases
|
||||
root.style.setProperty('--text-color', colors.text);
|
||||
root.style.setProperty('--border-color', colors.border);
|
||||
root.style.setProperty('--border-default', colors.accent);
|
||||
// Misc
|
||||
root.style.setProperty('--placeholder-color', colors.textMuted);
|
||||
root.style.setProperty('--scrollbar-thumb', colors.border);
|
||||
root.style.setProperty('--scrollbar-thumb-hover', colors.textMuted);
|
||||
@@ -1224,6 +1015,7 @@ const cheatingDaddy = {
|
||||
|
||||
// Core functionality
|
||||
initializeGemini,
|
||||
initializeLocal,
|
||||
startCapture,
|
||||
stopCapture,
|
||||
sendTextMessage,
|
||||
|
||||
+31
-447
@@ -1,13 +1,8 @@
|
||||
const { BrowserWindow, globalShortcut, ipcMain, screen } = require('electron');
|
||||
const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('os');
|
||||
const storage = require('../storage');
|
||||
|
||||
let mouseEventsIgnored = false;
|
||||
let windowResizing = false;
|
||||
let resizeAnimation = null;
|
||||
const RESIZE_ANIMATION_DURATION = 500; // milliseconds
|
||||
|
||||
function createWindow(sendToRenderer, geminiSessionRef) {
|
||||
// Get layout preference (default to 'normal')
|
||||
@@ -33,73 +28,14 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
||||
});
|
||||
|
||||
const { session, desktopCapturer } = require('electron');
|
||||
|
||||
// Setup display media request handler for screen capture
|
||||
if (process.platform === 'darwin') {
|
||||
// On macOS, use SystemAudioDump for audio (not browser loopback)
|
||||
// So we just need to capture the screen
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
async (request, callback) => {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize: { width: 0, height: 0 } // Skip thumbnail generation for speed
|
||||
});
|
||||
|
||||
if (sources.length === 0) {
|
||||
console.error('No screen sources available');
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// On macOS, directly use the first screen (system already granted permission)
|
||||
// Audio is handled separately by SystemAudioDump
|
||||
console.log('Screen capture source:', sources[0].name);
|
||||
callback({ video: sources[0], audio: 'loopback' });
|
||||
} catch (error) {
|
||||
console.error('Error getting screen sources:', error);
|
||||
callback(null);
|
||||
}
|
||||
},
|
||||
{ useSystemPicker: false } // Disable system picker, use our source directly
|
||||
);
|
||||
} else if (process.platform === 'win32') {
|
||||
// On Windows, use system picker so user can enable "Share audio" checkbox
|
||||
// This is REQUIRED for system audio capture on Windows
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
(request, callback) => {
|
||||
console.log('Windows: Using system picker for screen/audio selection');
|
||||
// Don't call callback - let system picker handle it
|
||||
// The system picker will provide both video and audio if user checks the box
|
||||
},
|
||||
{ useSystemPicker: true }
|
||||
);
|
||||
} else {
|
||||
// On Linux, try to get system audio via loopback
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
async (request, callback) => {
|
||||
try {
|
||||
const sources = await desktopCapturer.getSources({
|
||||
types: ['screen'],
|
||||
thumbnailSize: { width: 0, height: 0 }
|
||||
});
|
||||
|
||||
if (sources.length === 0) {
|
||||
console.error('No screen sources available');
|
||||
callback(null);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Linux: Using screen source with loopback audio');
|
||||
callback({ video: sources[0], audio: 'loopback' });
|
||||
} catch (error) {
|
||||
console.error('Error getting screen sources:', error);
|
||||
callback(null);
|
||||
}
|
||||
},
|
||||
{ useSystemPicker: false }
|
||||
);
|
||||
}
|
||||
session.defaultSession.setDisplayMediaRequestHandler(
|
||||
(request, callback) => {
|
||||
desktopCapturer.getSources({ types: ['screen'] }).then(sources => {
|
||||
callback({ video: sources[0], audio: 'loopback' });
|
||||
});
|
||||
},
|
||||
{ useSystemPicker: true }
|
||||
);
|
||||
|
||||
mainWindow.setResizable(false);
|
||||
mainWindow.setContentProtection(true);
|
||||
@@ -109,7 +45,6 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
mainWindow.setSkipTaskbar(true);
|
||||
console.log('Hidden from Windows taskbar');
|
||||
} catch (error) {
|
||||
console.warn('Could not hide from taskbar:', error.message);
|
||||
}
|
||||
@@ -119,7 +54,6 @@ function createWindow(sendToRenderer, geminiSessionRef) {
|
||||
if (process.platform === 'darwin') {
|
||||
try {
|
||||
mainWindow.setHiddenInMissionControl(true);
|
||||
console.log('Hidden from macOS Mission Control');
|
||||
} catch (error) {
|
||||
console.warn('Could not hide from Mission Control:', error.message);
|
||||
}
|
||||
@@ -366,8 +300,26 @@ function updateGlobalShortcuts(keybinds, mainWindow, sendToRenderer, geminiSessi
|
||||
|
||||
function setupWindowIpcHandlers(mainWindow, sendToRenderer, geminiSessionRef) {
|
||||
ipcMain.on('view-changed', (event, view) => {
|
||||
if (view !== 'assistant' && !mainWindow.isDestroyed()) {
|
||||
mainWindow.setIgnoreMouseEvents(false);
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -401,378 +353,10 @@ function setupWindowIpcHandlers(mainWindow, sendToRenderer, geminiSessionRef) {
|
||||
}
|
||||
});
|
||||
|
||||
function animateWindowResize(mainWindow, targetWidth, targetHeight, layoutMode) {
|
||||
return new Promise(resolve => {
|
||||
// Check if window is destroyed before starting animation
|
||||
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 };
|
||||
} 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 };
|
||||
}
|
||||
// With the sidebar layout, the window size is user-controlled.
|
||||
// This handler is kept for compatibility but is a no-op now.
|
||||
return { success: true };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user