chore: retire zen-backed summarization
Disable the active Zen summarization flow because the unauthenticated/free Zen provider is no longer available and now returns usage-limit errors for this feature.
Keep /api/text/summarize as an API-compatible stub that returns local sanitized or distilled fallback text with summarized=false, rather than attempting external model calls.
Remove notification and voice playback summary behavior from runtime paths. Notification {last_message} now always uses normalized truncated text, and TTS playback ignores historical summarize request fields.
Hide the notification summary settings and voice summarize-before-playback controls while preserving legacy persisted settings for compatibility. Also disable Zen model startup validation and make Zen model list routes return empty results.
Update module documentation and tests to describe the retired provider behavior and the remaining compatibility stubs.
This commit is contained in:
@@ -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<NotificationTemplateEvent, string>;
|
||||
|
||||
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<NotificationPermission>('default');
|
||||
const [pushSupported, setPushSupported] = React.useState(false);
|
||||
const [pushSubscribed, setPushSubscribed] = React.useState(false);
|
||||
const [pushBusy, setPushBusy] = React.useState(false);
|
||||
const [fetchedZenModels, setFetchedZenModels] = React.useState<Array<{ id: string; name: string }>>([]);
|
||||
|
||||
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 = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* --- Summarization --- */}
|
||||
<div className="mb-8">
|
||||
<div className="mb-1 px-1">
|
||||
<h3 className="typography-ui-header font-medium text-foreground">
|
||||
{t('settings.notifications.page.summary.title')}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<section className="px-2 pb-2 pt-0 space-y-0.5">
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={summarizeLastMessage}
|
||||
onClick={() => setSummarizeLastMessage(!summarizeLastMessage)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
setSummarizeLastMessage(!summarizeLastMessage);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={summarizeLastMessage}
|
||||
onChange={setSummarizeLastMessage}
|
||||
ariaLabel={t('settings.notifications.page.summary.toggleAria')}
|
||||
/>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.toggleLabel')}</span>
|
||||
</div>
|
||||
<div className="pl-6 pb-1">
|
||||
<span className="typography-meta text-muted-foreground">
|
||||
{t('settings.notifications.page.summary.requiresTemplateVariable')}
|
||||
{' '}
|
||||
<code className="text-[var(--primary-base)]">{'{last_message}'}</code>.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={cn("flex flex-col gap-2 py-1 sm:flex-row sm:items-center sm:gap-8")}>
|
||||
<div className="flex min-w-0 flex-col sm:w-56 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.modelLabel')}</span>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Icon name="information" className="h-3.5 w-3.5 text-muted-foreground/60 cursor-help" />
|
||||
</TooltipTrigger>
|
||||
<TooltipContent sideOffset={8} className="max-w-xs">
|
||||
{t('settings.notifications.page.summary.modelTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2 sm:w-fit sm:flex-initial">
|
||||
<Select
|
||||
value={utilitySelectedModelId || UTILITY_NOT_SELECTED_VALUE}
|
||||
onValueChange={handleUtilityModelChange}
|
||||
>
|
||||
<SelectTrigger className="w-fit min-w-[220px]">
|
||||
<SelectValue placeholder={t('settings.notifications.page.summary.notSelected')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={UTILITY_NOT_SELECTED_VALUE}>{t('settings.notifications.page.summary.notSelected')}</SelectItem>
|
||||
{utilityModelOptions.map((model) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summarizeLastMessage ? (
|
||||
<>
|
||||
<div className="flex items-center gap-8 py-1.5 mt-1 border-t border-[var(--surface-subtle)]">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.thresholdLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.thresholdHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
value={summaryThreshold}
|
||||
onValueChange={setSummaryThreshold}
|
||||
min={50}
|
||||
max={2000}
|
||||
step={50}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setSummaryThreshold(DEFAULT_SUMMARY_THRESHOLD)}
|
||||
disabled={summaryThreshold === DEFAULT_SUMMARY_THRESHOLD}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('settings.notifications.page.summary.resetThresholdAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<Icon name="restart" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<div className="flex min-w-0 flex-col w-56 shrink-0">
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.lengthLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.lengthHint')}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
<NumberInput
|
||||
value={summaryLength}
|
||||
onValueChange={setSummaryLength}
|
||||
min={20}
|
||||
max={500}
|
||||
step={10}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setSummaryLength(DEFAULT_SUMMARY_LENGTH)}
|
||||
disabled={summaryLength === DEFAULT_SUMMARY_LENGTH}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('settings.notifications.page.summary.resetLengthAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<Icon name="restart" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className={cn("py-1.5 mt-1 border-t border-[var(--surface-subtle)]", isMobile ? "flex flex-col gap-3" : "flex items-center gap-8")}>
|
||||
<div className={cn("flex min-w-0 flex-col", isMobile ? "w-full" : "w-56 shrink-0")}>
|
||||
<span className="typography-ui-label text-foreground">{t('settings.notifications.page.summary.maxLengthLabel')}</span>
|
||||
<span className="typography-meta text-muted-foreground">{t('settings.notifications.page.summary.maxLengthHint')}</span>
|
||||
</div>
|
||||
<div className={cn("flex items-center gap-2", isMobile ? "w-full" : "w-fit")}>
|
||||
<NumberInput
|
||||
value={maxLastMessageLength}
|
||||
onValueChange={setMaxLastMessageLength}
|
||||
min={50}
|
||||
max={1000}
|
||||
step={10}
|
||||
className="w-20 tabular-nums"
|
||||
/>
|
||||
<Button size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
onClick={() => setMaxLastMessageLength(DEFAULT_MAX_LAST_MESSAGE_LENGTH)}
|
||||
disabled={maxLastMessageLength === DEFAULT_MAX_LAST_MESSAGE_LENGTH}
|
||||
className="h-7 w-7 px-0 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('settings.notifications.page.summary.resetMaxLengthAria')}
|
||||
title={t('settings.common.actions.reset')}
|
||||
>
|
||||
<Icon name="restart" className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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<Array<{ name: string; locale: string }>>([]);
|
||||
@@ -1006,51 +998,6 @@ export const VoiceSettings: React.FC = () => {
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.messageReadAloudButton')}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={summarizeMessageTTS}
|
||||
onClick={() => setSummarizeMessageTTS(!summarizeMessageTTS)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeMessageTTS(!summarizeMessageTTS); } }}
|
||||
>
|
||||
<Checkbox checked={summarizeMessageTTS} onChange={setSummarizeMessageTTS} ariaLabel={t('settings.voice.page.field.summarizeBeforePlaybackAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.summarizeBeforePlayback')}</span>
|
||||
</div>
|
||||
|
||||
{voiceModeEnabled && (
|
||||
<div
|
||||
className="group flex cursor-pointer items-center gap-2 py-1.5"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-pressed={summarizeVoiceConversation}
|
||||
onClick={() => setSummarizeVoiceConversation(!summarizeVoiceConversation)}
|
||||
onKeyDown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); setSummarizeVoiceConversation(!summarizeVoiceConversation); } }}
|
||||
>
|
||||
<Checkbox checked={summarizeVoiceConversation} onChange={setSummarizeVoiceConversation} ariaLabel={t('settings.voice.page.field.summarizeVoiceModeResponsesAria')} />
|
||||
<span className="typography-ui-label text-foreground">{t('settings.voice.page.field.summarizeVoiceModeResponses')}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(summarizeMessageTTS || summarizeVoiceConversation) && (
|
||||
<>
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.summarizationThreshold')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeCharacterThreshold} onChange={(e) => setSummarizeCharacterThreshold(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={summarizeCharacterThreshold} onValueChange={setSummarizeCharacterThreshold} min={50} max={2000} step={50} className="w-16 tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-8 py-1.5">
|
||||
<span className="typography-ui-label text-foreground sm:w-56 shrink-0">{t('settings.voice.page.field.summaryMaxLength')}</span>
|
||||
<div className="flex items-center gap-2 w-fit">
|
||||
{!isMobile && <input type="range" min={50} max={2000} step={50} value={summarizeMaxLength} onChange={(e) => setSummarizeMaxLength(Number(e.target.value))} className={sliderClass} />}
|
||||
<NumberInput value={summarizeMaxLength} onValueChange={setSummarizeMaxLength} min={50} max={2000} step={50} className="w-16 tabular-nums" />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{voiceModeEnabled && isSupported && (
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -134,14 +134,11 @@ export function useServerTTS(options: UseServerTTSOptions = {}): UseServerTTSRet
|
||||
const audioSourceRef = useRef<AudioBufferSourceNode | null>(null);
|
||||
const abortControllerRef = useRef<AbortController | null>(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<boolean> => {
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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': '프로바이더',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Провайдер",
|
||||
|
||||
@@ -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': '提供方',
|
||||
|
||||
@@ -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<string, string>();
|
||||
let virtualDiffCounter = 0;
|
||||
let virtualDiffProviderDisposable: vscode.Disposable | null = null;
|
||||
|
||||
const asObject = (value: unknown): Record<string, unknown> | null =>
|
||||
value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: 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<Array<{ id: string; owned_by?: string }>> => {
|
||||
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<Array<{ id: string; owned_by?: string }>> => [];
|
||||
|
||||
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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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}' };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
+1
-2
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user