add page pre-inference and ago.js port for superhuman greeting context
This commit is contained in:
parent
6417d3853f
commit
80e20a4d85
3 changed files with 299 additions and 6 deletions
164
public/src/ago.js
Normal file
164
public/src/ago.js
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
// PUBLIC DOMAIN - NO LICENSE, NO WARRANTY
|
||||
// Copyright 2025 TimeHexOn & foxhop & russell@unturf
|
||||
// https://www.permacomputer.com
|
||||
//
|
||||
// JavaScript port of russell ballestrini's ago (https://pypi.org/project/ago/)
|
||||
// "he might have died by now but his code should last."
|
||||
|
||||
// Define time units as a data structure
|
||||
// Each unit contains:
|
||||
// - name: full name of the unit (e.g., "year")
|
||||
// - abbr: abbreviation (e.g., "y")
|
||||
// - seconds: number of seconds in this unit (for reference)
|
||||
// - extract: function to extract this unit's value from a delta object {days, seconds, microseconds}
|
||||
const TIME_UNITS = [
|
||||
{
|
||||
name: "year",
|
||||
abbr: "y",
|
||||
seconds: 31536000,
|
||||
extract: (td) => Math.floor(td.days / 365),
|
||||
},
|
||||
{
|
||||
name: "day",
|
||||
abbr: "d",
|
||||
seconds: 86400,
|
||||
extract: (td) => td.days % 365,
|
||||
},
|
||||
{
|
||||
name: "hour",
|
||||
abbr: "h",
|
||||
seconds: 3600,
|
||||
extract: (td) => Math.floor(td.seconds / 3600),
|
||||
},
|
||||
{
|
||||
name: "minute",
|
||||
abbr: "m",
|
||||
seconds: 60,
|
||||
extract: (td) => Math.floor(td.seconds / 60) % 60,
|
||||
},
|
||||
{
|
||||
name: "second",
|
||||
abbr: "s",
|
||||
seconds: 1,
|
||||
extract: (td) => td.seconds % 60,
|
||||
},
|
||||
{
|
||||
name: "millisecond",
|
||||
abbr: "ms",
|
||||
seconds: 0.001,
|
||||
extract: (td) => Math.floor(td.microseconds / 1000),
|
||||
},
|
||||
{
|
||||
name: "microsecond",
|
||||
abbr: "μs",
|
||||
seconds: 0.000001,
|
||||
extract: (td) => td.microseconds % 1000,
|
||||
},
|
||||
];
|
||||
|
||||
// Convert a millisecond delta to a Python-style timedelta object {days, seconds, microseconds}
|
||||
function msToTimedelta(ms) {
|
||||
const totalMs = Math.abs(ms);
|
||||
const days = Math.floor(totalMs / 86400000);
|
||||
const remainderMs = totalMs - days * 86400000;
|
||||
const seconds = Math.floor(remainderMs / 1000);
|
||||
const microseconds = (remainderMs % 1000) * 1000;
|
||||
return { days, seconds, microseconds };
|
||||
}
|
||||
|
||||
// Convert various input types to a timedelta and determine if it's in the past.
|
||||
// Accepts: Date object, {days, seconds, microseconds} timedelta, or Unix timestamp (number)
|
||||
// Returns: [timedelta, isPast]
|
||||
function getDeltaFromSubject(subject) {
|
||||
if (subject && typeof subject.days === "number" && typeof subject.seconds === "number") {
|
||||
// timedelta object
|
||||
const totalMs = subject.days * 86400000 + subject.seconds * 1000 + (subject.microseconds || 0) / 1000;
|
||||
return [
|
||||
{ days: Math.abs(subject.days), seconds: Math.abs(subject.seconds), microseconds: Math.abs(subject.microseconds || 0) },
|
||||
totalMs >= 0,
|
||||
];
|
||||
}
|
||||
|
||||
if (subject instanceof Date) {
|
||||
const deltaMs = Date.now() - subject.getTime();
|
||||
return [msToTimedelta(deltaMs), deltaMs >= 0];
|
||||
}
|
||||
|
||||
// Assume it's a timestamp (seconds or milliseconds)
|
||||
const ts = Number(subject);
|
||||
if (Number.isNaN(ts)) {
|
||||
throw new TypeError(`Cannot convert ${typeof subject} to a time delta`);
|
||||
}
|
||||
// Detect if timestamp is in milliseconds (> year 2100 in seconds)
|
||||
const tsMs = ts > 4102444800 ? ts : ts * 1000;
|
||||
const deltaMs = Date.now() - tsMs;
|
||||
return [msToTimedelta(deltaMs), deltaMs >= 0];
|
||||
}
|
||||
|
||||
// Accepts a delta, returns a dictionary of units.
|
||||
export function delta2dict(delta) {
|
||||
const td = typeof delta.days === "number" ? delta : msToTimedelta(delta);
|
||||
const result = {};
|
||||
for (const unit of TIME_UNITS) {
|
||||
result[unit.name] = unit.extract(td);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Extract time components from a timedelta, filtering out zero values.
|
||||
function extractComponents(delta) {
|
||||
const td = typeof delta.days === "number" ? delta : msToTimedelta(delta);
|
||||
const timeDict = delta2dict(td);
|
||||
const components = [];
|
||||
for (const unit of TIME_UNITS) {
|
||||
const value = timeDict[unit.name];
|
||||
if (value > 0) {
|
||||
components.push({
|
||||
unit: unit.name,
|
||||
abbr: unit.abbr,
|
||||
value,
|
||||
});
|
||||
}
|
||||
}
|
||||
return components;
|
||||
}
|
||||
|
||||
// Format the time components into a human-readable string.
|
||||
function formatComponents(components, precision = 2, abbreviate = false) {
|
||||
const result = [];
|
||||
for (const component of components.slice(0, precision)) {
|
||||
if (abbreviate) {
|
||||
result.push(`${component.value}${component.abbr}`);
|
||||
} else {
|
||||
let unitName = component.unit;
|
||||
if (component.value !== 1) {
|
||||
unitName += "s";
|
||||
}
|
||||
result.push(`${component.value} ${unitName}`);
|
||||
}
|
||||
}
|
||||
return result.join(", ");
|
||||
}
|
||||
|
||||
// Accept a subject, return a human readable timedelta string.
|
||||
//
|
||||
// subject: Date object, timedelta {days, seconds, microseconds}, or timestamp number
|
||||
// precision: the desired amount of unit precision (default: 2)
|
||||
// pastTense: format string for past timedeltas (default: "{} ago")
|
||||
// futureTense: format string for future timedeltas (default: "in {}")
|
||||
// abbreviate: boolean to abbreviate units (default: false)
|
||||
export function human(subject, precision = 2, pastTense = "{} ago", futureTense = "in {}", abbreviate = false) {
|
||||
const [delta, isPast] = getDeltaFromSubject(subject);
|
||||
const components = extractComponents(delta);
|
||||
|
||||
if (components.length === 0) {
|
||||
return "just now";
|
||||
}
|
||||
|
||||
const formatted = formatComponents(components, precision, abbreviate);
|
||||
|
||||
if (isPast) {
|
||||
return pastTense.replace("{}", formatted);
|
||||
}
|
||||
return futureTense.replace("{}", formatted);
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@
|
|||
|
||||
// Content extraction and processing functionality
|
||||
|
||||
import { human, delta2dict } from "./ago.js";
|
||||
|
||||
// URI translation rules — map dynamic page patterns to raw/plain text equivalents.
|
||||
// Each rule: { pattern: RegExp matching the full URI, translate: (match) => rawURI }
|
||||
const URI_TRANSLATIONS = [
|
||||
|
|
@ -137,3 +139,90 @@ export async function extractWebpageContent() {
|
|||
|
||||
return extractDOMContent();
|
||||
}
|
||||
|
||||
// --- Page Analysis (pre-inference classification) ---
|
||||
// Runs before the greeting to extract structured intelligence about the page.
|
||||
// The greeting uses these keywords instead of guessing from raw text.
|
||||
|
||||
// Build the classification prompt for a page
|
||||
export function buildPageAnalysisPrompt(pageContent, pageTitle, pageUrl) {
|
||||
const currentDate = new Date().toISOString().split("T")[0];
|
||||
// Trim content to keep classification fast
|
||||
const contentPreview = pageContent.substring(0, 4000);
|
||||
|
||||
return {
|
||||
system:
|
||||
"You are a page classification engine. Analyze web pages and extract structured metadata. Respond with ONLY a valid JSON object. No markdown fences, no explanation, no commentary. Just the JSON object.",
|
||||
user: `Classify this webpage. Current date: ${currentDate}
|
||||
|
||||
TITLE: ${pageTitle}
|
||||
URI: ${pageUrl}
|
||||
|
||||
CONTENT:
|
||||
${contentPreview}
|
||||
|
||||
Respond with this exact JSON structure (use null for unknown fields):
|
||||
{
|
||||
"type": "<lyrics|recipe|blog|news|wiki|email|documentation|product|landing|portfolio|social|academic|code|tutorial|legal|ecommerce|video|podcast|event|job|review|qa|forum|gallery|ci-log|error-page|manifesto|personal|other>",
|
||||
"author": "<author name or null>",
|
||||
"publishDate": "<YYYY-MM-DD or null>",
|
||||
"topics": ["<primary>", "<secondary>"],
|
||||
"entities": ["<key names, orgs, technologies>"],
|
||||
"tone": "<technical|casual|formal|creative|academic|promotional|instructional|journalistic|poetic|humorous|philosophical>",
|
||||
"domain": "<technology|science|cooking|music|art|politics|business|health|education|entertainment|sports|finance|law|philosophy|nature|travel|other>",
|
||||
"audience": "<one phrase>",
|
||||
"keyPhrases": ["<3-5 notable verbatim phrases from the content>"],
|
||||
"summary": "<one sentence>",
|
||||
"contentLanguage": "<ISO 639-1>",
|
||||
"freshness": "<evergreen|dated|breaking|historical|timeless>"
|
||||
}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse the JSON classification from LLM response
|
||||
export function parsePageAnalysis(response) {
|
||||
try {
|
||||
const jsonMatch = response.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
return JSON.parse(jsonMatch[0]);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Page analysis parsing failed:", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// Build enriched context string from analysis for the greeting prompt
|
||||
export function buildAnalysisContext(analysis) {
|
||||
if (!analysis) return "";
|
||||
|
||||
const currentDate = new Date().toISOString().split("T")[0];
|
||||
const lines = [`\nPAGE INTELLIGENCE (pre-analyzed):`, `Current Date: ${currentDate}`, `Page Type: ${analysis.type}`];
|
||||
|
||||
if (analysis.author) lines.push(`Author: ${analysis.author}`);
|
||||
if (analysis.publishDate) {
|
||||
lines.push(`Published: ${analysis.publishDate}`);
|
||||
// Use russell ballestrini's ago algorithm for human-readable time distance
|
||||
try {
|
||||
const publishedDate = new Date(analysis.publishDate);
|
||||
if (!Number.isNaN(publishedDate.getTime())) {
|
||||
const age = human(publishedDate, 2);
|
||||
lines.push(`Age: ${age}`);
|
||||
}
|
||||
} catch (e) {
|
||||
// LLM returned unparseable date, skip age
|
||||
}
|
||||
}
|
||||
|
||||
if (analysis.topics?.length) lines.push(`Topics: ${analysis.topics.join(", ")}`);
|
||||
if (analysis.entities?.length) lines.push(`Key Entities: ${analysis.entities.join(", ")}`);
|
||||
if (analysis.tone) lines.push(`Tone: ${analysis.tone}`);
|
||||
if (analysis.domain) lines.push(`Domain: ${analysis.domain}`);
|
||||
if (analysis.audience) lines.push(`Audience: ${analysis.audience}`);
|
||||
if (analysis.keyPhrases?.length) lines.push(`Notable Phrases: "${analysis.keyPhrases.join('", "')}"`);
|
||||
if (analysis.summary) lines.push(`Summary: ${analysis.summary}`);
|
||||
if (analysis.contentLanguage) lines.push(`Content Language: ${analysis.contentLanguage}`);
|
||||
if (analysis.freshness) lines.push(`Freshness: ${analysis.freshness}`);
|
||||
|
||||
return `${lines.join("\n")}\n`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,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 } from "./content.js";
|
||||
import { extractWebpageContent, buildPageAnalysisPrompt, parsePageAnalysis, buildAnalysisContext } from "./content.js";
|
||||
import {
|
||||
detectCurrentTheme,
|
||||
getThemeColors,
|
||||
|
|
@ -2331,7 +2331,41 @@ You have complete knowledge of this page content and can reference any details,
|
|||
const pageContent = await extractWebpageContent();
|
||||
const pageTitle = document.title || window.location.hostname;
|
||||
|
||||
// FIRST: Generate intro message with specialized intro system prompt
|
||||
// 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.
|
||||
introMsg.innerHTML =
|
||||
'<em style="color: #6c757d;">Reading page...</em>';
|
||||
let analysisContext = "";
|
||||
try {
|
||||
const analysisPrompt = buildPageAnalysisPrompt(
|
||||
pageContent,
|
||||
pageTitle,
|
||||
window.location.href,
|
||||
);
|
||||
const analysisHistory = [
|
||||
{ role: "system", content: analysisPrompt.system },
|
||||
{ role: "user", content: analysisPrompt.user },
|
||||
];
|
||||
let analysisResponse = "";
|
||||
for await (const chunk of sendMessageWithCustomHistory(
|
||||
analysisHistory,
|
||||
)) {
|
||||
analysisResponse += chunk;
|
||||
}
|
||||
const pageAnalysis = parsePageAnalysis(analysisResponse);
|
||||
analysisContext = buildAnalysisContext(pageAnalysis);
|
||||
if (pageAnalysis) {
|
||||
console.log("Page analysis:", pageAnalysis);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Page pre-analysis failed, proceeding without:", e);
|
||||
}
|
||||
|
||||
introMsg.innerHTML =
|
||||
'<em style="color: #6c757d;">Generating welcome message...</em>';
|
||||
|
||||
// Generate intro message with specialized intro system prompt
|
||||
// Get user's preferred language and localize the system prompt
|
||||
const userLang = getUserLanguagePreference();
|
||||
const languageName = NATIVE_LANGUAGE_NAMES[userLang] || "English";
|
||||
|
|
@ -2342,11 +2376,16 @@ You have complete knowledge of this page content and can reference any details,
|
|||
? `\n\nCRITICAL: You MUST write your ENTIRE response in ${languageName}. Do NOT write in English. Every word of your introduction must be in ${languageName}.`
|
||||
: "";
|
||||
|
||||
const introSystemPrompt = `${getUIText("systemPromptIntro")}
|
||||
// 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 introSystemPrompt = `${getUIText("systemPromptIntro")}
|
||||
${analysisContext}${typeGuidance}
|
||||
PAGE INFORMATION:
|
||||
Title: "${pageTitle}"
|
||||
URL: ${window.location.href}
|
||||
URI: ${window.location.href}
|
||||
|
||||
FULL PAGE CONTENT:
|
||||
${pageContent}
|
||||
|
|
@ -2388,11 +2427,12 @@ ${getUIText("systemPromptInstructions")} ${languageName}.${langInstruction}`;
|
|||
addCodeBlockCopyButtons(introMsg);
|
||||
|
||||
// SECOND: Set up conversation system prompt for follow-up messages
|
||||
// Include the analysis so follow-up messages inherit the classified context
|
||||
const conversationContextAppend = `
|
||||
|
||||
${analysisContext}
|
||||
PAGE CONTEXT FOR THIS CONVERSATION:
|
||||
You are embedded on the webpage: "${pageTitle}"
|
||||
URL: ${window.location.href}
|
||||
URI: ${window.location.href}
|
||||
|
||||
FULL PAGE CONTENT:
|
||||
${pageContent}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue