add vault encryption docs to browser-toys portal, computed page intelligence
browser-toys.html: new "Encrypting your chats" section documenting UncloseVault AES-256 encryption of conversations, settings, and API keys. How to enable, what gets encrypted, how it works, automatic plaintext migration. page-intelligence.js: deterministic page analysis (zero API calls) extracting structured data, heading outline, reading metrics, readability score, code detection, link topology, media inventory, forms, tables, entity patterns. Integrated into greeting flow alongside LLM classification.
This commit is contained in:
parent
a898a759f4
commit
4555e8bf98
3 changed files with 577 additions and 8 deletions
|
|
@ -163,10 +163,37 @@ curl -s https://uncloseai.com/downloads/SHA256SUMS | grep chrome | sha256sum -c<
|
|||
<ul>
|
||||
<li><strong>Chat history</strong> stays in your browser's localStorage, scoped per domain. Conversations on <code>example.com</code> are separate from conversations on <code>github.com</code>. Clear a site's localStorage to clear its chat history.</li>
|
||||
<li><strong>Settings</strong> (model selection, language preference, voice) are stored in localStorage.</li>
|
||||
<li><strong>Secrets</strong> (custom API keys, if you configure them) are protected by the optional UncloseVault, which encrypts with AES before writing to localStorage.</li>
|
||||
<li><strong>Secrets</strong> (custom API keys, if you configure them) are protected by UncloseVault.</li>
|
||||
<li><strong>The extension itself</strong> stores one boolean value: enabled on/off. That is the only data the extension touches.</li>
|
||||
</ul>
|
||||
<p>No data leaves your browser except the messages you choose to send to the public AI endpoints at <code>hermes.ai.unturf.com</code>, <code>qwen.ai.unturf.com</code>, and <code>speech.ai.unturf.com</code>.</p>
|
||||
<p>No data leaves your browser except the messages you choose to send to the public machine learning endpoints at <code>hermes.ai.unturf.com</code>, <code>qwen.ai.unturf.com</code>, and <code>speech.ai.unturf.com</code>.</p>
|
||||
|
||||
<h3 id="encryption">Encrypting your chats</h3>
|
||||
<p>UncloseVault encrypts your conversation history, settings, and API keys with AES-256 behind a password you choose. Everything stays in localStorage on your device, encrypted at rest.</p>
|
||||
<h4>How to enable</h4>
|
||||
<ol>
|
||||
<li>Click the <strong>uncloseai.</strong> button on any page to open the chat</li>
|
||||
<li>Open <strong>Settings</strong> (gear icon)</li>
|
||||
<li>Under <strong>Vault</strong>, click <strong>Create Vault</strong></li>
|
||||
<li>Choose a password (minimum 8 characters)</li>
|
||||
<li>Your settings, API keys, and all conversation history are now encrypted</li>
|
||||
</ol>
|
||||
<h4>What gets encrypted</h4>
|
||||
<ul>
|
||||
<li><strong>Conversation history</strong> — all chat messages, per domain, encrypted before writing to localStorage</li>
|
||||
<li><strong>Settings</strong> — model, voice, language preferences</li>
|
||||
<li><strong>API keys</strong> — custom endpoint credentials</li>
|
||||
<li><strong>Unsandbox keys</strong> — code execution credentials</li>
|
||||
</ul>
|
||||
<h4>How it works</h4>
|
||||
<ul>
|
||||
<li>AES-256 encryption via CryptoJS</li>
|
||||
<li>Password-derived key with device-specific salt (SHA-256)</li>
|
||||
<li>Session persistence across page navigations (encrypted session key in localStorage)</li>
|
||||
<li>Plaintext conversations are automatically migrated to encrypted storage when you create or unlock the vault</li>
|
||||
<li>Lock the vault at any time from Settings to end your session</li>
|
||||
</ul>
|
||||
<p>Without the vault password, conversation history and settings are unreadable. If you forget your password, the encrypted data cannot be recovered.</p>
|
||||
|
||||
<h3 id="security">Security</h3>
|
||||
<p>The extension code has been audited across 6 security categories with 49 automated tests. Findings: 0 critical, 0 high, 0 medium, 0 low, 3 informational.</p>
|
||||
|
|
@ -223,6 +250,7 @@ node scripts/build.js # outputs zips to dist/</code></pre>
|
|||
</li>
|
||||
<li><a href="#how-it-works">How it works</a></li>
|
||||
<li><a href="#storage">Storage & Privacy</a></li>
|
||||
<li><a href="#encryption">Encrypting Your Chats</a></li>
|
||||
<li><a href="#security">Security Audit</a></li>
|
||||
<li><a href="#source">Source Code</a></li>
|
||||
</ul>
|
||||
|
|
|
|||
530
public/src/page-intelligence.js
Normal file
530
public/src/page-intelligence.js
Normal file
|
|
@ -0,0 +1,530 @@
|
|||
// page-intelligence.js: Computed page metrics for augmenting LLM context
|
||||
//
|
||||
// Pure JS computation, zero API calls, instant execution.
|
||||
// Each analyzer extracts deterministic ground truth from the DOM or page content.
|
||||
// The LLM gets precise measurements instead of guessing from raw text.
|
||||
//
|
||||
// Copyright (C) 2025 TimeHexOn & foxhop & russell@unturf
|
||||
// https://www.permacomputer.com
|
||||
//
|
||||
// This program is free software: you can redistribute it and/or modify
|
||||
// it under the terms of the GNU Affero General Public License as published by
|
||||
// the Free Software Foundation, either version 3 of the License, or
|
||||
// (at your option) any later version.
|
||||
//
|
||||
// This program is distributed in the hope that it will be useful,
|
||||
// but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
// GNU Affero General Public License for more details.
|
||||
//
|
||||
// You should have received a copy of the GNU Affero General Public License
|
||||
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
// --- STRUCTURED DATA EXTRACTOR ---
|
||||
// Open Graph, JSON-LD, Twitter Cards, canonical URI, author, publish time
|
||||
export function extractStructuredData() {
|
||||
const result = {
|
||||
openGraph: {},
|
||||
jsonLd: [],
|
||||
twitterCards: {},
|
||||
canonical: null,
|
||||
author: null,
|
||||
publishedTime: null,
|
||||
};
|
||||
|
||||
// Open Graph: meta[property^="og:"]
|
||||
for (const meta of document.querySelectorAll('meta[property^="og:"]')) {
|
||||
const key = meta.getAttribute("property").replace("og:", "");
|
||||
result.openGraph[key] = meta.getAttribute("content");
|
||||
}
|
||||
|
||||
// JSON-LD: script[type="application/ld+json"]
|
||||
for (const script of document.querySelectorAll('script[type="application/ld+json"]')) {
|
||||
try {
|
||||
result.jsonLd.push(JSON.parse(script.textContent));
|
||||
} catch (e) {
|
||||
// Malformed JSON-LD, skip
|
||||
}
|
||||
}
|
||||
|
||||
// Twitter Cards: meta[name^="twitter:"]
|
||||
for (const meta of document.querySelectorAll('meta[name^="twitter:"]')) {
|
||||
const key = meta.getAttribute("name").replace("twitter:", "");
|
||||
result.twitterCards[key] = meta.getAttribute("content");
|
||||
}
|
||||
|
||||
// Canonical URI
|
||||
const canonical = document.querySelector('link[rel="canonical"]');
|
||||
if (canonical) result.canonical = canonical.getAttribute("href");
|
||||
|
||||
// Author meta
|
||||
const authorMeta = document.querySelector('meta[name="author"]');
|
||||
if (authorMeta) result.author = authorMeta.getAttribute("content");
|
||||
|
||||
// Article published time (Open Graph article extension)
|
||||
const publishedTime = document.querySelector('meta[property="article:published_time"]');
|
||||
if (publishedTime) result.publishedTime = publishedTime.getAttribute("content");
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- HEADING OUTLINE ---
|
||||
// h1-h6 hierarchy as compact table of contents
|
||||
export function extractHeadingOutline() {
|
||||
const headings = [];
|
||||
for (const el of document.querySelectorAll("h1, h2, h3, h4, h5, h6")) {
|
||||
const level = Number.parseInt(el.tagName.charAt(1), 10);
|
||||
const text = el.textContent.trim();
|
||||
if (text) {
|
||||
headings.push({ level, text: text.substring(0, 100) });
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
// --- READING METRICS ---
|
||||
// Word count, sentence count, paragraph count, reading time at 238 wpm (Brysbaert 2019)
|
||||
export function computeReadingMetrics(pageContent) {
|
||||
if (!pageContent) return { wordCount: 0, sentenceCount: 0, paragraphCount: 0, readingTimeMinutes: 0 };
|
||||
|
||||
// Strip markdown link syntax and metadata labels for cleaner count
|
||||
const cleanText = pageContent.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\*\*[^*]+\*\*:\s*/g, "");
|
||||
|
||||
const words = cleanText.match(/\b\w+\b/g) || [];
|
||||
const wordCount = words.length;
|
||||
const sentences = cleanText.match(/[^.!?]+[.!?]+/g) || [];
|
||||
const sentenceCount = Math.max(sentences.length, 1);
|
||||
const paragraphs = cleanText.split(/\n\s*\n/).filter((p) => p.trim().length > 0);
|
||||
const paragraphCount = paragraphs.length;
|
||||
const readingTimeMinutes = Math.ceil(wordCount / 238);
|
||||
|
||||
return { wordCount, sentenceCount, paragraphCount, readingTimeMinutes };
|
||||
}
|
||||
|
||||
// --- SYLLABLE COUNTER ---
|
||||
// Vowel-group heuristic for English words (~85% accuracy)
|
||||
function countSyllables(word) {
|
||||
const w = word.toLowerCase().replace(/[^a-z]/g, "");
|
||||
if (w.length <= 2) return 1;
|
||||
|
||||
let count = 0;
|
||||
const vowels = "aeiouy";
|
||||
let prevVowel = false;
|
||||
|
||||
for (let i = 0; i < w.length; i++) {
|
||||
const isVowel = vowels.includes(w[i]);
|
||||
if (isVowel && !prevVowel) count++;
|
||||
prevVowel = isVowel;
|
||||
}
|
||||
|
||||
// Silent trailing "e" (but not "le" as in "table")
|
||||
if (w.endsWith("e") && !w.endsWith("le") && count > 1) count--;
|
||||
|
||||
return Math.max(count, 1);
|
||||
}
|
||||
|
||||
// --- READABILITY SCORE ---
|
||||
// Flesch-Kincaid Grade Level: 0.39 * (words/sentences) + 11.8 * (syllables/words) - 15.59
|
||||
export function computeReadabilityScore(pageContent) {
|
||||
if (!pageContent) return { fleschKincaidGrade: 0, syllableCount: 0, wordsPerSentence: 0, syllablesPerWord: 0 };
|
||||
|
||||
const cleanText = pageContent.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\*\*[^*]+\*\*:\s*/g, "");
|
||||
|
||||
const words = cleanText.match(/\b[a-zA-Z]+\b/g) || [];
|
||||
const wordCount = words.length;
|
||||
if (wordCount === 0) return { fleschKincaidGrade: 0, syllableCount: 0, wordsPerSentence: 0, syllablesPerWord: 0 };
|
||||
|
||||
const sentences = cleanText.match(/[^.!?]+[.!?]+/g) || [];
|
||||
const sentenceCount = Math.max(sentences.length, 1);
|
||||
|
||||
let syllableCount = 0;
|
||||
for (const word of words) {
|
||||
syllableCount += countSyllables(word);
|
||||
}
|
||||
|
||||
const wordsPerSentence = wordCount / sentenceCount;
|
||||
const syllablesPerWord = syllableCount / wordCount;
|
||||
const grade = 0.39 * wordsPerSentence + 11.8 * syllablesPerWord - 15.59;
|
||||
|
||||
return {
|
||||
fleschKincaidGrade: Math.round(grade * 10) / 10,
|
||||
syllableCount,
|
||||
wordsPerSentence: Math.round(wordsPerSentence * 10) / 10,
|
||||
syllablesPerWord: Math.round(syllablesPerWord * 100) / 100,
|
||||
};
|
||||
}
|
||||
|
||||
// --- CODE DETECTION ---
|
||||
// Code blocks, language detection from class patterns, code-to-prose ratio
|
||||
export function detectCodeBlocks() {
|
||||
const result = {
|
||||
codeBlockCount: 0,
|
||||
languages: [],
|
||||
codeLineCount: 0,
|
||||
codeToProseRatio: 0,
|
||||
inlineCodeCount: 0,
|
||||
};
|
||||
|
||||
const languageSet = new Set();
|
||||
const preCodeBlocks = document.querySelectorAll("pre code");
|
||||
result.codeBlockCount = preCodeBlocks.length;
|
||||
|
||||
for (const block of preCodeBlocks) {
|
||||
result.codeLineCount += (block.textContent.match(/\n/g) || []).length + 1;
|
||||
|
||||
for (const cls of block.classList) {
|
||||
const langMatch = cls.match(/^(?:language-|lang-)(.+)$/);
|
||||
if (langMatch && langMatch[1] !== "none" && langMatch[1] !== "plaintext") {
|
||||
languageSet.add(langMatch[1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Inline code: code elements not inside pre
|
||||
const allCode = document.querySelectorAll("code");
|
||||
result.inlineCodeCount = Array.from(allCode).filter((el) => !el.parentElement || el.parentElement.tagName !== "PRE").length;
|
||||
|
||||
result.languages = Array.from(languageSet);
|
||||
|
||||
// Code-to-prose ratio by character count
|
||||
const bodyText = document.body?.textContent || "";
|
||||
const codeText = Array.from(preCodeBlocks)
|
||||
.map((b) => b.textContent)
|
||||
.join("");
|
||||
if (bodyText.length > 0) {
|
||||
result.codeToProseRatio = Math.round((codeText.length / bodyText.length) * 100) / 100;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- LINK TOPOLOGY ---
|
||||
// Internal vs external links, top 5 external domains by frequency
|
||||
export function analyzeLinkTopology() {
|
||||
const result = {
|
||||
internalCount: 0,
|
||||
externalCount: 0,
|
||||
totalCount: 0,
|
||||
topExternalDomains: [],
|
||||
};
|
||||
|
||||
const currentHost = window.location.hostname;
|
||||
const domainCounts = {};
|
||||
|
||||
for (const link of document.querySelectorAll("a[href]")) {
|
||||
result.totalCount++;
|
||||
try {
|
||||
const uri = new URL(link.href, window.location.origin);
|
||||
if (uri.hostname === currentHost || uri.hostname === "") {
|
||||
result.internalCount++;
|
||||
} else {
|
||||
result.externalCount++;
|
||||
domainCounts[uri.hostname] = (domainCounts[uri.hostname] || 0) + 1;
|
||||
}
|
||||
} catch (e) {
|
||||
result.internalCount++;
|
||||
}
|
||||
}
|
||||
|
||||
result.topExternalDomains = Object.entries(domainCounts)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 5)
|
||||
.map(([domain, count]) => ({ domain, count }));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// --- USER CONTEXT ---
|
||||
// Timezone, locale, local time, referrer, device type
|
||||
export function captureUserContext() {
|
||||
const width = window.innerWidth;
|
||||
let deviceType = "desktop";
|
||||
if (width < 768) deviceType = "mobile";
|
||||
else if (width < 1024) deviceType = "tablet";
|
||||
|
||||
return {
|
||||
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "unknown",
|
||||
language: navigator.language || "unknown",
|
||||
localTime: new Date().toLocaleTimeString("en-US", { hour12: false }),
|
||||
referrer: document.referrer || null,
|
||||
deviceType,
|
||||
};
|
||||
}
|
||||
|
||||
// --- MEDIA INVENTORY ---
|
||||
// Image count, alt text coverage, video embeds (youtube/vimeo), audio elements
|
||||
export function inventoryMedia() {
|
||||
const images = document.querySelectorAll("img");
|
||||
const imagesWithAlt = Array.from(images).filter((img) => img.alt && img.alt.trim().length > 0).length;
|
||||
|
||||
const videoEmbeds = [];
|
||||
for (const iframe of document.querySelectorAll("iframe")) {
|
||||
const src = iframe.src || "";
|
||||
if (src.includes("youtube.com") || src.includes("youtu.be")) {
|
||||
videoEmbeds.push({ type: "youtube", src });
|
||||
} else if (src.includes("vimeo.com")) {
|
||||
videoEmbeds.push({ type: "vimeo", src });
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
imageCount: images.length,
|
||||
imagesWithAlt,
|
||||
altCoverage: images.length > 0 ? Math.round((imagesWithAlt / images.length) * 100) : 0,
|
||||
videoEmbeds,
|
||||
videoElements: document.querySelectorAll("video").length,
|
||||
audioElements: document.querySelectorAll("audio").length,
|
||||
};
|
||||
}
|
||||
|
||||
// --- FORM DETECTION ---
|
||||
// Classify forms: login, search, contact, upload, checkout, generic
|
||||
export function detectForms() {
|
||||
return Array.from(document.querySelectorAll("form"))
|
||||
.slice(0, 10)
|
||||
.map((form) => {
|
||||
const inputs = Array.from(form.querySelectorAll("input, textarea, select"));
|
||||
const inputTypes = inputs.map((inp) => inp.type || inp.tagName.toLowerCase());
|
||||
|
||||
let classification = "generic";
|
||||
if (inputTypes.includes("password")) {
|
||||
classification = "login";
|
||||
} else if (inputTypes.includes("search") || form.getAttribute("role") === "search") {
|
||||
classification = "search";
|
||||
} else if (inputTypes.includes("file")) {
|
||||
classification = "upload";
|
||||
} else if (inputTypes.includes("email") && inputTypes.includes("textarea")) {
|
||||
classification = "contact";
|
||||
} else if (
|
||||
inputTypes.some((t) => t === "tel") &&
|
||||
(form.innerHTML.toLowerCase().includes("card") ||
|
||||
form.innerHTML.toLowerCase().includes("payment") ||
|
||||
form.innerHTML.toLowerCase().includes("cvv"))
|
||||
) {
|
||||
classification = "checkout";
|
||||
}
|
||||
|
||||
return { classification, fieldCount: inputs.length };
|
||||
});
|
||||
}
|
||||
|
||||
// --- TABLE EXTRACTION ---
|
||||
// Headers + row counts for up to 5 tables
|
||||
export function extractTables() {
|
||||
return Array.from(document.querySelectorAll("table"))
|
||||
.slice(0, 5)
|
||||
.map((table) => {
|
||||
const headerRow = table.querySelector("thead tr") || table.querySelector("tr");
|
||||
const headers = headerRow
|
||||
? Array.from(headerRow.querySelectorAll("th")).map((cell) => cell.textContent.trim().substring(0, 50))
|
||||
: [];
|
||||
const bodyRows = table.querySelectorAll("tbody tr").length;
|
||||
const totalRows = table.querySelectorAll("tr").length;
|
||||
const rowCount = bodyRows > 0 ? bodyRows : Math.max(totalRows - (headers.length > 0 ? 1 : 0), 0);
|
||||
|
||||
return { headers, rowCount };
|
||||
});
|
||||
}
|
||||
|
||||
// --- ENTITY PATTERNS ---
|
||||
// Regex extraction: emails, prices, dates, versions, IPs, percentages
|
||||
export function extractEntityPatterns(pageContent) {
|
||||
if (!pageContent) return {};
|
||||
|
||||
const dedupe = (matches) => [...new Set(matches || [])].slice(0, 10);
|
||||
|
||||
const result = {
|
||||
emails: dedupe(pageContent.match(/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/g)),
|
||||
prices: dedupe(pageContent.match(/\$\d+(?:,\d{3})*(?:\.\d{2})?/g)),
|
||||
dates: dedupe(pageContent.match(/\d{4}-\d{2}-\d{2}/g)),
|
||||
versions: dedupe(pageContent.match(/v\d+\.\d+(?:\.\d+)?(?:-[a-zA-Z0-9.]+)?/g)),
|
||||
ipAddresses: dedupe(pageContent.match(/\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b/g)),
|
||||
percentages: dedupe(pageContent.match(/\d+(?:\.\d+)?%/g)),
|
||||
};
|
||||
|
||||
// Only return non-empty arrays
|
||||
return Object.fromEntries(Object.entries(result).filter(([_, v]) => v.length > 0));
|
||||
}
|
||||
|
||||
// --- ORCHESTRATOR ---
|
||||
// Runs all analyzers with try/catch per analyzer. One failure does not block others.
|
||||
export function computePageIntelligence(pageContent) {
|
||||
const intelligence = {};
|
||||
|
||||
// DOM-based analyzers (read from document, no arguments)
|
||||
const domAnalyzers = {
|
||||
structuredData: extractStructuredData,
|
||||
headingOutline: extractHeadingOutline,
|
||||
codeBlocks: detectCodeBlocks,
|
||||
linkTopology: analyzeLinkTopology,
|
||||
userContext: captureUserContext,
|
||||
media: inventoryMedia,
|
||||
forms: detectForms,
|
||||
tables: extractTables,
|
||||
};
|
||||
|
||||
// Text-based analyzers (receive pageContent string)
|
||||
const textAnalyzers = {
|
||||
readingMetrics: computeReadingMetrics,
|
||||
readability: computeReadabilityScore,
|
||||
entityPatterns: extractEntityPatterns,
|
||||
};
|
||||
|
||||
for (const [key, fn] of Object.entries(domAnalyzers)) {
|
||||
try {
|
||||
intelligence[key] = fn();
|
||||
} catch (e) {
|
||||
console.warn(`Page intelligence: ${key} failed:`, e);
|
||||
intelligence[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, fn] of Object.entries(textAnalyzers)) {
|
||||
try {
|
||||
intelligence[key] = fn(pageContent);
|
||||
} catch (e) {
|
||||
console.warn(`Page intelligence: ${key} failed:`, e);
|
||||
intelligence[key] = null;
|
||||
}
|
||||
}
|
||||
|
||||
return intelligence;
|
||||
}
|
||||
|
||||
// Grade level description for readability scores
|
||||
function describeGradeLevel(grade) {
|
||||
if (grade < 1) return "kindergarten";
|
||||
if (grade <= 5) return "elementary";
|
||||
if (grade <= 8) return `${Math.round(grade)}th grade`;
|
||||
if (grade <= 12) return "high school";
|
||||
if (grade <= 16) return "college level";
|
||||
return "graduate level";
|
||||
}
|
||||
|
||||
// --- FORMATTER ---
|
||||
// Token-efficient: only includes non-empty sections.
|
||||
// Format: "Label: value | value | value" per line.
|
||||
export function formatPageIntelligence(intelligence) {
|
||||
if (!intelligence) return "";
|
||||
|
||||
const lines = [];
|
||||
|
||||
// Reading Metrics
|
||||
const rm = intelligence.readingMetrics;
|
||||
if (rm && rm.wordCount > 0) {
|
||||
lines.push(`Reading: ${rm.wordCount.toLocaleString()} words | ${rm.sentenceCount} sentences | ${rm.paragraphCount} paragraphs | ~${rm.readingTimeMinutes} min read`);
|
||||
}
|
||||
|
||||
// Readability
|
||||
const rs = intelligence.readability;
|
||||
if (rs && rs.fleschKincaidGrade > 0) {
|
||||
lines.push(`Readability: Flesch-Kincaid grade ${rs.fleschKincaidGrade} (${describeGradeLevel(rs.fleschKincaidGrade)})`);
|
||||
}
|
||||
|
||||
// Heading Outline (compact, max 8)
|
||||
const ho = intelligence.headingOutline;
|
||||
if (ho && ho.length > 0) {
|
||||
const display = ho
|
||||
.slice(0, 8)
|
||||
.map((h) => `${" ".repeat(h.level - 1)}h${h.level}: ${h.text}`)
|
||||
.join("\n ");
|
||||
const suffix = ho.length > 8 ? `\n +${ho.length - 8} more` : "";
|
||||
lines.push(`Headings:\n ${display}${suffix}`);
|
||||
}
|
||||
|
||||
// Code Detection
|
||||
const cd = intelligence.codeBlocks;
|
||||
if (cd && cd.codeBlockCount > 0) {
|
||||
const langStr = cd.languages.length > 0 ? ` (${cd.languages.join(", ")})` : "";
|
||||
const ratio = Math.round(cd.codeToProseRatio * 100);
|
||||
lines.push(`Code: ${cd.codeBlockCount} blocks${langStr} | ${cd.codeLineCount} lines | ${ratio}% code-to-prose`);
|
||||
}
|
||||
|
||||
// Link Topology
|
||||
const lt = intelligence.linkTopology;
|
||||
if (lt && lt.totalCount > 0) {
|
||||
let linkLine = `Links: ${lt.internalCount} internal, ${lt.externalCount} external`;
|
||||
if (lt.topExternalDomains.length > 0) {
|
||||
const domains = lt.topExternalDomains.map((d) => `${d.domain}(${d.count})`).join(", ");
|
||||
linkLine += ` | top: ${domains}`;
|
||||
}
|
||||
lines.push(linkLine);
|
||||
}
|
||||
|
||||
// Media Inventory
|
||||
const mi = intelligence.media;
|
||||
if (mi && (mi.imageCount > 0 || mi.videoEmbeds.length > 0 || mi.audioElements > 0)) {
|
||||
const parts = [];
|
||||
if (mi.imageCount > 0) parts.push(`${mi.imageCount} images (${mi.altCoverage}% with alt text)`);
|
||||
if (mi.videoEmbeds.length > 0) {
|
||||
const types = [...new Set(mi.videoEmbeds.map((v) => v.type))].join(", ");
|
||||
parts.push(`${mi.videoEmbeds.length} ${types} embed${mi.videoEmbeds.length > 1 ? "s" : ""}`);
|
||||
}
|
||||
if (mi.videoElements > 0) parts.push(`${mi.videoElements} video element${mi.videoElements > 1 ? "s" : ""}`);
|
||||
if (mi.audioElements > 0) parts.push(`${mi.audioElements} audio element${mi.audioElements > 1 ? "s" : ""}`);
|
||||
lines.push(`Media: ${parts.join(" | ")}`);
|
||||
}
|
||||
|
||||
// Structured Data
|
||||
const sd = intelligence.structuredData;
|
||||
if (sd) {
|
||||
const parts = [];
|
||||
if (sd.openGraph.type) parts.push(`og:type=${sd.openGraph.type}`);
|
||||
if (sd.author) parts.push(`author=${sd.author}`);
|
||||
if (sd.publishedTime) parts.push(`published=${sd.publishedTime.split("T")[0]}`);
|
||||
if (sd.canonical) parts.push(`canonical=${sd.canonical}`);
|
||||
if (sd.jsonLd.length > 0) {
|
||||
const types = sd.jsonLd.map((j) => j["@type"]).filter(Boolean);
|
||||
if (types.length > 0) parts.push(`json-ld: ${types.join(", ")}`);
|
||||
}
|
||||
if (parts.length > 0) lines.push(`Structured Data: ${parts.join(" | ")}`);
|
||||
}
|
||||
|
||||
// Entity Patterns
|
||||
const ep = intelligence.entityPatterns;
|
||||
if (ep && Object.keys(ep).length > 0) {
|
||||
const parts = [];
|
||||
if (ep.prices?.length > 0) parts.push(`prices: ${ep.prices.join(", ")}`);
|
||||
if (ep.versions?.length > 0) parts.push(`versions: ${ep.versions.join(", ")}`);
|
||||
if (ep.dates?.length > 0) parts.push(`dates: ${ep.dates.join(", ")}`);
|
||||
if (ep.emails?.length > 0) parts.push(`emails: ${ep.emails.length} found`);
|
||||
if (ep.ipAddresses?.length > 0) parts.push(`IPs: ${ep.ipAddresses.join(", ")}`);
|
||||
if (ep.percentages?.length > 0) parts.push(`percentages: ${ep.percentages.join(", ")}`);
|
||||
if (parts.length > 0) lines.push(`Entities: ${parts.join(" | ")}`);
|
||||
}
|
||||
|
||||
// Forms
|
||||
const forms = intelligence.forms;
|
||||
if (forms && forms.length > 0) {
|
||||
const formTypes = forms.map((f) => f.classification).join(", ");
|
||||
lines.push(`Forms: ${forms.length} form${forms.length > 1 ? "s" : ""} (${formTypes})`);
|
||||
}
|
||||
|
||||
// Tables
|
||||
const tables = intelligence.tables;
|
||||
if (tables && tables.length > 0) {
|
||||
const tableDescs = tables.map((t) => {
|
||||
const headerStr = t.headers.length > 0 ? t.headers.join(", ") : "no headers";
|
||||
return `(${headerStr} | ${t.rowCount} rows)`;
|
||||
});
|
||||
lines.push(`Tables: ${tables.length} table${tables.length > 1 ? "s" : ""} ${tableDescs.join(", ")}`);
|
||||
}
|
||||
|
||||
// User Context
|
||||
const uc = intelligence.userContext;
|
||||
if (uc) {
|
||||
const parts = [uc.timezone, uc.language, uc.deviceType];
|
||||
if (uc.referrer) {
|
||||
try {
|
||||
parts.push(`from: ${new URL(uc.referrer).hostname}`);
|
||||
} catch (e) {
|
||||
parts.push(`from: ${uc.referrer}`);
|
||||
}
|
||||
}
|
||||
lines.push(`User: ${parts.join(" | ")}`);
|
||||
}
|
||||
|
||||
if (lines.length === 0) return "";
|
||||
|
||||
return `\nCOMPUTED PAGE METRICS (deterministic):\n${lines.join("\n")}\n`;
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import { marked } from "https://cdn.jsdelivr.net/npm/marked/lib/marked.esm.js";
|
|||
import hljs from "https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/es/highlight.min.js";
|
||||
import { setSystemMessageAppend } from "./config.js";
|
||||
import { extractWebpageContent, buildPageAnalysisPrompt, parsePageAnalysis, buildAnalysisContext } from "./content.js";
|
||||
import { computePageIntelligence, formatPageIntelligence } from "./page-intelligence.js";
|
||||
import {
|
||||
detectCurrentTheme,
|
||||
getThemeColors,
|
||||
|
|
@ -2331,9 +2332,18 @@ You have complete knowledge of this page content and can reference any details,
|
|||
const pageContent = await extractWebpageContent();
|
||||
const pageTitle = document.title || window.location.hostname;
|
||||
|
||||
// PRE-INFERENCE: classify the page before greeting
|
||||
// This extracts structured intelligence (type, tone, entities, dates)
|
||||
// so the greeting seeds the conversation with precision, not guesswork.
|
||||
// COMPUTED INTELLIGENCE: instant, deterministic, zero API calls
|
||||
// Extracts ground truth measurements from DOM and content
|
||||
let computedContext = "";
|
||||
try {
|
||||
const computedIntel = computePageIntelligence(pageContent);
|
||||
computedContext = formatPageIntelligence(computedIntel);
|
||||
console.log("Computed page intelligence:", computedIntel);
|
||||
} catch (e) {
|
||||
console.warn("Computed page intelligence failed:", e);
|
||||
}
|
||||
|
||||
// LLM CLASSIFICATION: async pre-inference for subjective analysis
|
||||
introMsg.innerHTML =
|
||||
'<em style="color: #6c757d;">Reading page...</em>';
|
||||
let analysisContext = "";
|
||||
|
|
@ -2377,12 +2387,13 @@ You have complete knowledge of this page content and can reference any details,
|
|||
: "";
|
||||
|
||||
// Greeting instructions vary by page type
|
||||
const typeGuidance = analysisContext
|
||||
? `\nUse the PAGE INTELLIGENCE to shape your greeting style. For lyrics, discuss the music and emotions. For recipes, mention ingredients and techniques. For news, note how recent or dated the content is. For code or CI logs, reference the technology and build status. For manifestos or philosophy, engage with the ideas. For wikis, acknowledge the knowledge domain. For emails, understand the communication context. Match your tone to what the page actually is. Never mention "page intelligence" or "pre-analysis" to the user.\n`
|
||||
const hasIntelligence = computedContext || analysisContext;
|
||||
const typeGuidance = hasIntelligence
|
||||
? `\nUse the PAGE INTELLIGENCE and COMPUTED PAGE METRICS to shape your greeting style. Reference specific numbers (word count, reading time, code languages) naturally. For lyrics, discuss the music and emotions. For recipes, mention ingredients and techniques. For news, note how recent or dated the content is. For code or CI logs, reference the technology and build status. For manifestos or philosophy, engage with the ideas. For wikis, acknowledge the knowledge domain. For emails, understand the communication context. Match your tone to what the page actually is. Never mention "page intelligence", "computed metrics", or "pre-analysis" to the user.\n`
|
||||
: "";
|
||||
|
||||
const introSystemPrompt = `${getUIText("systemPromptIntro")}
|
||||
${analysisContext}${typeGuidance}
|
||||
${computedContext}${analysisContext}${typeGuidance}
|
||||
PAGE INFORMATION:
|
||||
Title: "${pageTitle}"
|
||||
URI: ${window.location.href}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue