diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0ff1c21..2b02c11 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -34,7 +34,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 18 + node-version: 22 cache: 'npm' - name: Set up Python (for native modules) diff --git a/build/entitlements.mac.plist b/build/entitlements.mac.plist new file mode 100644 index 0000000..281212c --- /dev/null +++ b/build/entitlements.mac.plist @@ -0,0 +1,14 @@ + + + + + com.apple.security.cs.allow-jit + + com.apple.security.cs.allow-unsigned-executable-memory + + com.apple.security.cs.disable-library-validation + + com.apple.security.device.audio-input + + + diff --git a/electron-builder.json5 b/electron-builder.json5 index a45ee5a..7a4c78f 100644 --- a/electron-builder.json5 +++ b/electron-builder.json5 @@ -40,6 +40,12 @@ mac: { target: ['dmg'], icon: 'public/app_logo.png', + entitlements: 'build/entitlements.mac.plist', + entitlementsInherit: 'build/entitlements.mac.plist', + extendInfo: { + NSMicrophoneUsageDescription: + 'Alice uses the microphone for voice activity detection and speech recognition.', + }, artifactName: 'Alice-AI-App-Mac-${version}-Installer.${ext}', }, win: { diff --git a/electron/main/settingsManager.ts b/electron/main/settingsManager.ts index b91b790..31cec4f 100644 --- a/electron/main/settingsManager.ts +++ b/electron/main/settingsManager.ts @@ -67,7 +67,20 @@ export interface AppSettings { SUMMARIZATION_MODEL?: string SUMMARIZATION_SYSTEM_PROMPT?: string ttsProvider?: 'openai' | 'local' - ttsVoice?: 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer' + ttsVoice?: + | 'alloy' + | 'ash' + | 'ballad' + | 'coral' + | 'echo' + | 'fable' + | 'nova' + | 'onyx' + | 'sage' + | 'shimmer' + | 'verse' + | 'marin' + | 'cedar' localTtsVoice?: string embeddingProvider?: 'openai' | 'local' ragEnabled?: boolean diff --git a/package-lock.json b/package-lock.json index 3519f65..e504da6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,7 +44,7 @@ "dotenv": "^17.4.2", "electron": "43.2.0", "electron-builder": "26.15.3", - "openai": "^6.33.0", + "openai": "^7.4.0", "pinia": "^3.0.3", "postcss": "^8.5.25", "prettier": "^3.9.6", @@ -58,6 +58,9 @@ "vue": "^3.5.26", "vue-tsc": "^2.0.6" }, + "engines": { + "node": ">=22.0.0" + }, "optionalDependencies": { "@rollup/rollup-linux-x64-gnu": "4.62.4" } @@ -6892,19 +6895,31 @@ } }, "node_modules/openai": { - "version": "6.33.0", - "resolved": "https://registry.npmjs.org/openai/-/openai-6.33.0.tgz", - "integrity": "sha512-xAYN1W3YsDXJWA5F277135YfkEk6H7D3D6vWwRhJ3OEkzRgcyK8z/P5P9Gyi/wB4N8kK9kM5ZjprfvyHagKmpw==", + "version": "7.4.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-7.4.0.tgz", + "integrity": "sha512-+C9Muit5x8j9R8ej8ZzVgKcrVDtqFqTy9gxFdov0EItLgU68zrJtF9ZeT0cyqJQW9S3PCJkdFgADtRGquRBtew==", "dev": true, "license": "Apache-2.0", - "bin": { - "openai": "bin/cli" + "engines": { + "node": ">=22.0.0" }, "peerDependencies": { + "@aws-sdk/credential-provider-node": ">=3.972.0 <4", + "@smithy/hash-node": ">=4.3.0 <5", + "@smithy/signature-v4": ">=5.4.0 <6", "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "peerDependenciesMeta": { + "@aws-sdk/credential-provider-node": { + "optional": true + }, + "@smithy/hash-node": { + "optional": true + }, + "@smithy/signature-v4": { + "optional": true + }, "ws": { "optional": true }, diff --git a/package.json b/package.json index 0b266f3..4f89050 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "author": "pmbstyle", "license": "MIT", "private": true, + "engines": { + "node": ">=22.0.0" + }, "keywords": [ "electron", "rollup", @@ -46,7 +49,7 @@ "dotenv": "^17.4.2", "electron": "43.2.0", "electron-builder": "26.15.3", - "openai": "^6.33.0", + "openai": "^7.4.0", "pinia": "^3.0.3", "postcss": "^8.5.25", "prettier": "^3.9.6", diff --git a/src/components/settings/AssistantSettingsTab.vue b/src/components/settings/AssistantSettingsTab.vue index 8cfc905..83a3ca9 100644 --- a/src/components/settings/AssistantSettingsTab.vue +++ b/src/components/settings/AssistantSettingsTab.vue @@ -326,7 +326,7 @@

Model used for generating conversation summaries (e.g., - gpt-4.1-nano). + gpt-5.6-luna).

diff --git a/src/components/settings/CoreSettingsTab.vue b/src/components/settings/CoreSettingsTab.vue index 87d05b1..c10285a 100644 --- a/src/components/settings/CoreSettingsTab.vue +++ b/src/components/settings/CoreSettingsTab.vue @@ -477,11 +477,18 @@ class="select select-bordered w-full focus:select-primary" > + + + + + + +
diff --git a/src/components/wizard/OnboardingWizard.vue b/src/components/wizard/OnboardingWizard.vue index 9f99c61..a161520 100644 --- a/src/components/wizard/OnboardingWizard.vue +++ b/src/components/wizard/OnboardingWizard.vue @@ -92,7 +92,7 @@ import { listZAIModelsForConfig } from '../../services/llmProviders/zai' const step = ref(1) const settingsStore = useSettingsStore() const scrollContainer = ref() -const OPENAI_SUMMARIZATION_MODEL = 'gpt-4.1-nano' +const OPENAI_SUMMARIZATION_MODEL = 'gpt-5.6-luna' const DEFAULT_MAIN_WINDOW_SIZE = { width: 500, height: 500, diff --git a/src/modules/conversation/__tests__/chatOrchestrator.test.ts b/src/modules/conversation/__tests__/chatOrchestrator.test.ts index 5c0afc7..f209ea6 100644 --- a/src/modules/conversation/__tests__/chatOrchestrator.test.ts +++ b/src/modules/conversation/__tests__/chatOrchestrator.test.ts @@ -111,6 +111,21 @@ describe('chatOrchestrator', () => { expect(dependencies.setAudioState).toHaveBeenCalledWith('IDLE') }) + it.each(['AbortError', 'APIUserAbortError'])( + 'silently handles %s from the OpenAI stream request', + async errorName => { + const error = Object.assign(new Error('Request was aborted.'), { + name: errorName, + }) + const { dependencies, orchestrator } = setup({ throwError: error }) + + await orchestrator.runChat() + + expect(dependencies.logError).not.toHaveBeenCalled() + expect(dependencies.handleStreamError).not.toHaveBeenCalled() + } + ) + it('skips work when store is not initialized', async () => { const ctx = setup() ctx.dependencies.isInitialized.mockReturnValue(false) diff --git a/src/modules/conversation/__tests__/speechQueue.test.ts b/src/modules/conversation/__tests__/speechQueue.test.ts index 43ff66c..6b13b24 100644 --- a/src/modules/conversation/__tests__/speechQueue.test.ts +++ b/src/modules/conversation/__tests__/speechQueue.test.ts @@ -63,21 +63,24 @@ describe('createSpeechQueueManager', () => { expect(deps.setAudioState).not.toHaveBeenCalled() }) - it('silently handles AbortError from TTS stream', async () => { - const abortError = Object.assign(new Error('cancel'), { - name: 'AbortError', - }) - const deps = buildDependencies({ - ttsStream: vi.fn(async () => { - throw abortError - }), - }) - const manager = createSpeechQueueManager(deps) - - await manager.enqueueSpeech('Hello') - - expect(deps.logError).not.toHaveBeenCalled() - }) + it.each(['AbortError', 'APIUserAbortError'])( + 'silently handles %s from TTS stream', + async errorName => { + const abortError = Object.assign(new Error('cancel'), { + name: errorName, + }) + const deps = buildDependencies({ + ttsStream: vi.fn(async () => { + throw abortError + }), + }) + const manager = createSpeechQueueManager(deps) + + await manager.enqueueSpeech('Hello') + + expect(deps.logError).not.toHaveBeenCalled() + } + ) it('does not enqueue a response that resolves after cancellation', async () => { let resolveTts: ((response: Response) => void) | undefined diff --git a/src/modules/conversation/__tests__/streamHandler.test.ts b/src/modules/conversation/__tests__/streamHandler.test.ts index 74281f5..a342e95 100644 --- a/src/modules/conversation/__tests__/streamHandler.test.ts +++ b/src/modules/conversation/__tests__/streamHandler.test.ts @@ -170,22 +170,24 @@ describe('createStreamHandler', () => { expect(spies.handleStreamError).toHaveBeenCalledWith(error) }) - it('returns early without error when the stream is aborted', async () => { - const { deps, spies } = createDependencies() - const handler = createStreamHandler(deps) - - const abortingStream: AsyncIterable = { - async *[Symbol.asyncIterator]() { - const abortError = new Error('aborted') - abortError.name = 'AbortError' - throw abortError - }, - } + it.each(['AbortError', 'APIUserAbortError'])( + 'returns early without error for %s', + async errorName => { + const { deps, spies } = createDependencies() + const handler = createStreamHandler(deps) + + const abortingStream: AsyncIterable = { + async *[Symbol.asyncIterator]() { + const abortError = new Error('aborted') + abortError.name = errorName + throw abortError + }, + } - const result = await handler.process({ stream: abortingStream }) + const result = await handler.process({ stream: abortingStream }) - expect(result.streamEndedNormally).toBe(false) - expect(spies.handleStreamError).not.toHaveBeenCalled() - }) + expect(result.streamEndedNormally).toBe(false) + expect(spies.handleStreamError).not.toHaveBeenCalled() + } + ) }) - diff --git a/src/modules/conversation/chatOrchestrator.ts b/src/modules/conversation/chatOrchestrator.ts index eda369f..4d17d21 100644 --- a/src/modules/conversation/chatOrchestrator.ts +++ b/src/modules/conversation/chatOrchestrator.ts @@ -1,6 +1,7 @@ import type OpenAI from 'openai' import type { ChatMessage } from '../../types/chat' import type { RagSearchResult } from '../../types/rag' +import { isExpectedAbortError } from '../../utils/isAbortError' export interface ChatDependencies { isInitialized(): boolean @@ -453,7 +454,7 @@ export function createChatOrchestrator( ) await dependencies.processStream(streamResult, placeholderTempId, false) } catch (error: any) { - if (error?.name === 'AbortError') return + if (abortController.signal.aborted || isExpectedAbortError(error)) return dependencies.logError('Error starting OpenAI response stream:', error) diff --git a/src/modules/conversation/reminderHandler.ts b/src/modules/conversation/reminderHandler.ts index c30d288..c7b0742 100644 --- a/src/modules/conversation/reminderHandler.ts +++ b/src/modules/conversation/reminderHandler.ts @@ -1,4 +1,5 @@ import type { ChatMessage } from '../../types/chat' +import { isExpectedAbortError } from '../../utils/isAbortError' export interface ReminderData { message: string @@ -46,7 +47,7 @@ export function createReminderHandler( await dependencies.enqueueSpeech(data.message) } } catch (error: any) { - if (error?.name === 'AbortError') { + if (isExpectedAbortError(error)) { return } dependencies.logError( @@ -64,4 +65,3 @@ export function createReminderHandler( }, } } - diff --git a/src/modules/conversation/speechQueue.ts b/src/modules/conversation/speechQueue.ts index 4b1564b..0d87ce7 100644 --- a/src/modules/conversation/speechQueue.ts +++ b/src/modules/conversation/speechQueue.ts @@ -1,4 +1,5 @@ import type { AudioState } from '../../stores/generalStore' +import { isExpectedAbortError } from '../../utils/isAbortError' export interface SpeechQueueDependencies { createAbortController(): AbortController @@ -42,7 +43,7 @@ export function createSpeechQueueManager( dependencies.setAudioState('SPEAKING') } } catch (error: any) { - if (error?.name === 'AbortError') { + if (abortController.signal.aborted || isExpectedAbortError(error)) { return } dependencies.logError('TTS stream creation failed:', error) diff --git a/src/modules/conversation/streamHandler.ts b/src/modules/conversation/streamHandler.ts index 3b9f2c6..60f57b3 100644 --- a/src/modules/conversation/streamHandler.ts +++ b/src/modules/conversation/streamHandler.ts @@ -4,6 +4,7 @@ import type { StreamProcessingOptions, StreamProcessingResult, } from './types' +import { isExpectedAbortError } from '../../utils/isAbortError' const SENTENCE_END_REGEX = /[.!?]\s*$/ @@ -139,7 +140,7 @@ export function createStreamHandler( await flushSentence() } catch (error: any) { - if (error?.name === 'AbortError') { + if (isExpectedAbortError(error)) { return { streamEndedNormally: false } } streamEndedNormally = false diff --git a/src/modules/conversation/toolCallHandler.ts b/src/modules/conversation/toolCallHandler.ts index f276438..18083b1 100644 --- a/src/modules/conversation/toolCallHandler.ts +++ b/src/modules/conversation/toolCallHandler.ts @@ -1,18 +1,11 @@ import type OpenAI from 'openai' import type { ToolCallHandler, ToolCallHandlerDependencies } from './types' import type { AudioState } from '../../stores/generalStore' +import { isExpectedAbortError } from '../../utils/isAbortError' const PREVIOUS_RESPONSE_NOT_FOUND = 'Previous response with id' const NOT_FOUND_SUFFIX = 'not found' -function isAbortError(error: unknown): boolean { - return ( - !!error && - typeof error === 'object' && - (error as { name?: string }).name === 'AbortError' - ) -} - function isPreviousResponseMissingError(error: unknown): boolean { if (!error || typeof error !== 'object') return false const message = (error as { message?: string }).message || '' @@ -87,7 +80,7 @@ export function createToolCallHandler( await attemptContinuation(originalResponseIdForTool) return } catch (error) { - if (isAbortError(error)) { + if (isExpectedAbortError(error)) { return } @@ -109,7 +102,7 @@ export function createToolCallHandler( await attemptContinuation(null) return } catch (retryError) { - if (!isAbortError(retryError)) { + if (!isExpectedAbortError(retryError)) { dependencies.logError( '[Error Recovery] Retry also failed:', retryError diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 56853d9..da61747 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -369,7 +369,7 @@ const fallbackToOpenAITTS = async ( return openai.audio.speech.create( { - model: 'tts-1', + model: 'gpt-4o-mini-tts', voice: settings.ttsVoice || 'nova', input: text, response_format: 'mp3', @@ -801,7 +801,9 @@ export const createSummarizationResponse = async ( ...(summarizationModel.startsWith('gpt-5') ? { reasoning: { - effort: 'minimal', + effort: summarizationModel.startsWith('gpt-5.6') + ? 'none' + : 'minimal', }, text: { verbosity: 'low', @@ -871,7 +873,9 @@ export const createContextAnalysisResponse = async ( ...(analysisModel.startsWith('gpt-5') ? { reasoning: { - effort: 'minimal', + effort: analysisModel.startsWith('gpt-5.6') + ? 'none' + : 'minimal', }, text: { verbosity: 'low', diff --git a/src/services/llmProviders/streamAdapters.ts b/src/services/llmProviders/streamAdapters.ts index 08a24eb..d12997d 100644 --- a/src/services/llmProviders/streamAdapters.ts +++ b/src/services/llmProviders/streamAdapters.ts @@ -1,6 +1,4 @@ -function isAbortError(error: any): boolean { - return error?.name === 'AbortError' -} +import { isExpectedAbortError } from '../../utils/isAbortError' export async function* convertLocalLLMStreamToResponsesFormat( stream: any, @@ -190,7 +188,7 @@ export async function* convertLocalLLMStreamToResponsesFormat( } } } catch (error) { - if (isAbortError(error)) { + if (isExpectedAbortError(error)) { throw error } @@ -517,7 +515,7 @@ export async function* convertOpenRouterStreamToResponsesFormat(stream: any) { } } } catch (error) { - if (isAbortError(error)) { + if (isExpectedAbortError(error)) { throw error } diff --git a/src/stores/conversationStore.ts b/src/stores/conversationStore.ts index e24e1d7..f608056 100644 --- a/src/stores/conversationStore.ts +++ b/src/stores/conversationStore.ts @@ -7,6 +7,7 @@ import type { AudioState } from './generalStore' import { useSettingsStore } from './settingsStore' import { executeFunction } from '../utils/functionCaller' import eventBus from '../utils/eventBus' +import { isExpectedAbortError } from '../utils/isAbortError' import { createStreamHandler } from '../modules/conversation/streamHandler' import { createToolCallHandler } from '../modules/conversation/toolCallHandler' import { createApiInputBuilder } from '../modules/conversation/apiInputBuilder' @@ -408,7 +409,7 @@ export const useConversationStore = defineStore('conversation', () => { } } } catch (error: any) { - if (error.name !== 'AbortError') { + if (!isExpectedAbortError(error)) { console.error( '[ConversationStore] Failed to speak scheduler reminder:', error @@ -776,7 +777,7 @@ export const useConversationStore = defineStore('conversation', () => { setAudioState: (state: string) => setAudioState(state as AudioState), getAudioState: () => audioState.value, handleStreamError: (error: unknown) => { - if ((error as any)?.name === 'AbortError') return + if (isExpectedAbortError(error)) return console.error('Error processing stream:', error) }, } @@ -994,7 +995,7 @@ export const useConversationStore = defineStore('conversation', () => { ) await processStream(streamResult, placeholderTempId, false) } catch (error: any) { - if (error.name !== 'AbortError') { + if (!isExpectedAbortError(error)) { console.error('Error starting OpenAI response stream:', error) if ( diff --git a/src/stores/generalStore.ts b/src/stores/generalStore.ts index 1f5fce8..2748bac 100644 --- a/src/stores/generalStore.ts +++ b/src/stores/generalStore.ts @@ -1,6 +1,7 @@ import { ref, watch } from 'vue' import { defineStore } from 'pinia' import eventBus from '../utils/eventBus' +import { isExpectedAbortError } from '../utils/isAbortError' import type { ChatMessage } from '../types/chat' import { createHistoryManager } from '../modules/conversation/historyManager' import { useCustomAvatarsStore } from './customAvatarsStore' @@ -153,9 +154,11 @@ export const useGeneralStore = defineStore('general', () => { videoSource.value = newSource aiVideo.value.load() } - aiVideo.value - .play() - .catch(e => console.warn('Video play interrupted or failed:', e)) + aiVideo.value.play().catch(error => { + if (!isExpectedAbortError(error)) { + console.warn('Video play failed:', error) + } + }) } const handleVideoLoadedData = () => { diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 37ca5d8..32001c4 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -67,7 +67,20 @@ export interface AliceSettings { SUMMARIZATION_MODEL: string SUMMARIZATION_SYSTEM_PROMPT: string ttsProvider: 'openai' | 'google' | 'local' - ttsVoice: 'alloy' | 'echo' | 'fable' | 'nova' | 'onyx' | 'shimmer' + ttsVoice: + | 'alloy' + | 'ash' + | 'ballad' + | 'coral' + | 'echo' + | 'fable' + | 'nova' + | 'onyx' + | 'sage' + | 'shimmer' + | 'verse' + | 'marin' + | 'cedar' googleTtsVoice: string localTtsVoice: string embeddingProvider: 'openai' | 'local' @@ -168,7 +181,7 @@ const defaultSettings: AliceSettings = { mcpServersConfig: '[]', MAX_HISTORY_MESSAGES_FOR_API: 10, SUMMARIZATION_MESSAGE_COUNT: 20, - SUMMARIZATION_MODEL: 'gpt-4.1-nano', + SUMMARIZATION_MODEL: 'gpt-5.6-luna', SUMMARIZATION_SYSTEM_PROMPT: DEFAULT_SUMMARIZATION_SYSTEM_PROMPT, ttsProvider: 'openai', ttsVoice: 'nova', diff --git a/src/utils/isAbortError.ts b/src/utils/isAbortError.ts new file mode 100644 index 0000000..bfec355 --- /dev/null +++ b/src/utils/isAbortError.ts @@ -0,0 +1,10 @@ +const EXPECTED_ABORT_ERROR_NAMES = new Set(['AbortError', 'APIUserAbortError']) + +export function isExpectedAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') { + return false + } + + const name = (error as { name?: unknown }).name + return typeof name === 'string' && EXPECTED_ABORT_ERROR_NAMES.has(name) +}