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:
Bohdan Triapitsyn
2026-04-18 13:46:51 +03:00
parent 4901cf60b8
commit a9c51f115e
11 changed files with 357 additions and 229 deletions
+39 -7
View File
@@ -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;
}