cli html renderer

This commit is contained in:
sebseb7
2026-04-04 16:51:00 +02:00
parent 2e9a5e9e7f
commit 92b313fa79
6 changed files with 127 additions and 14 deletions

32
package-lock.json generated
View File

@@ -10,6 +10,8 @@
"license": "ISC",
"dependencies": {
"@openrouter/sdk": "^0.11.2",
"chalk": "^5.6.2",
"dotenv": "^17.2.3",
"exa-js": "^2.11.0",
"express": "^5.2.1"
}
@@ -99,6 +101,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chalk": {
"version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/content-disposition": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.0.1.tgz",
@@ -175,9 +189,9 @@
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"version": "17.4.0",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.0.tgz",
"integrity": "sha512-kCKF62fwtzwYm0IGBNjRUjtJgMfGapII+FslMHIjMR5KTnwEmBmWLDRSnc3XSNP8bNy34tekgQyDT0hr7pERRQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
@@ -273,6 +287,18 @@
"zod-to-json-schema": "^3.20.0"
}
},
"node_modules/exa-js/node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",

View File

@@ -13,6 +13,7 @@
"description": "",
"dependencies": {
"@openrouter/sdk": "^0.11.2",
"chalk": "^5.6.2",
"dotenv": "^17.2.3",
"exa-js": "^2.11.0",
"express": "^5.2.1"

View File

@@ -2,6 +2,7 @@ import dotenv from 'dotenv';
import { createClients } from './src/clients.js';
import { getConfig, validateConfig } from './src/config/env.js';
import { createSearchService } from './src/services/searchService.js';
import { renderHTML } from './src/utils/htmlConsoleRenderer.js';
// Load environment variables from .env file
dotenv.config();
@@ -49,13 +50,11 @@ async function runCLI() {
const result = await searchService.search(question);
console.log(result);
console.log('');
console.log('═══════════════════════════════════════════════════════════');
console.log('FINAL ANSWER:');
console.log('═══════════════════════════════════════════════════════════');
console.log(result.fullAnswerHTMLSnippet || result.answer || 'No answer generated');
console.log(renderHTML(result.fullAnswerHTMLSnippet) || 'No answer generated');
console.log('');
if (result.mostRelevantSources && result.mostRelevantSources.length > 0) {

View File

@@ -1,7 +1,9 @@
import { logOpenRouterCall } from './openRouterLogger.js';
function parseResponse(response) {
return { cost: response.usage.cost, data: JSON.parse(response.choices[0].message.content) };
if(!response?.usage?.cost) console.log(response);
return { cost: response?.usage?.cost, data: JSON.parse(response.choices[0].message.content) };
}
export async function summarizeSources({ openrouter, text, question }) {
@@ -71,12 +73,12 @@ export async function summarizeFinalAnswer({ openrouter, text, question }) {
`;
const params = {
model: 'openai/gpt-5.4-mini',
model: 'openai/gpt-oss-120b:nitro',
messages: [
{ role: 'system', content: prompt },
{ role: 'user', content: text },
],
reasoning: { effort: 'none' },
reasoning: { effort: 'low' },
response_format: {
type: 'json_schema',
json_schema: {

View File

@@ -48,7 +48,7 @@ async function fetchDetailedContents({ exa, question, sources, broadcast }) {
);
const content = await exa.getContents([source.url], EXA_CONTENT_OPTIONS(question));
return { url: source.url, content };
return { url: source.url, content, cost: content.costDollars.total };
} catch (error) {
broadcast(`⚠️ Could not fetch content for ${source.url}: ${error.message}`, 'warning');
return { url: source.url, content: null };
@@ -82,7 +82,8 @@ export function createSearchService({ exa, openrouter, broadcast }) {
broadcast(`✅ Found ${result.results.length} results`, 'success', {
count: result.results.length,
});
let cost = result.costDollars.total;
const cost=[];
cost.push({type:'exa_search', amount: result.costDollars.total});
const extractedContent = extractContent(result);
@@ -104,7 +105,7 @@ export function createSearchService({ exa, openrouter, broadcast }) {
question,
});
summary = summaryResult.data;
cost += summaryResult.cost;
cost.push({type:'openrouter_summarize', amount: summaryResult.cost});
} catch (error) {
throw new SearchServiceError('Failed to generate summary', 500, error);
}
@@ -127,6 +128,11 @@ export function createSearchService({ exa, openrouter, broadcast }) {
broadcast('🔧 Enhancing summaries with detailed content...', 'info');
enrichSourcesWithDetails(summary.sources, detailedContents);
for (const detailed of detailedContents) {
if (detailed.cost) {
cost.push({type:'exa_get_content', amount: detailed.cost});
}
}
broadcast('📊 Generating final summary...', 'info');
@@ -138,7 +144,7 @@ export function createSearchService({ exa, openrouter, broadcast }) {
question,
});
finalSummary = finalSummaryResult.data;
cost += finalSummaryResult.cost;
cost.push({type:'openrouter_final_summary', amount: finalSummaryResult.cost});
} catch (error) {
throw new SearchServiceError('Failed to generate final summary', 500, error);
}
@@ -151,7 +157,6 @@ export function createSearchService({ exa, openrouter, broadcast }) {
answerLength: finalSummary.fullAnswerHTMLSnippet?.length || 0,
sourcesCount: finalSummary.mostRelevantSources?.length || 0,
});
console.log(`Total cost of API calls: $${cost.toFixed(6)}`);
return finalSummary;
},

View File

@@ -0,0 +1,80 @@
import chalk from 'chalk';
/**
* Simple HTML to console renderer using chalk for styling.
* Supports: <b>, <i>, <u>, <ul>, <li>, <span style="color:...">, <p>, <div>, <hr/>
*/
// Color mapping for span styles
const colorMap = {
red: chalk.red,
blue: chalk.blue,
green: chalk.green,
yellow: chalk.yellow,
cyan: chalk.cyan,
magenta: chalk.magenta,
white: chalk.white,
gray: chalk.gray,
grey: chalk.gray,
black: chalk.black,
};
/**
* Parse HTML string and convert to styled console output
* @param {string} html - HTML string to render
* @returns {string} - Formatted string with chalk styling
*/
export function renderHTML(html) {
if (!html) return '';
let text = html;
// Handle <hr/> as a line
text = text.replace(/<hr\s*\/?>/gi, '\n' + '─'.repeat(60) + '\n');
// Handle <span style="color:...">content</span>
text = text.replace(/<span\s+style\s*=\s*["']color:\s*(\w+)["']\s*>([\s\S]*?)<\/span>/gi, (match, color, content) => {
const colorFn = colorMap[color.toLowerCase()];
return colorFn ? colorFn(content) : content;
});
// Handle <b>content</b> - bold
text = text.replace(/<b>([\s\S]*?)<\/b>/gi, (match, content) => chalk.bold(content));
// Handle <i>content</i> - italic (dimmed as terminal alternative)
text = text.replace(/<i>([\s\S]*?)<\/i>/gi, (match, content) => chalk.italic(content));
// Handle <u>content</u> - underline
text = text.replace(/<u>([\s\S]*?)<\/u>/gi, (match, content) => chalk.underline(content));
// Handle <li>content</li> - list item with bullet
text = text.replace(/<li>([\s\S]*?)<\/li>/gi, (match, content) => `${content.trim()}\n`);
// Handle <ul>content</ul> - unordered list (just preserve content, items will be formatted)
text = text.replace(/<ul>([\s\S]*?)<\/ul>/gi, (match, content) => `${content}\n`);
// Handle <p>content</p> - paragraph with spacing
text = text.replace(/<p>([\s\S]*?)<\/p>/gi, (match, content) => `${content.trim()}\n\n`);
// Handle <div>content</div> - block with newline
text = text.replace(/<div>([\s\S]*?)<\/div>/gi, (match, content) => `${content.trim()}\n\n`);
// Clean up any remaining HTML tags that weren't processed
text = text.replace(/<[^>]+>/g, '');
// Clean up excessive blank lines (more than 2 consecutive newlines)
text = text.replace(/\n{3,}/g, '\n\n');
// Trim extra whitespace at start and end
text = text.trim();
return text;
}
/**
* Render HTML and print to console
* @param {string} html - HTML string to render
*/
export function printHTML(html) {
console.log(renderHTML(html));
}