From 6296ebd192cf541d686932a23d1799f5d3582e37 Mon Sep 17 00:00:00 2001 From: viper Date: Sun, 7 Jun 2026 10:23:57 -0500 Subject: [PATCH] release Jarvis One 0.1.21 --- .github/workflows/release.yml | 18 +- CHANGELOG.md | 26 + DEVLOG.md | 19 + app/package.json | 2 +- app/src-tauri/Cargo.toml | 2 +- app/src-tauri/tauri.conf.json | 2 +- app/src/features/ambient/ambientAudio.ts | 767 ++---------------- app/src/features/ambient/tracks.test.ts | 45 +- app/src/features/ambient/tracks.ts | 74 +- app/src/features/chat/Composer.paths.test.ts | 19 + app/src/features/chat/Composer.tsx | 133 +-- .../features/onboarding/steps/Permissions.tsx | 41 +- .../features/settings/sections/Ambient.tsx | 43 +- .../features/tasks/NotificationEngine.test.ts | 28 +- app/src/features/tasks/NotificationEngine.ts | 34 +- app/src/features/tasks/TaskService.test.ts | 122 +++ app/src/features/tasks/TaskService.ts | 18 +- app/src/lib/ai/models.test.ts | 32 + app/src/lib/ai/models.ts | 65 ++ app/src/lib/ai/providers/mock.ts | 33 +- app/src/lib/ai/router.test.ts | 54 ++ app/src/lib/ai/router.ts | 76 +- app/src/lib/tauri.ts | 72 +- app/src/stores/auth.ts | 9 + app/src/stores/ui.ts | 19 +- package-lock.json | 6 +- package.json | 2 +- 27 files changed, 757 insertions(+), 1004 deletions(-) create mode 100644 app/src/features/chat/Composer.paths.test.ts create mode 100644 app/src/features/tasks/TaskService.test.ts create mode 100644 app/src/lib/ai/models.test.ts create mode 100644 app/src/lib/ai/models.ts create mode 100644 app/src/lib/ai/router.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7e07c2d12..1f08e3f14 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ # TAURI_SIGNING_PRIVATE_KEY - Tauri updater signing private key # TAURI_SIGNING_PRIVATE_KEY_PASSWORD # Until those exist, builds will be unsigned and the install script will warn. -# 3. To cut a release: `git tag v0.1.17 && git push origin v0.1.17`. +# 3. To cut a release: `git tag v0.1.20 && git push origin v0.1.20`. # The workflow creates a draft release; review it on GitHub and hit Publish. name: Release @@ -50,8 +50,22 @@ permissions: contents: write jobs: + release-preflight: + name: Validate release configuration + runs-on: ubuntu-22.04 + steps: + - name: Require updater signing key + env: + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + run: | + if [ -z "$TAURI_SIGNING_PRIVATE_KEY" ]; then + echo "::error::TAURI_SIGNING_PRIVATE_KEY is required because createUpdaterArtifacts is enabled." + exit 1 + fi + release: name: Build (${{ matrix.label }}) + needs: release-preflight strategy: # One platform failing should not abort the others; users on # working platforms still get installers. @@ -112,7 +126,7 @@ jobs: libssl-dev - name: Install npm dependencies - run: npm install + run: npm ci - name: Prepare Windows signing config if: matrix.platform == 'windows-latest' diff --git a/CHANGELOG.md b/CHANGELOG.md index 17beedef0..0a6ba70a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ All notable changes to Jarvis are documented here. Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.21] - 2026-06-07 + +### Added + +- Added a persistent provider/model picker; `/model` now opens it or accepts a provider and model directly. +- Added automatic Windows file-path detection so referenced files are included in AI context. +- Added a five-track hosted ambient playlist ready for public Cloudflare R2 URLs, with sequential repeat playback. + +### Fixed + +- Built-in Jarvis agents now honor the selected provider and model. +- Real provider failures are surfaced instead of silently returning unrelated canned mock responses. +- Mock mode clearly identifies itself and no longer pretends to analyze unavailable context. +- Reminder channels, completion notifications, and desktop notification permission handling now behave correctly. + +### Release + +- Built and updater-signed Windows NSIS/MSI artifacts for the silent `0.1.21` update. + ## [0.1.20] - 2026-06-06 ### Added @@ -20,11 +39,18 @@ All notable changes to Jarvis are documented here. Format follows [Keep a Change - Windows release packaging now validates that the updater private key's sibling `.pub` file matches the public key embedded in Tauri config before building. - Removed local helper files that copied updater private-key material into the repository worktree. +### Fixed + +- Reminder delivery now respects each reminder's `banner` and `in_app` channels instead of always sending both surfaces. +- Ordinary task edits no longer emit a false "Task done" notification; actual completion now emits it and closes scheduled reminders. +- Notification permission checks now use the Tauri notification plugin on desktop, including the onboarding permission step. + ### Release - Generated updater-signed 0.1.20 NSIS/MSI artifacts, `latest.json`, and current-artifact SHA-256 checksums. - The updater manifest now targets the stable hyphenated GitHub asset name so release filename normalization cannot break downloads. - Verified a silent per-user NSIS upgrade from 0.1.19 to 0.1.20 and confirmed the relaunched executable reports version 0.1.20. +- Fixed the cross-platform GitHub Release workflow by adding the `tauri` npm script expected by `tauri-action`, switching dependency installation to `npm ci`, and adding an explicit updater-key secret preflight. - Windows Authenticode signing remains separate from Tauri updater signing and still requires `WINDOWS_CERT_BASE64` or `WINDOWS_CERT_THUMBPRINT`. ## [0.1.19] - 2026-06-06 diff --git a/DEVLOG.md b/DEVLOG.md index 46e76e7ff..b5f6d7550 100644 --- a/DEVLOG.md +++ b/DEVLOG.md @@ -1,3 +1,16 @@ +## 2026-06-07 - v0.1.21 AI, Music, and Notification Update + +- Replaced procedural ambient synthesis with a five-track hosted playlist that advances in order and repeats. +- Added placeholder Cloudflare R2 URLs for later replacement with final music links. +- Reworked `/model` and the composer picker to persist a real provider/model pair and route built-in Jarvis through it. +- Limited the chat picker to implemented adapters instead of providers that still route to mock. +- Added automatic absolute Windows file-path extraction so file-summary prompts receive referenced file content. +- Removed silent fallback from failed real providers to canned mock prose; API failures now remain visible. +- Added regression tests for model routing, model catalogs, file paths, reminder channels, and task completion. +- Verified `209` tests, TypeScript typecheck, and the production frontend build. + +--- + ## 2026-06-06 - v0.1.20 Plugins Major Update **Actor:** Codex @@ -24,8 +37,14 @@ - `npm run release:windows` generated matching 0.1.20 NSIS/MSI updater signatures with the private key selected only after its sibling `.pub` matched `tauri.conf.json`. - `npm run release:stage` completed cleanly and regenerated `releases/latest.json` plus current-only `releases/SHA256SUMS.txt`. - Public release verification caught GitHub's space-to-period asset-name normalization; the manifest generator now prefers the stable hyphenated NSIS asset and the published manifest was replaced. +- The public `v0.1.20` release contains the locally verified Windows NSIS/MSI artifacts, updater signatures, `latest.json`, and `SHA256SUMS.txt`. - The silent local NSIS upgrade completed with exit code 0; `%LOCALAPPDATA%\Jarvis One\jarvis.exe` and the relaunched process both report `ProductVersion=0.1.20`. +- The first tagged cross-platform Release run failed before compilation because `tauri-action` invokes `npm run tauri build` and the app package exposed only `tauri:build`. Added the expected `tauri` script, changed workflow installs to `npm ci`, and added an explicit `TAURI_SIGNING_PRIVATE_KEY` preflight. +- A follow-up run from the stale `v0.1.20` tag reproduced the same missing-script failure on Linux, Windows, and macOS ARM; the remaining queued macOS x64 job was cancelled after confirmation. +- The GitHub repository currently has no Actions secrets configured, so a future updater-producing matrix release will stop at the new preflight until the maintainer deliberately installs the updater key and optional platform signing credentials. - The Windows installer is updater-signed but remains Authenticode `NotSigned` because no trusted Windows certificate is configured. macOS/Linux build, signing, and runtime verification remain pending. +- Reminder dispatch now honors `banner` versus `in_app` channels, task-completion notifications fire only from the completion path, and onboarding requests desktop notification permission through the Tauri plugin rather than the browser-only API. +- Added focused notification and task-service regression coverage for channel isolation, false completion suppression, and scheduled-reminder closure. --- diff --git a/app/package.json b/app/package.json index 63b3aed39..e69db1195 100644 --- a/app/package.json +++ b/app/package.json @@ -1,6 +1,6 @@ { "name": "@jarvis/app", - "version": "0.1.20", + "version": "0.1.21", "private": true, "type": "module", "scripts": { diff --git a/app/src-tauri/Cargo.toml b/app/src-tauri/Cargo.toml index 2dc0dd5c1..26a04e944 100644 --- a/app/src-tauri/Cargo.toml +++ b/app/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jarvis" -version = "0.1.20" +version = "0.1.21" description = "The AI workspace where every model, agent, voice, and task lives under one persistent memory and one always-available assistant." authors = ["Jarvis"] edition = "2021" diff --git a/app/src-tauri/tauri.conf.json b/app/src-tauri/tauri.conf.json index e4499dfd1..df9795d97 100644 --- a/app/src-tauri/tauri.conf.json +++ b/app/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Jarvis One", - "version": "0.1.20", + "version": "0.1.21", "identifier": "ai.jarvis.desktop", "build": { "beforeDevCommand": "npm run jarvis", diff --git a/app/src/features/ambient/ambientAudio.ts b/app/src/features/ambient/ambientAudio.ts index 07c54652e..18b5fca1f 100644 --- a/app/src/features/ambient/ambientAudio.ts +++ b/app/src/features/ambient/ambientAudio.ts @@ -1,23 +1,14 @@ import type { AmbientTrack } from '@/stores/ui'; +import { AMBIENT_TRACKS, getAmbientTrackIndex } from './tracks'; export class AmbientAudioEngine { private static instance: AmbientAudioEngine | null = null; - private ctx: AudioContext | null = null; - private masterGain: GainNode | null = null; - - // Track specific nodes and sources to allow crossfades - private activeTrackId: AmbientTrack | null = null; - private activeTrackNodes: { - gain: GainNode; - sources: { stop: () => void }[]; - } | null = null; - - private currentVolumePercent = 40; + private audio: HTMLAudioElement | null = null; + private currentTrackIndex = 0; private isEngineRunning = false; + private currentVolumePercent = 40; - private constructor() { - // Lazy initialization of AudioContext on first play/user gesture - } + private constructor() {} public static getInstance(): AmbientAudioEngine { if (!AmbientAudioEngine.instance) { @@ -26,730 +17,98 @@ export class AmbientAudioEngine { return AmbientAudioEngine.instance; } - private initContext(): AudioContext { - if (!this.ctx) { - const AudioContextClass = window.AudioContext || (window as any).webkitAudioContext; - this.ctx = new AudioContextClass(); - this.masterGain = this.ctx.createGain(); - this.masterGain.connect(this.ctx.destination); - - // Master volume scale: audible at normal speaker levels without hitting full app volume. - this.updateMasterVolume(); + private getAudio(): HTMLAudioElement { + if (!this.audio) { + this.audio = new Audio(); + this.audio.preload = 'auto'; + this.audio.addEventListener('ended', this.playNextTrack); + this.audio.addEventListener('error', this.handleTrackError); } - - void this.resume(); - return this.ctx; + return this.audio; } - private updateMasterVolume(): void { - if (!this.masterGain || !this.ctx) return; - const targetGain = (this.currentVolumePercent / 100) * 0.55; - this.masterGain.gain.setValueAtTime(this.masterGain.gain.value, this.ctx.currentTime); - this.masterGain.gain.linearRampToValueAtTime(targetGain, this.ctx.currentTime + 0.1); - } - - public play(track: AmbientTrack, volume: number): void { - this.currentVolumePercent = volume; - const ctx = this.initContext(); - this.updateMasterVolume(); - - if (this.activeTrackId === track && this.isEngineRunning) { - return; - } - - this.isEngineRunning = true; - - // If a track is already playing, crossfade - if (this.activeTrackNodes) { - const oldNodes = this.activeTrackNodes; - const now = ctx.currentTime; - // Fade out old track over 2 seconds - oldNodes.gain.gain.setValueAtTime(oldNodes.gain.gain.value, now); - oldNodes.gain.gain.linearRampToValueAtTime(0, now + 2.0); - - setTimeout(() => { - try { - oldNodes.sources.forEach(src => src.stop()); - } catch (e) { - // Ignore if already stopped - } - }, 2500); + private loadCurrentTrack(): void { + const audio = this.getAudio(); + const track = AMBIENT_TRACKS[this.currentTrackIndex] ?? AMBIENT_TRACKS[0]; + if (audio.src !== track.url) { + audio.src = track.url; + audio.load(); } + } - // Start the new track - this.activeTrackId = track; - const trackGain = ctx.createGain(); - trackGain.gain.setValueAtTime(0, ctx.currentTime); - trackGain.connect(this.masterGain!); + private startPlayback(): void { + if (!this.isEngineRunning) return; + this.loadCurrentTrack(); + void this.getAudio().play().catch((err) => { + console.warn('Ambient music playback is waiting for a user gesture:', err); + }); + } - // Fade in new track over 1.5 seconds - trackGain.gain.linearRampToValueAtTime(1.0, ctx.currentTime + 1.5); + private playNextTrack = (): void => { + this.currentTrackIndex = (this.currentTrackIndex + 1) % AMBIENT_TRACKS.length; + this.startPlayback(); + }; - const sources: { stop: () => void }[] = []; + private handleTrackError = (): void => { + const failedTrack = AMBIENT_TRACKS[this.currentTrackIndex]; + console.warn(`Ambient music failed to load: ${failedTrack?.url ?? 'unknown track'}`); + }; - // Synthesize the chosen track - if (track === 'calm-focus') { - this.buildCalmFocus(ctx, trackGain, sources); - } else if (track === 'calm-piano') { - this.buildCalmPiano(ctx, trackGain, sources); - } else if (track === 'soothing-rain') { - this.buildSoothingRain(ctx, trackGain, sources); - } else if (track === 'soothing-space') { - this.buildSoothingSpace(ctx, trackGain, sources); - } else if (track === 'warm-hearth') { - this.buildWarmHearth(ctx, trackGain, sources); - } else if (track === 'deep-ocean') { - this.buildDeepOcean(ctx, trackGain, sources); - } else if (track === 'starlight') { - this.buildStarlight(ctx, trackGain, sources); - } else if (track === 'forest-rain') { - this.buildForestRain(ctx, trackGain, sources); - } else if (track === 'lofi-night') { - this.buildLofiNight(ctx, trackGain, sources); - } else if (track === 'lofi-rain') { - this.buildLofiRain(ctx, trackGain, sources); - } else if (track === 'rap-cipher') { - this.buildRapCipher(ctx, trackGain, sources); - } else if (track === 'rap-instrumental') { - this.buildRapInstrumental(ctx, trackGain, sources); - } + public play(track: AmbientTrack, volume: number): void { + const nextTrackIndex = getAmbientTrackIndex(track); + const trackChanged = nextTrackIndex !== this.currentTrackIndex; + this.currentTrackIndex = nextTrackIndex; + this.currentVolumePercent = volume; + this.isEngineRunning = true; - this.activeTrackNodes = { - gain: trackGain, - sources - }; + const audio = this.getAudio(); + audio.volume = Math.max(0, Math.min(1, volume / 100)); + if (trackChanged) audio.pause(); + this.startPlayback(); } public stop(): void { - if (!this.isEngineRunning || !this.ctx || !this.activeTrackNodes) { - return; - } - this.isEngineRunning = false; - const ctx = this.ctx; - const now = ctx.currentTime; - const activeNodes = this.activeTrackNodes; - this.activeTrackNodes = null; - this.activeTrackId = null; - - // 1. Soft Swish sound effect: a brief noise burst filtered via a fast closing lowpass - try { - const bufferSize = ctx.sampleRate * 0.15; // 150ms buffer - const buffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const data = buffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) { - data[i] = Math.random() * 2 - 1; - } - - const noise = ctx.createBufferSource(); - noise.buffer = buffer; - - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.Q.value = 1.0; - filter.frequency.setValueAtTime(1200, now); - filter.frequency.exponentialRampToValueAtTime(80, now + 0.12); - - const swishGain = ctx.createGain(); - swishGain.gain.setValueAtTime(0.015, now); - swishGain.gain.exponentialRampToValueAtTime(0.0001, now + 0.15); - - noise.connect(filter); - filter.connect(swishGain); - swishGain.connect(ctx.destination); - noise.start(now); - } catch (e) { - console.warn('Failed to synthesize ambient swish exit sound:', e); - } - - // 2. Fade main audio to 0 over 1.5s - activeNodes.gain.gain.setValueAtTime(activeNodes.gain.gain.value, now); - activeNodes.gain.gain.linearRampToValueAtTime(0, now + 1.5); - - setTimeout(() => { - try { - activeNodes.sources.forEach(src => src.stop()); - } catch (e) { - // Safe to ignore - } - }, 1800); + if (!this.audio) return; + this.audio.pause(); } public setVolume(volume: number): void { this.currentVolumePercent = volume; - this.updateMasterVolume(); + if (this.audio) { + this.audio.volume = Math.max(0, Math.min(1, volume / 100)); + } } public setTrack(track: AmbientTrack): void { + this.currentTrackIndex = getAmbientTrackIndex(track); if (this.isEngineRunning) { - this.play(track, this.currentVolumePercent); - } else { - this.activeTrackId = track; + this.getAudio().pause(); + this.startPlayback(); } } public async resume(): Promise { - if (!this.ctx || this.ctx.state !== 'suspended') return; + if (!this.isEngineRunning) return; + this.getAudio().volume = Math.max(0, Math.min(1, this.currentVolumePercent / 100)); try { - await this.ctx.resume(); + await this.getAudio().play(); } catch (err) { - console.warn('Ambient audio resume was blocked until the next user gesture:', err); + console.warn('Ambient music playback is waiting for a user gesture:', err); } } public isPlaying(): boolean { - return this.isEngineRunning; + return this.isEngineRunning && Boolean(this.audio && !this.audio.paused); } public dispose(): void { this.stop(); - if (this.ctx) { - this.ctx.close(); - this.ctx = null; - this.masterGain = null; - } - } - - // --- Track synthesis builders --- - - private createNoiseSource(ctx: AudioContext, seconds = 2, brown = false): AudioBufferSourceNode { - const bufferSize = Math.max(1, Math.floor(ctx.sampleRate * seconds)); - const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const output = noiseBuffer.getChannelData(0); - let lastOut = 0.0; - for (let i = 0; i < bufferSize; i++) { - const white = Math.random() * 2 - 1; - if (brown) { - output[i] = (lastOut + (0.02 * white)) / 1.02; - lastOut = output[i]; - output[i] *= 3.5; - } else { - output[i] = white; - } - } - const source = ctx.createBufferSource(); - source.buffer = noiseBuffer; - source.loop = true; - return source; - } - - private pulse( - ctx: AudioContext, - dest: AudioNode, - frequency: number, - when: number, - duration: number, - gainValue: number, - type: OscillatorType = 'sine', - ): void { - const oscillator = ctx.createOscillator(); - const gain = ctx.createGain(); - oscillator.type = type; - oscillator.frequency.setValueAtTime(frequency, when); - gain.gain.setValueAtTime(0.0001, when); - gain.gain.exponentialRampToValueAtTime(gainValue, when + 0.015); - gain.gain.exponentialRampToValueAtTime(0.0001, when + duration); - oscillator.connect(gain); - gain.connect(dest); - oscillator.start(when); - oscillator.stop(when + duration + 0.02); - } - - private buildCalmFocus(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const chordGain = ctx.createGain(); - chordGain.gain.setValueAtTime(0.16, ctx.currentTime); - chordGain.connect(dest); - - [261.63, 329.63, 392.0, 523.25].forEach((frequency, index) => { - const osc = ctx.createOscillator(); - osc.type = index === 0 ? 'triangle' : 'sine'; - osc.frequency.setValueAtTime(frequency, ctx.currentTime); - const gain = ctx.createGain(); - gain.gain.setValueAtTime(index === 0 ? 0.22 : 0.12, ctx.currentTime); - osc.connect(gain); - gain.connect(chordGain); - osc.start(); - sources.push(osc); - }); - - const shimmer = ctx.createOscillator(); - shimmer.type = 'sine'; - shimmer.frequency.setValueAtTime(0.045, ctx.currentTime); - const shimmerGain = ctx.createGain(); - shimmerGain.gain.setValueAtTime(0.055, ctx.currentTime); - shimmer.connect(shimmerGain); - shimmerGain.connect(chordGain.gain); - shimmer.start(); - sources.push(shimmer); - } - - private buildSoothingRain(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const pad = ctx.createGain(); - pad.gain.setValueAtTime(0.12, ctx.currentTime); - pad.connect(dest); - [196, 246.94, 293.66].forEach((frequency) => { - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(frequency, ctx.currentTime); - osc.connect(pad); - osc.start(); - sources.push(osc); - }); - - const rain = this.createNoiseSource(ctx, 2, true); - const filter = ctx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.setValueAtTime(1700, ctx.currentTime); - filter.Q.setValueAtTime(0.6, ctx.currentTime); - const rainGain = ctx.createGain(); - rainGain.gain.setValueAtTime(0.2, ctx.currentTime); - rain.connect(filter); - filter.connect(rainGain); - rainGain.connect(dest); - rain.start(); - sources.push(rain); - } - - private buildCalmPiano(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - this.buildCalmFocus(ctx, dest, sources); - const notes = [523.25, 659.25, 783.99, 987.77]; - let index = 0; - const interval = window.setInterval(() => { - if (!this.isEngineRunning) return; - const when = ctx.currentTime + 0.025; - this.pulse(ctx, dest, notes[index % notes.length]!, when, 0.45, 0.055, 'triangle'); - index += 1; - }, 2400); - sources.push({ stop: () => window.clearInterval(interval) }); - } - - private buildSoothingSpace(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - this.buildStarlight(ctx, dest, sources); - const sub = ctx.createOscillator(); - sub.type = 'sine'; - sub.frequency.setValueAtTime(73.42, ctx.currentTime); - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0.08, ctx.currentTime); - sub.connect(subGain); - subGain.connect(dest); - sub.start(); - sources.push(sub); - } - - private buildLofiNight(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - this.buildCalmFocus(ctx, dest, sources); - - const beatGain = ctx.createGain(); - beatGain.gain.setValueAtTime(0.35, ctx.currentTime); - beatGain.connect(dest); - const bpm = 78; - const step = 60 / bpm / 2; - let count = 0; - const interval = window.setInterval(() => { - if (!this.isEngineRunning) return; - const when = ctx.currentTime + 0.02; - const position = count % 8; - if (position === 0 || position === 4) this.pulse(ctx, beatGain, 58, when, 0.18, 0.45, 'sine'); - if (position === 2 || position === 6) this.pulse(ctx, beatGain, 190, when, 0.08, 0.18, 'triangle'); - this.pulse(ctx, beatGain, 8200, when, 0.035, position % 2 === 0 ? 0.04 : 0.025, 'square'); - count += 1; - }, step * 1000); - - sources.push({ stop: () => window.clearInterval(interval) }); - } - - private buildRapInstrumental(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const sub = ctx.createOscillator(); - sub.type = 'sine'; - sub.frequency.setValueAtTime(49, ctx.currentTime); - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0.16, ctx.currentTime); - sub.connect(subGain); - subGain.connect(dest); - sub.start(); - sources.push(sub); - - const beatGain = ctx.createGain(); - beatGain.gain.setValueAtTime(0.42, ctx.currentTime); - beatGain.connect(dest); - const bpm = 92; - const step = 60 / bpm / 4; - let count = 0; - const interval = window.setInterval(() => { - if (!this.isEngineRunning) return; - const when = ctx.currentTime + 0.015; - const position = count % 16; - if ([0, 6, 10].includes(position)) this.pulse(ctx, beatGain, 46, when, 0.2, 0.55, 'sine'); - if ([4, 12].includes(position)) this.pulse(ctx, beatGain, 210, when, 0.09, 0.2, 'triangle'); - if (position % 2 === 0 || position === 15) this.pulse(ctx, beatGain, 9500, when, 0.025, 0.055, 'square'); - count += 1; - }, step * 1000); - - sources.push({ stop: () => window.clearInterval(interval) }); - } - - private buildLofiRain(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - this.buildLofiNight(ctx, dest, sources); - const rain = this.createNoiseSource(ctx, 2, true); - const filter = ctx.createBiquadFilter(); - filter.type = 'bandpass'; - filter.frequency.setValueAtTime(1400, ctx.currentTime); - filter.Q.setValueAtTime(0.5, ctx.currentTime); - const rainGain = ctx.createGain(); - rainGain.gain.setValueAtTime(0.08, ctx.currentTime); - rain.connect(filter); - filter.connect(rainGain); - rainGain.connect(dest); - rain.start(); - sources.push(rain); - } - - private buildRapCipher(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - this.buildRapInstrumental(ctx, dest, sources); - const leadGain = ctx.createGain(); - leadGain.gain.setValueAtTime(0.18, ctx.currentTime); - leadGain.connect(dest); - const notes = [146.83, 174.61, 196, 220]; - let count = 0; - const interval = window.setInterval(() => { - if (!this.isEngineRunning) return; - const when = ctx.currentTime + 0.02; - if (count % 4 !== 3) this.pulse(ctx, leadGain, notes[count % notes.length]!, when, 0.12, 0.08, 'sawtooth'); - count += 1; - }, 330); - sources.push({ stop: () => window.clearInterval(interval) }); - } - - private buildWarmHearth(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - // 1. Two detuned low pads at 110Hz (A2) and 165Hz (E3) - const osc1 = ctx.createOscillator(); - osc1.type = 'sine'; - osc1.frequency.setValueAtTime(110, ctx.currentTime); - - const osc2 = ctx.createOscillator(); - osc2.type = 'triangle'; - osc2.frequency.setValueAtTime(165.5, ctx.currentTime); // detuned - - const padGain = ctx.createGain(); - padGain.gain.setValueAtTime(0.3, ctx.currentTime); - - // Filter to keep it warm and mellow - const filter = ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.setValueAtTime(350, ctx.currentTime); - - osc1.connect(padGain); - osc2.connect(padGain); - padGain.connect(filter); - filter.connect(dest); - - osc1.start(); - osc2.start(); - sources.push(osc1, osc2); - - if (!prefersReducedMotion) { - // Slow LFO on filter cutoff to create breathing fireplace heat - const filterLfo = ctx.createOscillator(); - filterLfo.frequency.setValueAtTime(0.08, ctx.currentTime); // very slow (12s period) - - const filterLfoGain = ctx.createGain(); - filterLfoGain.gain.setValueAtTime(120, ctx.currentTime); // mod range - - filterLfo.connect(filterLfoGain); - filterLfoGain.connect(filter.frequency); - filterLfo.start(); - sources.push(filterLfo); - } - - // 2. Fire Crackle: heavy filtered brownian noise with sporadic spikes - const crackleGain = ctx.createGain(); - crackleGain.gain.setValueAtTime(0.04, ctx.currentTime); - crackleGain.connect(dest); - - // Create brown noise buffer - const bufferSize = ctx.sampleRate * 2.0; // 2 seconds loop - const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const output = noiseBuffer.getChannelData(0); - let lastOut = 0.0; - for (let i = 0; i < bufferSize; i++) { - const white = Math.random() * 2 - 1; - output[i] = (lastOut + (0.02 * white)) / 1.02; - lastOut = output[i]; - output[i] *= 3.5; // compensation - } - - const noiseSource = ctx.createBufferSource(); - noiseSource.buffer = noiseBuffer; - noiseSource.loop = true; - - // Highpass to isolate crackle click frequencies - const highpass = ctx.createBiquadFilter(); - highpass.type = 'highpass'; - highpass.frequency.setValueAtTime(1000, ctx.currentTime); - - // Random gains via script node or interval LFO to simulate crackle frequency - const crackleFilter = ctx.createBiquadFilter(); - crackleFilter.type = 'bandpass'; - crackleFilter.frequency.setValueAtTime(2500, ctx.currentTime); - crackleFilter.Q.setValueAtTime(1.5, ctx.currentTime); - - noiseSource.connect(highpass); - highpass.connect(crackleFilter); - crackleFilter.connect(crackleGain); - - noiseSource.start(); - sources.push(noiseSource); - - // Trigger random crackle burst volumes using timer interval - let crackleInterval = setInterval(() => { - if (!this.isEngineRunning || !ctx) { - clearInterval(crackleInterval); - return; - } - const now = ctx.currentTime; - // Sporadic volume spikes for crackles - const volumeSpike = Math.random() > 0.6 ? (Math.random() * 0.15 + 0.02) : 0.02; - crackleGain.gain.setValueAtTime(crackleGain.gain.value, now); - crackleGain.gain.exponentialRampToValueAtTime(volumeSpike, now + 0.05); - }, 280); - - // Store custom cleanup - const originalStop = sources[0].stop; - sources[0].stop = () => { - clearInterval(crackleInterval); - sources.forEach(s => { - if (s !== osc1) { - try { s.stop(); } catch(e) {} - } - }); - try { osc1.stop(); } catch(e) {} - }; - } - - private buildDeepOcean(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - // 1. Deep Sub bass at ~55Hz (A1) - const subOsc = ctx.createOscillator(); - subOsc.type = 'sine'; - subOsc.frequency.setValueAtTime(55, ctx.currentTime); - - const subGain = ctx.createGain(); - subGain.gain.setValueAtTime(0.65, ctx.currentTime); - - subOsc.connect(subGain); - subGain.connect(dest); - subOsc.start(); - sources.push(subOsc); - - if (!prefersReducedMotion) { - // Very slow LFO on sub volume (simulate deep swells) - const subLfo = ctx.createOscillator(); - subLfo.frequency.setValueAtTime(0.06, ctx.currentTime); // 16.6s period - - const subLfoGain = ctx.createGain(); - subLfoGain.gain.setValueAtTime(0.2, ctx.currentTime); // modulate up/down by 0.2 - - const subBaseGain = ctx.createGain(); - subBaseGain.gain.setValueAtTime(0.45, ctx.currentTime); - - subLfo.connect(subLfoGain); - subLfoGain.connect(subGain.gain); - subLfo.start(); - sources.push(subLfo); - } - - // 2. Low rolling water texture: white noise through a narrow shifting bandpass filter - const bufferSize = ctx.sampleRate * 2.0; - const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const data = noiseBuffer.getChannelData(0); - for (let i = 0; i < bufferSize; i++) { - data[i] = Math.random() * 2 - 1; - } - - const waterNoise = ctx.createBufferSource(); - waterNoise.buffer = noiseBuffer; - waterNoise.loop = true; - - const bpf = ctx.createBiquadFilter(); - bpf.type = 'bandpass'; - bpf.frequency.setValueAtTime(150, ctx.currentTime); - bpf.Q.setValueAtTime(0.8, ctx.currentTime); - - const waterGain = ctx.createGain(); - waterGain.gain.setValueAtTime(0.18, ctx.currentTime); - - waterNoise.connect(bpf); - bpf.connect(waterGain); - waterGain.connect(dest); - - waterNoise.start(); - sources.push(waterNoise); - - if (!prefersReducedMotion) { - // Swell filter LFO to simulate waves breaking - const waveLfo = ctx.createOscillator(); - waveLfo.frequency.setValueAtTime(0.075, ctx.currentTime); // ~13.3s period - - const waveLfoGain = ctx.createGain(); - waveLfoGain.gain.setValueAtTime(80, ctx.currentTime); // modulate filter between 70Hz and 230Hz - - waveLfo.connect(waveLfoGain); - waveLfoGain.connect(bpf.frequency); - waveLfo.start(); - sources.push(waveLfo); - } - } - - private buildStarlight(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - // Ethereal chord (A Major: A4 at 440, C#5 at 554.37, E5 at 659.25) - const frequencies = [440.0, 554.37, 659.25]; - const chordGain = ctx.createGain(); - chordGain.gain.setValueAtTime(0.15, ctx.currentTime); - chordGain.connect(dest); - - frequencies.forEach((freq, idx) => { - const osc = ctx.createOscillator(); - osc.type = 'sine'; - osc.frequency.setValueAtTime(freq, ctx.currentTime); - - const individualGain = ctx.createGain(); - individualGain.gain.setValueAtTime(0.3, ctx.currentTime); - - osc.connect(individualGain); - individualGain.connect(chordGain); - osc.start(); - sources.push(osc); - - if (!prefersReducedMotion) { - // Ethereal slow volume LFO per frequency to weave them in and out - const vLfo = ctx.createOscillator(); - vLfo.frequency.setValueAtTime(0.05 + idx * 0.02, ctx.currentTime); // unsynced speeds - - const vLfoGain = ctx.createGain(); - vLfoGain.gain.setValueAtTime(0.15, ctx.currentTime); - - vLfo.connect(vLfoGain); - vLfoGain.connect(individualGain.gain); - vLfo.start(); - sources.push(vLfo); - - // Pitch shimmer (frequency drift) - const pLfo = ctx.createOscillator(); - pLfo.frequency.setValueAtTime(0.1 + idx * 0.05, ctx.currentTime); - - const pLfoGain = ctx.createGain(); - pLfoGain.gain.setValueAtTime(1.5, ctx.currentTime); // small shift in Hz - - pLfo.connect(pLfoGain); - pLfoGain.connect(osc.frequency); - pLfo.start(); - sources.push(pLfo); - } - }); - - // Reverb simulation via multiple tape-delay copies with feedback - try { - const delayNode = ctx.createDelay(1.0); - delayNode.delayTime.setValueAtTime(0.6, ctx.currentTime); - - const feedback = ctx.createGain(); - feedback.gain.setValueAtTime(0.45, ctx.currentTime); - - chordGain.connect(delayNode); - delayNode.connect(feedback); - feedback.connect(delayNode); // feedback loop - feedback.connect(dest); // connect to main output - } catch (e) { - console.warn('Failed to build starlight delay effect:', e); - } - } - - private buildForestRain(ctx: AudioContext, dest: AudioNode, sources: { stop: () => void }[]): void { - const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; - - // 1. Rain texture: filtered brownian noise - const rainGain = ctx.createGain(); - rainGain.gain.setValueAtTime(0.35, ctx.currentTime); - rainGain.connect(dest); - - // Create brown noise buffer - const bufferSize = ctx.sampleRate * 2.0; - const noiseBuffer = ctx.createBuffer(1, bufferSize, ctx.sampleRate); - const output = noiseBuffer.getChannelData(0); - let lastOut = 0.0; - for (let i = 0; i < bufferSize; i++) { - const white = Math.random() * 2 - 1; - output[i] = (lastOut + (0.02 * white)) / 1.02; - lastOut = output[i]; - output[i] *= 3.5; - } - - const rainNoise = ctx.createBufferSource(); - rainNoise.buffer = noiseBuffer; - rainNoise.loop = true; - - const bpf = ctx.createBiquadFilter(); - bpf.type = 'bandpass'; - bpf.frequency.setValueAtTime(1800, ctx.currentTime); - bpf.Q.setValueAtTime(0.5, ctx.currentTime); - - rainNoise.connect(bpf); - bpf.connect(rainGain); - rainNoise.start(); - sources.push(rainNoise); - - // 2. Distant Thunder: low sine waves with random volume triggers - const thunderOsc = ctx.createOscillator(); - thunderOsc.type = 'sine'; - thunderOsc.frequency.setValueAtTime(80, ctx.currentTime); - - const thunderGain = ctx.createGain(); - thunderGain.gain.setValueAtTime(0.001, ctx.currentTime); // hidden initially - - const thunderLP = ctx.createBiquadFilter(); - thunderLP.type = 'lowpass'; - thunderLP.frequency.setValueAtTime(120, ctx.currentTime); - - thunderOsc.connect(thunderLP); - thunderLP.connect(thunderGain); - thunderGain.connect(dest); - - thunderOsc.start(); - sources.push(thunderOsc); - - // Random thunder trigger - let thunderInterval = setInterval(() => { - if (!this.isEngineRunning || !ctx) { - clearInterval(thunderInterval); - return; - } - // 15% chance of thunder rumble every 6s - if (Math.random() > 0.82) { - const now = ctx.currentTime; - const rollVol = Math.random() * 0.16 + 0.04; - const duration = Math.random() * 2.5 + 2.0; // rumble lasts 2-4.5s - - thunderGain.gain.setValueAtTime(thunderGain.gain.value, now); - thunderGain.gain.linearRampToValueAtTime(rollVol, now + 0.4); // soft strike - thunderGain.gain.exponentialRampToValueAtTime(rollVol * 0.3, now + 1.2); // rumble - thunderGain.gain.exponentialRampToValueAtTime(0.001, now + duration); // fade out - } - }, 6000); - - const originalStop = sources[0].stop; - sources[0].stop = () => { - clearInterval(thunderInterval); - sources.forEach(s => { - if (s !== rainNoise) { - try { s.stop(); } catch(e) {} - } - }); - try { rainNoise.stop(); } catch(e) {} - }; + if (!this.audio) return; + this.audio.removeEventListener('ended', this.playNextTrack); + this.audio.removeEventListener('error', this.handleTrackError); + this.audio.removeAttribute('src'); + this.audio.load(); + this.audio = null; } } diff --git a/app/src/features/ambient/tracks.test.ts b/app/src/features/ambient/tracks.test.ts index 3a3cfb4e7..4a6270165 100644 --- a/app/src/features/ambient/tracks.test.ts +++ b/app/src/features/ambient/tracks.test.ts @@ -2,38 +2,33 @@ import { describe, expect, it } from 'vitest'; import { AMBIENT_TRACKS, FREE_AMBIENT_TRACK, + getAmbientTrackIndex, getPlayableAmbientTrack, planAllowsAmbientTrack, } from './tracks'; -describe('ambient track entitlements', () => { - it('allows calm and soothing tracks on the free tier', () => { - expect(planAllowsAmbientTrack('calm-focus', 'free')).toBe(true); - expect(planAllowsAmbientTrack('soothing-rain', 'free')).toBe(true); +describe('ambient hosted playlist', () => { + it('contains five ordered remote music placeholders', () => { + expect(AMBIENT_TRACKS).toHaveLength(5); + expect(AMBIENT_TRACKS.map((track) => track.id)).toEqual([ + 'music-1', + 'music-2', + 'music-3', + 'music-4', + 'music-5', + ]); + expect(AMBIENT_TRACKS.every((track) => track.url.startsWith('https://'))).toBe(true); }); - it('locks premium music on free and unlocks it on paid tiers', () => { - expect(planAllowsAmbientTrack('lofi-night', 'free')).toBe(false); - expect(planAllowsAmbientTrack('rap-instrumental', 'free')).toBe(false); - expect(planAllowsAmbientTrack('lofi-night', 'starter')).toBe(true); - expect(planAllowsAmbientTrack('rap-instrumental', 'pro')).toBe(true); - expect(planAllowsAmbientTrack('lofi-rain', 'free')).toBe(false); - expect(planAllowsAmbientTrack('rap-cipher', 'starter')).toBe(true); - }); - - it('falls back to the free calm track when a locked track is persisted', () => { - expect(getPlayableAmbientTrack('lofi-night', 'free')).toBe(FREE_AMBIENT_TRACK); - expect(getPlayableAmbientTrack('lofi-night', 'ultra')).toBe('lofi-night'); - }); - - it('keeps calm and soothing music free while gating beat categories', () => { + it('allows every playlist track on every plan', () => { for (const track of AMBIENT_TRACKS) { - if (track.category === 'calm' || track.category === 'soothing') { - expect(track.premium, `${track.id} should stay free`).toBe(false); - } - if (track.category === 'lofi' || track.category === 'rap') { - expect(track.premium, `${track.id} should be paid`).toBe(true); - } + expect(planAllowsAmbientTrack(track.id, 'free')).toBe(true); } }); + + it('falls back to the first track for an old persisted track value', () => { + const oldTrack = 'calm-focus' as never; + expect(getPlayableAmbientTrack(oldTrack, 'free')).toBe(FREE_AMBIENT_TRACK); + expect(getAmbientTrackIndex(oldTrack)).toBe(0); + }); }); diff --git a/app/src/features/ambient/tracks.ts b/app/src/features/ambient/tracks.ts index ab7b480bb..bfe26d74f 100644 --- a/app/src/features/ambient/tracks.ts +++ b/app/src/features/ambient/tracks.ts @@ -1,43 +1,69 @@ import type { PlanId } from '@/lib/entitlements'; import type { AmbientTrack } from '@/stores/ui'; -export type AmbientTrackCategory = 'soundscape' | 'calm' | 'soothing' | 'lofi' | 'rap'; - export interface AmbientTrackDef { id: AmbientTrack; label: string; desc: string; - category: AmbientTrackCategory; - premium: boolean; + url: string; + premium: false; } -export const FREE_AMBIENT_TRACK: AmbientTrack = 'calm-focus'; - +// Replace these five placeholder URLs with the public Cloudflare R2 URLs. +// The player advances through the list in order and repeats after track 5. export const AMBIENT_TRACKS: readonly AmbientTrackDef[] = [ - { id: 'calm-focus', label: 'Calm Focus', desc: 'Soft no-copyright synth piano', category: 'calm', premium: false }, - { id: 'calm-piano', label: 'Calm Piano', desc: 'Minimal procedural piano pulses', category: 'calm', premium: false }, - { id: 'soothing-rain', label: 'Soothing Rain', desc: 'Gentle rain with warm pads', category: 'soothing', premium: false }, - { id: 'soothing-space', label: 'Soothing Space', desc: 'Slow celestial pads and shimmer', category: 'soothing', premium: false }, - { id: 'warm-hearth', label: 'Warm Hearth', desc: 'Crackling fireside pad', category: 'soundscape', premium: false }, - { id: 'deep-ocean', label: 'Deep Ocean', desc: 'Low sub swells and waves', category: 'soundscape', premium: false }, - { id: 'starlight', label: 'Starlight', desc: 'Ethereal cosmic shimmer', category: 'soundscape', premium: false }, - { id: 'forest-rain', label: 'Forest Rain', desc: 'Soft rain and distant thunder', category: 'soundscape', premium: false }, - { id: 'lofi-night', label: 'Lo-Fi Night', desc: 'Procedural chill beat loop', category: 'lofi', premium: true }, - { id: 'lofi-rain', label: 'Lo-Fi Rain', desc: 'Chill beat with soft rain texture', category: 'lofi', premium: true }, - { id: 'rap-cipher', label: 'Rap Cipher', desc: 'Procedural 808 cipher groove', category: 'rap', premium: true }, - { id: 'rap-instrumental', label: 'Rap Instrumental', desc: 'Procedural 808 beat bed', category: 'rap', premium: true }, + { + id: 'music-1', + label: 'Music 1', + desc: 'Cloudflare R2 track placeholder', + url: 'https://YOUR-R2-PUBLIC-DOMAIN.example/jarvis-music-1.mp3', + premium: false, + }, + { + id: 'music-2', + label: 'Music 2', + desc: 'Cloudflare R2 track placeholder', + url: 'https://YOUR-R2-PUBLIC-DOMAIN.example/jarvis-music-2.mp3', + premium: false, + }, + { + id: 'music-3', + label: 'Music 3', + desc: 'Cloudflare R2 track placeholder', + url: 'https://YOUR-R2-PUBLIC-DOMAIN.example/jarvis-music-3.mp3', + premium: false, + }, + { + id: 'music-4', + label: 'Music 4', + desc: 'Cloudflare R2 track placeholder', + url: 'https://YOUR-R2-PUBLIC-DOMAIN.example/jarvis-music-4.mp3', + premium: false, + }, + { + id: 'music-5', + label: 'Music 5', + desc: 'Cloudflare R2 track placeholder', + url: 'https://YOUR-R2-PUBLIC-DOMAIN.example/jarvis-music-5.mp3', + premium: false, + }, ] as const; +export const FREE_AMBIENT_TRACK: AmbientTrack = 'music-1'; + export function getAmbientTrackDef(track: AmbientTrack): AmbientTrackDef { return AMBIENT_TRACKS.find((item) => item.id === track) ?? AMBIENT_TRACKS[0]; } -export function planAllowsAmbientTrack(track: AmbientTrack, plan: PlanId, admin = false): boolean { - if (admin) return true; - const def = getAmbientTrackDef(track); - return !def.premium || plan !== 'free'; +export function getAmbientTrackIndex(track: AmbientTrack): number { + const index = AMBIENT_TRACKS.findIndex((item) => item.id === track); + return index >= 0 ? index : 0; +} + +export function planAllowsAmbientTrack(_track: AmbientTrack, _plan: PlanId, _admin = false): boolean { + return true; } -export function getPlayableAmbientTrack(track: AmbientTrack, plan: PlanId, admin = false): AmbientTrack { - return planAllowsAmbientTrack(track, plan, admin) ? track : FREE_AMBIENT_TRACK; +export function getPlayableAmbientTrack(track: AmbientTrack, _plan: PlanId, _admin = false): AmbientTrack { + return getAmbientTrackDef(track).id; } diff --git a/app/src/features/chat/Composer.paths.test.ts b/app/src/features/chat/Composer.paths.test.ts new file mode 100644 index 000000000..9d6b0c535 --- /dev/null +++ b/app/src/features/chat/Composer.paths.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { extractAbsoluteFilePaths } from './Composer'; + +describe('composer file path detection', () => { + it('extracts a Windows path with spaces from a natural-language request', () => { + expect( + extractAbsoluteFilePaths( + 'C:\\Users\\viper\\Documents\\APPS HTML\\Scripts\\Editor\\context_map.json please summarize this', + ), + ).toEqual([ + 'C:\\Users\\viper\\Documents\\APPS HTML\\Scripts\\Editor\\context_map.json', + ]); + }); + + it('deduplicates repeated file paths', () => { + const path = 'C:\\project\\AnimalOutputGenerator.cs'; + expect(extractAbsoluteFilePaths(`${path} summarize ${path}`)).toEqual([path]); + }); +}); diff --git a/app/src/features/chat/Composer.tsx b/app/src/features/chat/Composer.tsx index 621e903a3..bbb156b76 100644 --- a/app/src/features/chat/Composer.tsx +++ b/app/src/features/chat/Composer.tsx @@ -41,6 +41,11 @@ import { import { MentionTypeahead } from './MentionTypeahead'; import { SlashCommandTypeahead, SLASH_COMMANDS, type SlashCommandDef } from './SlashCommandTypeahead'; import { getChatDragKind, getChatDropPayload } from './dropPayload'; +import { + REAL_CHAT_PROVIDERS, + defaultModelForProvider, + getModelOptions, +} from '@/lib/ai/models'; export interface ComposerProps { chatId: ChatId | string; @@ -59,27 +64,7 @@ const MAX_LINES = 8; const MIN_HEIGHT = MIN_LINES * LINE_HEIGHT + PADDING_Y; const MAX_HEIGHT = MAX_LINES * LINE_HEIGHT + PADDING_Y; -const PROVIDERS: ProviderId[] = [ - 'anthropic', - 'openai', - 'google', - 'xai', - 'openrouter', - 'groq', - 'deepseek', - 'mistral', - 'together', - 'ollama', - 'cohere', - 'perplexity', - 'fireworks', - 'replicate', - 'hyperbolic', - 'novita', - 'lambda', - 'mock', - 'local', -]; +const PROVIDERS: ProviderId[] = [...REAL_CHAT_PROVIDERS]; const PROVIDER_LABELS: Record = { anthropic: 'Anthropic', openai: 'OpenAI', @@ -110,6 +95,12 @@ type AudioContextCtor = typeof AudioContext; const GROQ_STT_MODEL = 'whisper-large-v3-turbo'; const STT_INACTIVITY_MS = 30_000; const STT_ACTIVITY_RMS = 0.015; +const WINDOWS_FILE_PATH_RE = + /[A-Za-z]:\\[^\r\n<>:"|?*]+?\.(?:json|cs|ts|tsx|js|jsx|md|txt|html|css|scss|py|rs|go|java|cpp|c|h|hpp|xml|yaml|yml|toml|ini|sql)\b/gi; + +export function extractAbsoluteFilePaths(text: string): string[] { + return Array.from(new Set(text.match(WINDOWS_FILE_PATH_RE) ?? [])).slice(0, 8); +} /** * Find an active "@xxx" mention being typed at the caret. @@ -288,7 +279,11 @@ export function Composer({ chatId, placeholder, compact = false, disableRouteSla const agents = useAgentStore((s) => s.agents); const provider = useAuthStore((s) => s.defaultProvider); + const selectedModels = useAuthStore((s) => s.selectedModels); const setDefaultProvider = useAuthStore((s) => s.setDefaultProvider); + const setSelectedModel = useAuthStore((s) => s.setSelectedModel); + const defaultLocalModel = useAuthStore((s) => s.defaultLocalModel); + const [modelPickerOpen, setModelPickerOpen] = useState(false); const setSettingsOpen = useUIStore((s) => s.setSettingsOpen); const clearAudioSilenceTimer = () => { @@ -488,13 +483,24 @@ export function Composer({ chatId, placeholder, compact = false, disableRouteSla return true; } if (cmd === 'model') { - const wanted = rest.toLowerCase() as ProviderId; - if (wanted && PROVIDERS.includes(wanted)) { - setDefaultProvider(wanted); - await addSystem(`Model provider changed to ${PROVIDER_LABELS[wanted]}.`); - } else { - await addSystem(`Available providers: ${PROVIDERS.join(', ')}.`); + if (!rest) { + setText(''); + setModelPickerOpen(true); + return true; } + const [providerRaw, ...modelParts] = rest.split(/\s+/); + const wanted = providerRaw?.toLowerCase() as ProviderId; + if (!PROVIDERS.includes(wanted)) { + await addSystem(`Available AI providers: ${PROVIDERS.join(', ')}.`); + return true; + } + const wantedModel = + modelParts.join(' ').trim() || + selectedModels[wanted] || + defaultModelForProvider(wanted, defaultLocalModel); + setDefaultProvider(wanted); + setSelectedModel(wanted, wantedModel); + await addSystem(`AI model changed to ${PROVIDER_LABELS[wanted]} / ${wantedModel}.`); return true; } const routes: Record = { @@ -646,13 +652,16 @@ export function Composer({ chatId, placeholder, compact = false, disableRouteSla }); const mentionedAgentIds = extractMentionedAgentIds(trimmed, agents); + const messageFilePaths = Array.from( + new Set([...attachedFiles, ...extractAbsoluteFilePaths(trimmed)]), + ).slice(0, 8); window.dispatchEvent( new CustomEvent('jarvis:send', { detail: { chatId, text: trimmed || 'Attached context.', mentionedAgentIds, - filePaths: attachedFiles, + filePaths: messageFilePaths, terminalRefs: attachedTerminals, contextNodes: attachedContexts, speakReply: voiceReplyRequestedRef.current, @@ -1297,7 +1306,13 @@ export function Composer({ chatId, placeholder, compact = false, disableRouteSla
{ + setDefaultProvider(nextProvider); + setSelectedModel(nextProvider, nextModel); + }} /> {composerSttEnabled && ( void; + model: string; + open: boolean; + onOpenChange: (open: boolean) => void; + onChange: (provider: ProviderId, model: string) => void; } -function ModelPicker({ provider, onChange }: ModelPickerProps) { - const [open, setOpen] = useState(false); +function ModelPicker({ provider, model, open, onOpenChange, onChange }: ModelPickerProps) { return ( - + @@ -1410,28 +1427,38 @@ function ModelPicker({ provider, onChange }: ModelPickerProps) { className="w-[260px] p-1 bg-elevated text-foreground" >
- Model provider + AI model
    {PROVIDERS.map((p) => ( -
  • - +
  • +
    + {PROVIDER_LABELS[p]} +
    + {getModelOptions(p).map((option) => { + const active = provider === p && model === option.id; + return ( + + ); + })}
  • ))}
diff --git a/app/src/features/onboarding/steps/Permissions.tsx b/app/src/features/onboarding/steps/Permissions.tsx index a5738f5ef..03b23d19f 100644 --- a/app/src/features/onboarding/steps/Permissions.tsx +++ b/app/src/features/onboarding/steps/Permissions.tsx @@ -1,13 +1,28 @@ -import { useState, type ReactNode } from 'react'; +import { useEffect, useState, type ReactNode } from 'react'; import { Mic, Bell, Check, X, Loader2, AlertTriangle } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; +import { + getNotificationPermission, + requestNotificationPermission, + type NotificationPermissionState, +} from '@/lib/tauri'; type PermStatus = 'unknown' | 'requesting' | 'granted' | 'denied' | 'unavailable'; export function Permissions() { const [micStatus, setMicStatus] = useState('unknown'); - const [notifStatus, setNotifStatus] = useState(() => readInitialNotifStatus()); + const [notifStatus, setNotifStatus] = useState('unknown'); + + useEffect(() => { + let active = true; + void getNotificationPermission().then((permission) => { + if (active) setNotifStatus(notificationPermissionToStatus(permission)); + }); + return () => { + active = false; + }; + }, []); async function requestMic() { if (!navigator.mediaDevices?.getUserMedia) { @@ -25,19 +40,9 @@ export function Permissions() { } async function requestNotif() { - if (typeof Notification === 'undefined') { - setNotifStatus('unavailable'); - return; - } setNotifStatus('requesting'); - try { - const result = await Notification.requestPermission(); - setNotifStatus( - result === 'granted' ? 'granted' : result === 'denied' ? 'denied' : 'unknown', - ); - } catch { - setNotifStatus('denied'); - } + const permission = await requestNotificationPermission(); + setNotifStatus(notificationPermissionToStatus(permission)); } return ( @@ -174,9 +179,9 @@ function StatusPill({ status }: { status: PermStatus }) { ); } -function readInitialNotifStatus(): PermStatus { - if (typeof Notification === 'undefined') return 'unavailable'; - if (Notification.permission === 'granted') return 'granted'; - if (Notification.permission === 'denied') return 'denied'; +function notificationPermissionToStatus(permission: NotificationPermissionState): PermStatus { + if (permission === 'unavailable') return 'unavailable'; + if (permission === 'granted') return 'granted'; + if (permission === 'denied') return 'denied'; return 'unknown'; } diff --git a/app/src/features/settings/sections/Ambient.tsx b/app/src/features/settings/sections/Ambient.tsx index 569670414..786d8cab5 100644 --- a/app/src/features/settings/sections/Ambient.tsx +++ b/app/src/features/settings/sections/Ambient.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from 'react'; -import { Lock, Moon, Play, Pause, Music } from 'lucide-react'; +import { Moon, Play, Pause, Music } from 'lucide-react'; import { useUIStore } from '@/stores/ui'; import { useAuthStore } from '@/stores/auth'; import { effectivePlan, isAdminIdentity } from '@/lib/entitlements'; @@ -7,7 +7,6 @@ import { Button } from '@/components/ui/button'; import { Label } from '@/components/ui/label'; import { Separator } from '@/components/ui/separator'; import { Switch } from '@/components/ui/switch'; -import { toast } from '@/components/ui/toast'; import { AMBIENT_TRACKS, FREE_AMBIENT_TRACK, @@ -16,7 +15,7 @@ import { /** * Ambient settings — controls the V2 idle takeover (breathing orb, clock, - * rotating quote, glance cards) and the V3 procedural ambient soundtrack. + * rotating quote, glance cards) and the hosted ambient music playlist. */ const PRESETS_MIN: { label: string; value: number }[] = [ { label: '1 min', value: 1 }, @@ -52,8 +51,6 @@ export function Ambient() { const thresholdMin = Math.round(ambientThresholdMs / 60000); const admin = isAdminIdentity({ email, cloudEmail, localUserId }); const activePlan = effectivePlan(plan, admin); - const premiumUnlocked = admin || activePlan !== 'free'; - useEffect(() => { if (planAllowsAmbientTrack(ambientTrack, activePlan, admin)) return; setAmbientTrack(FREE_AMBIENT_TRACK); @@ -136,7 +133,7 @@ export function Ambient() {

- Soft procedural soundscape while ambient is active. + Play the hosted Jarvis music playlist while ambient is active.

- Choose a procedurally synthesized soundtrack. + Choose where the five-track playlist starts. It continues in order and repeats.

{AMBIENT_TRACKS.map((t) => { const active = ambientTrack === t.id; - const locked = !planAllowsAmbientTrack(t.id, activePlan, admin); return (
- {!premiumUnlocked ? ( -

- Calm and soothing procedural tracks are free. Lo-fi and rap instrumental tracks unlock on any paid tier. -

- ) : ( -

- Premium music unlocked. All tracks are generated locally with no copyrighted samples. -

- )} +

+ Replace the five placeholder URLs in the ambient track configuration with your public R2 links. +

@@ -231,7 +208,7 @@ export function Ambient() {

- Keep the ambient soundscape playing continuously, even when not in idle mode. + Keep the hosted music playlist playing continuously, even when not in idle mode.

{ expect(firedEvents).toHaveLength(1); }); + it('keeps in-app-only reminders out of OS notification delivery', async () => { + mocks.listOpen.mockResolvedValue([task({ reminders: [reminder({ channels: ['in_app'] })] })]); + + await expect(pollOnce(2000)).resolves.toBe(1); + + expect(mocks.toastInfo).toHaveBeenCalledWith( + 'Release check', + 'Stretch and check the build', + 6000, + ); + expect(mocks.notify).not.toHaveBeenCalled(); + }); + + it('keeps banner-only reminders out of in-app toast delivery', async () => { + mocks.listOpen.mockResolvedValue([task({ reminders: [reminder({ channels: ['banner'] })] })]); + + await expect(pollOnce(2000)).resolves.toBe(1); + + expect(mocks.toastInfo).not.toHaveBeenCalled(); + expect(mocks.notify).toHaveBeenCalledWith('Release check', 'Stretch and check the build', { + fallbackToast: false, + }); + }); + it('leaves future reminders scheduled', async () => { - mocks.listOpen.mockResolvedValue([ - task({ reminders: [reminder({ fires_at: 3000 })] }), - ]); + mocks.listOpen.mockResolvedValue([task({ reminders: [reminder({ fires_at: 3000 })] })]); await expect(pollOnce(2000)).resolves.toBe(0); expect(mocks.notify).not.toHaveBeenCalled(); diff --git a/app/src/features/tasks/NotificationEngine.ts b/app/src/features/tasks/NotificationEngine.ts index 7653ef009..34511dcb5 100644 --- a/app/src/features/tasks/NotificationEngine.ts +++ b/app/src/features/tasks/NotificationEngine.ts @@ -1,6 +1,6 @@ import { taskRepo } from '@/lib/db/repositories'; import { useAuthStore } from '@/stores/auth'; -import { notify } from '@/lib/tauri'; +import { notify, requestNotificationPermission } from '@/lib/tauri'; import { toast } from '@/components/ui/toast'; import type { Reminder, Task } from '@/types/task'; @@ -10,7 +10,7 @@ import type { Reminder, Task } from '@/types/task'; * - Polls scheduled reminders every 30 seconds. * - When a reminder's fires_at <= now, dispatches via: * (1) native/browser notification banner - * (2) in-app toast (always, as a soft fallback / always-visible cue) + * (2) in-app toast when the reminder includes the in_app channel * (3) the `jarvis:reminder` custom event (for the rest of the app * to react - e.g., voice service speaks the reminder). * @@ -112,6 +112,7 @@ export async function pollOnce(now: number = Date.now()): Promise { async function deliverReminder(task: Task, reminder: Reminder): Promise { const title = task.title; const body = reminder.message_override || reminder.smart_reason || 'Reminder'; + const channels = new Set(reminder.channels); // Always emit the in-app event so other features can react. if (typeof window !== 'undefined') { @@ -126,27 +127,26 @@ async function deliverReminder(task: Task, reminder: Reminder): Promise { } } - // Always show an in-app toast - it's the always-visible surface. - try { - toast.info(title, body, 6000); - } catch { - /* toast is best-effort */ + if (channels.has('in_app')) { + try { + toast.info(title, body, 6000); + } catch { + /* toast is best-effort */ + } } - await notify(title, body, { fallbackToast: false }); + if (channels.has('banner')) { + await notify(title, body, { fallbackToast: false }); + } } /** - * Ask for browser notification permission ahead of time. + * Ask for native/browser notification permission ahead of time. * Optional - the engine will request lazily on first delivery if you * don't call this. Useful from a settings page or onboarding step. */ -export async function ensureNotificationPermission(): Promise { - if (typeof window === 'undefined' || typeof Notification === 'undefined') return 'unavailable'; - if (Notification.permission !== 'default') return Notification.permission; - try { - return await Notification.requestPermission(); - } catch { - return 'denied'; - } +export async function ensureNotificationPermission(): Promise< + NotificationPermission | 'unavailable' +> { + return requestNotificationPermission(); } diff --git a/app/src/features/tasks/TaskService.test.ts b/app/src/features/tasks/TaskService.test.ts new file mode 100644 index 000000000..eb82446a4 --- /dev/null +++ b/app/src/features/tasks/TaskService.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Reminder, Task } from '@/types/task'; +import type { ReminderId, TaskId } from '@/types/common'; + +const mocks = vi.hoisted(() => ({ + getById: vi.fn(), + update: vi.fn(), + settingsGet: vi.fn(), + getState: vi.fn(), + notifyDone: vi.fn(), +})); + +vi.mock('@/lib/db/repositories', () => ({ + taskRepo: { + getById: mocks.getById, + update: mocks.update, + }, + settingsRepo: { + get: mocks.settingsGet, + }, +})); + +vi.mock('@/stores/auth', () => ({ + useAuthStore: { + getState: mocks.getState, + }, +})); + +vi.mock('@/lib/notifications', () => ({ + notifyDone: mocks.notifyDone, +})); + +import { completeTask, updateTask } from './TaskService'; + +const TASK_ID = 'task_1' as TaskId; +const REMINDER_ID = 'rem_1' as ReminderId; +const FIRED_REMINDER_ID = 'rem_2' as ReminderId; + +function reminder(overrides: Partial = {}): Reminder { + return { + id: REMINDER_ID, + task_id: TASK_ID, + fires_at: 1000, + channels: ['banner', 'in_app'], + status: 'scheduled', + snooze_history: [], + ...overrides, + } as Reminder; +} + +function task(overrides: Partial = {}): Task { + return { + id: TASK_ID, + workspace_id: 'workspace_1', + title: 'Release check', + status: 'open', + priority: 'normal', + effort: 3, + context_tags: [], + energy_required: 'medium', + reminders: [reminder(), reminder({ id: FIRED_REMINDER_ID, status: 'fired' })], + created_by: 'user_text', + source_refs: [], + created_at: 0, + updated_at: 0, + ...overrides, + } as Task; +} + +describe('TaskService notifications', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(Date, 'now').mockReturnValue(2000); + mocks.getState.mockReturnValue({ workspaceId: 'workspace_1' }); + mocks.settingsGet.mockResolvedValue(undefined); + mocks.update.mockResolvedValue(undefined); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not report ordinary task edits as completed', async () => { + mocks.getById.mockResolvedValue(task()); + + await expect(updateTask(TASK_ID, { title: 'Renamed release check' })).resolves.toEqual( + expect.objectContaining({ + title: 'Renamed release check', + status: 'open', + }), + ); + + expect(mocks.notifyDone).not.toHaveBeenCalled(); + }); + + it('reports actual completion and closes scheduled reminders', async () => { + mocks.getById.mockResolvedValue(task()); + + await expect(completeTask(TASK_ID)).resolves.toEqual( + expect.objectContaining({ + status: 'done', + done_at: 2000, + reminders: [ + expect.objectContaining({ id: 'rem_1', status: 'completed' }), + expect.objectContaining({ id: 'rem_2', status: 'fired' }), + ], + }), + ); + + expect(mocks.update).toHaveBeenCalledWith( + 'task_1', + expect.objectContaining({ + status: 'done', + reminders: [ + expect.objectContaining({ id: 'rem_1', status: 'completed' }), + expect.objectContaining({ id: 'rem_2', status: 'fired' }), + ], + }), + ); + expect(mocks.notifyDone).toHaveBeenCalledWith('tasks', 'Task done', 'Release check'); + }); +}); diff --git a/app/src/features/tasks/TaskService.ts b/app/src/features/tasks/TaskService.ts index 46e9970ba..1f4b8d877 100644 --- a/app/src/features/tasks/TaskService.ts +++ b/app/src/features/tasks/TaskService.ts @@ -2,12 +2,7 @@ import { taskRepo, settingsRepo } from '@/lib/db/repositories'; import { newTaskId, newReminderId } from '@/lib/ids'; import { useAuthStore } from '@/stores/auth'; import { notifyDone } from '@/lib/notifications'; -import type { - Reminder, - Task, - TaskInput, - QuietHours, -} from '@/types/task'; +import type { Reminder, Task, TaskInput, QuietHours } from '@/types/task'; import type { ContextRef, TaskId, ReminderId } from '@/types/common'; import { pickReminderTimes, type SchedulerContext } from './Scheduler'; @@ -126,8 +121,7 @@ export async function updateTask(id: TaskId, patch: Partial): Promise): Promise { * `snooze_history` and resets status back to 'scheduled' so it'll fire * again at the new time. */ -export async function snoozeReminder(reminderId: ReminderId, until: number, reason?: string): Promise { +export async function snoozeReminder( + reminderId: ReminderId, + until: number, + reason?: string, +): Promise { const all = await findTaskByReminderId(reminderId); if (!all) return null; diff --git a/app/src/lib/ai/models.test.ts b/app/src/lib/ai/models.test.ts new file mode 100644 index 000000000..40bf3ed01 --- /dev/null +++ b/app/src/lib/ai/models.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + REAL_CHAT_PROVIDERS, + defaultModelForProvider, + getModelOptions, + isRealChatProvider, +} from './models'; + +describe('chat model catalog', () => { + it('only advertises providers with working chat adapters', () => { + expect(REAL_CHAT_PROVIDERS).toEqual([ + 'google', + 'groq', + 'openai', + 'anthropic', + 'ollama', + 'local', + 'mock', + ]); + expect(isRealChatProvider('openrouter')).toBe(false); + }); + + it('provides selectable model ids for each advertised provider', () => { + for (const provider of REAL_CHAT_PROVIDERS) { + expect(getModelOptions(provider).length, provider).toBeGreaterThan(0); + } + }); + + it('uses the configured local model as the local default', () => { + expect(defaultModelForProvider('ollama', 'qwen2.5:3b')).toBe('qwen2.5:3b'); + }); +}); diff --git a/app/src/lib/ai/models.ts b/app/src/lib/ai/models.ts new file mode 100644 index 000000000..7d5b160ff --- /dev/null +++ b/app/src/lib/ai/models.ts @@ -0,0 +1,65 @@ +import type { ProviderId } from '@/types'; +import { ANTHROPIC_DEFAULT_MODEL } from './providers/anthropic'; +import { GOOGLE_DEFAULT_MODEL } from './providers/google'; +import { GROQ_DEFAULT_MODEL } from './providers/groq'; +import { OLLAMA_DEFAULT_MODEL } from './providers/ollama'; +import { OPENAI_DEFAULT_MODEL } from './providers/openai'; + +export interface ModelOption { + provider: ProviderId; + id: string; + label: string; +} + +export const REAL_CHAT_PROVIDERS: readonly ProviderId[] = [ + 'google', + 'groq', + 'openai', + 'anthropic', + 'ollama', + 'local', + 'mock', +]; + +export const CHAT_MODEL_OPTIONS: readonly ModelOption[] = [ + { provider: 'google', id: GOOGLE_DEFAULT_MODEL, label: 'Gemini 2.5 Flash Lite' }, + { provider: 'google', id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash' }, + { provider: 'google', id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash' }, + { provider: 'groq', id: GROQ_DEFAULT_MODEL, label: 'Llama 3.3 70B Versatile' }, + { provider: 'groq', id: 'llama-3.1-8b-instant', label: 'Llama 3.1 8B Instant' }, + { provider: 'groq', id: 'mixtral-8x7b-32768', label: 'Mixtral 8x7B' }, + { provider: 'openai', id: OPENAI_DEFAULT_MODEL, label: 'GPT-4o Mini' }, + { provider: 'openai', id: 'gpt-4o', label: 'GPT-4o' }, + { provider: 'openai', id: 'gpt-4.1-mini', label: 'GPT-4.1 Mini' }, + { provider: 'anthropic', id: ANTHROPIC_DEFAULT_MODEL, label: 'Claude 3.5 Sonnet' }, + { provider: 'anthropic', id: 'claude-3-5-haiku-20241022', label: 'Claude 3.5 Haiku' }, + { provider: 'ollama', id: OLLAMA_DEFAULT_MODEL, label: 'Ollama default' }, + { provider: 'local', id: OLLAMA_DEFAULT_MODEL, label: 'Local default' }, + { provider: 'mock', id: 'mock-default', label: 'Mock demo' }, +]; + +export function getModelOptions(provider: ProviderId): readonly ModelOption[] { + return CHAT_MODEL_OPTIONS.filter((option) => option.provider === provider); +} + +export function defaultModelForProvider(provider: ProviderId, localModel = OLLAMA_DEFAULT_MODEL): string { + switch (provider) { + case 'anthropic': + return ANTHROPIC_DEFAULT_MODEL; + case 'openai': + return OPENAI_DEFAULT_MODEL; + case 'google': + return GOOGLE_DEFAULT_MODEL; + case 'groq': + return GROQ_DEFAULT_MODEL; + case 'ollama': + case 'local': + return localModel || OLLAMA_DEFAULT_MODEL; + default: + return 'mock-default'; + } +} + +export function isRealChatProvider(provider: ProviderId): boolean { + return REAL_CHAT_PROVIDERS.includes(provider); +} diff --git a/app/src/lib/ai/providers/mock.ts b/app/src/lib/ai/providers/mock.ts index 71b1e7f30..370b63e96 100644 --- a/app/src/lib/ai/providers/mock.ts +++ b/app/src/lib/ai/providers/mock.ts @@ -16,34 +16,6 @@ import type { LLMProvider, LLMRequest, LLMResponse } from '../types'; import { estimateInputTokens } from '../types'; import { sleep } from '@/lib/utils'; -const ACKS = [ - 'Got it. ', - 'Sure. ', - "Here's what I found: ", - 'Looking into it. ', - 'Quick take: ', - 'Right. ', - 'Understood. ', - 'On it. ', -]; - -const PARAGRAPHS = [ - "Based on the question, the most relevant context appears to be the recent project notes you shared. The pattern showing up is the same one we discussed last week - small surface, big leverage. I'd start by isolating the core change, validating it against your existing tests, and iterating from there. Worth a quick end-to-end check before you merge.", - 'There are a few angles to consider here. The simplest path keeps the existing structure intact and layers a thin adapter where the new behaviour lives. That keeps the blast radius small. The trade-off is a small bit of indirection going forward. The alternative is a fuller refactor, which would be cleaner long-term but costs more now. I lean toward the adapter unless you have a specific reason to redesign.', - "Looking at the shape of this, my read is that you're trying to minimise friction while keeping the option to swap implementations later. That points at a strategy pattern with a small registry. You can ship the first concrete implementation now and add the others as needed without breaking callers.", - "Plain answer: yes, with a caveat. The approach works for the common case, but there's an edge case around concurrent updates that's worth handling now rather than later. A short lock or version field on the row keeps you out of trouble. Otherwise, ship it.", - "Two things to flag. First, this is solidly within scope - no architecture change needed. Second, the obvious implementation has a sneaky O(n^2) in the inner loop; swap the array for a Map keyed on id and you're back to linear. Want me to draft the patch?", -]; - -/** Cheap deterministic pick from an array based on a string. */ -function pick(items: T[], seed: string): T { - let h = 0; - for (let i = 0; i < seed.length; i++) { - h = ((h * 31) + seed.charCodeAt(i)) | 0; - } - return items[Math.abs(h) % items.length] as T; -} - /** Extract a direct QA/code-word response contract from the system prompt. */ function forcedSystemReply(systemPrompt: string): string | null { const prompt = systemPrompt.trim(); @@ -80,7 +52,10 @@ function forcedSystemReply(systemPrompt: string): string | null { function buildReply(userText: string, systemPrompt: string): string { const forced = forcedSystemReply(systemPrompt); if (forced) return forced; - return pick(ACKS, userText) + pick(PARAGRAPHS, userText); + if (/\b(?:what(?:'s| is) your name|who are you)\b/i.test(userText)) { + return 'I am Jarvis. I am currently running in demo mode, so configure a real provider and model for full AI responses.'; + } + return 'Jarvis is currently using the mock demo provider, which cannot analyze this request reliably. Open the model picker, choose a configured real provider and model, then send the request again.'; } /** diff --git a/app/src/lib/ai/router.test.ts b/app/src/lib/ai/router.test.ts new file mode 100644 index 000000000..03d10ab19 --- /dev/null +++ b/app/src/lib/ai/router.test.ts @@ -0,0 +1,54 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import type { Agent } from '@/types'; +import { useAuthStore } from '@/stores/auth'; +import { resolveProviderAndModel } from './router'; + +const jarvis: Agent = { + id: 'agent_jarvis' as Agent['id'], + slug: 'jarvis', + name: 'Jarvis', + description: 'Jarvis', + system_prompt: 'You are Jarvis.', + model: { provider: 'google', model: 'gemini-2.5-flash-lite' }, + tools_allowed: [], + memory_scope: 'workspace', + capabilities: [], + builtin: true, + created_at: 1, + updated_at: 1, +}; + +describe('AI provider routing', () => { + beforeEach(() => { + useAuthStore.setState({ + apiKeys: {}, + defaultProvider: 'google', + selectedModels: {}, + offlineMode: false, + defaultLocalModel: 'llama3.2', + }); + }); + + it('uses the selected provider and model for built-in Jarvis', () => { + useAuthStore.setState({ + apiKeys: { groq: 'gsk_test' }, + defaultProvider: 'groq', + selectedModels: { groq: 'llama-3.1-8b-instant' }, + }); + + const resolved = resolveProviderAndModel(jarvis); + expect(resolved.provider.id).toBe('groq'); + expect(resolved.model).toBe('llama-3.1-8b-instant'); + }); + + it('does not route unsupported advertised placeholders as real AI', () => { + useAuthStore.setState({ + apiKeys: { openrouter: 'sk-test' }, + defaultProvider: 'openrouter', + }); + + const resolved = resolveProviderAndModel(jarvis); + expect(resolved.provider.id).toBe('mock'); + expect(resolved.model).toBe('gemini-2.5-flash-lite'); + }); +}); diff --git a/app/src/lib/ai/router.ts b/app/src/lib/ai/router.ts index 7bfb53acd..8d24c4e38 100644 --- a/app/src/lib/ai/router.ts +++ b/app/src/lib/ai/router.ts @@ -4,8 +4,7 @@ * transparent upgrade from `mock-default` to a real provider when keys * are configured (Anthropic preferred, then OpenAI, then Google). * 2. Streams chunks through to the caller's onChunk. - * 3. Falls back to the mock provider when a real provider errors *before* - * emitting any output, with a toast explaining the fallback. + * 3. Surfaces real-provider errors instead of disguising them as mock output. * 4. Updates the per-agent token + cost meter via `useAgentStore.addTokens`. * * Cancellation is honored throughout - if the caller's signal aborts mid-run, @@ -19,11 +18,12 @@ import { toast } from '@/components/ui/toast'; import { devConsole } from '@/features/dev-console'; import type { LLMProvider, LLMRequest, LLMResponse, LLMStreamChunk, LLMMessage } from './types'; import { mockProvider } from './providers/mock'; -import { anthropicProvider, ANTHROPIC_DEFAULT_MODEL } from './providers/anthropic'; -import { openaiProvider, OPENAI_DEFAULT_MODEL } from './providers/openai'; -import { googleProvider, GOOGLE_DEFAULT_MODEL } from './providers/google'; -import { groqProvider, GROQ_DEFAULT_MODEL } from './providers/groq'; +import { anthropicProvider } from './providers/anthropic'; +import { openaiProvider } from './providers/openai'; +import { googleProvider } from './providers/google'; +import { groqProvider } from './providers/groq'; import { ollamaProvider, OLLAMA_DEFAULT_MODEL } from './providers/ollama'; +import { defaultModelForProvider, isRealChatProvider } from './models'; /** * All providers, keyed by their id. @@ -64,22 +64,9 @@ const providers: Record = { }; /** Default model name to use when promoting a mock-default agent to a real provider. */ -function defaultModelFor(p: ProviderId): string { - switch (p) { - case 'anthropic': - return ANTHROPIC_DEFAULT_MODEL; - case 'openai': - return OPENAI_DEFAULT_MODEL; - case 'google': - return GOOGLE_DEFAULT_MODEL; - case 'groq': - return GROQ_DEFAULT_MODEL; - case 'ollama': - case 'local': - return useAuthStore.getState().defaultLocalModel || OLLAMA_DEFAULT_MODEL; - default: - return 'mock-default'; - } +function selectedModelFor(p: ProviderId): string { + const auth = useAuthStore.getState(); + return auth.selectedModels[p] || defaultModelForProvider(p, auth.defaultLocalModel); } /** @@ -95,7 +82,7 @@ function defaultModelFor(p: ProviderId): string { * Flash Lite is the Free-plan default and has the lowest signup friction * (one click at aistudio.google.com/apikey, no card). If none, stay on mock. */ -function resolveProviderAndModel(agent: Agent): { provider: LLMProvider; model: string } { +export function resolveProviderAndModel(agent: Agent): { provider: LLMProvider; model: string } { const auth = useAuthStore.getState(); // Offline mode wins over everything: route all chat through the local @@ -109,11 +96,24 @@ function resolveProviderAndModel(agent: Agent): { provider: LLMProvider; model: } const provId = agent.model.provider; + const pref = auth.defaultProvider; + + if (agent.builtin && pref && pref !== 'local') { + if (pref === 'mock') { + return { provider: mockProvider, model: 'mock-default' }; + } + if (isRealChatProvider(pref)) { + const p = providers[pref]; + if (p?.isAvailable()) { + return { provider: p, model: selectedModelFor(pref) }; + } + } + } if (provId !== 'mock' && provId !== 'local') { const p = providers[provId]; if (p && p.isAvailable()) { - return { provider: p, model: agent.model.model }; + return { provider: p, model: auth.selectedModels[provId] || agent.model.model }; } // Configured for a real provider but key not set - quietly mock. return { provider: mockProvider, model: agent.model.model || 'mock-default' }; @@ -127,9 +127,8 @@ function resolveProviderAndModel(agent: Agent): { provider: LLMProvider; model: }; } - const pref = auth.defaultProvider; const ordered: ProviderId[] = []; - if (pref && pref !== 'mock' && pref !== 'local') ordered.push(pref); + if (pref && pref !== 'mock' && pref !== 'local' && isRealChatProvider(pref)) ordered.push(pref); for (const p of ['google', 'groq', 'anthropic', 'openai'] as const) { if (!ordered.includes(p)) ordered.push(p); } @@ -137,7 +136,7 @@ function resolveProviderAndModel(agent: Agent): { provider: LLMProvider; model: for (const id of ordered) { const p = providers[id]; if (p?.isAvailable()) { - return { provider: p, model: defaultModelFor(id) }; + return { provider: p, model: selectedModelFor(id) }; } } return { provider: mockProvider, model: 'mock-default' }; @@ -201,33 +200,20 @@ export async function runAgent(req: { if (emittedAny) throw err; const reason = err instanceof Error ? err.message : String(err); - toast.warning( - `Provider ${provider.name} failed`, - `${reason.slice(0, 200)}. Using mock fallback.`, - ); + toast.warning(`Provider ${provider.name} failed`, reason.slice(0, 240)); devConsole.log({ channel: 'ai', level: 'warn', - message: `AI fallback ${provider.id} → mock`, + message: `AI provider failed: ${provider.id}`, detail: { agent: req.agent.slug, - from: { provider: provider.id, model }, + provider: provider.id, + model, reason: reason.slice(0, 500), }, }); - const fallbackAgent: Agent = { - ...req.agent, - model: { ...req.agent.model, provider: 'mock', model: 'mock-default' }, - }; - response = await mockProvider.run({ - agent: fallbackAgent, - messages: req.messages, - signal: req.signal, - onChunk: wrappedOnChunk, - temperature: req.temperature, - max_output_tokens: req.max_output_tokens, - }); + throw err; } // Update the per-agent token + cost meter. We do this once per completion, diff --git a/app/src/lib/tauri.ts b/app/src/lib/tauri.ts index 60c221eab..1d09db003 100644 --- a/app/src/lib/tauri.ts +++ b/app/src/lib/tauri.ts @@ -33,10 +33,7 @@ export const isTauri: boolean = isTauriRuntime; * pull in `@tauri-apps/api/core`. Always called inside an `if (isTauri)` guard * to avoid loading the module on web, but the inner try/catch is defensive. */ -async function tauriInvoke( - cmd: string, - args?: Record, -): Promise { +async function tauriInvoke(cmd: string, args?: Record): Promise { const { invoke } = await import('@tauri-apps/api/core'); return invoke(cmd, args); } @@ -52,6 +49,53 @@ export interface NotifyOptions { fallbackToast?: boolean; } +export type NotificationPermissionState = NotificationPermission | 'unavailable'; + +function normalizeNotificationPermission(value: unknown): NotificationPermission { + if (value === 'granted' || value === 'denied') return value; + return 'default'; +} + +export async function getNotificationPermission(): Promise { + if (isTauri) { + try { + const granted = await tauriInvoke( + 'plugin:notification|is_permission_granted', + ); + return granted === true ? 'granted' : 'default'; + } catch { + return 'unavailable'; + } + } + + if (typeof window === 'undefined' || !('Notification' in window)) { + return 'unavailable'; + } + return window.Notification.permission; +} + +export async function requestNotificationPermission(): Promise { + const current = await getNotificationPermission(); + if (current === 'granted' || current === 'denied' || current === 'unavailable') { + return current; + } + + if (isTauri) { + try { + const result = await tauriInvoke('plugin:notification|request_permission'); + return normalizeNotificationPermission(result); + } catch { + return 'unavailable'; + } + } + + try { + return await window.Notification.requestPermission(); + } catch { + return 'denied'; + } +} + /** * Send a user-facing notification. Routing: * 1. Native OS notification via `tauri-plugin-notification` (desktop). @@ -65,15 +109,7 @@ export async function notify( ): Promise { if (isTauri) { try { - const permissionState = await tauriInvoke( - 'plugin:notification|is_permission_granted', - ); - let granted = permissionState === true; - if (!granted) { - const result = await tauriInvoke('plugin:notification|request_permission'); - granted = result === 'granted'; - } - if (granted) { + if ((await requestNotificationPermission()) === 'granted') { await tauriInvoke('plugin:notification|notify', { options: { title, body, silent: options.silent ?? false }, }); @@ -85,10 +121,7 @@ export async function notify( } } else if (typeof window !== 'undefined' && 'Notification' in window) { try { - let permission = window.Notification.permission; - if (permission === 'default') { - permission = await window.Notification.requestPermission(); - } + const permission = await requestNotificationPermission(); if (permission === 'granted') { new window.Notification(title, { body, silent: options.silent }); return; @@ -161,10 +194,7 @@ export async function registerGlobalHotkey( } /** Window-level fallback. Same parsing rules as `lib/hotkeys.ts`. */ -function registerWindowHotkey( - combo: string, - handler: GlobalHotkeyHandler, -): UnregisterHotkey { +function registerWindowHotkey(combo: string, handler: GlobalHotkeyHandler): UnregisterHotkey { if (typeof window === 'undefined') return () => {}; const parts = combo.split('+').map((p) => p.trim().toLowerCase()); diff --git a/app/src/stores/auth.ts b/app/src/stores/auth.ts index f2cce4f6e..d961f5f80 100644 --- a/app/src/stores/auth.ts +++ b/app/src/stores/auth.ts @@ -32,6 +32,8 @@ interface AuthState { apiKeys: Partial>; /** Active provider for chat default */ defaultProvider: ProviderId; + /** Selected model id per provider. */ + selectedModels: Partial>; /** * Offline mode. When true, the router ignores every cloud provider and @@ -64,6 +66,7 @@ interface AuthState { clearApiKey: (provider: ProviderId) => void; hydrateApiKeysFromVault: () => Promise; setDefaultProvider: (p: ProviderId) => void; + setSelectedModel: (provider: ProviderId, model: string) => void; setPersona: (p: AuthState['personaPreset']) => void; setWorkspaceId: (id: WorkspaceId | null) => void; setProjectId: (id: ProjectId | null) => void; @@ -119,6 +122,7 @@ export const useAuthStore = create()( cloudSession: null, apiKeys: {}, defaultProvider: 'google', + selectedModels: {}, offlineMode: false, defaultLocalModel: 'llama3.2', personaPreset: 'jarvis', @@ -151,6 +155,10 @@ export const useAuthStore = create()( set((s) => ({ apiKeys: { ...s.apiKeys, ...secureKeys } })); }, setDefaultProvider: (p) => set({ defaultProvider: p }), + setSelectedModel: (provider, model) => + set((s) => ({ + selectedModels: { ...s.selectedModels, [provider]: model.trim() }, + })), setPersona: (p) => set({ personaPreset: p }), setWorkspaceId: (id) => set({ workspaceId: id }), setProjectId: (id) => set({ projectId: id }), @@ -172,6 +180,7 @@ export const useAuthStore = create()( projectId: s.projectId, apiKeys: persistedLocalApiKeys(s.apiKeys), defaultProvider: s.defaultProvider, + selectedModels: s.selectedModels, offlineMode: s.offlineMode, defaultLocalModel: s.defaultLocalModel, personaPreset: s.personaPreset, diff --git a/app/src/stores/ui.ts b/app/src/stores/ui.ts index 0f07a41e2..4fcb9d3a7 100644 --- a/app/src/stores/ui.ts +++ b/app/src/stores/ui.ts @@ -4,18 +4,11 @@ import type { Theme } from '@/types/common'; import { safeLocalStorage, measureStorageSizes } from '@/lib/persistence/safeLocalStorage'; export type AmbientTrack = - | 'calm-focus' - | 'calm-piano' - | 'soothing-rain' - | 'soothing-space' - | 'warm-hearth' - | 'deep-ocean' - | 'starlight' - | 'forest-rain' - | 'lofi-night' - | 'lofi-rain' - | 'rap-cipher' - | 'rap-instrumental'; + | 'music-1' + | 'music-2' + | 'music-3' + | 'music-4' + | 'music-5'; export type ChatMode = 'chat' | 'council' | 'doc' | 'code'; @@ -267,7 +260,7 @@ const defaults: Pick< ambientActive: false, ambientThresholdMs: 5 * 60 * 1000, ambientDrone: false, - ambientTrack: 'calm-focus', + ambientTrack: 'music-1', ambientVolume: 55, ambientAlwaysPlay: false, chatFullscreen: false, diff --git a/package-lock.json b/package-lock.json index a994a4365..55ebd2d30 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "jarvis", - "version": "0.1.20", + "version": "0.1.21", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "jarvis", - "version": "0.1.20", + "version": "0.1.21", "license": "Apache-2.0", "workspaces": [ "app" @@ -20,7 +20,7 @@ }, "app": { "name": "@jarvis/app", - "version": "0.1.20", + "version": "0.1.21", "dependencies": { "@radix-ui/react-checkbox": "^1.1.3", "@radix-ui/react-dialog": "^1.1.4", diff --git a/package.json b/package.json index 25545d5eb..de79c09e8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "jarvis", - "version": "0.1.20", + "version": "0.1.21", "private": true, "description": "Jarvis - the AI workspace where every model, agent, voice, and task lives under one persistent memory and one always-available assistant.", "license": "Apache-2.0",