diff --git a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx index 17bda432..f02ba10a 100644 --- a/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/NotificationSettings.tsx @@ -1,18 +1,11 @@ import React from 'react'; import { useUIStore } from '@/stores/useUIStore'; -import { useConfigStore } from '@/stores/useConfigStore'; import { isDesktopShell, isVSCodeRuntime } from '@/lib/desktop'; -import { useDeviceInfo } from '@/lib/device'; -import { updateDesktopSettings } from '@/lib/persistence'; import { Checkbox } from '@/components/ui/checkbox'; import { toast } from '@/components/ui'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { Input } from '@/components/ui/input'; -import { NumberInput } from '@/components/ui/number-input'; import { Button } from '@/components/ui/button'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { Icon } from "@/components/icon/Icon"; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -42,16 +35,8 @@ const TEMPLATE_EVENT_LABEL_KEYS = { question: 'settings.notifications.page.template.event.question', } as const satisfies Record; -const UTILITY_PREFERRED_MODEL_ID = 'big-pickle'; -const UTILITY_NOT_SELECTED_VALUE = '__not_selected__'; - -const DEFAULT_SUMMARY_THRESHOLD = 200; -const DEFAULT_SUMMARY_LENGTH = 100; -const DEFAULT_MAX_LAST_MESSAGE_LENGTH = 250; - export const NotificationSettings: React.FC = () => { const { t } = useI18n(); - const { isMobile } = useDeviceInfo(); const isDesktop = React.useMemo(() => isDesktopShell(), []); const isVSCode = React.useMemo(() => isVSCodeRuntime(), []); const isBrowser = !isDesktop && !isVSCode; @@ -69,92 +54,11 @@ export const NotificationSettings: React.FC = () => { const setNotifyOnQuestion = useUIStore(state => state.setNotifyOnQuestion); const notificationTemplates = useUIStore(state => state.notificationTemplates); const setNotificationTemplates = useUIStore(state => state.setNotificationTemplates); - const summarizeLastMessage = useUIStore(state => state.summarizeLastMessage); - const setSummarizeLastMessage = useUIStore(state => state.setSummarizeLastMessage); - const summaryThreshold = useUIStore(state => state.summaryThreshold); - const setSummaryThreshold = useUIStore(state => state.setSummaryThreshold); - const summaryLength = useUIStore(state => state.summaryLength); - const setSummaryLength = useUIStore(state => state.setSummaryLength); - const maxLastMessageLength = useUIStore(state => state.maxLastMessageLength); - const setMaxLastMessageLength = useUIStore(state => state.setMaxLastMessageLength); - const settingsZenModel = useConfigStore((state) => state.settingsZenModel); - const setSettingsZenModel = useConfigStore((state) => state.setSettingsZenModel); const [notificationPermission, setNotificationPermission] = React.useState('default'); const [pushSupported, setPushSupported] = React.useState(false); const [pushSubscribed, setPushSubscribed] = React.useState(false); const [pushBusy, setPushBusy] = React.useState(false); - const [fetchedZenModels, setFetchedZenModels] = React.useState>([]); - - React.useEffect(() => { - const controller = new AbortController(); - void fetch('/api/zen/models', { - method: 'GET', - headers: { Accept: 'application/json' }, - signal: controller.signal, - }) - .then(async (response) => { - if (!response.ok) { - return [] as Array<{ id: string; name: string }>; - } - const payload = await response.json().catch(() => ({})); - const models = Array.isArray(payload?.models) ? payload.models : []; - return models - .map((entry: unknown) => { - const id = typeof (entry as { id?: unknown })?.id === 'string' - ? (entry as { id: string }).id.trim() - : ''; - if (!id) { - return null; - } - return { id, name: id }; - }) - .filter((entry: { id: string; name: string } | null): entry is { id: string; name: string } => entry !== null); - }) - .then((models) => { - setFetchedZenModels(models); - }) - .catch((error) => { - if (error?.name !== 'AbortError') { - console.warn('Failed to load zen utility models:', error); - } - }); - - return () => { - controller.abort(); - }; - }, []); - - const utilityModelOptions = React.useMemo(() => { - return fetchedZenModels; - }, [fetchedZenModels]); - - const utilitySelectedModelId = React.useMemo(() => { - if (settingsZenModel && utilityModelOptions.some((model) => model.id === settingsZenModel)) { - return settingsZenModel; - } - if (utilityModelOptions.some((model) => model.id === UTILITY_PREFERRED_MODEL_ID)) { - return UTILITY_PREFERRED_MODEL_ID; - } - return utilityModelOptions[0]?.id ?? ''; - }, [settingsZenModel, utilityModelOptions]); - - const handleUtilityModelChange = React.useCallback( - async (value: string) => { - const modelId = value === UTILITY_NOT_SELECTED_VALUE ? undefined : value; - setSettingsZenModel(modelId); - try { - await updateDesktopSettings({ - zenModel: modelId ?? '', - gitProviderId: '', - gitModelId: '', - }); - } catch (error) { - console.warn('Failed to save utility model setting:', error); - } - }, - [setSettingsZenModel] - ); React.useEffect(() => { if (!isBrowser) { @@ -763,165 +667,6 @@ export const NotificationSettings: React.FC = () => { - {/* --- Summarization --- */} -
-
-

- {t('settings.notifications.page.summary.title')} -

-
- -
-
setSummarizeLastMessage(!summarizeLastMessage)} - onKeyDown={(event) => { - if (event.key === ' ' || event.key === 'Enter') { - event.preventDefault(); - setSummarizeLastMessage(!summarizeLastMessage); - } - }} - > - - {t('settings.notifications.page.summary.toggleLabel')} -
-
- - {t('settings.notifications.page.summary.requiresTemplateVariable')} - {' '} - {'{last_message}'}. - -
- -
-
-
- {t('settings.notifications.page.summary.modelLabel')} - - - - - - {t('settings.notifications.page.summary.modelTooltip')} - - -
-
-
- -
-
- - {summarizeLastMessage ? ( - <> -
-
- {t('settings.notifications.page.summary.thresholdLabel')} - {t('settings.notifications.page.summary.thresholdHint')} -
-
- - -
-
-
-
- {t('settings.notifications.page.summary.lengthLabel')} - {t('settings.notifications.page.summary.lengthHint')} -
-
- - -
-
- - ) : ( -
-
- {t('settings.notifications.page.summary.maxLengthLabel')} - {t('settings.notifications.page.summary.maxLengthHint')} -
-
- - -
-
- )} -
-
)} diff --git a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx index ad3bc212..5c09ce29 100644 --- a/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/VoiceSettings.tsx @@ -177,14 +177,6 @@ export const VoiceSettings: React.FC = () => { const setShowMessageTTSButtons = useConfigStore((state) => state.setShowMessageTTSButtons); const voiceModeEnabled = useConfigStore((state) => state.voiceModeEnabled); const setVoiceModeEnabled = useConfigStore((state) => state.setVoiceModeEnabled); - const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS); - const setSummarizeMessageTTS = useConfigStore((state) => state.setSummarizeMessageTTS); - const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation); - const setSummarizeVoiceConversation = useConfigStore((state) => state.setSummarizeVoiceConversation); - const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); - const setSummarizeCharacterThreshold = useConfigStore((state) => state.setSummarizeCharacterThreshold); - const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength); - const setSummarizeMaxLength = useConfigStore((state) => state.setSummarizeMaxLength); const [isSayAvailable, setIsSayAvailable] = useState(false); const [sayVoices, setSayVoices] = useState>([]); @@ -1006,51 +998,6 @@ export const VoiceSettings: React.FC = () => { {t('settings.voice.page.field.messageReadAloudButton')} -
setSummarizeMessageTTS(!summarizeMessageTTS)} - onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeMessageTTS(!summarizeMessageTTS); } }} - > - - {t('settings.voice.page.field.summarizeBeforePlayback')} -
- - {voiceModeEnabled && ( -
setSummarizeVoiceConversation(!summarizeVoiceConversation)} - onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeVoiceConversation(!summarizeVoiceConversation); } }} - > - - {t('settings.voice.page.field.summarizeVoiceModeResponses')} -
- )} - - {(summarizeMessageTTS || summarizeVoiceConversation) && ( - <> -
- {t('settings.voice.page.field.summarizationThreshold')} -
- {!isMobile && setSummarizeCharacterThreshold(Number(e.target.value))} className={sliderClass} />} - -
-
- -
- {t('settings.voice.page.field.summaryMaxLength')} -
- {!isMobile && setSummarizeMaxLength(Number(e.target.value))} className={sliderClass} />} - -
-
- - )} {voiceModeEnabled && isSupported && ( diff --git a/packages/ui/src/hooks/useBrowserVoice.ts b/packages/ui/src/hooks/useBrowserVoice.ts index 20877dae..0f481728 100644 --- a/packages/ui/src/hooks/useBrowserVoice.ts +++ b/packages/ui/src/hooks/useBrowserVoice.ts @@ -35,7 +35,7 @@ import { getSyncMessages, getSyncParts } from '@/sync/sync-refs'; import { useConfigStore } from '@/stores/useConfigStore'; import { useServerTTS } from './useServerTTS'; import { useSayTTS } from './useSayTTS'; -import { summarizeText, shouldSummarize, sanitizeForTTS } from '@/lib/voice/summarize'; +import { sanitizeForTTS } from '@/lib/voice/summarize'; export type BrowserVoiceStatus = 'idle' | 'listening' | 'processing' | 'speaking' | 'error'; @@ -151,8 +151,6 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); - const summarizeVoiceConversation = useConfigStore((state) => state.summarizeVoiceConversation); - const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); const shouldCheckOpenAIAvailability = voiceModeEnabled && (voiceProvider === 'openai' || voiceProvider === 'openai-compatible'); const shouldCheckSayAvailability = voiceModeEnabled && voiceProvider === 'say'; @@ -476,17 +474,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { // Speak the response setStatus('speaking'); try { - // Summarize text if enabled and over threshold - let textToSpeak = textParts; - if (summarizeVoiceConversation && shouldSummarize(textParts, 'voice')) { - console.log('[useBrowserVoice] Summarizing AI response before speaking...'); - textToSpeak = await summarizeText(textParts, { - threshold: summarizeCharacterThreshold, - }); - } else { - // Still sanitize for TTS even when not summarizing - textToSpeak = sanitizeForTTS(textParts); - } + const textToSpeak = sanitizeForTTS(textParts); // Helper to restart listening after speech ends // Only auto-restart if conversation mode is enabled @@ -613,7 +601,7 @@ export function useBrowserVoice(): UseBrowserVoiceReturn { setStatus('error'); processingMessageRef.current = false; } - }, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, summarizeVoiceConversation, summarizeCharacterThreshold, conversationMode, startCurrentSTT]); + }, [currentSessionId, currentProviderId, currentModelId, currentAgentName, language, sendMessage, setPendingInputText, createSession, speechRate, speechPitch, speechVolume, isServerTTSAvailable, speakServerTTS, isSayTTSAvailable, speakSayTTS, voiceProvider, sayVoice, browserVoice, openaiVoice, openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, conversationMode, startCurrentSTT]); // Handle speech recognition result const handleSpeechResult = useCallback(async (text: string, isFinal: boolean) => { diff --git a/packages/ui/src/hooks/useMessageTTS.ts b/packages/ui/src/hooks/useMessageTTS.ts index fe827a3f..39b2b9db 100644 --- a/packages/ui/src/hooks/useMessageTTS.ts +++ b/packages/ui/src/hooks/useMessageTTS.ts @@ -10,7 +10,7 @@ import { useConfigStore } from '@/stores/useConfigStore'; import { useServerTTS } from './useServerTTS'; import { useSayTTS } from './useSayTTS'; import { browserVoiceService } from '@/lib/voice/browserVoiceService'; -import { summarizeText, shouldSummarize, sanitizeForTTS } from '@/lib/voice/summarize'; +import { sanitizeForTTS } from '@/lib/voice/summarize'; export interface UseMessageTTSReturn { /** Whether TTS is currently playing for this message */ @@ -34,8 +34,6 @@ export function useMessageTTS(): UseMessageTTSReturn { const openaiCompatibleVoice = useConfigStore((state) => state.openaiCompatibleVoice); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); const openaiCompatibleTtsModel = useConfigStore((state) => state.openaiCompatibleTtsModel); - const summarizeMessageTTS = useConfigStore((state) => state.summarizeMessageTTS); - const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); const showMessageTTSButtons = useConfigStore((state) => state.showMessageTTSButtons); const isServerProvider = voiceProvider === 'openai' || voiceProvider === 'openai-compatible'; @@ -66,16 +64,7 @@ export function useMessageTTS(): UseMessageTTSReturn { setIsPlaying(true); try { - // Summarize text if enabled and over threshold - let textToSpeak = text; - if (summarizeMessageTTS && shouldSummarize(text, 'message')) { - textToSpeak = await summarizeText(text, { - threshold: summarizeCharacterThreshold, - }); - } else { - // Still sanitize for TTS even when not summarizing - textToSpeak = sanitizeForTTS(text); - } + const textToSpeak = sanitizeForTTS(text); if (isServerProvider && isServerTTSAvailable) { const voice = voiceProvider === 'openai-compatible' ? openaiCompatibleVoice : openaiVoice; @@ -87,7 +76,7 @@ export function useMessageTTS(): UseMessageTTSReturn { speed: speechRate, pitch: speechPitch, volume: speechVolume, - summarize: false, // We already summarized client-side + summarize: false, baseURL, onEnd: () => setIsPlaying(false), onError: () => setIsPlaying(false), @@ -132,8 +121,6 @@ export function useMessageTTS(): UseMessageTTSReturn { openaiCompatibleVoice, openaiCompatibleUrl, openaiCompatibleTtsModel, - summarizeMessageTTS, - summarizeCharacterThreshold, isServerTTSAvailable, isSayTTSAvailable, speakServerTTS, diff --git a/packages/ui/src/hooks/useServerTTS.ts b/packages/ui/src/hooks/useServerTTS.ts index d8eefc85..39b34917 100644 --- a/packages/ui/src/hooks/useServerTTS.ts +++ b/packages/ui/src/hooks/useServerTTS.ts @@ -134,14 +134,11 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet const audioSourceRef = useRef(null); const abortControllerRef = useRef(null); - // Get current model, threshold, and max length from config store for summarization + // Get current model and API settings from config store. const currentProviderId = useConfigStore((state) => state.currentProviderId); const currentModelId = useConfigStore((state) => state.currentModelId); - const summarizeCharacterThreshold = useConfigStore((state) => state.summarizeCharacterThreshold); - const summarizeMaxLength = useConfigStore((state) => state.summarizeMaxLength); const openaiApiKey = useConfigStore((state) => state.openaiApiKey); const openaiCompatibleUrl = useConfigStore((state) => state.openaiCompatibleUrl); - const settingsZenModel = useConfigStore((state) => state.settingsZenModel); // Check if server TTS is available const checkAvailability = useCallback(async (): Promise => { @@ -275,19 +272,14 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet model: options?.model || undefined, speed: options?.speed || 0.9, instructions: options?.instructions, - summarize: options?.summarize ?? true, // Summarize by default for voice output + summarize: false, // Use provided provider/model, or fall back to current chat model providerId: options?.providerId || currentProviderId || undefined, modelId: options?.modelId || currentModelId || undefined, - // Use provided threshold, or fall back to user setting, or default to 200 - threshold: options?.threshold ?? summarizeCharacterThreshold ?? 200, - // Max character length for summaries - maxLength: summarizeMaxLength ?? 500, // Send API key from settings if available apiKey: openaiApiKey || undefined, // Send custom base URL for OpenAI-compatible servers baseURL: options?.baseURL || undefined, - ...(settingsZenModel ? { zenModel: settingsZenModel } : {}), }), signal: abortControllerRef.current.signal, }); @@ -350,7 +342,7 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet options?.onError?.(errorMsg); setIsPlaying(false); } - }, [stop, currentProviderId, currentModelId, summarizeCharacterThreshold, summarizeMaxLength, openaiApiKey, settingsZenModel]); + }, [stop, currentProviderId, currentModelId, openaiApiKey]); // Cleanup on unmount useEffect(() => { diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index b830659a..f78039f9 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1302,7 +1302,7 @@ export const settingsDict = { 'settings.notifications.page.testNotification.body': 'This is a test notification from OpenChamber.', 'settings.voice.page.section.voiceSetup': 'Voice Setup', 'settings.voice.page.section.speechRecognition': 'Speech Recognition', - 'settings.voice.page.section.playbackAndSummary': 'Playback & Summarization', + 'settings.voice.page.section.playbackAndSummary': 'Playback', 'settings.voice.page.field.enableVoiceModeAria': 'Enable voice mode', 'settings.voice.page.field.enableVoiceMode': 'Enable Voice Mode', 'settings.voice.page.field.provider': 'Provider', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 6e6846e6..b9405d26 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1302,7 +1302,7 @@ export const settingsDict = { "settings.notifications.page.testNotification.body": "Esta es una notificación de prueba de OpenChamber.", "settings.voice.page.section.voiceSetup": "Configuración de voz", "settings.voice.page.section.speechRecognition": "Reconocimiento de voz", - "settings.voice.page.section.playbackAndSummary": "Reproducción y resumen", + "settings.voice.page.section.playbackAndSummary": "Reproducción", "settings.voice.page.field.enableVoiceModeAria": "Habilitar modo de voz", "settings.voice.page.field.enableVoiceMode": "Habilitar modo de voz", "settings.voice.page.field.provider": "Proveedor", diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index e435eabe..9e6470db 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1302,7 +1302,7 @@ export const settingsDict = { 'settings.notifications.page.testNotification.body': 'OpenChamber의 테스트 알림입니다.', 'settings.voice.page.section.voiceSetup': '음성', 'settings.voice.page.section.speechRecognition': '음성 인식', - 'settings.voice.page.section.playbackAndSummary': '재생 및 요약', + 'settings.voice.page.section.playbackAndSummary': '재생', 'settings.voice.page.field.enableVoiceModeAria': '음성 모드 활성화', 'settings.voice.page.field.enableVoiceMode': '음성 모드 활성화', 'settings.voice.page.field.provider': '프로바이더', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 885c3a41..f44e5fb9 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1548,7 +1548,7 @@ export const settingsDict = { 'settings.voice.page.stt.wasmNotLoaded': 'Model zostanie pobrany przy pierwszym użyciu', 'settings.voice.page.stt.wasmDownload': 'Pobierz', 'settings.voice.page.stt.wasmRetry': 'Spróbuj ponownie', - 'settings.voice.page.section.playbackAndSummary': 'Odtwarzanie i podsumowanie', + 'settings.voice.page.section.playbackAndSummary': 'Odtwarzanie', 'settings.voice.page.section.speechRecognition': 'Rozpoznawanie mowy', 'settings.voice.page.section.voiceSetup': 'Konfiguracja głosu', 'settings.voice.page.tooltip.browser': 'Darmowe, offline, ograniczone wsparcie mobilne.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 66aacf56..ba8714b3 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1302,7 +1302,7 @@ export const settingsDict = { "settings.notifications.page.testNotification.body": "Esta é uma notificação de teste do OpenChamber.", "settings.voice.page.section.voiceSetup": "Configurações de voz", "settings.voice.page.section.speechRecognition": "Reconhecimento de voz", - "settings.voice.page.section.playbackAndSummary": "Reprodução e resumo", + "settings.voice.page.section.playbackAndSummary": "Reprodução", "settings.voice.page.field.enableVoiceModeAria": "Ativar modo de voz", "settings.voice.page.field.enableVoiceMode": "Ativar modo de voz", "settings.voice.page.field.provider": "Provedor", diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 78800b80..6e219e34 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1302,7 +1302,7 @@ export const settingsDict = { "settings.notifications.page.testNotification.body": "Це тестове сповіщення від OpenChamber.", "settings.voice.page.section.voiceSetup": "Налаштування голосу", "settings.voice.page.section.speechRecognition": "Розпізнавання мовлення", - "settings.voice.page.section.playbackAndSummary": "Відтворення та підсумовування", + "settings.voice.page.section.playbackAndSummary": "Відтворення", "settings.voice.page.field.enableVoiceModeAria": "Увімкнути голосовий режим", "settings.voice.page.field.enableVoiceMode": "Увімкнути голосовий режим", "settings.voice.page.field.provider": "Провайдер", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 2a6c8bc8..79d0efd3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1302,7 +1302,7 @@ export const settingsDict = { 'settings.notifications.page.testNotification.body': '这是来自 OpenChamber 的测试通知。', 'settings.voice.page.section.voiceSetup': '语音设置', 'settings.voice.page.section.speechRecognition': '语音识别', - 'settings.voice.page.section.playbackAndSummary': '播放与摘要', + 'settings.voice.page.section.playbackAndSummary': '播放', 'settings.voice.page.field.enableVoiceModeAria': '启用语音模式', 'settings.voice.page.field.enableVoiceMode': '启用语音模式', 'settings.voice.page.field.provider': '提供方', diff --git a/packages/vscode/src/bridge-system-runtime.ts b/packages/vscode/src/bridge-system-runtime.ts index 76b453c0..95c0ccbc 100644 --- a/packages/vscode/src/bridge-system-runtime.ts +++ b/packages/vscode/src/bridge-system-runtime.ts @@ -22,19 +22,6 @@ type SystemRuntimeDeps = { clientReloadDelayMs: number; }; -type NotificationBridgePayload = { - title?: string; - body?: string; - tag?: string; -}; - -type NotificationsNotifyRequestPayload = { - payload?: NotificationBridgePayload; -}; - -const ZEN_MODELS_URL = 'https://opencode.ai/zen/v1/models'; -const ZEN_MODELS_CACHE_TTL_MS = 5 * 60 * 1000; -let cachedZenModels: { models: Array<{ id: string; owned_by?: string }>; at: number } | null = null; const getOpenChamberConfigDir = (): string => { if (process.platform === 'win32') { @@ -91,11 +78,6 @@ const virtualDiffContents = new Map(); let virtualDiffCounter = 0; let virtualDiffProviderDisposable: vscode.Disposable | null = null; -const asObject = (value: unknown): Record | null => - value && typeof value === 'object' && !Array.isArray(value) - ? (value as Record) - : null; - const ensureVirtualDiffProviderRegistered = (ctx?: BridgeContext): void => { if (virtualDiffProviderDisposable) { return; @@ -204,56 +186,7 @@ const reconstructOriginalContentFromPatch = (modifiedContent: string, patch: str return lines.join('\n'); }; -const fetchFreeZenModels = async (): Promise> => { - const now = Date.now(); - if (cachedZenModels && now - cachedZenModels.at < ZEN_MODELS_CACHE_TTL_MS) { - return cachedZenModels.models; - } - - const signal = AbortSignal.timeout(8_000); - const [response, metadataResponse] = await Promise.all([ - fetch(ZEN_MODELS_URL, { - headers: { Accept: 'application/json' }, - signal, - }), - fetch('https://models.dev/api.json', { - headers: { Accept: 'application/json' }, - signal, - }), - ]); - - if (!response.ok) { - throw new Error(`zen models request failed (${response.status})`); - } - if (!metadataResponse.ok) { - throw new Error(`models.dev request failed (${metadataResponse.status})`); - } - - const rawPayload = await response.json().catch(() => null); - const rawMetadata = await metadataResponse.json().catch(() => null); - const payload = asObject(rawPayload); - const metadata = asObject(rawMetadata); - const metadataProvider = asObject(metadata?.opencode); - const metadataModels = asObject(metadataProvider?.models); - const rows = Array.isArray(payload?.data) ? payload.data : []; - const models = rows - .map((entry) => { - const id = typeof (entry as { id?: unknown })?.id === 'string' - ? (entry as { id: string }).id.trim() - : ''; - const ownedBy = typeof (entry as { owned_by?: unknown })?.owned_by === 'string' - ? (entry as { owned_by: string }).owned_by - : undefined; - const metadataModel = asObject(metadataModels?.[id]); - const cost = asObject(metadataModel?.cost); - if (!id || cost?.input !== 0 || cost?.output !== 0) return null; - return ownedBy ? { id, owned_by: ownedBy } : { id }; - }) - .filter((entry): entry is { id: string; owned_by?: string } => entry !== null); - - cachedZenModels = { models, at: Date.now() }; - return models; -}; +const fetchFreeZenModels = async (): Promise> => []; export async function handleSystemBridgeMessage( message: BridgeMessageInput, @@ -293,16 +226,8 @@ export async function handleSystemBridgeMessage( } case 'api:zen:models': { - try { - const models = await fetchFreeZenModels(); - return { id, type, success: true, data: { models } }; - } catch (error) { - if (cachedZenModels) { - return { id, type, success: true, data: { models: cachedZenModels.models } }; - } - const errorMessage = error instanceof Error ? error.message : String(error); - return { id, type, success: false, error: errorMessage }; - } + const models = await fetchFreeZenModels(); + return { id, type, success: true, data: { models } }; } case 'api:openchamber:update-check': { @@ -546,26 +471,13 @@ export async function handleSystemBridgeMessage( } } - case 'notifications:can-notify': { - return { id, type, success: true, data: true }; - } - - case 'notifications:notify': { - const request = (payload || {}) as NotificationsNotifyRequestPayload; - const notification = request.payload || {}; - const title = typeof notification.title === 'string' ? notification.title.trim() : ''; - const body = typeof notification.body === 'string' ? notification.body.trim() : ''; - - const message = title && body - ? `${title}: ${body}` - : title || body; - - if (!message) { - return { id, type, success: true, data: { shown: false } }; + case 'api:notifications/auto-accept': { + const request = (payload || {}) as { sessionId?: unknown; enabled?: unknown }; + const sessionId = typeof request.sessionId === 'string' ? request.sessionId.trim() : ''; + if (!sessionId) { + return { id, type, success: false, error: 'sessionId is required' }; } - - void vscode.window.showInformationMessage(message); - return { id, type, success: true, data: { shown: true } }; + return { id, type, success: true, data: { success: true } }; } default: diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 19e5c33e..2e579c2b 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -249,9 +249,6 @@ const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjec const resolveNotificationTemplate = (...args) => notificationTemplateRuntime.resolveNotificationTemplate(...args); const shouldApplyResolvedTemplateMessage = (...args) => notificationTemplateRuntime.shouldApplyResolvedTemplateMessage(...args); const fetchFreeZenModels = (...args) => notificationTemplateRuntime.fetchFreeZenModels(...args); -const resolveZenModel = (...args) => notificationTemplateRuntime.resolveZenModel(...args); -const validateZenModelAtStartup = (...args) => notificationTemplateRuntime.validateZenModelAtStartup(...args); -const summarizeText = (...args) => notificationTemplateRuntime.summarizeText(...args); const extractTextFromParts = (...args) => notificationTemplateRuntime.extractTextFromParts(...args); const extractLastMessageText = (...args) => notificationTemplateRuntime.extractLastMessageText(...args); const fetchLastAssistantMessageText = (...args) => notificationTemplateRuntime.fetchLastAssistantMessageText(...args); @@ -668,8 +665,6 @@ notificationTemplateRuntime = createNotificationTemplateRuntime({ const notificationTriggerRuntime = createNotificationTriggerRuntime({ readSettingsFromDisk, prepareNotificationLastMessage, - summarizeText, - resolveZenModel, buildTemplateVariables, extractLastMessageText, fetchLastAssistantMessageText, @@ -1081,9 +1076,6 @@ async function main(options = {}) { const sayTTSCapability = await detectSayTtsCapability(process); - // Startup model validation is best-effort and runs in background. - void validateZenModelAtStartup(); - const app = express(); const serverStartedAt = new Date().toISOString(); app.set('trust proxy', true); @@ -1138,7 +1130,6 @@ async function main(options = {}) { tunnelAuthController, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, - resolveZenModel, sayTTSCapability, ensurePushInitialized, ensureGlobalWatcherStarted, diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index 225b18b0..f17721e1 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -1,7 +1,7 @@ # Notifications Module Documentation ## Purpose -This module provides notification message preparation utilities for the web server runtime, including text truncation, plain-text normalization, and optional message summarization for system notifications. +This module provides notification message preparation utilities for the web server runtime, including text truncation and plain-text normalization for system notifications. ## Entrypoints and structure - `packages/web/server/lib/notifications/index.js`: public entrypoint imported by `packages/web/server/index.js`. @@ -9,7 +9,7 @@ This module provides notification message preparation utilities for the web serv - `packages/web/server/lib/notifications/push-runtime.js`: push subscription persistence, VAPID initialization, and UI visibility runtime. - `packages/web/server/lib/notifications/emitter-runtime.js`: desktop/stdout + UI SSE notification emission runtime. - `packages/web/server/lib/notifications/runtime.js`: trigger runtime for OpenCode event-driven notification fanout. -- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables, zen-model helpers, and session text/title enrichment runtime. +- `packages/web/server/lib/notifications/template-runtime.js`: notification template variables and session text/title enrichment runtime. Zen-model helpers are retained as compatibility stubs only. - `packages/web/server/lib/notifications/message.js`: helper implementation module. - `packages/web/server/lib/notifications/message.test.js`: unit tests for notification message helpers. @@ -17,7 +17,7 @@ This module provides notification message preparation utilities for the web serv ### Notifications API (re-exported from message.js) - `truncateNotificationText(text, maxLength)`: Truncates text to specified max length, appending `...` if truncated. -- `prepareNotificationLastMessage({ message, settings, summarize })`: Prepares the last message for notification display, with optional summarization support. +- `prepareNotificationLastMessage({ message, settings })`: Prepares the last message for notification display by normalizing and truncating text. ### Route registration API (routes.js) - `registerNotificationRoutes(app, dependencies)`: Registers notification-owned endpoints: @@ -67,14 +67,14 @@ This module provides notification message preparation utilities for the web serv - `broadcastUiNotification(payload)` ### Template runtime API (template-runtime.js) -- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime and consumes shared text summarization from `packages/web/server/lib/text/summarization.js` in `notification` mode. +- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime. Model-backed summarization was retired after the Zen provider became unavailable. - Returned API: - `resolveNotificationTemplate(template, variables)` - `shouldApplyResolvedTemplateMessage(template, resolved, variables)` - - `fetchFreeZenModels()` - - `resolveZenModel(override)` - - `validateZenModelAtStartup()` - - `summarizeText(text, targetLength, zenModel)` + - `fetchFreeZenModels()` compatibility stub returning `[]` + - `resolveZenModel(override)` compatibility stub preserving stored values without validation + - `validateZenModelAtStartup()` compatibility no-op + - `summarizeText(text, targetLength, zenModel)` compatibility stub returning local fallback text - `extractLastMessageText(payload, maxLength?)` - `fetchLastAssistantMessageText(sessionId, messageId, maxLength?)` - `maybeCacheSessionInfoFromEvent(payload)` @@ -85,16 +85,10 @@ This module provides notification message preparation utilities for the web serv ### Default values - `DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH`: 250 (default max length for notification text). -- `DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD`: 200 (minimum message length to trigger summarization). -- `DEFAULT_NOTIFICATION_SUMMARY_LENGTH`: 100 (target length for summarized messages). ## Settings object format -The `settings` parameter for `prepareNotificationLastMessage` supports: -- `summarizeLastMessage` (boolean): Whether to enable summarization for long messages. -- `summaryThreshold` (number): Minimum message length to trigger summarization (default: 200). -- `summaryLength` (number): Target length for summarized messages (default: 100). -- `maxLastMessageLength` (number): Maximum length for the final notification text (default: 250). +The `settings` parameter for `prepareNotificationLastMessage` supports `maxLastMessageLength` (number), the maximum length for the final notification text (default: 250). Legacy summarization settings may still exist in persisted settings but are ignored. ## Response contracts @@ -105,8 +99,7 @@ The `settings` parameter for `prepareNotificationLastMessage` supports: ### `prepareNotificationLastMessage` - Returns empty string for empty/null message. -- Returns truncated original message if summarization disabled, message under threshold, or summarization fails. -- Returns truncated summary if summarization succeeds and returns non-empty string. +- Returns truncated original message. Model-backed notification summarization is retired. - Normalizes markdown-like formatting to plain text before truncation. - Always applies `maxLastMessageLength` truncation to final result. @@ -120,10 +113,10 @@ The `settings` parameter for `prepareNotificationLastMessage` supports: 5. Add corresponding unit tests in `packages/web/server/lib/notifications/message.test.js`. ### Error handling -- `prepareNotificationLastMessage` catches summarization errors and falls back to original message. +- `prepareNotificationLastMessage` does not call model summarization. - Invalid numeric parameters default to safe fallback values. - Non-string inputs are handled gracefully (return empty string). ### Testing - Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes. -- Unit tests should cover truncation behavior, summarization success/failure, and edge cases (empty strings, invalid inputs). +- Unit tests should cover truncation behavior and edge cases (empty strings, invalid inputs). diff --git a/packages/web/server/lib/notifications/message.js b/packages/web/server/lib/notifications/message.js index 8eb2e52d..1ca08d7b 100644 --- a/packages/web/server/lib/notifications/message.js +++ b/packages/web/server/lib/notifications/message.js @@ -1,6 +1,4 @@ const DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH = 250; -const DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD = 200; -const DEFAULT_NOTIFICATION_SUMMARY_LENGTH = 100; const resolvePositiveNumber = (value, fallback) => { if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) { @@ -42,29 +40,13 @@ export const truncateNotificationText = (text, maxLength = DEFAULT_NOTIFICATION_ return `${text.slice(0, safeMaxLength)}...`; }; -export const prepareNotificationLastMessage = async ({ message, settings, summarize }) => { +export const prepareNotificationLastMessage = async ({ message, settings }) => { const originalMessage = typeof message === 'string' ? message : ''; if (!originalMessage) { return ''; } - const shouldSummarize = settings?.summarizeLastMessage === true && typeof summarize === 'function'; - const summaryThreshold = resolvePositiveNumber(settings?.summaryThreshold, DEFAULT_NOTIFICATION_SUMMARY_THRESHOLD); - const summaryLength = resolvePositiveNumber(settings?.summaryLength, DEFAULT_NOTIFICATION_SUMMARY_LENGTH); const maxLastMessageLength = resolvePositiveNumber(settings?.maxLastMessageLength, DEFAULT_NOTIFICATION_MESSAGE_MAX_LENGTH); - - let messageForNotification = originalMessage; - if (shouldSummarize && originalMessage.length > summaryThreshold) { - try { - const summary = await summarize(originalMessage, summaryLength); - if (typeof summary === 'string' && summary.trim().length > 0) { - messageForNotification = summary; - } - } catch { - messageForNotification = originalMessage; - } - } - - const plainTextMessage = normalizeNotificationPlainText(messageForNotification) || normalizeNotificationPlainText(originalMessage); + const plainTextMessage = normalizeNotificationPlainText(originalMessage); return truncateNotificationText(plainTextMessage, maxLastMessageLength); }; diff --git a/packages/web/server/lib/notifications/message.test.js b/packages/web/server/lib/notifications/message.test.js index dcc0158e..0fe4c615 100644 --- a/packages/web/server/lib/notifications/message.test.js +++ b/packages/web/server/lib/notifications/message.test.js @@ -7,15 +7,9 @@ describe('notification message helpers', () => { expect(truncateNotificationText('abcdef', 3)).toBe('abc...'); }); - it('falls back to original message when summarization fails', async () => { - const message = '0123456789'; - const summarize = async () => { - throw new Error('summarization failed'); - }; - + it('ignores retired summarization settings and truncates original message', async () => { const result = await prepareNotificationLastMessage({ - message, - summarize, + message: '0123456789', settings: { summarizeLastMessage: true, summaryThreshold: 5, @@ -27,44 +21,10 @@ describe('notification message helpers', () => { expect(result).toBe('0123...'); }); - it('falls back to original message when summary is empty', async () => { + it('normalizes markdown message to plain text', async () => { const result = await prepareNotificationLastMessage({ - message: '0123456789', - summarize: async () => ' ', + message: "**Committed.**\n\n- Commit: `85924b9d`\n- Message: `fix desktop notifications`", settings: { - summarizeLastMessage: true, - summaryThreshold: 5, - summaryLength: 3, - maxLastMessageLength: 4, - }, - }); - - expect(result).toBe('0123...'); - }); - - it('uses summary when summarization succeeds', async () => { - const result = await prepareNotificationLastMessage({ - message: '0123456789', - summarize: async () => 'short summary', - settings: { - summarizeLastMessage: true, - summaryThreshold: 5, - summaryLength: 3, - maxLastMessageLength: 100, - }, - }); - - expect(result).toBe('short summary'); - }); - - it('normalizes markdown summary to plain text', async () => { - const result = await prepareNotificationLastMessage({ - message: '0123456789', - summarize: async () => "**Committed.**\n\n- Commit: `85924b9d`\n- Message: `fix desktop notifications`", - settings: { - summarizeLastMessage: true, - summaryThreshold: 5, - summaryLength: 80, maxLastMessageLength: 200, }, }); diff --git a/packages/web/server/lib/notifications/runtime.js b/packages/web/server/lib/notifications/runtime.js index c7b4357d..909acc2e 100644 --- a/packages/web/server/lib/notifications/runtime.js +++ b/packages/web/server/lib/notifications/runtime.js @@ -2,8 +2,6 @@ export const createNotificationTriggerRuntime = (deps) => { const { readSettingsFromDisk, prepareNotificationLastMessage, - summarizeText, - resolveZenModel, buildTemplateVariables, extractLastMessageText, fetchLastAssistantMessageText, @@ -248,11 +246,9 @@ export const createNotificationTriggerRuntime = (deps) => { lastMessage = await fetchLastAssistantMessageText(sessionId, messageId); } - const notifZenModel = await resolveZenModel(settings?.zenModel); variables.last_message = await prepareNotificationLastMessage({ message: lastMessage, settings, - summarize: (text, len) => summarizeText(text, len, notifZenModel), }); const resolvedTitle = resolveNotificationTemplate(completionTemplate.title, variables); @@ -310,11 +306,9 @@ export const createNotificationTriggerRuntime = (deps) => { lastMessage = await fetchLastAssistantMessageText(sessionId, errorMessageId); } - const errZenModel = await resolveZenModel(settings?.zenModel); variables.last_message = await prepareNotificationLastMessage({ message: lastMessage, settings, - summarize: (text, len) => summarizeText(text, len, errZenModel), }); const errorTemplate = (settings.notificationTemplates || {}).error || { title: 'Tool error', message: '{last_message}' }; diff --git a/packages/web/server/lib/notifications/template-runtime.js b/packages/web/server/lib/notifications/template-runtime.js index 58698b01..377e18bd 100644 --- a/packages/web/server/lib/notifications/template-runtime.js +++ b/packages/web/server/lib/notifications/template-runtime.js @@ -3,20 +3,15 @@ import { summarizeText as summarizeSharedText } from '../text/summarization.js'; export const createNotificationTemplateRuntime = (deps) => { const { readSettingsFromDisk, - persistSettings, buildOpenCodeUrl, getOpenCodeAuthHeaders, resolveGitBinaryForSpawn, } = deps; const NOTIFICATION_BODY_MAX_CHARS = 1000; - const ZEN_DEFAULT_MODEL = 'gpt-5-nano'; - const ZEN_MODELS_CACHE_TTL = 5 * 60 * 1000; const SESSION_INFO_CACHE_TTL_MS = 60 * 1000; - let validatedZenFallback = null; - let cachedZenModels = null; - let cachedZenModelsTimestamp = 0; + const cachedZenModels = { models: [] }; const sessionTitleCache = new Map(); const sessionInfoCache = new Map(); @@ -62,116 +57,18 @@ export const createNotificationTemplateRuntime = (deps) => { return true; }; - const fetchFreeZenModels = async () => { - const now = Date.now(); - if (cachedZenModels && now - cachedZenModelsTimestamp < ZEN_MODELS_CACHE_TTL) { - return cachedZenModels.models; - } - - const controller = typeof AbortController !== 'undefined' ? new AbortController() : null; - const timeout = controller ? setTimeout(() => controller.abort(), 8000) : null; - try { - const [zenResponse, metadataResponse] = await Promise.all([ - fetch('https://opencode.ai/zen/v1/models', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }), - fetch('https://models.dev/api.json', { - signal: controller?.signal, - headers: { Accept: 'application/json' }, - }), - ]); - if (!zenResponse.ok) { - throw new Error(`zen/v1/models responded with status ${zenResponse.status}`); - } - if (!metadataResponse.ok) { - throw new Error(`models.dev responded with status ${metadataResponse.status}`); - } - - const data = await zenResponse.json(); - const metadata = await metadataResponse.json(); - const metadataModels = metadata?.opencode?.models && typeof metadata.opencode.models === 'object' - ? metadata.opencode.models - : {}; - const allModels = Array.isArray(data?.data) ? data.data : []; - const freeModels = allModels - .filter((model) => { - const id = typeof model?.id === 'string' ? model.id.trim() : ''; - const cost = id ? metadataModels[id]?.cost : null; - return id && cost?.input === 0 && cost?.output === 0; - }) - .map((model) => ({ id: model.id.trim(), owned_by: model.owned_by })); - - cachedZenModels = { models: freeModels }; - cachedZenModelsTimestamp = Date.now(); - return freeModels; - } finally { - if (timeout) clearTimeout(timeout); - } - }; + const fetchFreeZenModels = async () => []; const resolveZenModel = async (override) => { const overrideModel = typeof override === 'string' ? override.trim() : ''; - let settingsModel = ''; - try { - const settings = await readSettingsFromDisk(); - if (typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0) { - settingsModel = settings.zenModel.trim(); - } - } catch { - } - - const candidate = overrideModel || settingsModel; - try { - const models = await fetchFreeZenModels(); - const modelIds = models.map((model) => model.id); - if (candidate && modelIds.includes(candidate)) { - return candidate; - } - if (modelIds.includes(ZEN_DEFAULT_MODEL)) { - return ZEN_DEFAULT_MODEL; - } - if (modelIds.length > 0) { - return modelIds[0]; - } - } catch { - if (candidate) { - return candidate; - } - } - - return validatedZenFallback || ZEN_DEFAULT_MODEL; + if (overrideModel) return overrideModel; + const settings = await readSettingsFromDisk().catch(() => ({})); + return typeof settings?.zenModel === 'string' && settings.zenModel.trim().length > 0 + ? settings.zenModel.trim() + : ''; }; - const validateZenModelAtStartup = async () => { - try { - const freeModels = await fetchFreeZenModels(); - const freeModelIds = freeModels.map((model) => model.id); - - if (freeModelIds.length > 0) { - validatedZenFallback = freeModelIds[0]; - - const settings = await readSettingsFromDisk(); - const storedModel = typeof settings?.zenModel === 'string' ? settings.zenModel.trim() : ''; - - if (!storedModel || !freeModelIds.includes(storedModel)) { - const fallback = freeModelIds[0]; - console.log( - storedModel - ? `[zen] Stored model "${storedModel}" not found in free models, falling back to "${fallback}"` - : `[zen] No model configured, setting default to "${fallback}"` - ); - await persistSettings({ zenModel: fallback }); - } else { - console.log(`[zen] Stored model "${storedModel}" verified as available`); - } - } else { - console.warn('[zen] No free models returned from API, skipping validation'); - } - } catch (error) { - console.warn('[zen] Startup model validation failed (non-blocking):', error?.message || error); - } - }; + const validateZenModelAtStartup = async () => {}; const summarizeText = async (text, targetLength, zenModel) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; @@ -179,7 +76,7 @@ export const createNotificationTemplateRuntime = (deps) => { text, threshold: 0, maxLength: targetLength, - zenModel: zenModel || ZEN_DEFAULT_MODEL, + zenModel, mode: 'notification', }); return typeof result?.summary === 'string' && result.summary.trim().length > 0 diff --git a/packages/web/server/lib/notifications/template-runtime.test.js b/packages/web/server/lib/notifications/template-runtime.test.js index 22c225bd..ba7177bd 100644 --- a/packages/web/server/lib/notifications/template-runtime.test.js +++ b/packages/web/server/lib/notifications/template-runtime.test.js @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createNotificationTemplateRuntime } from './template-runtime.js'; @@ -11,78 +11,16 @@ const createRuntime = (settings = {}) => createNotificationTemplateRuntime({ }); describe('notification template runtime zen models', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('uses zen models with zero-cost metadata as selectable', async () => { - vi.stubGlobal('fetch', vi.fn(async (url) => { - if (String(url).includes('models.dev')) { - return { - ok: true, - json: async () => ({ - opencode: { - models: { - 'big-pickle': { cost: { input: 0, output: 0 } }, - 'gpt-5-nano': { cost: { input: 0, output: 0 } }, - 'gpt-5.5': { cost: { input: 5, output: 30 } }, - 'hy3-preview-free': { cost: { input: 0, output: 0 } }, - }, - }, - }), - }; - } - return { - ok: true, - json: async () => ({ - data: [ - { id: 'big-pickle', owned_by: 'opencode' }, - { id: 'gpt-5-nano', owned_by: 'opencode' }, - { id: 'gpt-5.5', owned_by: 'opencode' }, - { id: 'hy3-preview-free', owned_by: 'opencode' }, - ], - }), - }; - })); - + it('returns no selectable zen models after provider retirement', async () => { const runtime = createRuntime(); const models = await runtime.fetchFreeZenModels(); - expect(models.map((model) => model.id)).toEqual([ - 'big-pickle', - 'gpt-5-nano', - 'hy3-preview-free', - ]); + expect(models).toEqual([]); }); - it('falls back to a valid unauthenticated model when stored zen model is stale', async () => { - vi.stubGlobal('fetch', vi.fn(async (url) => { - if (String(url).includes('models.dev')) { - return { - ok: true, - json: async () => ({ - opencode: { - models: { - 'big-pickle': { cost: { input: 0, output: 0 } }, - 'gpt-5-nano': { cost: { input: 0, output: 0 } }, - }, - }, - }), - }; - } - return { - ok: true, - json: async () => ({ - data: [ - { id: 'big-pickle', owned_by: 'opencode' }, - { id: 'gpt-5-nano', owned_by: 'opencode' }, - ], - }), - }; - })); - + it('preserves stored zen model value for compatibility without validation', async () => { const runtime = createRuntime({ zenModel: 'trinity-large-preview-free' }); - await expect(runtime.resolveZenModel()).resolves.toBe('gpt-5-nano'); + await expect(runtime.resolveZenModel()).resolves.toBe('trinity-large-preview-free'); }); }); diff --git a/packages/web/server/lib/opencode/bootstrap-runtime.js b/packages/web/server/lib/opencode/bootstrap-runtime.js index ea96bf5b..98a1f045 100644 --- a/packages/web/server/lib/opencode/bootstrap-runtime.js +++ b/packages/web/server/lib/opencode/bootstrap-runtime.js @@ -23,7 +23,6 @@ export const createBootstrapRuntime = (dependencies) => { tunnelAuthController, readSettingsFromDiskMigrated, normalizeTunnelSessionTtlMs, - resolveZenModel, sayTTSCapability, ensurePushInitialized, ensureGlobalWatcherStarted, @@ -78,7 +77,7 @@ export const createBootstrapRuntime = (dependencies) => { normalizeTunnelSessionTtlMs, }); - registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }); + registerTtsRoutes(app, { sayTTSCapability }); registerNotificationRoutes(app, { uiAuthController, diff --git a/packages/web/server/lib/text/DOCUMENTATION.md b/packages/web/server/lib/text/DOCUMENTATION.md index 56f5d2d7..f4e689fc 100644 --- a/packages/web/server/lib/text/DOCUMENTATION.md +++ b/packages/web/server/lib/text/DOCUMENTATION.md @@ -1,15 +1,15 @@ # Text Module Documentation ## Purpose -This module provides shared text transformation helpers that are not owned by a single product surface. Today it contains the shared summarization pipeline used by TTS, notifications, and note distillation flows. +This module provides shared text transformation helpers that are not owned by a single product surface. It previously proxied model-backed summarization through the opencode.ai Zen provider; that provider is no longer available for this use, so summarization now returns local sanitized/distilled fallback text only. ## Entrypoints and structure -- `packages/web/server/lib/text/summarization.js`: Shared summarize + sanitize helpers backed by opencode.ai zen API. +- `packages/web/server/lib/text/summarization.js`: Shared summarize stub + sanitize helpers. It performs no external model calls. ## Public exports ### Summarization (summarization.js) -- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared summarization entrypoint. +- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Retired summarization entrypoint retained as an API-compatible stub. `zenModel` is ignored. - `sanitizeForTTS(text)`: Sanitizes text for speech output. - `sanitizeForNotification(text)`: Sanitizes text for compact notification output. - `sanitizeForNote(text)`: Sanitizes text for short note/distillation output. @@ -23,9 +23,9 @@ This module provides shared text transformation helpers that are not owned by a ### `summarizeText` Returns object with: -- `summary`: Final transformed text. -- `summarized`: Boolean indicating whether model summarization succeeded. -- `reason`: Optional failure/skip reason. +- `summary`: Local sanitized/distilled fallback text. +- `summarized`: Always `false` while the model provider is unavailable. +- `reason`: Skip reason, usually `Model summarization provider unavailable` for text above threshold. - `originalLength`: Optional original text length. - `summaryLength`: Optional final summary length. diff --git a/packages/web/server/lib/text/summarization.js b/packages/web/server/lib/text/summarization.js index c6592357..6a2e54d3 100644 --- a/packages/web/server/lib/text/summarization.js +++ b/packages/web/server/lib/text/summarization.js @@ -7,53 +7,6 @@ * - note: distilled project note */ -function buildSummarizationPrompt(maxLength, mode = 'tts') { - if (mode === 'note') { - return `You are distilling selected assistant text into a single short project note. - -Goal: -- Produce one concise note the user may want to keep in project notes. - -Rules: -1. Output ONLY the final note text. -2. Keep the result under ${maxLength} characters. -3. Prefer one sentence or a short sentence fragment. -4. Keep the most useful insight, decision, constraint, or recommendation. -5. Be concrete and specific. -6. Do not use markdown, bullets, code fences, headings, or quotes. -7. Do not mention the assistant, the text, or that this is a summary. -8. Do not include filler like In summary or Heres a note. -9. If the text contains multiple ideas, keep only the most important one. -10. Rewrite and compress the input into a distilled note. Do not copy the source text verbatim unless it is already an extremely short note. -11. Prefer a shorter phrasing than the input whenever possible. -12. Write the result as a plain sentence or sentence fragment, not as a bullet point.`; - } - - if (mode === 'notification') { - return `Summarize the following text in approximately ${maxLength} characters. Be concise and capture the key point. - -Rules: -1. Output plain text only. -2. Do not use markdown, bullets, headings, code fences, backticks, or quotes. -3. Output only the summary text. -4. Prefer a short notification-friendly sentence.`; - } - - return `You are a text summarizer for text-to-speech output. Create a concise, natural-sounding summary that captures the key points. Keep the summary under ${maxLength} characters. - -CRITICAL INSTRUCTIONS: -1. Output ONLY the final summary - no thinking, no reasoning, no explanations -2. Do not show your work or thought process -3. Do not use any special characters, markdown, code, URLs, file paths, or formatting -4. Do not include phrases like "Here's a summary" or "In summary" -5. Just provide clean, speakable text that can be read aloud -6. Stay within the ${maxLength} character limit - -Your response should be ready to speak immediately.`; -} - -const SUMMARIZE_TIMEOUT_MS = 30_000; - export function sanitizeForTTS(text) { if (!text || typeof text !== 'string') return ''; @@ -115,65 +68,6 @@ function sanitizeByMode(text, mode) { return sanitizeForTTS(text); } -function clampToMaxLength(text, maxLength) { - if (!text) return ''; - const limit = Number.isFinite(maxLength) ? Math.max(0, Math.floor(maxLength)) : Infinity; - if (text.length <= limit) return text; - return text.slice(0, limit).trim(); -} - -function extractZenOutputText(data) { - if (!data || typeof data !== 'object') return null; - const output = data.output; - if (!Array.isArray(output)) return null; - - const messageItem = output.find((item) => item && typeof item === 'object' && item.type === 'message'); - if (!messageItem) return null; - - const content = messageItem.content; - if (!Array.isArray(content)) return null; - - const textItem = content.find((item) => item && typeof item === 'object' && item.type === 'output_text'); - const text = typeof textItem?.text === 'string' ? textItem.text.trim() : ''; - return text || null; -} - -function extractZenChatCompletionText(data) { - if (!data || typeof data !== 'object') return null; - const choices = data.choices; - if (!Array.isArray(choices)) return null; - - const choice = choices.find((item) => item && typeof item === 'object'); - const content = choice?.message?.content; - if (typeof content === 'string') { - const text = content.trim(); - return text || null; - } - if (!Array.isArray(content)) return null; - - const text = content - .map((item) => { - if (typeof item === 'string') return item; - if (item && typeof item === 'object' && typeof item.text === 'string') return item.text; - return ''; - }) - .join('') - .trim(); - return text || null; -} - -function getZenCompletionEndpoint(model) { - if (typeof model !== 'string') return 'responses'; - if ( - model.startsWith('gpt-') - || model.startsWith('claude-') - || model.startsWith('gemini-') - ) { - return 'responses'; - } - return 'chat/completions'; -} - function distillNoteFallback(text, maxLength) { const sanitized = sanitizeForNote(text); if (!sanitized) return ''; @@ -200,95 +94,45 @@ function distillNoteFallback(text, maxLength) { return clipped ? `${clipped}…` : best.slice(0, idealLimit).trim(); } +function distillNotificationFallback(text, maxLength) { + const sanitized = sanitizeForNotification(text); + if (!sanitized) return ''; + + const sentences = sanitized + .split(/(?<=[.!?])\s+/) + .map((part) => part.trim()) + .filter(Boolean); + const candidate = sentences.find((sentence) => sentence.length >= 20) || sentences[0] || sanitized; + const limit = Number.isFinite(maxLength) ? Math.max(20, Math.floor(maxLength)) : 100; + if (candidate.length <= limit) return candidate; + + const clipped = candidate.slice(0, Math.max(0, limit - 1)).trim(); + return clipped ? `${clipped}…` : candidate.slice(0, limit).trim(); +} + function fallbackByMode(text, maxLength, mode) { if (mode === 'note') return distillNoteFallback(text, maxLength); + if (mode === 'notification') return distillNotificationFallback(text, maxLength); return sanitizeByMode(text, mode); } export async function summarizeText({ text, threshold = 200, maxLength = 500, zenModel, mode = 'tts' }) { + void zenModel; + + const summary = fallbackByMode(text || '', maxLength, mode); if (!text || text.length <= threshold) { return { - summary: fallbackByMode(text || '', maxLength, mode), + summary, summarized: false, reason: text ? 'Text under threshold' : 'No text provided', }; } - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), SUMMARIZE_TIMEOUT_MS); - - try { - const prompt = buildSummarizationPrompt(maxLength, mode); - const model = zenModel || 'gpt-5-nano'; - const endpoint = getZenCompletionEndpoint(model); - const response = await fetch(`https://opencode.ai/zen/v1/${endpoint}`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(endpoint === 'responses' - ? { - model, - input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }], - stream: false, - reasoning: { effort: 'low' }, - } - : { - model, - messages: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }], - stream: false, - }), - signal: controller.signal, - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - console.error('[Summarize] zen API error:', response.status, errorBody); - return { - summary: fallbackByMode(text, maxLength, mode), - summarized: false, - reason: `zen API returned ${response.status}`, - }; - } - - const data = await response.json(); - const summary = endpoint === 'responses' - ? extractZenOutputText(data) - : extractZenChatCompletionText(data); - - if (summary) { - const sanitized = sanitizeByMode(summary, mode); - const finalSummary = mode === 'note' - ? (sanitized && sanitized !== sanitizeForNote(text) ? sanitized : distillNoteFallback(text, maxLength)) - : sanitized; - const clippedSummary = clampToMaxLength(finalSummary, maxLength); - return { - summary: clippedSummary, - summarized: true, - originalLength: text.length, - summaryLength: clippedSummary.length, - }; - } - - return { - summary: fallbackByMode(text, maxLength, mode), - summarized: false, - reason: 'No response from model', - }; - } catch (error) { - if (error.name === 'AbortError') { - console.error('[Summarize] Request timed out'); - return { - summary: fallbackByMode(text, maxLength, mode), - summarized: false, - reason: 'Request timed out', - }; - } - console.error('[Summarize] Error:', error); - return { - summary: fallbackByMode(text, maxLength, mode), - summarized: false, - reason: error.message, - }; - } finally { - clearTimeout(timer); - } + return { + summary, + summarized: false, + reason: 'Model summarization provider unavailable', + originalLength: text.length, + summaryLength: summary.length, + }; } diff --git a/packages/web/server/lib/text/summarization.test.js b/packages/web/server/lib/text/summarization.test.js index 2a37d330..4ea04836 100644 --- a/packages/web/server/lib/text/summarization.test.js +++ b/packages/web/server/lib/text/summarization.test.js @@ -1,118 +1,34 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { summarizeText } from './summarization.js'; -const originalFetch = globalThis.fetch; +describe('text summarization stubs', () => { + it('does not call the retired zen provider', async () => { + const result = await summarizeText({ + text: 'The implementation now correctly loads notification templates before dispatching the notification. It also fetches the latest assistant message when the event payload does not include message parts. This should make completion notifications match user settings.', + threshold: 0, + maxLength: 80, + zenModel: 'gpt-5-nano', + mode: 'notification', + }); -function stubFetch(fetchMock) { - globalThis.fetch = fetchMock; -} - -describe('text summarization zen requests', () => { - afterEach(() => { - globalThis.fetch = originalFetch; + expect(result.summarized).toBe(false); + expect(result.reason).toBe('Model summarization provider unavailable'); + expect(result.summary).toBe('The implementation now correctly loads notification templates before dispatchin…'); }); - it('uses responses endpoint for gpt models', async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - output: [{ - type: 'message', - content: [{ type: 'output_text', text: 'Short summary' }], - }], - }), - })); - stubFetch(fetchMock); - + it('returns local note fallback while provider is unavailable', async () => { const result = await summarizeText({ - text: 'Long text '.repeat(30), + text: 'First sentence. Second sentence with the useful insight.', threshold: 0, maxLength: 100, - zenModel: 'gpt-5-nano', - mode: 'notification', + mode: 'note', }); - expect(fetchMock).toHaveBeenCalledWith( - 'https://opencode.ai/zen/v1/responses', - expect.objectContaining({ - body: expect.stringContaining('"input"'), - }), - ); - expect(result.summary).toBe('Short summary'); - }); - - it('uses chat completions endpoint for openai-compatible zen models', async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - choices: [{ message: { content: 'Chat summary' } }], - }), - })); - stubFetch(fetchMock); - - const result = await summarizeText({ - text: 'Long text '.repeat(30), - threshold: 0, - maxLength: 100, - zenModel: 'big-pickle', - mode: 'notification', + expect(result).toMatchObject({ + summary: 'First sentence.', + summarized: false, + reason: 'Model summarization provider unavailable', }); - - expect(fetchMock).toHaveBeenCalledWith( - 'https://opencode.ai/zen/v1/chat/completions', - expect.objectContaining({ - body: expect.stringContaining('"messages"'), - }), - ); - expect(result.summary).toBe('Chat summary'); - }); - - it('clamps successful model summaries to the requested max length', async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - output: [{ - type: 'message', - content: [{ type: 'output_text', text: 'This response is too long' }], - }], - }), - })); - stubFetch(fetchMock); - - const result = await summarizeText({ - text: 'Long text '.repeat(30), - threshold: 0, - maxLength: 12, - zenModel: 'gpt-5-nano', - mode: 'notification', - }); - - expect(result.summary).toBe('This respons'); - expect(result.summaryLength).toBe(12); - }); - - it('does not clamp successful model summaries for non-finite max lengths', async () => { - const fetchMock = vi.fn(async () => ({ - ok: true, - json: async () => ({ - output: [{ - type: 'message', - content: [{ type: 'output_text', text: 'Full response' }], - }], - }), - })); - stubFetch(fetchMock); - - const result = await summarizeText({ - text: 'Long text '.repeat(30), - threshold: 0, - maxLength: Infinity, - zenModel: 'gpt-5-nano', - mode: 'notification', - }); - - expect(result.summary).toBe('Full response'); - expect(result.summaryLength).toBe(13); }); }); diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index 6a6b6d7d..81a46760 100644 --- a/packages/web/server/lib/tts/DOCUMENTATION.md +++ b/packages/web/server/lib/tts/DOCUMENTATION.md @@ -1,14 +1,14 @@ # TTS Module Documentation ## Purpose -This module provides server-side Text-to-Speech services using OpenAI's TTS API. Shared text summarization now lives in `packages/web/server/lib/text/` and is consumed here in `tts` mode. +This module provides server-side Text-to-Speech services using OpenAI's TTS API. The historical shared text summarization endpoint now lives in `packages/web/server/lib/text/` as an API-compatible stub because the previous Zen model provider is unavailable. ## Entrypoints and structure - `packages/web/server/lib/tts/index.js`: Public entrypoint imported by `packages/web/server/index.js`. - `packages/web/server/lib/tts/routes.js`: Express route registration for `/api/voice/*`, `/api/tts/*`, and `/api/stt/*` endpoints. - `packages/web/server/lib/tts/capability-runtime.js`: runtime helper for probing local macOS `say` TTS voice capability. - `packages/web/server/lib/tts/service.js`: TTS service implementation with OpenAI integration. -- `packages/web/server/lib/text/summarization.js`: Shared text summarization and sanitization utilities using opencode.ai zen API. +- `packages/web/server/lib/text/summarization.js`: Shared text summarization stub and sanitization utilities. It performs no external Zen calls. - `packages/web/server/lib/tts/stt.js`: STT proxy for OpenAI-compatible transcription endpoints. - `packages/web/server/lib/tts/base-url.js`: shared base URL validation and normalization for custom OpenAI-compatible endpoints. @@ -20,7 +20,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API. - `TTS_VOICES`: Array of supported OpenAI voice identifiers. ### Shared text summarization (re-exported from ../text/summarization.js) -- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared text summarizer. TTS uses `mode: 'tts'`. +- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Retired shared text summarizer retained as a stub. TTS uses `mode: 'tts'`; `zenModel` is ignored. - `sanitizeForTTS(text)`: Sanitizes text by removing markdown, URLs, file paths, and other non-speakable content. - `sanitizeForNote(text)`: Re-exported for note-mode callers that still import through the TTS surface. @@ -33,10 +33,10 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API. - `TTS_VOICES`: Array of supported OpenAI voices: `['alloy', 'ash', 'ballad', 'coral', 'echo', 'fable', 'nova', 'onyx', 'sage', 'shimmer', 'verse', 'marin', 'cedar']`. ### Summarization defaults -- `SUMMARIZE_TIMEOUT_MS`: 30000 (30 seconds timeout for zen API requests). +- No model request timeout is used; the summarization provider is disabled. ### Default values -- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano', `mode` = 'tts'. +- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `mode` = 'tts'. `zenModel` is ignored. - `generateSpeechStream` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0. - `generateSpeechBuffer` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0. @@ -61,9 +61,9 @@ Generates speech and returns as Buffer for caching purposes. ### `summarizeText` Returns object with: -- `summary`: Sanitized summary text or original text (if not summarized). -- `summarized`: Boolean indicating if summarization was performed. -- `reason`: Optional string explaining why summarization was skipped (e.g., 'Text under threshold', 'Request timed out'). +- `summary`: Sanitized or locally distilled fallback text. +- `summarized`: Always `false` while the model provider is unavailable. +- `reason`: String explaining why summarization was skipped. - `originalLength`: Optional number for original text length. - `summaryLength`: Optional number for summarized text length. @@ -90,10 +90,10 @@ OpenAI API keys are resolved in order: The TTS module is used by `packages/web/server/index.js` for: - Generating speech streams for client playback. - Generating speech buffers for caching. -- Summarizing long messages before TTS synthesis. +- Sanitizing text before TTS synthesis. Historical summarization calls now return local fallback text. - Sanitizing text to remove non-speakable content. -The summarization logic itself is shared with notifications and notes, but this module uses it only in `tts` mode. +The historical summarization API is shared with notifications and notes, but currently acts as a no-model fallback/stub. The server-side TTS approach bypasses mobile Safari's audio context restrictions by generating audio on the server and streaming to clients. @@ -113,7 +113,7 @@ The server-side TTS approach bypasses mobile Safari's audio context restrictions ### Error handling - `generateSpeechStream` and `generateSpeechBuffer` throw descriptive errors for missing API keys or empty text. -- `summarizeText` catches zen API errors and returns mode-specific fallback text with `summarized: false`. +- `summarizeText` does not call Zen and returns mode-specific fallback text with `summarized: false`. - All errors are logged to console with `[TTSService]` or `[Summarize]` prefix. ### API key management @@ -125,7 +125,7 @@ The server-side TTS approach bypasses mobile Safari's audio context restrictions - Run `bun run type-check`, `bun run lint`, and `bun run build` before finalizing changes. - Test API key resolution with environment variable and auth file. - Test speech generation with various text lengths and voice options. -- Test summarization behavior above and below threshold. +- Test summarization stub behavior above and below threshold. - Test sanitization with markdown, URLs, and code blocks. - Verify streaming and buffer generation produce valid MP3 audio. diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 7faf462d..7d28a40d 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -1,8 +1,8 @@ import express from 'express'; import { normalizeCustomOpenAIBaseURL } from './base-url.js'; -import { summarizeText, sanitizeForTTS, sanitizeForNote, sanitizeForNotification } from '../text/summarization.js'; +import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js'; -export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { +export function registerTtsRoutes(app, { sayTTSCapability }) { let ttsModulePromise = null; const getTtsModule = async () => { if (!ttsModulePromise) { @@ -44,7 +44,7 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { // Server-side TTS endpoint - streams audio from OpenAI TTS API app.post('/api/tts/speak', async (req, res) => { try { - const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, summarize = false, providerId, modelId, threshold = 200, maxLength = 500, apiKey, baseURL } = req.body || {}; + const { text, voice = 'nova', model = 'gpt-4o-mini-tts', speed = 0.9, instructions, providerId, modelId, apiKey, baseURL } = req.body || {}; const normalizedBaseURLResult = normalizeCustomOpenAIBaseURL(baseURL); if (normalizedBaseURLResult.error) { @@ -74,20 +74,8 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { let textToSpeak = text.trim(); - // Optionally summarize long text before speaking using zen API - if (summarize && textToSpeak.length > threshold) { - try { - const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel, mode: 'tts' }); - - if (result.summarized && result.summary) { - textToSpeak = result.summary; - } - } catch (summarizeError) { - console.error('[TTS/speak] Summarization failed:', summarizeError); - // Continue with original text if summarization fails - } - } + // Historical summarize request fields are intentionally ignored. The + // model-backed summarization provider is retired. const result = await ttsService.generateSpeechStream({ text: textToSpeak, @@ -123,36 +111,13 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { return res.status(400).json({ error: 'Text is required' }); } - const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - let result = await summarizeText({ + const result = await summarizeText({ text, threshold, maxLength, - zenModel: sumZenModel, mode: typeof mode === 'string' ? mode : 'tts', }); - if (mode === 'note' && !result.summarized) { - const notificationResult = await summarizeText({ - text, - threshold, - maxLength, - zenModel: sumZenModel, - mode: 'notification', - }); - if (notificationResult.summarized && notificationResult.summary) { - result = { - ...notificationResult, - summary: sanitizeForNote(sanitizeForNotification(notificationResult.summary)), - }; - } else { - return res.status(502).json({ - error: 'Note summarization failed', - reason: notificationResult.reason || result.reason || 'No distilled result from model', - }); - } - } - return res.json(result); } catch (error) { console.error('[Summarize] Error:', error); diff --git a/packages/web/server/lib/tts/routes.test.js b/packages/web/server/lib/tts/routes.test.js index abc4b7cc..c2d61396 100644 --- a/packages/web/server/lib/tts/routes.test.js +++ b/packages/web/server/lib/tts/routes.test.js @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { describe, expect, it } from 'vitest'; import express from 'express'; import request from 'supertest'; @@ -15,17 +15,7 @@ const createApp = () => { }; describe('tts routes', () => { - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('retries note summarization with notification mode before failing', async () => { - vi.stubGlobal('fetch', vi.fn(async () => ({ - ok: false, - status: 503, - json: async () => ({ error: 'unavailable' }), - }))); - + it('returns local note fallback while model summarization is retired', async () => { const response = await request(createApp()) .post('/api/text/summarize') .send({ @@ -35,54 +25,15 @@ describe('tts routes', () => { mode: 'note', }); - expect(response.status).toBe(502); - expect(fetch).toHaveBeenCalledTimes(2); - expect(response.body).toEqual({ - error: 'Note summarization failed', - reason: 'zen API returned 503', - }); - }); - - it('uses notification summarizer result when note mode falls back', async () => { - vi.stubGlobal('fetch', vi.fn() - .mockResolvedValueOnce({ - ok: false, - status: 503, - json: async () => ({ error: 'unavailable' }), - }) - .mockResolvedValueOnce({ - ok: true, - json: async () => ({ - output: [{ - type: 'message', - content: [{ type: 'output_text', text: '**Keep provider state stable** during streaming.' }], - }], - }), - })); - - const response = await request(createApp()) - .post('/api/text/summarize') - .send({ - text: 'First sentence. Preserve provider state references during streaming to avoid wide rerenders.', - threshold: 0, - maxLength: 100, - mode: 'note', - }); - expect(response.status).toBe(200); expect(response.body).toMatchObject({ - summary: 'Keep provider state stable during streaming.', - summarized: true, + summary: 'First sentence.', + summarized: false, + reason: 'Model summarization provider unavailable', }); }); - it('keeps notification fallback behavior', async () => { - vi.stubGlobal('fetch', vi.fn(async () => ({ - ok: false, - status: 503, - json: async () => ({ error: 'unavailable' }), - }))); - + it('keeps notification fallback behavior without calling zen', async () => { const response = await request(createApp()) .post('/api/text/summarize') .send({ @@ -96,7 +47,7 @@ describe('tts routes', () => { expect(response.body).toMatchObject({ summary: 'Notification text that should fall back cleanly.', summarized: false, - reason: 'zen API returned 503', + reason: 'Model summarization provider unavailable', }); }); });