glance-cli 0.13.0 ā 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +9 -0
- package/dist/cli.js +198 -1064
- package/package.json +4 -2
- package/src/cli/commands.ts +854 -0
- package/src/cli/config.ts +24 -0
- package/src/cli/display.ts +270 -0
- package/src/cli/errors.ts +31 -0
- package/src/cli/index.ts +239 -0
- package/src/cli/logger.ts +43 -0
- package/src/cli/types.ts +114 -0
- package/src/cli/utils.ts +239 -0
- package/src/cli/validators.ts +176 -0
- package/src/cli.ts +17 -0
- package/src/core/compat.ts +96 -0
- package/src/core/extractor.ts +532 -0
- package/src/core/fetcher.ts +592 -0
- package/src/core/formatter.ts +742 -0
- package/src/core/language-detector.ts +382 -0
- package/src/core/screenshot.ts +444 -0
- package/src/core/service-detector.ts +411 -0
- package/src/core/summarizer.ts +656 -0
- package/src/core/text-cleaner.ts +150 -0
- package/src/core/voice.ts +708 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command handlers for Glance CLI
|
|
3
|
+
* Exports individual command functions that can be used programmatically
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { writeFile } from "node:fs/promises";
|
|
7
|
+
import chalk from "chalk";
|
|
8
|
+
import clipboard from "clipboardy";
|
|
9
|
+
import {
|
|
10
|
+
extractCleanText,
|
|
11
|
+
extractLinks,
|
|
12
|
+
extractMetadata,
|
|
13
|
+
} from "../core/extractor";
|
|
14
|
+
import { fetchPage } from "../core/fetcher";
|
|
15
|
+
import { formatOutput } from "../core/formatter";
|
|
16
|
+
import {
|
|
17
|
+
detectLanguage,
|
|
18
|
+
shouldAutoDetectLanguage,
|
|
19
|
+
} from "../core/language-detector";
|
|
20
|
+
import { takeScreenshot } from "../core/screenshot";
|
|
21
|
+
import { getDefaultModel, showCostWarning } from "../core/service-detector";
|
|
22
|
+
import { detectProvider, summarize } from "../core/summarizer";
|
|
23
|
+
import { sanitizeAIResponse } from "../core/text-cleaner";
|
|
24
|
+
import { cleanTextForSpeech, createVoiceSynthesizer } from "../core/voice";
|
|
25
|
+
import { CONFIG, LANGUAGE_MAP } from "./config";
|
|
26
|
+
import { showServiceStatus } from "./display";
|
|
27
|
+
import { ErrorCodes, GlanceError } from "./errors";
|
|
28
|
+
import { logger } from "./logger";
|
|
29
|
+
import type { ServiceStatus } from "./types";
|
|
30
|
+
import {
|
|
31
|
+
createSpinner,
|
|
32
|
+
formatFileSize,
|
|
33
|
+
getFileExtension,
|
|
34
|
+
sanitizeOutputForTerminal,
|
|
35
|
+
withRetry,
|
|
36
|
+
} from "./utils";
|
|
37
|
+
import { validateAPIKeys } from "./validators";
|
|
38
|
+
|
|
39
|
+
export interface GlanceOptions {
|
|
40
|
+
url?: string;
|
|
41
|
+
model?: string;
|
|
42
|
+
language?: string;
|
|
43
|
+
tldr?: boolean;
|
|
44
|
+
keyPoints?: boolean;
|
|
45
|
+
eli5?: boolean;
|
|
46
|
+
full?: boolean;
|
|
47
|
+
customQuestion?: string;
|
|
48
|
+
stream?: boolean;
|
|
49
|
+
maxTokens?: number;
|
|
50
|
+
format?: string;
|
|
51
|
+
output?: string;
|
|
52
|
+
screenshot?: string;
|
|
53
|
+
fullRender?: boolean;
|
|
54
|
+
metadata?: boolean;
|
|
55
|
+
links?: boolean;
|
|
56
|
+
read?: boolean;
|
|
57
|
+
voice?: string;
|
|
58
|
+
audioOutput?: string;
|
|
59
|
+
listVoices?: boolean;
|
|
60
|
+
checkServices?: boolean;
|
|
61
|
+
freeOnly?: boolean;
|
|
62
|
+
preferQuality?: boolean;
|
|
63
|
+
debug?: boolean;
|
|
64
|
+
copy?: boolean;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Main glance command - fetch and summarize a webpage
|
|
69
|
+
*/
|
|
70
|
+
export async function glance(
|
|
71
|
+
url: string,
|
|
72
|
+
options: GlanceOptions = {},
|
|
73
|
+
): Promise<string> {
|
|
74
|
+
const startTime = Date.now();
|
|
75
|
+
|
|
76
|
+
// Set debug logging if requested
|
|
77
|
+
if (options.debug) {
|
|
78
|
+
logger.setLevel("debug");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Language will be determined after fetching content
|
|
82
|
+
let language: string = options.language || "en";
|
|
83
|
+
let languageName =
|
|
84
|
+
LANGUAGE_MAP[language as keyof typeof LANGUAGE_MAP] || "English";
|
|
85
|
+
|
|
86
|
+
// Note: Caching temporarily disabled to eliminate corruption issues
|
|
87
|
+
|
|
88
|
+
// Fetch the webpage
|
|
89
|
+
const fetchSpinner = createSpinner("Fetching webpage...");
|
|
90
|
+
fetchSpinner.start();
|
|
91
|
+
|
|
92
|
+
let html: string;
|
|
93
|
+
try {
|
|
94
|
+
html = await withRetry(
|
|
95
|
+
() => fetchPage(url, { fullRender: options.fullRender }),
|
|
96
|
+
{
|
|
97
|
+
onRetry: (attempt, _error) => {
|
|
98
|
+
fetchSpinner.text = `Fetching webpage... (retry ${attempt})`;
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
fetchSpinner.succeed("Webpage fetched successfully");
|
|
103
|
+
} catch (error: unknown) {
|
|
104
|
+
fetchSpinner.fail("Failed to fetch webpage");
|
|
105
|
+
throw new GlanceError(
|
|
106
|
+
error instanceof Error ? error.message : String(error),
|
|
107
|
+
ErrorCodes.FETCH_FAILED,
|
|
108
|
+
"Could not fetch the webpage. Please check the URL and your internet connection.",
|
|
109
|
+
true,
|
|
110
|
+
"Try again or use --full-render for JavaScript-heavy sites",
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Extract content
|
|
115
|
+
const extractSpinner = createSpinner("Extracting content...");
|
|
116
|
+
extractSpinner.start();
|
|
117
|
+
|
|
118
|
+
const cleanText = extractCleanText(html);
|
|
119
|
+
|
|
120
|
+
// Auto-detect language if not specified by user
|
|
121
|
+
if (!options.language && shouldAutoDetectLanguage()) {
|
|
122
|
+
const detectionResult = detectLanguage(
|
|
123
|
+
url,
|
|
124
|
+
html,
|
|
125
|
+
cleanText,
|
|
126
|
+
options.language,
|
|
127
|
+
);
|
|
128
|
+
language = detectionResult.detected;
|
|
129
|
+
languageName =
|
|
130
|
+
LANGUAGE_MAP[language as keyof typeof LANGUAGE_MAP] || "English";
|
|
131
|
+
|
|
132
|
+
// Show detection info to user if confidence is not high
|
|
133
|
+
if (
|
|
134
|
+
detectionResult.confidence !== "high" &&
|
|
135
|
+
detectionResult.source !== "default"
|
|
136
|
+
) {
|
|
137
|
+
logger.info(`Auto-detected language: ${languageName}`);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Log detection result if in debug mode
|
|
141
|
+
if (options.debug) {
|
|
142
|
+
logger.debug(
|
|
143
|
+
`Language detected: ${language} (${detectionResult.confidence} confidence from ${detectionResult.source})`,
|
|
144
|
+
);
|
|
145
|
+
logger.debug(`Detection signals: ${detectionResult.signals.join(", ")}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (cleanText.length > CONFIG.MAX_CONTENT_SIZE) {
|
|
150
|
+
extractSpinner.fail("Content too large");
|
|
151
|
+
throw new GlanceError(
|
|
152
|
+
`Content size (${formatFileSize(cleanText.length)}) exceeds maximum allowed`,
|
|
153
|
+
ErrorCodes.CONTENT_TOO_LARGE,
|
|
154
|
+
`The webpage content is too large to process (>${formatFileSize(CONFIG.MAX_CONTENT_SIZE)})`,
|
|
155
|
+
false,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
extractSpinner.succeed(
|
|
160
|
+
`Content extracted (${formatFileSize(cleanText.length)})`,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Handle metadata extraction
|
|
164
|
+
if (options.metadata) {
|
|
165
|
+
const metadata = extractMetadata(html);
|
|
166
|
+
console.log(chalk.bold("\nš Page Metadata:"));
|
|
167
|
+
console.log(JSON.stringify(metadata, null, 2));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Handle links extraction
|
|
171
|
+
if (options.links) {
|
|
172
|
+
const links = extractLinks(html);
|
|
173
|
+
console.log(chalk.bold(`\nš Found ${links.length} links:`));
|
|
174
|
+
links.forEach((link) => {
|
|
175
|
+
const display = link.text ? `${link.text} (${link.href})` : link.href;
|
|
176
|
+
console.log(chalk.cyan(` ⢠${display}`));
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Handle screenshot
|
|
181
|
+
if (options.screenshot) {
|
|
182
|
+
await handleScreenshot(url, options.screenshot);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Handle full content mode (no summarization)
|
|
186
|
+
if (options.full) {
|
|
187
|
+
const fullContent = await handleFullContent(cleanText, {
|
|
188
|
+
...options,
|
|
189
|
+
language,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Note: Caching disabled
|
|
193
|
+
|
|
194
|
+
return fullContent;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Summarize content with detected or specified language
|
|
198
|
+
const { rawSummary, formattedSummary } = await summarizeContentWithRaw(
|
|
199
|
+
cleanText,
|
|
200
|
+
url,
|
|
201
|
+
{ ...options, language },
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
// Note: Caching disabled
|
|
205
|
+
|
|
206
|
+
// Display summary immediately if voice synthesis is requested
|
|
207
|
+
if (options.read || options.audioOutput) {
|
|
208
|
+
// Show the full formatted summary first
|
|
209
|
+
console.log(formattedSummary);
|
|
210
|
+
console.log(""); // Add spacing
|
|
211
|
+
|
|
212
|
+
// Then clean the raw text for speech and read it aloud
|
|
213
|
+
const cleanedSummary = cleanTextForSpeech(rawSummary);
|
|
214
|
+
await handleVoiceSynthesis(cleanedSummary, { ...options, language });
|
|
215
|
+
return formattedSummary;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const summary = formattedSummary;
|
|
219
|
+
|
|
220
|
+
// Save to file if output specified
|
|
221
|
+
if (options.output) {
|
|
222
|
+
await saveToFile(summary, options.output);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Copy to clipboard if requested
|
|
226
|
+
if (options.copy) {
|
|
227
|
+
// Copy formatted output for JSON/markdown, raw summary for terminal
|
|
228
|
+
const contentToCopy =
|
|
229
|
+
options.format === "json" || options.format === "markdown"
|
|
230
|
+
? summary
|
|
231
|
+
: rawSummary || summary;
|
|
232
|
+
await copyToClipboard(contentToCopy);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const duration = Date.now() - startTime;
|
|
236
|
+
logger.debug(`Total execution time: ${duration}ms`);
|
|
237
|
+
|
|
238
|
+
return summary;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Determine output format from user options
|
|
243
|
+
* Priority: 1) --format flag, 2) file extension, 3) terminal default
|
|
244
|
+
*/
|
|
245
|
+
function getOutputFormat(
|
|
246
|
+
options: GlanceOptions,
|
|
247
|
+
): "terminal" | "markdown" | "json" | "plain" {
|
|
248
|
+
// 1. If format is explicitly specified, use it
|
|
249
|
+
if (options.format) {
|
|
250
|
+
const formatMap: Record<string, string> = {
|
|
251
|
+
md: "markdown",
|
|
252
|
+
json: "json",
|
|
253
|
+
plain: "plain",
|
|
254
|
+
markdown: "markdown",
|
|
255
|
+
terminal: "terminal",
|
|
256
|
+
};
|
|
257
|
+
return (formatMap[options.format.toLowerCase()] || "terminal") as
|
|
258
|
+
| "terminal"
|
|
259
|
+
| "markdown"
|
|
260
|
+
| "json"
|
|
261
|
+
| "plain";
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// 2. If output file is specified, auto-detect from extension
|
|
265
|
+
if (options.output) {
|
|
266
|
+
const extension = getFileExtension(options.output).toLowerCase();
|
|
267
|
+
const extensionMap: Record<string, string> = {
|
|
268
|
+
md: "markdown",
|
|
269
|
+
markdown: "markdown",
|
|
270
|
+
json: "json",
|
|
271
|
+
txt: "plain",
|
|
272
|
+
text: "plain",
|
|
273
|
+
};
|
|
274
|
+
const detectedFormat = extensionMap[extension];
|
|
275
|
+
if (detectedFormat) {
|
|
276
|
+
return detectedFormat as "terminal" | "markdown" | "json" | "plain";
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// 3. Default to terminal if no output file or unrecognized extension
|
|
281
|
+
return "terminal";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Save content to file
|
|
286
|
+
*/
|
|
287
|
+
async function saveToFile(content: string, filename: string): Promise<void> {
|
|
288
|
+
try {
|
|
289
|
+
await writeFile(filename, content, "utf-8");
|
|
290
|
+
logger.info(`Content saved to ${filename}`);
|
|
291
|
+
} catch (error: unknown) {
|
|
292
|
+
throw new GlanceError(
|
|
293
|
+
error instanceof Error ? error.message : String(error),
|
|
294
|
+
ErrorCodes.EXPORT_FAILED,
|
|
295
|
+
`Failed to save content to ${filename}`,
|
|
296
|
+
false,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Copy content to clipboard
|
|
303
|
+
*/
|
|
304
|
+
async function copyToClipboard(content: string): Promise<void> {
|
|
305
|
+
try {
|
|
306
|
+
await clipboard.write(content);
|
|
307
|
+
logger.info(chalk.green("ā Copied to clipboard"));
|
|
308
|
+
} catch (error: unknown) {
|
|
309
|
+
logger.warn(
|
|
310
|
+
chalk.yellow("ā Could not copy to clipboard:"),
|
|
311
|
+
error instanceof Error ? error.message : String(error),
|
|
312
|
+
);
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Handle full content mode with optional AI formatting and translation
|
|
318
|
+
*/
|
|
319
|
+
async function handleFullContent(
|
|
320
|
+
content: string,
|
|
321
|
+
options: GlanceOptions & { language: string },
|
|
322
|
+
): Promise<string> {
|
|
323
|
+
let finalContent = content;
|
|
324
|
+
const needsTranslation = options.language && options.language !== "en";
|
|
325
|
+
|
|
326
|
+
// Always apply smart formatting for better readability
|
|
327
|
+
const fullModeSpinner = createSpinner(
|
|
328
|
+
needsTranslation
|
|
329
|
+
? "š Translating and formatting full content..."
|
|
330
|
+
: "š§¾ Applying smart formatting...",
|
|
331
|
+
);
|
|
332
|
+
fullModeSpinner.start();
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
// Determine model to use
|
|
336
|
+
const model =
|
|
337
|
+
options.model ||
|
|
338
|
+
(await getDefaultModel(undefined, !!options.preferQuality));
|
|
339
|
+
const _provider = detectProvider(model);
|
|
340
|
+
|
|
341
|
+
// Use AI for translation or formatting
|
|
342
|
+
const aiOptions = {
|
|
343
|
+
model,
|
|
344
|
+
language: options.language,
|
|
345
|
+
stream: false, // Don't stream for full content
|
|
346
|
+
maxTokens: options.maxTokens || 8000,
|
|
347
|
+
translate: needsTranslation,
|
|
348
|
+
format: true, // Always apply smart formatting
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const processedContent = await summarize(finalContent, {
|
|
352
|
+
model: aiOptions.model,
|
|
353
|
+
language: aiOptions.language,
|
|
354
|
+
stream: aiOptions.stream,
|
|
355
|
+
maxTokens: aiOptions.maxTokens,
|
|
356
|
+
translate: aiOptions.translate as boolean | undefined,
|
|
357
|
+
format: aiOptions.format,
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
finalContent = sanitizeAIResponse(processedContent);
|
|
361
|
+
|
|
362
|
+
fullModeSpinner.succeed(
|
|
363
|
+
needsTranslation
|
|
364
|
+
? "Translation and formatting complete"
|
|
365
|
+
: "Smart formatting applied",
|
|
366
|
+
);
|
|
367
|
+
} catch (error: unknown) {
|
|
368
|
+
fullModeSpinner.fail(
|
|
369
|
+
needsTranslation
|
|
370
|
+
? "Translation failed - showing original content"
|
|
371
|
+
: "Smart formatting failed - showing original content",
|
|
372
|
+
);
|
|
373
|
+
logger.error("Full content processing error:", error);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// Format the output
|
|
377
|
+
const formattedOutput = formatOutput(finalContent, {
|
|
378
|
+
format: getOutputFormat(options),
|
|
379
|
+
url: "full-content",
|
|
380
|
+
isFullContent: true,
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
// Save to file if output specified
|
|
384
|
+
if (options.output) {
|
|
385
|
+
await saveToFile(formattedOutput, options.output);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return formattedOutput;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Summarize content using AI - returns both raw and formatted versions
|
|
393
|
+
*/
|
|
394
|
+
async function summarizeContentWithRaw(
|
|
395
|
+
content: string,
|
|
396
|
+
url: string,
|
|
397
|
+
options: GlanceOptions & { language: string },
|
|
398
|
+
): Promise<{ rawSummary: string; formattedSummary: string }> {
|
|
399
|
+
const model =
|
|
400
|
+
options.model ||
|
|
401
|
+
(await getDefaultModel(undefined, !!options.preferQuality));
|
|
402
|
+
|
|
403
|
+
// Show cost warning if using premium model
|
|
404
|
+
if (!options.freeOnly) {
|
|
405
|
+
await showCostWarning(model);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const summarizeSpinner = options.stream
|
|
409
|
+
? null
|
|
410
|
+
: createSpinner(`Processing with ${model}...`);
|
|
411
|
+
|
|
412
|
+
summarizeSpinner?.start();
|
|
413
|
+
|
|
414
|
+
try {
|
|
415
|
+
const rawSummary = await withRetry(
|
|
416
|
+
() =>
|
|
417
|
+
summarize(content, {
|
|
418
|
+
model,
|
|
419
|
+
tldr: options.tldr,
|
|
420
|
+
keyPoints: options.keyPoints,
|
|
421
|
+
eli5: options.eli5,
|
|
422
|
+
language: options.language,
|
|
423
|
+
stream: options.stream,
|
|
424
|
+
maxTokens: options.maxTokens,
|
|
425
|
+
customQuestion: options.customQuestion,
|
|
426
|
+
}),
|
|
427
|
+
{
|
|
428
|
+
attempts: 2,
|
|
429
|
+
onRetry: (attempt) => {
|
|
430
|
+
if (summarizeSpinner) {
|
|
431
|
+
summarizeSpinner.text = `Processing with ${model}... (retry ${attempt})`;
|
|
432
|
+
}
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
summarizeSpinner?.succeed("Summary generated successfully");
|
|
438
|
+
|
|
439
|
+
// Clean and format the summary
|
|
440
|
+
const cleanSummary = sanitizeOutputForTerminal(
|
|
441
|
+
sanitizeAIResponse(rawSummary),
|
|
442
|
+
);
|
|
443
|
+
const formattedSummary = formatOutput(cleanSummary, {
|
|
444
|
+
format: getOutputFormat(options),
|
|
445
|
+
url: url,
|
|
446
|
+
customQuestion: options.customQuestion,
|
|
447
|
+
});
|
|
448
|
+
|
|
449
|
+
return { rawSummary: cleanSummary, formattedSummary };
|
|
450
|
+
} catch (error: unknown) {
|
|
451
|
+
summarizeSpinner?.fail("Failed to generate summary");
|
|
452
|
+
throw new GlanceError(
|
|
453
|
+
error instanceof Error ? error.message : String(error),
|
|
454
|
+
ErrorCodes.SUMMARIZE_FAILED,
|
|
455
|
+
"Failed to generate summary. The AI service might be unavailable.",
|
|
456
|
+
true,
|
|
457
|
+
"Try a different model with --model or check your API keys",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Summarize content using AI
|
|
464
|
+
*/
|
|
465
|
+
async function _summarizeContent(
|
|
466
|
+
content: string,
|
|
467
|
+
url: string,
|
|
468
|
+
options: GlanceOptions & { language: string },
|
|
469
|
+
): Promise<string> {
|
|
470
|
+
const model =
|
|
471
|
+
options.model ||
|
|
472
|
+
(await getDefaultModel(undefined, !!options.preferQuality));
|
|
473
|
+
|
|
474
|
+
// Show cost warning if using premium model
|
|
475
|
+
if (!options.freeOnly) {
|
|
476
|
+
await showCostWarning(model);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const summarizeSpinner = options.stream
|
|
480
|
+
? null
|
|
481
|
+
: createSpinner(`Processing with ${model}...`);
|
|
482
|
+
|
|
483
|
+
summarizeSpinner?.start();
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const summary = await withRetry(
|
|
487
|
+
() =>
|
|
488
|
+
summarize(content, {
|
|
489
|
+
model,
|
|
490
|
+
tldr: options.tldr,
|
|
491
|
+
keyPoints: options.keyPoints,
|
|
492
|
+
eli5: options.eli5,
|
|
493
|
+
language: options.language,
|
|
494
|
+
stream: options.stream,
|
|
495
|
+
maxTokens: options.maxTokens,
|
|
496
|
+
customQuestion: options.customQuestion,
|
|
497
|
+
}),
|
|
498
|
+
{
|
|
499
|
+
attempts: 2,
|
|
500
|
+
onRetry: (attempt) => {
|
|
501
|
+
if (summarizeSpinner) {
|
|
502
|
+
summarizeSpinner.text = `Processing with ${model}... (retry ${attempt})`;
|
|
503
|
+
}
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
);
|
|
507
|
+
|
|
508
|
+
summarizeSpinner?.succeed("Summary generated successfully");
|
|
509
|
+
|
|
510
|
+
// Clean and format the summary
|
|
511
|
+
const cleanSummary = sanitizeOutputForTerminal(sanitizeAIResponse(summary));
|
|
512
|
+
const formattedSummary = formatOutput(cleanSummary, {
|
|
513
|
+
format: getOutputFormat(options),
|
|
514
|
+
url: url,
|
|
515
|
+
customQuestion: options.customQuestion,
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
return formattedSummary;
|
|
519
|
+
} catch (error: unknown) {
|
|
520
|
+
summarizeSpinner?.fail("Failed to generate summary");
|
|
521
|
+
throw new GlanceError(
|
|
522
|
+
error instanceof Error ? error.message : String(error),
|
|
523
|
+
ErrorCodes.SUMMARIZE_FAILED,
|
|
524
|
+
"Failed to generate summary. The AI service might be unavailable.",
|
|
525
|
+
true,
|
|
526
|
+
"Try a different model with --model or check your API keys",
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* Handle voice synthesis
|
|
533
|
+
*/
|
|
534
|
+
async function handleVoiceSynthesis(
|
|
535
|
+
cleanedText: string,
|
|
536
|
+
options: GlanceOptions & { language: string },
|
|
537
|
+
): Promise<void> {
|
|
538
|
+
try {
|
|
539
|
+
const synthesizer = createVoiceSynthesizer();
|
|
540
|
+
|
|
541
|
+
if (options.audioOutput) {
|
|
542
|
+
const audioSpinner = createSpinner(
|
|
543
|
+
`šµ Generating audio file: ${options.audioOutput}`,
|
|
544
|
+
);
|
|
545
|
+
audioSpinner.start();
|
|
546
|
+
|
|
547
|
+
// Use the already cleaned text directly
|
|
548
|
+
const result = await synthesizer.synthesizeCleanedText(cleanedText, {
|
|
549
|
+
voice: options.voice,
|
|
550
|
+
language: options.language,
|
|
551
|
+
outputFile: options.audioOutput,
|
|
552
|
+
});
|
|
553
|
+
|
|
554
|
+
if (!result.success) {
|
|
555
|
+
throw new Error(result.error || "Voice synthesis failed");
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
audioSpinner.succeed(`šµ Audio saved to ${options.audioOutput}`);
|
|
559
|
+
} else {
|
|
560
|
+
const readSpinner = createSpinner(
|
|
561
|
+
`š¤ Generating speech and preparing to read aloud...`,
|
|
562
|
+
);
|
|
563
|
+
readSpinner.start();
|
|
564
|
+
|
|
565
|
+
// Use the already cleaned text directly
|
|
566
|
+
const result = await synthesizer.synthesizeCleanedText(cleanedText, {
|
|
567
|
+
voice: options.voice,
|
|
568
|
+
language: options.language,
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
if (!result.success) {
|
|
572
|
+
throw new Error(result.error || "Voice synthesis failed");
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
readSpinner.succeed(`š¤ Reading aloud completed`);
|
|
576
|
+
}
|
|
577
|
+
} catch (error: unknown) {
|
|
578
|
+
logger.error("Voice synthesis failed:", error);
|
|
579
|
+
throw new GlanceError(
|
|
580
|
+
error instanceof Error ? error.message : String(error),
|
|
581
|
+
ErrorCodes.VOICE_SYNTHESIS_FAILED,
|
|
582
|
+
"Failed to synthesize voice. Check your voice settings or try a different voice.",
|
|
583
|
+
false,
|
|
584
|
+
);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Handle screenshot capture
|
|
590
|
+
*/
|
|
591
|
+
async function handleScreenshot(url: string, filename: string): Promise<void> {
|
|
592
|
+
const screenshotSpinner = createSpinner(`Capturing screenshot: ${filename}`);
|
|
593
|
+
screenshotSpinner.start();
|
|
594
|
+
|
|
595
|
+
try {
|
|
596
|
+
await takeScreenshot(url, filename);
|
|
597
|
+
screenshotSpinner.succeed(`Screenshot saved to ${filename}`);
|
|
598
|
+
} catch (error: unknown) {
|
|
599
|
+
screenshotSpinner.fail("Failed to capture screenshot");
|
|
600
|
+
logger.error("Screenshot error:", error);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* Export content to file
|
|
606
|
+
*/
|
|
607
|
+
async function _exportContent(
|
|
608
|
+
content: string,
|
|
609
|
+
filename: string,
|
|
610
|
+
options: { url?: string; isFullContent?: boolean } = {},
|
|
611
|
+
): Promise<void> {
|
|
612
|
+
const extension = getFileExtension(filename);
|
|
613
|
+
const format = extension || "txt";
|
|
614
|
+
|
|
615
|
+
const formattedContent = formatOutput(content, {
|
|
616
|
+
format: format as "terminal" | "markdown" | "json" | "html" | "plain",
|
|
617
|
+
url: options.url || "exported-content",
|
|
618
|
+
isFullContent: options.isFullContent,
|
|
619
|
+
});
|
|
620
|
+
|
|
621
|
+
try {
|
|
622
|
+
await writeFile(filename, formattedContent, "utf-8");
|
|
623
|
+
logger.info(`Content exported to ${filename}`);
|
|
624
|
+
} catch (error: unknown) {
|
|
625
|
+
throw new GlanceError(
|
|
626
|
+
error instanceof Error ? error.message : String(error),
|
|
627
|
+
ErrorCodes.EXPORT_FAILED,
|
|
628
|
+
`Failed to export content to ${filename}`,
|
|
629
|
+
false,
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Cache functionality removed - see next-features/cache-system-plan.md
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* List voices command
|
|
638
|
+
*/
|
|
639
|
+
export async function listVoicesCommand(): Promise<void> {
|
|
640
|
+
try {
|
|
641
|
+
const synthesizer = createVoiceSynthesizer();
|
|
642
|
+
// Get all available voices
|
|
643
|
+
const englishVoices = await synthesizer.listVoices("en");
|
|
644
|
+
const frenchVoices = await synthesizer.listVoices("fr");
|
|
645
|
+
const spanishVoices = await synthesizer.listVoices("es");
|
|
646
|
+
const haitianVoices = await synthesizer.listVoices("ht");
|
|
647
|
+
|
|
648
|
+
console.log(chalk.bold("\nš¤ Available Voices by Language:\n"));
|
|
649
|
+
|
|
650
|
+
if (englishVoices.length > 0) {
|
|
651
|
+
console.log(chalk.bold("šŗšø English:"));
|
|
652
|
+
for (const v of englishVoices) console.log(` ${v}`);
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
if (frenchVoices.length > 0) {
|
|
656
|
+
console.log(chalk.bold("\nš«š· French:"));
|
|
657
|
+
for (const v of frenchVoices) console.log(` ${v}`);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
if (spanishVoices.length > 0) {
|
|
661
|
+
console.log(chalk.bold("\nšŖšø Spanish:"));
|
|
662
|
+
for (const v of spanishVoices) console.log(` ${v}`);
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
if (haitianVoices.length > 0) {
|
|
666
|
+
console.log(chalk.bold("\nšš¹ Haitian Creole:"));
|
|
667
|
+
for (const v of haitianVoices) console.log(` ${v}`);
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
console.log(
|
|
671
|
+
chalk.dim("\nUse with: glance <url> --voice <voice-name> --read"),
|
|
672
|
+
);
|
|
673
|
+
} catch (error: unknown) {
|
|
674
|
+
logger.error("Failed to list voices:", error);
|
|
675
|
+
throw error;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
/**
|
|
680
|
+
* Check services command
|
|
681
|
+
*/
|
|
682
|
+
export async function checkServicesCommand(): Promise<void> {
|
|
683
|
+
const spinner = createSpinner("Detecting available services...");
|
|
684
|
+
spinner.start();
|
|
685
|
+
|
|
686
|
+
try {
|
|
687
|
+
// Use our own validators instead of the old detectServices
|
|
688
|
+
const [ollamaCheck, openaiCheck, geminiCheck] = await Promise.all([
|
|
689
|
+
validateAPIKeys("ollama").catch(() => ({
|
|
690
|
+
valid: false,
|
|
691
|
+
error: "Connection failed",
|
|
692
|
+
})),
|
|
693
|
+
validateAPIKeys("openai").catch(() => ({
|
|
694
|
+
valid: false,
|
|
695
|
+
error: "API key missing",
|
|
696
|
+
})),
|
|
697
|
+
validateAPIKeys("google").catch(() => ({
|
|
698
|
+
valid: false,
|
|
699
|
+
error: "API key missing",
|
|
700
|
+
})),
|
|
701
|
+
]);
|
|
702
|
+
|
|
703
|
+
// Get Ollama models if available
|
|
704
|
+
let ollamaModels: string[] = [];
|
|
705
|
+
if (ollamaCheck.valid) {
|
|
706
|
+
try {
|
|
707
|
+
const endpoint =
|
|
708
|
+
process.env.OLLAMA_ENDPOINT || "http://localhost:11434";
|
|
709
|
+
const response = await fetch(`${endpoint}/api/tags`);
|
|
710
|
+
if (response.ok) {
|
|
711
|
+
const data = await response.json();
|
|
712
|
+
ollamaModels =
|
|
713
|
+
data.models?.map((m: { name: string }) => m.name) || [];
|
|
714
|
+
}
|
|
715
|
+
} catch {
|
|
716
|
+
// Ignore model fetch errors
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
// Check ElevenLabs
|
|
721
|
+
const elevenlabsCheck = {
|
|
722
|
+
valid: !!process.env.ELEVENLABS_API_KEY,
|
|
723
|
+
error: process.env.ELEVENLABS_API_KEY ? undefined : "API key missing",
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
spinner.succeed("Service detection complete");
|
|
727
|
+
|
|
728
|
+
const serviceStatus: ServiceStatus = {
|
|
729
|
+
ollama: {
|
|
730
|
+
available: ollamaCheck.valid,
|
|
731
|
+
models: ollamaModels,
|
|
732
|
+
error: ollamaCheck.error,
|
|
733
|
+
},
|
|
734
|
+
openai: {
|
|
735
|
+
available: openaiCheck.valid,
|
|
736
|
+
error: openaiCheck.error,
|
|
737
|
+
},
|
|
738
|
+
gemini: {
|
|
739
|
+
available: geminiCheck.valid,
|
|
740
|
+
error: geminiCheck.error,
|
|
741
|
+
},
|
|
742
|
+
elevenlabs: {
|
|
743
|
+
available: elevenlabsCheck.valid,
|
|
744
|
+
voices: [],
|
|
745
|
+
error: elevenlabsCheck.error,
|
|
746
|
+
},
|
|
747
|
+
defaultModel: ollamaCheck.valid
|
|
748
|
+
? "ollama"
|
|
749
|
+
: openaiCheck.valid
|
|
750
|
+
? "gpt-4o-mini"
|
|
751
|
+
: geminiCheck.valid
|
|
752
|
+
? "gemini-2.0-flash-exp"
|
|
753
|
+
: "None available",
|
|
754
|
+
priority: "Free services first",
|
|
755
|
+
recommendations: [],
|
|
756
|
+
};
|
|
757
|
+
|
|
758
|
+
// Add recommendations based on missing services
|
|
759
|
+
if (!serviceStatus.ollama.available) {
|
|
760
|
+
serviceStatus.recommendations?.push("Install Ollama for free local AI");
|
|
761
|
+
}
|
|
762
|
+
if (!serviceStatus.openai.available) {
|
|
763
|
+
serviceStatus.recommendations?.push(
|
|
764
|
+
"Add OpenAI API key for premium quality",
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (!serviceStatus.elevenlabs.available) {
|
|
768
|
+
serviceStatus.recommendations?.push(
|
|
769
|
+
"Add ElevenLabs API key for natural voice synthesis",
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
showServiceStatus(serviceStatus);
|
|
774
|
+
} catch (error: unknown) {
|
|
775
|
+
spinner.fail("Service detection failed");
|
|
776
|
+
logger.error("Service detection error:", error);
|
|
777
|
+
|
|
778
|
+
// Show empty status with error message
|
|
779
|
+
const emptyStatus: ServiceStatus = {
|
|
780
|
+
ollama: { available: false, error: "Detection failed" },
|
|
781
|
+
openai: { available: false, error: "Detection failed" },
|
|
782
|
+
gemini: { available: false, error: "Detection failed" },
|
|
783
|
+
elevenlabs: { available: false, error: "Detection failed" },
|
|
784
|
+
recommendations: ["Check your internet connection and try again"],
|
|
785
|
+
};
|
|
786
|
+
|
|
787
|
+
showServiceStatus(emptyStatus);
|
|
788
|
+
throw error;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* List Ollama models command
|
|
794
|
+
*/
|
|
795
|
+
export async function listModelsCommand(): Promise<void> {
|
|
796
|
+
const endpoint = process.env.OLLAMA_ENDPOINT || CONFIG.OLLAMA_ENDPOINT;
|
|
797
|
+
const spinner = createSpinner("Fetching Ollama models...");
|
|
798
|
+
spinner.start();
|
|
799
|
+
|
|
800
|
+
try {
|
|
801
|
+
const response = await fetch(`${endpoint}/api/tags`);
|
|
802
|
+
|
|
803
|
+
if (!response.ok) {
|
|
804
|
+
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
const data = (await response.json()) as {
|
|
808
|
+
models: Array<{
|
|
809
|
+
name: string;
|
|
810
|
+
size?: number;
|
|
811
|
+
details?: { parameter_size?: string };
|
|
812
|
+
}>;
|
|
813
|
+
};
|
|
814
|
+
spinner.succeed("Models fetched successfully");
|
|
815
|
+
|
|
816
|
+
if (!data.models || data.models.length === 0) {
|
|
817
|
+
console.log(chalk.yellow("\nNo models found. Install models with:"));
|
|
818
|
+
console.log(chalk.cyan(" ollama pull llama3"));
|
|
819
|
+
console.log(chalk.cyan(" ollama pull mistral"));
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
console.log(
|
|
824
|
+
chalk.bold(`\nš¦ Available Ollama Models (${data.models.length}):\n`),
|
|
825
|
+
);
|
|
826
|
+
|
|
827
|
+
data.models.forEach(
|
|
828
|
+
(model: {
|
|
829
|
+
name: string;
|
|
830
|
+
size?: number;
|
|
831
|
+
details?: { parameter_size?: string };
|
|
832
|
+
}) => {
|
|
833
|
+
const size = model.size ? `(${(model.size / 1e9).toFixed(1)}GB)` : "";
|
|
834
|
+
console.log(` ${chalk.cyan(model.name)} ${chalk.gray(size)}`);
|
|
835
|
+
if (model.details?.parameter_size) {
|
|
836
|
+
console.log(
|
|
837
|
+
` ${chalk.gray(model.details.parameter_size)} parameters`,
|
|
838
|
+
);
|
|
839
|
+
}
|
|
840
|
+
},
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
console.log(
|
|
844
|
+
chalk.dim("\nUse any model with: glance <url> --model <model-name>"),
|
|
845
|
+
);
|
|
846
|
+
} catch (error: unknown) {
|
|
847
|
+
spinner.fail("Failed to fetch models");
|
|
848
|
+
console.error(
|
|
849
|
+
chalk.red("\nCannot connect to Ollama. Make sure it's running:"),
|
|
850
|
+
);
|
|
851
|
+
console.log(chalk.cyan(" ollama serve"));
|
|
852
|
+
throw error;
|
|
853
|
+
}
|
|
854
|
+
}
|