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.
This commit is contained in:
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -13,6 +13,7 @@ export {
|
||||
export {
|
||||
summarizeText,
|
||||
sanitizeForTTS,
|
||||
} from './summarization.js';
|
||||
sanitizeForNote,
|
||||
} from '../text/summarization.js';
|
||||
|
||||
export { transcribeAudio } from './stt.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 });
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user