From a9c51f115ede68611b8585e5d3d25c4eea3c8bcc Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sat, 18 Apr 2026 13:46:51 +0300 Subject: [PATCH] refactor(text): extract shared summarization service with tts/note/notification modes Move server-side summarization out of lib/tts/summarization.js into a shared lib/text module exposing tts, note and notification modes. TTS and notification runtimes now delegate to the shared service, note mode is used to distill a selected excerpt into a short project note. Expose the new endpoint via /api/text/summarize and route it through the common request middleware. Client voice/summarize.ts accepts a mode option and points at the new endpoint with an absolute URL that respects the desktop server origin. VS Code webview swaps the stubbed tts summarize path for /api/text/summarize and hardens URL parsing with window.location.href as the base. --- packages/ui/src/lib/voice/summarize.ts | 46 +++- packages/vscode/webview/main.tsx | 4 +- .../server/lib/notifications/DOCUMENTATION.md | 2 +- .../lib/notifications/template-runtime.js | 45 +--- .../web/server/lib/opencode/core-routes.js | 1 + packages/web/server/lib/text/DOCUMENTATION.md | 35 +++ packages/web/server/lib/text/summarization.js | 240 ++++++++++++++++++ packages/web/server/lib/tts/DOCUMENTATION.md | 17 +- packages/web/server/lib/tts/index.js | 3 +- packages/web/server/lib/tts/routes.js | 22 +- packages/web/server/lib/tts/summarization.js | 171 ------------- 11 files changed, 357 insertions(+), 229 deletions(-) create mode 100644 packages/web/server/lib/text/DOCUMENTATION.md create mode 100644 packages/web/server/lib/text/summarization.js delete mode 100644 packages/web/server/lib/tts/summarization.js diff --git a/packages/ui/src/lib/voice/summarize.ts b/packages/ui/src/lib/voice/summarize.ts index ab17e04c..3b78d193 100644 --- a/packages/ui/src/lib/voice/summarize.ts +++ b/packages/ui/src/lib/voice/summarize.ts @@ -1,12 +1,24 @@ /** - * Text summarization utility for TTS + * Text summarization utility * - * Calls the server-side summarization endpoint which uses + * Calls the server-side text summarization endpoint which uses * the opencode.ai zen API with gpt-5-nano. */ import { useConfigStore } from '@/stores/useConfigStore'; +const resolveSummarizeUrl = (): string => { + if (typeof window === 'undefined') { + return '/api/text/summarize'; + } + + const desktopServer = (window as typeof window & { + __OPENCHAMBER_DESKTOP_SERVER__?: { origin: string }; + }).__OPENCHAMBER_DESKTOP_SERVER__; + const baseOrigin = desktopServer?.origin || window.location.origin; + return new URL('/api/text/summarize', baseOrigin).toString(); +}; + /** * Summarize text using the server-side zen API endpoint * @@ -21,25 +33,32 @@ export async function summarizeText( threshold?: number; /** Max characters for the summary output */ maxLength?: number; + /** Summarization mode */ + mode?: 'tts' | 'note'; } ): Promise { const store = useConfigStore.getState(); const threshold = options?.threshold ?? store.summarizeCharacterThreshold; const maxLength = options?.maxLength ?? store.summarizeMaxLength; + const mode = options?.mode ?? 'tts'; + const normalizedSource = text.replace(/\s+/g, ' ').trim(); // Don't summarize if text is under threshold if (text.length <= threshold) { + if (mode === 'note') { + throw new Error('Note summarization threshold bypass is not allowed'); + } return text; } try { const zenModel = store.settingsZenModel; - const response = await fetch('/api/tts/summarize', { + const response = await fetch(resolveSummarizeUrl(), { method: 'POST', headers: { 'Content-Type': 'application/json', }, - body: JSON.stringify({ text, threshold, maxLength, ...(zenModel ? { zenModel } : {}) }), + body: JSON.stringify({ text, threshold, maxLength, mode, ...(zenModel ? { zenModel } : {}) }), }); if (!response.ok) { @@ -56,14 +75,27 @@ export async function summarizeText( summaryLength?: number; }; - if (data.summarized && data.summary) { - return data.summary; + if (typeof data.summary === 'string' && data.summary.trim().length > 0) { + const summary = data.summary.trim(); + if (mode === 'note') { + const normalizedSummary = summary.replace(/\s+/g, ' ').trim(); + if (normalizedSummary === normalizedSource) { + throw new Error('Note distillation returned source text unchanged'); + } + } + return summary; } - // Return original text if not summarized + if (mode === 'note') { + throw new Error('Note summarization returned no distilled result'); + } + // Return original text if the server produced nothing usable return text; } catch (err) { console.error('[summarize] Failed to summarize:', err); + if (mode === 'note') { + throw err instanceof Error ? err : new Error('Note summarization failed'); + } // Return original text on error return text; } diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 92759244..9a5a537a 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -294,7 +294,7 @@ if (workspaceFolder) { const normalizeUrl = (input: string | URL) => { try { - return typeof input === 'string' ? new URL(input, window.location.origin) : new URL(input.toString()); + return typeof input === 'string' ? new URL(input, window.location.href) : new URL(input.toString(), window.location.href); } catch { return null; } @@ -523,7 +523,7 @@ const handleLocalApiRequest = async (url: URL, init?: RequestInit) => { }); } - if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/tts/summarize') && method === 'POST') { + if ((pathname === '/api/tts/speak' || pathname === '/api/tts/say/speak' || pathname === '/api/text/summarize') && method === 'POST') { return new Response(JSON.stringify({ error: 'TTS endpoints are not available in VS Code runtime' }), { status: 501, headers: { 'Content-Type': 'application/json' }, diff --git a/packages/web/server/lib/notifications/DOCUMENTATION.md b/packages/web/server/lib/notifications/DOCUMENTATION.md index a0a27dd0..225b18b0 100644 --- a/packages/web/server/lib/notifications/DOCUMENTATION.md +++ b/packages/web/server/lib/notifications/DOCUMENTATION.md @@ -67,7 +67,7 @@ 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 + zen helper runtime. +- `createNotificationTemplateRuntime(dependencies)`: creates shared notification/template runtime and consumes shared text summarization from `packages/web/server/lib/text/summarization.js` in `notification` mode. - Returned API: - `resolveNotificationTemplate(template, variables)` - `shouldApplyResolvedTemplateMessage(template, resolved, variables)` diff --git a/packages/web/server/lib/notifications/template-runtime.js b/packages/web/server/lib/notifications/template-runtime.js index a93005ed..0b628aed 100644 --- a/packages/web/server/lib/notifications/template-runtime.js +++ b/packages/web/server/lib/notifications/template-runtime.js @@ -1,3 +1,5 @@ +import { summarizeText as summarizeSharedText } from '../text/summarization.js'; + export const createNotificationTemplateRuntime = (deps) => { const { readSettingsFromDisk, @@ -136,39 +138,16 @@ export const createNotificationTemplateRuntime = (deps) => { const summarizeText = async (text, targetLength, zenModel) => { if (!text || typeof text !== 'string' || text.trim().length === 0) return text; - - try { - const prompt = `Summarize the following text in approximately ${targetLength} characters. Be concise and capture the key point. Output plain text only. Do not use markdown, bullets, headings, code fences, backticks, or quotes. Output only the summary text.\n\nText:\n${text}`; - - const completionTimeout = createTimeoutSignal(15000); - let response; - try { - response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: zenModel || ZEN_DEFAULT_MODEL, - input: [{ role: 'user', content: prompt }], - max_output_tokens: 1000, - stream: false, - reasoning: { effort: 'low' }, - }), - signal: completionTimeout.signal, - }); - } finally { - completionTimeout.cleanup(); - } - - if (!response.ok) return text; - - const data = await response.json(); - const summary = data?.output?.find((item) => item?.type === 'message') - ?.content?.find((item) => item?.type === 'output_text')?.text?.trim(); - - return summary || text; - } catch { - return text; - } + const result = await summarizeSharedText({ + text, + threshold: 0, + maxLength: targetLength, + zenModel: zenModel || ZEN_DEFAULT_MODEL, + mode: 'notification', + }); + return typeof result?.summary === 'string' && result.summary.trim().length > 0 + ? result.summary + : text; }; const extractTextFromParts = (parts, maxLength = NOTIFICATION_BODY_MAX_CHARS) => { diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 9c3823b4..0d2ed3d6 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -260,6 +260,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/terminal') || req.path.startsWith('/api/opencode') || req.path.startsWith('/api/push') || + req.path.startsWith('/api/text') || req.path.startsWith('/api/voice') || req.path.startsWith('/api/tts') || req.path.startsWith('/api/openchamber/tunnel') diff --git a/packages/web/server/lib/text/DOCUMENTATION.md b/packages/web/server/lib/text/DOCUMENTATION.md new file mode 100644 index 00000000..56f5d2d7 --- /dev/null +++ b/packages/web/server/lib/text/DOCUMENTATION.md @@ -0,0 +1,35 @@ +# 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. + +## Entrypoints and structure +- `packages/web/server/lib/text/summarization.js`: Shared summarize + sanitize helpers backed by opencode.ai zen API. + +## Public exports + +### Summarization (summarization.js) +- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared summarization entrypoint. +- `sanitizeForTTS(text)`: Sanitizes text for speech output. +- `sanitizeForNotification(text)`: Sanitizes text for compact notification output. +- `sanitizeForNote(text)`: Sanitizes text for short note/distillation output. + +## Modes +- `tts`: Speakable summary for TTS flows. +- `notification`: Short plain-text summary for notification bodies. +- `note`: Distilled short project-memory note. + +## Response contract + +### `summarizeText` +Returns object with: +- `summary`: Final transformed text. +- `summarized`: Boolean indicating whether model summarization succeeded. +- `reason`: Optional failure/skip reason. +- `originalLength`: Optional original text length. +- `summaryLength`: Optional final summary length. + +## Notes for contributors +- Keep this module neutral. Do not re-couple it to TTS-specific naming or routing. +- Add new mode semantics here when multiple product surfaces need the same text pipeline. +- Prefer mode-specific prompt and sanitize behavior over creating duplicated summarizers in unrelated modules. diff --git a/packages/web/server/lib/text/summarization.js b/packages/web/server/lib/text/summarization.js new file mode 100644 index 00000000..231ce2c7 --- /dev/null +++ b/packages/web/server/lib/text/summarization.js @@ -0,0 +1,240 @@ +/** + * Shared text summarization service. + * + * Modes: + * - tts: concise speakable text + * - notification: concise notification text + * - 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 ''; + + return text + .replace(/[*_~`#]/g, '') + .replace(/```[\s\S]*?```/g, '') + .replace(/`[^`]*`/g, '') + .replace(/^\s*[$#>]\s*/gm, '') + .replace(/[|&;<>]/g, ' ') + .replace(/\\/g, '') + .replace(/[\[\]{}()]/g, '') + .replace(/["']/g, '') + .replace(/https?:\/\/[^\s]+/g, ' a link ') + .replace(/\/[\w\-./]+/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +export function sanitizeForNotification(text) { + if (!text || typeof text !== 'string') return ''; + + return text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/^[\t ]*[-*+]\s+/gm, '') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/__(.*?)__/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/\s*\n\s*/g, ' ') + .replace(/\s+/g, ' ') + .trim(); +} + +export function sanitizeForNote(text) { + if (!text || typeof text !== 'string') return ''; + + return text + .replace(/```[\s\S]*?```/g, ' ') + .replace(/`([^`]*)`/g, '$1') + .replace(/^\s*[-*+]\s+/gm, '') + .replace(/^#{1,6}\s+/gm, '') + .replace(/\*\*(.*?)\*\*/g, '$1') + .replace(/__(.*?)__/g, '$1') + .replace(/\*(.*?)\*/g, '$1') + .replace(/_(.*?)_/g, '$1') + .replace(/\[(.*?)\]\((.*?)\)/g, '$1') + .replace(/https?:\/\/[^\s]+/g, '') + .replace(/["']/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function sanitizeByMode(text, mode) { + if (mode === 'note') return sanitizeForNote(text); + if (mode === 'notification') return sanitizeForNotification(text); + return sanitizeForTTS(text); +} + +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 distillNoteFallback(text, maxLength) { + const sanitized = sanitizeForNote(text); + if (!sanitized) return ''; + + const normalized = sanitized + .replace(/^In summary[:,]?\s*/i, '') + .replace(/^Here(?:s| is) (?:a )?note[:,]?\s*/i, '') + .trim(); + + const sentences = normalized + .split(/(?<=[.!?])\s+/) + .map((part) => part.trim()) + .filter(Boolean); + + const best = (sentences[0] || normalized) + .split(/[;:()-]\s+/)[0] + .split(/,\s+/)[0] + .trim(); + const idealLimit = Math.min(maxLength, Math.max(32, Math.floor(normalized.length * 0.65))); + + if (best.length <= idealLimit) return best; + + const clipped = best.slice(0, Math.max(0, idealLimit - 1)).trim(); + return clipped ? `${clipped}…` : best.slice(0, idealLimit).trim(); +} + +function fallbackByMode(text, maxLength, mode) { + if (mode === 'note') return distillNoteFallback(text, maxLength); + return sanitizeByMode(text, mode); +} + +export async function summarizeText({ text, threshold = 200, maxLength = 500, zenModel, mode = 'tts' }) { + if (!text || text.length <= threshold) { + return { + summary: fallbackByMode(text || '', maxLength, mode), + 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 response = await fetch('https://opencode.ai/zen/v1/responses', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: zenModel || 'gpt-5-nano', + input: [{ role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }], + stream: false, + reasoning: { effort: 'low' }, + }), + 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 = extractZenOutputText(data); + + if (summary) { + const sanitized = sanitizeByMode(summary, mode); + const finalSummary = mode === 'note' + ? (sanitized && sanitized !== sanitizeForNote(text) ? sanitized : distillNoteFallback(text, maxLength)) + : sanitized; + return { + summary: finalSummary, + summarized: true, + originalLength: text.length, + summaryLength: finalSummary.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); + } +} diff --git a/packages/web/server/lib/tts/DOCUMENTATION.md b/packages/web/server/lib/tts/DOCUMENTATION.md index d39caf69..6a6b6d7d 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, along with text summarization and sanitization utilities for preparing content for speech synthesis. +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. ## 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/tts/summarization.js`: Text summarization and sanitization utilities using opencode.ai zen API. +- `packages/web/server/lib/text/summarization.js`: Shared text summarization and sanitization utilities using opencode.ai zen API. - `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. @@ -19,9 +19,10 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, - `TTSService`: TTS service class for OpenAI audio generation. - `TTS_VOICES`: Array of supported OpenAI voice identifiers. -### Summarization (from summarization.js) -- `summarizeText({ text, threshold, maxLength, zenModel })`: Summarizes text for TTS output using opencode.ai zen API. +### Shared text summarization (re-exported from ../text/summarization.js) +- `summarizeText({ text, threshold, maxLength, zenModel, mode })`: Shared text summarizer. TTS uses `mode: 'tts'`. - `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. ### Capability runtime (capability-runtime.js) - `detectSayTtsCapability(processLike)`: probes local `say -v "?"` support and returns `{ available, voices, reason }`. @@ -35,7 +36,7 @@ This module provides server-side Text-to-Speech services using OpenAI's TTS API, - `SUMMARIZE_TIMEOUT_MS`: 30000 (30 seconds timeout for zen API requests). ### Default values -- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano'. +- `summarizeText` defaults: `threshold` = 200, `maxLength` = 500, `zenModel` = 'gpt-5-nano', `mode` = 'tts'. - `generateSpeechStream` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0. - `generateSpeechBuffer` defaults: `voice` = 'coral', `model` = 'gpt-4o-mini-tts', `speed` = 1.0. @@ -66,6 +67,8 @@ Returns object with: - `originalLength`: Optional number for original text length. - `summaryLength`: Optional number for summarized text length. +The route-level text summarize API is now `/api/text/summarize`. + ### `sanitizeForTTS` Returns sanitized string with markdown, URLs, file paths, and special characters removed. @@ -90,6 +93,8 @@ The TTS module is used by `packages/web/server/index.js` for: - Summarizing long messages before TTS synthesis. - 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 server-side TTS approach bypasses mobile Safari's audio context restrictions by generating audio on the server and streaming to clients. ## Notes for contributors @@ -108,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 falls back to original text with `summarized: false`. +- `summarizeText` catches zen API errors and returns mode-specific fallback text with `summarized: false`. - All errors are logged to console with `[TTSService]` or `[Summarize]` prefix. ### API key management diff --git a/packages/web/server/lib/tts/index.js b/packages/web/server/lib/tts/index.js index d51746be..0e4b71b5 100644 --- a/packages/web/server/lib/tts/index.js +++ b/packages/web/server/lib/tts/index.js @@ -13,6 +13,7 @@ export { export { summarizeText, sanitizeForTTS, -} from './summarization.js'; + sanitizeForNote, +} from '../text/summarization.js'; export { transcribeAudio } from './stt.js'; diff --git a/packages/web/server/lib/tts/routes.js b/packages/web/server/lib/tts/routes.js index 5f30424c..fef720ba 100644 --- a/packages/web/server/lib/tts/routes.js +++ b/packages/web/server/lib/tts/routes.js @@ -1,5 +1,6 @@ import express from 'express'; import { normalizeCustomOpenAIBaseURL } from './base-url.js'; +import { summarizeText, sanitizeForTTS, sanitizeForNote } from '../text/summarization.js'; export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { let ttsModulePromise = null; @@ -76,9 +77,8 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { // Optionally summarize long text before speaking using zen API if (summarize && textToSpeak.length > threshold) { try { - const { summarizeText } = await getTtsModule(); const speakZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel }); + const result = await summarizeText({ text: textToSpeak, threshold, maxLength, zenModel: speakZenModel, mode: 'tts' }); if (result.summarized && result.summary) { textToSpeak = result.summary; @@ -115,23 +115,29 @@ export function registerTtsRoutes(app, { resolveZenModel, sayTTSCapability }) { } }); - app.post('/api/tts/summarize', async (req, res) => { + app.post('/api/text/summarize', async (req, res) => { try { - const { summarizeText } = await getTtsModule(); - const { text, threshold = 200, maxLength = 500 } = req.body || {}; + const { text, threshold = 200, maxLength = 500, mode } = req.body || {}; if (!text || typeof text !== 'string' || !text.trim()) { return res.status(400).json({ error: 'Text is required' }); } const sumZenModel = await resolveZenModel(typeof req.body?.zenModel === 'string' ? req.body.zenModel : undefined); - const result = await summarizeText({ text, threshold, maxLength, zenModel: sumZenModel }); + const result = await summarizeText({ + text, + threshold, + maxLength, + zenModel: sumZenModel, + mode: typeof mode === 'string' ? mode : 'tts', + }); return res.json(result); } catch (error) { console.error('[Summarize] Error:', error); - const { sanitizeForTTS } = await getTtsModule(); - const sanitized = sanitizeForTTS(req.body?.text || ''); + const sanitized = typeof req.body?.mode === 'string' && req.body.mode === 'note' + ? sanitizeForNote(req.body?.text || '') + : sanitizeForTTS(req.body?.text || ''); return res.json({ summary: sanitized, summarized: false, reason: error.message }); } }); diff --git a/packages/web/server/lib/tts/summarization.js b/packages/web/server/lib/tts/summarization.js deleted file mode 100644 index cc0dfaac..00000000 --- a/packages/web/server/lib/tts/summarization.js +++ /dev/null @@ -1,171 +0,0 @@ -/** - * Text Summarization Service - * - * Uses the opencode.ai zen API with gpt-5-nano for fast, lightweight summarization. - * Used by all TTS implementations (Browser, Say, OpenAI). - */ - -function buildSummarizationPrompt(maxLength) { - 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; - -/** - * Sanitize text for TTS output - * Removes markdown, URLs, file paths, and other non-speakable content - */ -export function sanitizeForTTS(text) { - if (!text || typeof text !== 'string') return ''; - - return text - // Remove markdown formatting - .replace(/[*_~`#]/g, '') - // Remove code blocks - .replace(/```[\s\S]*?```/g, '') - .replace(/`[^`]*`/g, '') - // Remove shell-like command patterns - .replace(/^\s*[$#>]\s*/gm, '') - // Remove common shell operators - .replace(/[|&;<>]/g, ' ') - // Remove backslashes (escape characters) - .replace(/\\/g, '') - // Remove brackets that might be interpreted specially - .replace(/[[\]{}()]/g, '') - // Remove quotes that might cause issues - .replace(/["']/g, '') - // Remove URLs - .replace(/https?:\/\/[^\s]+/g, ' a link ') - // Remove file paths - .replace(/\/[\w\-./]+/g, '') - // Collapse multiple spaces/newlines - .replace(/\s+/g, ' ') - .trim(); -} - -/** - * Extract text from zen API response - */ -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; -} - -/** - * Summarize text using the opencode.ai zen API - * - * @param {Object} options - * @param {string} options.text - The text to summarize - * @param {number} options.threshold - Character threshold (don't summarize if under this length) - * @param {number} options.maxLength - Maximum character length for the summary output (50-2000) - * @param {string} [options.zenModel] - Override zen model (defaults to gpt-5-nano) - * @returns {Promise<{summary: string, summarized: boolean, reason?: string}>} - */ -export async function summarizeText({ - text, - threshold = 200, - maxLength = 500, - zenModel, -}) { - // Don't summarize if text is under threshold - if (!text || text.length <= threshold) { - return { - summary: sanitizeForTTS(text || ''), - 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); - - const response = await fetch('https://opencode.ai/zen/v1/responses', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - model: zenModel || 'gpt-5-nano', - input: [ - { role: 'user', content: `${prompt}\n\nText to summarize:\n${text}` }, - ], - stream: false, - reasoning: { effort: 'low' }, - }), - signal: controller.signal, - }); - - if (!response.ok) { - const errorBody = await response.json().catch(() => ({})); - console.error('[Summarize] zen API error:', response.status, errorBody); - return { - summary: sanitizeForTTS(text), - summarized: false, - reason: `zen API returned ${response.status}`, - }; - } - - const data = await response.json(); - const summary = extractZenOutputText(data); - - if (summary) { - const sanitized = sanitizeForTTS(summary); - return { - summary: sanitized, - summarized: true, - originalLength: text.length, - summaryLength: sanitized.length, - }; - } - - return { - summary: sanitizeForTTS(text), - summarized: false, - reason: 'No response from model', - }; - } catch (error) { - if (error.name === 'AbortError') { - console.error('[Summarize] Request timed out'); - return { - summary: sanitizeForTTS(text), - summarized: false, - reason: 'Request timed out', - }; - } - console.error('[Summarize] Error:', error); - return { - summary: sanitizeForTTS(text), - summarized: false, - reason: error.message, - }; - } finally { - clearTimeout(timer); - } -}