clean up history
This commit is contained in:
@@ -0,0 +1,11 @@
|
||||
# Service worker contains API keys
|
||||
src/background/service-worker.js
|
||||
src/offscreen/offscreen.js
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"manifest_version": 3,
|
||||
"name": "InTruth",
|
||||
"version": "1.1.0",
|
||||
"description": "Real-time fact-checking of political speech and debates",
|
||||
|
||||
"permissions": [
|
||||
"activeTab",
|
||||
"scripting",
|
||||
"storage",
|
||||
"tabCapture",
|
||||
"offscreen"
|
||||
],
|
||||
|
||||
"host_permissions": [
|
||||
"https://api.anthropic.com/*",
|
||||
"https://google.serper.dev/*",
|
||||
"https://fonts.googleapis.com/*",
|
||||
"https://fonts.gstatic.com/*",
|
||||
"wss://api.deepgram.com/*"
|
||||
],
|
||||
|
||||
"background": {
|
||||
"service_worker": "src/background/service-worker.js"
|
||||
},
|
||||
|
||||
"action": {
|
||||
"default_popup": "src/popup/popup.html",
|
||||
"default_icon": {
|
||||
"16": "assets/icon16.png",
|
||||
"48": "assets/icon48.png",
|
||||
"128": "assets/icon128.png"
|
||||
}
|
||||
},
|
||||
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": [
|
||||
"https://www.youtube.com/*",
|
||||
"https://www.jubileemedia.com/*",
|
||||
"https://*.jubilee.com/*"
|
||||
],
|
||||
"js": [
|
||||
"src/content/lexical-features.js",
|
||||
"src/content/session-export.js",
|
||||
"src/content/overlay.js"
|
||||
],
|
||||
"css": ["src/content/overlay.css"]
|
||||
}
|
||||
],
|
||||
|
||||
"icons": {
|
||||
"16": "assets/icon16.png",
|
||||
"48": "assets/icon48.png",
|
||||
"128": "assets/icon128.png"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
// service-worker.js --> now combined with fact-checker.js and claim-detector.js for import conflicts
|
||||
// transcription runs in content script via web speech API;
|
||||
// responsibile for starting / stopping content script, receiving transcripts,
|
||||
// and routing them to claim detection
|
||||
// 5.31.2026 -- serper call before claude call for more accurate verdicts
|
||||
// 6.12.2026 -- switch to deepgram; too many conflicts w/ webaudio
|
||||
|
||||
let ANTHROPIC_KEY = '';
|
||||
const SERPER_KEY = '';
|
||||
|
||||
async function loadKeys() {
|
||||
return new Promise(resolve => {
|
||||
chrome.storage.local.get(['anthropicKey'], (data) => {
|
||||
ANTHROPIC_KEY = data.anthropicKey || '';
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const EVALUATE_PROMPT = ``;
|
||||
|
||||
// ── Speaker parsing (mirrors overlay.js) ─────────────────────────────────────
|
||||
|
||||
function parseSpeakersFromTitle(title) {
|
||||
if (!title) return [];
|
||||
const roleMatch = title.match(/(\d+)\s+([a-z]+(?:\s+[a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+([a-z]+(?:\s+[a-z]+)?)/i);
|
||||
if (roleMatch) {
|
||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
return [cap(roleMatch[2]), cap(roleMatch[4])];
|
||||
}
|
||||
const nameMatch = title.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:and|vs\.?|versus|&)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
|
||||
if (nameMatch) {
|
||||
const clean = name => name.trim().split(' ').pop();
|
||||
return [clean(nameMatch[1]), clean(nameMatch[2])];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
// ── Serper ────────────────────────────────────────────────────────────────────
|
||||
|
||||
const BLOCKED_DOMAINS = [
|
||||
'reddit.com', 'facebook.com', 'twitter.com', 'x.com',
|
||||
'tiktok.com', 'instagram.com', 'pinterest.com', 'quora.com',
|
||||
'yelp.com', 'tripadvisor.com', 'youtube.com',
|
||||
'democrats.org', 'republicans.org', 'gop.com', 'dnc.org',
|
||||
'afscme.org', 'ntu.org', 'americanprogress.org', 'heritage.org',
|
||||
'breitbart.com', 'dailykos.com', 'mediamatters.org', 'newsmax.com',
|
||||
'thefederalist.com', 'motherjones.com', 'nationalreview.com',
|
||||
'democrats-appropriations.house.gov', 'waysandmeans.house.gov',
|
||||
'bostonkravmaga.com',
|
||||
];
|
||||
|
||||
async function searchWeb(query, retries = 2) {
|
||||
try {
|
||||
const res = await fetch('https://google.serper.dev/search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-API-KEY': SERPER_KEY },
|
||||
body: JSON.stringify({ q: query, num: 6 }),
|
||||
});
|
||||
const data = await res.json();
|
||||
return (data.organic ?? [])
|
||||
.map(r => r.link)
|
||||
.filter(url => url && !BLOCKED_DOMAINS.some(d => url.includes(d)))
|
||||
.slice(0, 3);
|
||||
} catch (err) {
|
||||
if (retries > 0) {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
return searchWeb(query, retries - 1);
|
||||
}
|
||||
console.error('[serper] error:', err);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ── Claude ────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function callClaude(userMessage, systemPrompt) {
|
||||
const res = await fetch('https://api.anthropic.com/v1/messages', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-api-key': ANTHROPIC_KEY,
|
||||
'anthropic-version': '2023-06-01',
|
||||
'anthropic-dangerous-direct-browser-access': 'true',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'claude-haiku-4-5-20251001',
|
||||
max_tokens: 768,
|
||||
temperature: 0,
|
||||
system: systemPrompt,
|
||||
messages: [{ role: 'user', content: userMessage }],
|
||||
}),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.error) {
|
||||
const msg = data.error.message || 'Unknown API error';
|
||||
console.error('[claude] API error:', msg);
|
||||
if (activeTabId) chrome.tabs.sendMessage(activeTabId, { type: 'PIPELINE_ERROR', message: msg }).catch(() => {});
|
||||
return '';
|
||||
}
|
||||
const raw = data.content?.[0]?.text?.trim() || '';
|
||||
return raw.replace(/```json\s*/g, '').replace(/```\s*/g, '').trim();
|
||||
}
|
||||
|
||||
function parseArray(str) {
|
||||
const start = str.indexOf('[');
|
||||
const end = str.lastIndexOf(']');
|
||||
if (start === -1 || end === -1) return [];
|
||||
try { return JSON.parse(str.slice(start, end + 1)); }
|
||||
catch { return []; }
|
||||
}
|
||||
|
||||
// ── Lexical features ──────────────────────────────────────────────────────────
|
||||
|
||||
const HEDGING_WORDS = ['think','believe','maybe','perhaps','probably','might','could','seem','appears','guess','suppose','somewhat'];
|
||||
const CERTAINTY_WORDS = ['definitely','certainly','absolutely','always','never','clearly','obviously','undoubtedly','exactly','proven'];
|
||||
const FILLER_WORDS = ['um','uh','like','basically','actually','literally','right','okay'];
|
||||
const EMOTIONAL_WORDS = ['disaster','terrible','horrible','amazing','incredible','great','awful','fantastic','disgusting','wonderful','worst','best'];
|
||||
const EXCLUSIVE_WORDS = ['but','except','however','although','unless','without','exclude'];
|
||||
const FP_SINGULAR = ['i','me','my','mine','myself'];
|
||||
|
||||
function extractLexical(text) {
|
||||
const words = text.toLowerCase().split(/\s+/).filter(Boolean);
|
||||
const total = words.length || 1;
|
||||
const rate = (list) => Math.round(words.filter(w => list.some(h => w.includes(h))).length / total * 100);
|
||||
return {
|
||||
rates: {
|
||||
hedging: rate(HEDGING_WORDS),
|
||||
certainty: rate(CERTAINTY_WORDS),
|
||||
filler: rate(FILLER_WORDS),
|
||||
emotional: rate(EMOTIONAL_WORDS),
|
||||
exclusive: rate(EXCLUSIVE_WORDS),
|
||||
firstPersonSg: Math.round(words.filter(w => FP_SINGULAR.includes(w)).length / total * 100),
|
||||
},
|
||||
wordsPerSecond: null,
|
||||
wordCount: total,
|
||||
};
|
||||
}
|
||||
|
||||
function buildLexicalSummary(f) {
|
||||
const r = f.rates || f;
|
||||
const notes = [];
|
||||
if (r.hedging > 8) notes.push(`hedging language (${r.hedging}%)`);
|
||||
if (r.certainty > 8) notes.push(`certainty markers (${r.certainty}%)`);
|
||||
if (r.filler > 8) notes.push(`filler words (${r.filler}%)`);
|
||||
if (r.emotional > 8) notes.push(`emotional language (${r.emotional}%)`);
|
||||
if (r.exclusive > 8) notes.push(`qualifying words (${r.exclusive}%)`);
|
||||
if (r.firstPersonSg > 8) notes.push(`first-person singular (${r.firstPersonSg}%)`);
|
||||
if (f.wordsPerSecond) {
|
||||
const pace = f.wordsPerSecond > 3.5 ? 'fast' : f.wordsPerSecond < 2 ? 'slow' : 'moderate';
|
||||
notes.push(`speech rate ${f.wordsPerSecond} w/s (${pace})`);
|
||||
}
|
||||
return notes.length ? `Features detected: ${notes.join(', ')}.` : 'Neutral delivery.';
|
||||
}
|
||||
|
||||
// ── Claim deduplication ───────────────────────────────────────────────────────
|
||||
|
||||
const recentClaims = new Map(); // key → [timestamp, originalClaim]
|
||||
const CLAIM_DEDUP_MS = 200000;
|
||||
|
||||
function normalizeClaimKey(claim) {
|
||||
return claim.toLowerCase()
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.split(/\s+/)
|
||||
.filter(w => w.length >= 4)
|
||||
.sort()
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
function isDuplicate(claim) {
|
||||
const key = normalizeClaimKey(claim);
|
||||
const now = Date.now();
|
||||
|
||||
for (const [k, v] of recentClaims) {
|
||||
const t = Array.isArray(v) ? v[0] : v;
|
||||
if (now - t > CLAIM_DEDUP_MS) recentClaims.delete(k);
|
||||
}
|
||||
|
||||
if (recentClaims.has(key)) return true;
|
||||
|
||||
const keyWords = new Set(key.split(' ').filter(Boolean));
|
||||
const figures = (claim.match(/\$[\d,.]+(?:\s*(?:trillion|billion|million|thousand))?/gi) || [])
|
||||
.map(d => d.replace(/[,\s]/g, '').toLowerCase());
|
||||
|
||||
for (const [k, v] of recentClaims) {
|
||||
const kWords = k.split(' ').filter(Boolean);
|
||||
if (kWords.filter(w => keyWords.has(w)).length / Math.max(keyWords.size, kWords.length) >= 0.35) return true;
|
||||
if (figures.length) {
|
||||
const origClaim = Array.isArray(v) ? v[1] : '';
|
||||
if (origClaim) {
|
||||
const origFigures = (origClaim.match(/\$[\d,.]+(?:\s*(?:trillion|billion|million|thousand))?/gi) || [])
|
||||
.map(d => d.replace(/[,\s]/g, '').toLowerCase());
|
||||
if (figures.some(f => origFigures.includes(f))) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
recentClaims.set(key, [now, claim]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Rolling window ────────────────────────────────────────────────────────────
|
||||
|
||||
const WINDOW_SIZE = 4;
|
||||
const WINDOW_KEEP = 15;
|
||||
|
||||
// Each entry: { text, speakerId, speakerName }
|
||||
let sentenceWindow = [];
|
||||
let sentenceCount = 0;
|
||||
let windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0 };
|
||||
let windowStartTime = null;
|
||||
let pageTitle = '';
|
||||
let pageDate = '';
|
||||
let currentSpeakerId = null;
|
||||
let speakerIdToName = {}; // confirmed: { 0: 'Harris', 1: 'Trump' }
|
||||
let confirmedSpeakers = new Set(); // IDs that have been confirmed by user
|
||||
|
||||
function resetWindow() {
|
||||
sentenceWindow = [];
|
||||
sentenceCount = 0;
|
||||
windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0 };
|
||||
windowStartTime = null;
|
||||
currentSpeakerId = null;
|
||||
lastSpeakerId = null;
|
||||
speakerIdToName = {};
|
||||
confirmedSpeakers = new Set();
|
||||
}
|
||||
|
||||
async function onNewSentence(text, speakerId) {
|
||||
// flush window early on speaker change (mid-window turn transition)
|
||||
if (lastSpeakerId !== null &&
|
||||
speakerId !== null &&
|
||||
speakerId !== undefined &&
|
||||
speakerId !== lastSpeakerId &&
|
||||
sentenceCount % WINDOW_SIZE !== 0 &&
|
||||
sentenceWindow.length >= 2) {
|
||||
// fire evaluation for the previous speaker's sentences before processing this one
|
||||
const flushText = sentenceWindow.map(s => s.text).join(' ');
|
||||
const flushCounts = {};
|
||||
sentenceWindow.slice(-WINDOW_SIZE).forEach(s => {
|
||||
if (s.speakerId !== null && s.speakerId !== undefined)
|
||||
flushCounts[s.speakerId] = (flushCounts[s.speakerId] || 0) + 1;
|
||||
});
|
||||
const flushDominantId = Object.keys(flushCounts).length
|
||||
? Object.entries(flushCounts).sort((a,b) => b[1]-a[1])[0][0]
|
||||
: null;
|
||||
const flushDominantSpeaker = flushDominantId !== null ? (speakerIdToName[flushDominantId] || null) : null;
|
||||
const flushLexSnapshot = JSON.parse(JSON.stringify(windowLexical));
|
||||
const flushLexSummary = buildLexicalSummary(flushLexSnapshot);
|
||||
windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0 };
|
||||
windowStartTime = null;
|
||||
await evaluateClaims(flushText, pageTitle, flushLexSummary, flushLexSnapshot, flushDominantSpeaker, flushDominantId);
|
||||
}
|
||||
lastSpeakerId = speakerId;
|
||||
|
||||
// label with confirmed name if available, else Speaker N for Claude to infer
|
||||
const confirmedName = (speakerId !== null && speakerId !== undefined) ? speakerIdToName[speakerId] : null;
|
||||
const label = confirmedName ? `[${confirmedName}]` : (speakerId !== null && speakerId !== undefined ? `[Speaker ${speakerId}]` : null);
|
||||
const labeledText = label ? `${label} ${text}` : text;
|
||||
|
||||
sentenceWindow.push({ text: labeledText, speakerId, speakerName: confirmedName });
|
||||
if (sentenceWindow.length > WINDOW_KEEP) sentenceWindow.shift();
|
||||
sentenceCount++;
|
||||
|
||||
if (!windowStartTime) windowStartTime = Date.now();
|
||||
|
||||
// accumulate lexical
|
||||
const f = extractLexical(text);
|
||||
const r = f.rates, wr = windowLexical.rates;
|
||||
wr.hedging = Math.round((wr.hedging + r.hedging) / 2);
|
||||
wr.certainty = Math.round((wr.certainty + r.certainty) / 2);
|
||||
wr.filler = Math.round((wr.filler + r.filler) / 2);
|
||||
wr.emotional = Math.round((wr.emotional + r.emotional) / 2);
|
||||
wr.exclusive = Math.round((wr.exclusive + r.exclusive) / 2);
|
||||
wr.firstPersonSg = Math.round((wr.firstPersonSg + r.firstPersonSg) / 2);
|
||||
windowLexical.wordCount += f.wordCount;
|
||||
|
||||
if (sentenceCount % WINDOW_SIZE === 0) {
|
||||
const contextText = sentenceWindow.map(s => s.text).join(' ');
|
||||
|
||||
// dominant speaker ID = whoever appears most in this window
|
||||
// count only the CURRENT window's sentences (last WINDOW_SIZE), not full rolling buffer
|
||||
const currentWindowSentences = sentenceWindow.slice(-WINDOW_SIZE);
|
||||
const counts = {};
|
||||
currentWindowSentences.forEach(s => {
|
||||
if (s.speakerId !== null && s.speakerId !== undefined) {
|
||||
counts[s.speakerId] = (counts[s.speakerId] || 0) + 1;
|
||||
}
|
||||
});
|
||||
const dominantSpeakerId = Object.keys(counts).length
|
||||
? Object.entries(counts).sort((a, b) => b[1] - a[1])[0][0]
|
||||
: null;
|
||||
// use confirmed name from speakerIdToName — ground truth from Deepgram + user confirmation
|
||||
const dominantSpeaker = dominantSpeakerId !== null
|
||||
? (speakerIdToName[dominantSpeakerId] || null)
|
||||
: null;
|
||||
|
||||
// speech rate
|
||||
const elapsed = windowStartTime ? (Date.now() - windowStartTime) / 1000 : null;
|
||||
if (elapsed && elapsed > 0) windowLexical.wordsPerSecond = Math.round(windowLexical.wordCount / elapsed * 10) / 10;
|
||||
windowStartTime = null;
|
||||
|
||||
const lexicalSnapshot = JSON.parse(JSON.stringify(windowLexical));
|
||||
const lexicalSummary = buildLexicalSummary(lexicalSnapshot);
|
||||
|
||||
// reset for next window
|
||||
windowLexical = { rates: { hedging: 0, certainty: 0, filler: 0, emotional: 0, exclusive: 0, firstPersonSg: 0 }, wordsPerSecond: null, wordCount: 0 };
|
||||
windowStartTime = null;
|
||||
|
||||
try {
|
||||
await evaluateClaims(contextText, pageTitle, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId);
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Evaluation pipeline ───────────────────────────────────────────────────────
|
||||
|
||||
async function evaluateClaims(contextText, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId) {
|
||||
try {
|
||||
const dateContext = pageDate ? `\nDate: ${pageDate}` : '';
|
||||
|
||||
// build speaker legend from title names for Claude
|
||||
const titleNames = parseSpeakersFromTitle(title || '');
|
||||
const nameList = titleNames.join(' and ');
|
||||
const speakerLegend = titleNames.length
|
||||
? `\nDebate participants: ${nameList}.` +
|
||||
`\nSpeaker attribution rules:` +
|
||||
`\n- [Speaker N] labels indicate turn order only — do NOT map Speaker 0 to the first name listed.` +
|
||||
`\n- Identify speakers using: (1) first-person language — when someone says "I", "my plan", "I intend to", they ARE the speaker — attribute the claim to the known participant whose policies match; (2) policy content — match stated positions to each participant's known platform; (3) cross-references — participants typically refer to each other by name.` +
|
||||
`\n- Use your knowledge of each named participant's background, policies, and public record to attribute correctly.` +
|
||||
`\n- If a moderator or third party is speaking, attribute to them if identifiable, otherwise use "Unknown".` +
|
||||
`\n- NEVER output "Speaker N" or any [Speaker N] format in any field.`
|
||||
: `\nIdentify speakers using first-person language, policy content, and speech patterns. Never output "Speaker N".`;
|
||||
|
||||
const titleContext = title
|
||||
? `Video: "${title}"${dateContext}${speakerLegend}\n\nEvaluate claims as they were made at the time of this recording. Do not apply knowledge of events after this date.\n\n`
|
||||
: '';
|
||||
const lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : '';
|
||||
|
||||
// already-checked claims list for Claude
|
||||
const checkedList = [...recentClaims.values()]
|
||||
.filter(v => Array.isArray(v) && v[1])
|
||||
.map(v => v[1])
|
||||
.slice(-15)
|
||||
.join('\n- ');
|
||||
const alreadyChecked = checkedList
|
||||
? `\n\nClaims already fact-checked this session — do NOT re-evaluate these or close variants:\n- ${checkedList}\n`
|
||||
: '';
|
||||
|
||||
const raw = await callClaude(
|
||||
`${titleContext}Transcript: "${contextText}"${alreadyChecked}${lexicalContext}`,
|
||||
EVALUATE_PROMPT
|
||||
);
|
||||
const results = parseArray(raw);
|
||||
const valid = results.filter(r => r.claim && r.verdict && !isDuplicate(r.claim));
|
||||
|
||||
if (!valid.length) return;
|
||||
|
||||
if (activeTabId) {
|
||||
chrome.tabs.sendMessage(activeTabId, {
|
||||
type: 'NEW_VERDICT',
|
||||
results: valid.map(r => ({
|
||||
...r,
|
||||
sources: [],
|
||||
pending: true,
|
||||
lexical: lexicalSnapshot,
|
||||
dominantSpeakerId, // raw Deepgram ID — overlay resolves to name at render time
|
||||
speaker: dominantSpeaker || (r.speaker && !r.speaker.match(/^Speaker\s*\d+$/i) ? r.speaker : null),
|
||||
})),
|
||||
}).catch(() => {});
|
||||
console.log('[pipeline] fast verdicts sent:', valid.length, '| speaker:', dominantSpeaker);
|
||||
}
|
||||
|
||||
groundAndUpdate(contextText, valid, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId);
|
||||
|
||||
} catch (err) {
|
||||
console.error('[pipeline] error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async function groundAndUpdate(contextText, fastResults, title, lexicalSummary, lexicalSnapshot, dominantSpeaker, dominantSpeakerId) {
|
||||
try {
|
||||
const dateCtx = pageDate ? `\nDate: ${pageDate}` : '';
|
||||
const titleContext = title
|
||||
? `Video: "${title}"${dateCtx}\nEvaluate claims as they were made at the time of this recording. Web search results may include articles published after the debate date — ignore any information that was not publicly known at the time of the debate.\n\n`
|
||||
: '';
|
||||
const lexicalContext = lexicalSummary ? `\n\nLexical analysis: ${lexicalSummary}` : '';
|
||||
|
||||
const groundedAll = await Promise.all(fastResults.map(async (fastResult) => {
|
||||
try {
|
||||
const urls = await searchWeb(fastResult.claim);
|
||||
if (!urls.length) return null;
|
||||
const raw = await callClaude(
|
||||
`${titleContext}Transcript: "${contextText}"\n\nEvaluate ONLY this specific claim:\n1. ${fastResult.claim}\n\nWeb search results:\n${urls.join('\n')}${lexicalContext}`,
|
||||
EVALUATE_PROMPT
|
||||
);
|
||||
const results = parseArray(raw);
|
||||
const match = results.find(r => r.claim && r.verdict);
|
||||
if (!match) return null;
|
||||
// re-resolve speaker at grounding time — user may have confirmed since fast pass
|
||||
const lateResolved = dominantSpeakerId !== null && dominantSpeakerId !== undefined
|
||||
? speakerIdToName[dominantSpeakerId] || null
|
||||
: null;
|
||||
const resolvedSpeaker = lateResolved
|
||||
|| dominantSpeaker
|
||||
|| (match.speaker && !match.speaker.match(/^Speaker\s*\d+$/i) ? match.speaker : null)
|
||||
|| (fastResult.speaker && !fastResult.speaker.match(/^Speaker\s*\d+$/i) ? fastResult.speaker : null);
|
||||
|
||||
// never downgrade TRUE to MISLEADING in grounded pass — fast verdict had no sources to nitpick
|
||||
const fastWasTrue = fastResult.verdict === 'TRUE' || fastResult.verdict === 'SUBSTANTIALLY TRUE';
|
||||
const groundedIsMisleading = match.verdict === 'MISLEADING';
|
||||
const finalVerdict = (fastWasTrue && groundedIsMisleading) ? fastResult.verdict : match.verdict;
|
||||
|
||||
return { ...match, verdict: finalVerdict, sources: urls, pending: false, lexical: lexicalSnapshot, speaker: resolvedSpeaker, dominantSpeakerId };
|
||||
} catch (err) {
|
||||
console.error('[grounded] error:', fastResult.claim.slice(0, 40), err);
|
||||
return null;
|
||||
}
|
||||
}));
|
||||
|
||||
const valid = groundedAll.filter(Boolean);
|
||||
if (valid.length && activeTabId) {
|
||||
chrome.tabs.sendMessage(activeTabId, { type: 'UPDATE_VERDICTS', results: valid }).catch(() => {});
|
||||
console.log('[pipeline] grounded verdicts sent:', valid.length);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[grounded] error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let activeTabId = null;
|
||||
let isCapturing = false;
|
||||
let keepAliveInterval = null;
|
||||
|
||||
function startKeepAlive() {
|
||||
keepAliveInterval = setInterval(() => chrome.runtime.getPlatformInfo(() => {}), 20000);
|
||||
}
|
||||
|
||||
function stopKeepAlive() {
|
||||
clearInterval(keepAliveInterval);
|
||||
keepAliveInterval = null;
|
||||
}
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
|
||||
chrome.runtime.onConnect.addListener(() => console.log('[service-worker] woken by port connect'));
|
||||
|
||||
// notify overlay if service worker was killed and restarted mid-session
|
||||
chrome.runtime.onStartup.addListener(() => {
|
||||
isCapturing = false;
|
||||
activeTabId = null;
|
||||
});
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, sender, sendResponse) => {
|
||||
switch (msg.type) {
|
||||
|
||||
case 'START_FACTCHECK':
|
||||
startFactCheck()
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch(err => sendResponse({ ok: false, error: err.message }));
|
||||
return true;
|
||||
|
||||
case 'STOP_FACTCHECK':
|
||||
stopFactCheck();
|
||||
sendResponse({ ok: true });
|
||||
break;
|
||||
|
||||
case 'TRANSCRIPT_RESULT':
|
||||
// always process transcript for pipeline — activeTabId only needed for forwarding to overlay
|
||||
if (msg.isFinal) {
|
||||
if (msg.speaker !== null && msg.speaker !== undefined) {
|
||||
currentSpeakerId = msg.speaker;
|
||||
if (activeTabId && !confirmedSpeakers.has(currentSpeakerId) && !speakerIdToName[currentSpeakerId]) {
|
||||
chrome.tabs.sendMessage(activeTabId, {
|
||||
type: 'NEW_SPEAKER',
|
||||
speakerId: currentSpeakerId,
|
||||
sample: msg.text.slice(0, 80),
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
onNewSentence(msg.text, currentSpeakerId);
|
||||
}
|
||||
if (activeTabId) {
|
||||
chrome.tabs.sendMessage(activeTabId, {
|
||||
type: 'TRANSCRIPT_RESULT', text: msg.text, isFinal: msg.isFinal, interim: msg.interim,
|
||||
}).catch(() => {});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'SPEAKER_NAMES':
|
||||
// merge incoming confirmed entries — never overwrite already-confirmed IDs
|
||||
if (msg.speakerIdToName) {
|
||||
Object.entries(msg.speakerIdToName).forEach(([id, name]) => {
|
||||
const numId = parseInt(id);
|
||||
if (!confirmedSpeakers.has(numId)) {
|
||||
speakerIdToName[numId] = name;
|
||||
confirmedSpeakers.add(numId);
|
||||
}
|
||||
});
|
||||
console.log('[service-worker] speaker map updated:', speakerIdToName);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'PAGE_TITLE':
|
||||
pageTitle = msg.title || '';
|
||||
pageDate = msg.date || '';
|
||||
console.log('[service-worker] page title:', pageTitle.slice(0, 60));
|
||||
console.log('[service-worker] page date:', pageDate);
|
||||
// speaker names passed to Claude as context — Claude resolves attribution
|
||||
break;
|
||||
|
||||
case 'PIPELINE_ERROR':
|
||||
// forward from offscreen doc to overlay
|
||||
if (activeTabId) {
|
||||
chrome.tabs.sendMessage(activeTabId, { type: 'PIPELINE_ERROR', message: msg.message }).catch(() => {});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'REQUEST_NEW_STREAM':
|
||||
// offscreen doc lost its stream — get a fresh tabCapture stream ID
|
||||
if (activeTabId && isCapturing) {
|
||||
chrome.tabCapture.getMediaStreamId({ targetTabId: activeTabId }, (streamId) => {
|
||||
if (chrome.runtime.lastError) {
|
||||
console.error('[service-worker] failed to get new stream:', chrome.runtime.lastError.message);
|
||||
return;
|
||||
}
|
||||
chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId }).catch(() => {});
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case 'GET_STATUS':
|
||||
sendResponse({ isCapturing });
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Start / stop ──────────────────────────────────────────────────────────────
|
||||
|
||||
async function startFactCheck() {
|
||||
if (isCapturing) return;
|
||||
|
||||
await loadKeys();
|
||||
if (!ANTHROPIC_KEY) {
|
||||
throw new Error('Anthropic API key not set. Please enter it in the extension popup.');
|
||||
}
|
||||
|
||||
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
|
||||
if (!tab) throw new Error('No active tab found.');
|
||||
activeTabId = tab.id;
|
||||
|
||||
try {
|
||||
await ensureOffscreenDocument();
|
||||
console.log('[service-worker] offscreen document created');
|
||||
} catch (err) {
|
||||
console.error('[service-worker] offscreen creation failed:', err);
|
||||
}
|
||||
|
||||
const streamId = await new Promise((resolve, reject) => {
|
||||
chrome.tabCapture.getMediaStreamId({ targetTabId: activeTabId }, id => {
|
||||
if (chrome.runtime.lastError) reject(new Error(chrome.runtime.lastError.message));
|
||||
else resolve(id);
|
||||
});
|
||||
});
|
||||
|
||||
const response = await chrome.runtime.sendMessage({ type: 'START_CAPTURE', streamId });
|
||||
if (!response?.ok) throw new Error('Failed to start capture: ' + response?.error);
|
||||
|
||||
// reset BEFORE sending START_FACTCHECK — transcripts arrive immediately after
|
||||
isCapturing = true;
|
||||
resetWindow();
|
||||
recentClaims.clear();
|
||||
startKeepAlive();
|
||||
|
||||
await chrome.tabs.sendMessage(activeTabId, { type: 'START_FACTCHECK' });
|
||||
console.log('[service-worker] started on tab', activeTabId);
|
||||
}
|
||||
|
||||
function stopFactCheck() {
|
||||
resetWindow();
|
||||
recentClaims.clear();
|
||||
pageTitle = '';
|
||||
pageDate = '';
|
||||
|
||||
if (!isCapturing) return;
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'STOP_CAPTURE' }).catch(() => {});
|
||||
chrome.offscreen.closeDocument().catch(() => {});
|
||||
if (activeTabId) chrome.tabs.sendMessage(activeTabId, { type: 'STOP_FACTCHECK' }).catch(() => {});
|
||||
|
||||
activeTabId = null;
|
||||
isCapturing = false;
|
||||
stopKeepAlive();
|
||||
console.log('[service-worker] stopped');
|
||||
}
|
||||
|
||||
async function ensureOffscreenDocument() {
|
||||
const existing = await chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
|
||||
if (existing.length > 0) return;
|
||||
await chrome.offscreen.createDocument({
|
||||
url: chrome.runtime.getURL('src/offscreen/offscreen.html'),
|
||||
reasons: ['USER_MEDIA'],
|
||||
justification: 'Capture tab audio for Deepgram transcription',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// lexical-features.js
|
||||
// extracts speaker commitment to an utterance from transcript text alone
|
||||
|
||||
// -- word lists
|
||||
|
||||
const EXCLUSIVE_WORDS = new Set([
|
||||
'but', 'except', 'without', 'exclude', 'excluding', 'however',
|
||||
'although', 'unless', 'despite', 'rather', 'instead', 'yet',
|
||||
'nonetheless', 'nevertheless', 'notwithstanding', 'whereas'
|
||||
]);
|
||||
|
||||
const HEDGING_WORDS = new Set([
|
||||
'maybe', 'perhaps', 'possibly', 'probably', 'might', 'could',
|
||||
'seems', 'appear', 'appears', 'apparently', 'roughly', 'around',
|
||||
'approximately', 'somewhat', 'kind of', 'sort of', 'i think',
|
||||
'i believe', 'i feel', 'i guess', 'i suppose', 'not sure',
|
||||
'uncertain', 'unclear', 'allegedly', 'supposedly', 'purportedly'
|
||||
]);
|
||||
|
||||
const CERTAINTY_WORDS = new Set([
|
||||
'always', 'never', 'definitely', 'certainly', 'absolutely',
|
||||
'clearly', 'obviously', 'undoubtedly', 'without question',
|
||||
'proven', 'fact', 'facts', 'evidence', 'study', 'studies',
|
||||
'research', 'data', 'statistics', 'percent', '%', 'million',
|
||||
'billion', 'every', 'all', 'none', 'guaranteed'
|
||||
]);
|
||||
|
||||
const EMOTIONAL_WORDS = new Set([
|
||||
'terrible', 'horrible', 'awful', 'great', 'amazing', 'fantastic',
|
||||
'disaster', 'catastrophe', 'wonderful', 'incredible', 'unbelievable',
|
||||
'disgrace', 'shameful', 'outrageous', 'ridiculous', 'pathetic',
|
||||
'love', 'hate', 'fear', 'angry', 'anger', 'sad', 'happy',
|
||||
'excited', 'disgusted', 'afraid', 'scared', 'worried', 'proud',
|
||||
'ashamed', 'brilliant', 'stupid', 'evil', 'corrupt'
|
||||
]);
|
||||
|
||||
const FILLER_WORDS = new Set([
|
||||
'uh', 'um', 'uhh', 'umm', 'er', 'err', 'like', 'you know',
|
||||
'i mean', 'basically', 'literally', 'actually', 'honestly',
|
||||
'frankly', 'right', 'okay', 'so', 'well', 'anyway'
|
||||
]);
|
||||
|
||||
const FIRST_PERSON_SINGULAR = new Set([
|
||||
'i', 'me', 'my', 'mine', 'myself'
|
||||
]);
|
||||
|
||||
const FIRST_PERSON_PLURAL = new Set([
|
||||
'we', 'us', 'our', 'ours', 'ourselves'
|
||||
]);
|
||||
|
||||
const THIRD_PERSON = new Set([
|
||||
'they', 'them', 'their', 'theirs', 'he', 'she', 'his', 'her',
|
||||
'it', 'its', 'him'
|
||||
]);
|
||||
|
||||
// -- main extractor
|
||||
|
||||
/**
|
||||
* extract lexical commitment features from a transcript string
|
||||
* @param {string} text - the transcript chunk
|
||||
* @param {number} durationSeconds - approximate duration (optional, for speech rate)
|
||||
* @returns {object} feature object
|
||||
*/
|
||||
function extractLexicalFeatures(text, durationSeconds) {
|
||||
const lower = text.toLowerCase();
|
||||
const words = lower.match(/\b\w+\b/g) || [];
|
||||
const wordCount = words.length;
|
||||
|
||||
if (wordCount === 0) return null;
|
||||
|
||||
let exclusiveCount = 0;
|
||||
let hedgingCount = 0;
|
||||
let certaintyCount = 0;
|
||||
let emotionalCount = 0;
|
||||
let fillerCount = 0;
|
||||
let firstPersonSing = 0;
|
||||
let firstPersonPlur = 0;
|
||||
let thirdPerson = 0;
|
||||
|
||||
for (const word of words) {
|
||||
if (EXCLUSIVE_WORDS.has(word)) exclusiveCount++;
|
||||
if (HEDGING_WORDS.has(word)) hedgingCount++;
|
||||
if (CERTAINTY_WORDS.has(word)) certaintyCount++;
|
||||
if (EMOTIONAL_WORDS.has(word)) emotionalCount++;
|
||||
if (FILLER_WORDS.has(word)) fillerCount++;
|
||||
if (FIRST_PERSON_SINGULAR.has(word)) firstPersonSing++;
|
||||
if (FIRST_PERSON_PLURAL.has(word)) firstPersonPlur++;
|
||||
if (THIRD_PERSON.has(word)) thirdPerson++;
|
||||
}
|
||||
|
||||
// also check multi-word phrases
|
||||
if (lower.includes('i think')) hedgingCount++;
|
||||
if (lower.includes('i believe')) hedgingCount++;
|
||||
if (lower.includes('i guess')) hedgingCount++;
|
||||
if (lower.includes('i suppose')) hedgingCount++;
|
||||
if (lower.includes('sort of')) hedgingCount++;
|
||||
if (lower.includes('kind of')) hedgingCount++;
|
||||
if (lower.includes('you know')) fillerCount++;
|
||||
if (lower.includes('i mean')) fillerCount++;
|
||||
|
||||
// -- rates (per 100 words for normalization)
|
||||
|
||||
const per100 = (n) => parseFloat(((n / wordCount) * 100).toFixed(1));
|
||||
|
||||
// -- rough speech rate calculation
|
||||
|
||||
const wordsPerSecond = durationSeconds && durationSeconds > 0
|
||||
? parseFloat((wordCount / durationSeconds).toFixed(1))
|
||||
: null;
|
||||
|
||||
// -- avg word length (proxy for phoneme density)
|
||||
|
||||
const avgWordLength = parseFloat(
|
||||
(words.reduce((sum, w) => sum + w.length, 0) / wordCount).toFixed(1)
|
||||
);
|
||||
|
||||
// -- commitment score (heuristic -1 to +1)
|
||||
// positive = high commitment, negative = low commitment
|
||||
// certainty words and first-person singular push positive
|
||||
// hedging words and filler push negative
|
||||
// emotional words push negative (overconfidence is a form of low precision)
|
||||
|
||||
const commitmentScore = parseFloat((
|
||||
(certaintyCount * 0.3)
|
||||
+ (firstPersonSing * 0.15)
|
||||
- (hedgingCount * 0.4)
|
||||
- (fillerCount * 0.25)
|
||||
- (emotionalCount * 0.1)
|
||||
+ (exclusiveCount * 0.1) // exclusive words signal careful qualification
|
||||
).toFixed(2));
|
||||
|
||||
const commitmentLabel =
|
||||
commitmentScore > 0.3 ? 'HIGH' :
|
||||
commitmentScore < -0.3 ? 'LOW' :
|
||||
'MEDIUM';
|
||||
|
||||
// -- result
|
||||
|
||||
return {
|
||||
wordCount,
|
||||
wordsPerSecond,
|
||||
avgWordLength,
|
||||
rates: {
|
||||
hedging: per100(hedgingCount),
|
||||
certainty: per100(certaintyCount),
|
||||
emotional: per100(emotionalCount),
|
||||
filler: per100(fillerCount),
|
||||
exclusive: per100(exclusiveCount),
|
||||
firstPersonSg: per100(firstPersonSing),
|
||||
firstPersonPl: per100(firstPersonPlur),
|
||||
thirdPerson: per100(thirdPerson),
|
||||
},
|
||||
commitmentScore,
|
||||
commitmentLabel,
|
||||
// human-readable summary for Claude
|
||||
summary: buildSummary({
|
||||
wordCount, wordsPerSecond, hedgingCount, certaintyCount,
|
||||
emotionalCount, fillerCount, exclusiveCount,
|
||||
firstPersonSing, firstPersonPlur, commitmentLabel
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function buildSummary({ wordCount, wordsPerSecond, hedgingCount, certaintyCount,
|
||||
emotionalCount, fillerCount, exclusiveCount, firstPersonSing,
|
||||
firstPersonPlur, commitmentLabel }) {
|
||||
|
||||
const parts = [];
|
||||
|
||||
if (wordsPerSecond !== null) {
|
||||
const rateDesc = wordsPerSecond > 3.5 ? 'fast' : wordsPerSecond < 2 ? 'slow' : 'moderate';
|
||||
parts.push(`speech rate: ${wordsPerSecond} words/sec (${rateDesc})`);
|
||||
}
|
||||
|
||||
if (hedgingCount > 0)
|
||||
parts.push(`${hedgingCount} hedging expression${hedgingCount > 1 ? 's' : ''} (e.g. "maybe", "I think")`);
|
||||
|
||||
if (fillerCount > 0)
|
||||
parts.push(`${fillerCount} filler word${fillerCount > 1 ? 's' : ''} (e.g. "um", "you know")`);
|
||||
|
||||
if (certaintyCount > 0)
|
||||
parts.push(`${certaintyCount} certainty marker${certaintyCount > 1 ? 's' : ''} (e.g. "always", "definitely", statistics)`);
|
||||
|
||||
if (emotionalCount > 0)
|
||||
parts.push(`${emotionalCount} emotional word${emotionalCount > 1 ? 's' : ''}`);
|
||||
|
||||
if (exclusiveCount > 0)
|
||||
parts.push(`${exclusiveCount} exclusive/qualifying word${exclusiveCount > 1 ? 's' : ''} (e.g. "but", "except")`);
|
||||
|
||||
if (firstPersonSing > 0)
|
||||
parts.push(`${firstPersonSing} first-person singular pronoun${firstPersonSing > 1 ? 's' : ''} (I/me/my)`);
|
||||
|
||||
if (firstPersonPlur > 0)
|
||||
parts.push(`${firstPersonPlur} first-person plural pronoun${firstPersonPlur > 1 ? 's' : ''} (we/our)`);
|
||||
|
||||
const summary = parts.length
|
||||
? `Lexical features: ${parts.join(', ')}. Overall commitment: ${commitmentLabel}.`
|
||||
: `No strong commitment signals detected. Overall commitment: ${commitmentLabel}.`;
|
||||
|
||||
return summary;
|
||||
}
|
||||
@@ -0,0 +1,551 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
||||
|
||||
#rtfc-panel {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 16px;
|
||||
width: 320px;
|
||||
max-height: 620px;
|
||||
background: rgba(15, 15, 15, 0.88);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 0.5px solid rgba(255,255,255,0.08);
|
||||
border-radius: 14px;
|
||||
z-index: 9999;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 12px;
|
||||
color: #e8e8e8;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-shadow: 0 4px 24px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* ── Header ── */
|
||||
|
||||
#rtfc-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 11px 14px 10px;
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.07);
|
||||
flex-shrink: 0;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#rtfc-header:active { cursor: grabbing; }
|
||||
|
||||
#rtfc-header > span {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.rtfc-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: #4ade80;
|
||||
animation: rtfc-blink 2s ease-in-out infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes rtfc-blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.35; }
|
||||
}
|
||||
|
||||
.rtfc-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
#rtfc-export {
|
||||
background: none;
|
||||
border: 0.5px solid rgba(255,255,255,0.15);
|
||||
border-radius: 4px;
|
||||
color: #888;
|
||||
font-size: 10px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-weight: 500;
|
||||
padding: 3px 7px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s, border-color 0.15s;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
#rtfc-export:hover {
|
||||
color: #e8e8e8;
|
||||
border-color: rgba(255,255,255,0.35);
|
||||
}
|
||||
|
||||
#rtfc-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
font-size: 15px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
transition: color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
#rtfc-close:hover { color: #e8e8e8; }
|
||||
|
||||
/* ── Body ── */
|
||||
|
||||
#rtfc-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
#rtfc-body::-webkit-scrollbar { width: 3px; }
|
||||
#rtfc-body::-webkit-scrollbar-track { background: transparent; }
|
||||
#rtfc-body::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||
|
||||
/* ── Section headers ── */
|
||||
|
||||
.rtfc-section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 8px 14px 4px;
|
||||
}
|
||||
|
||||
.rtfc-section-label {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.rtfc-toggle-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #555;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: color 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.rtfc-toggle-btn:hover { color: #aaa; }
|
||||
|
||||
/* ── Transcript ── */
|
||||
|
||||
#rtfc-transcript-section {
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.06);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#rtfc-transcript-feed {
|
||||
max-height: 100px;
|
||||
overflow-y: auto;
|
||||
padding: 4px 14px;
|
||||
font-size: 11.5px;
|
||||
color: #888;
|
||||
line-height: 1.7;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
#rtfc-transcript-feed::-webkit-scrollbar { width: 3px; }
|
||||
#rtfc-transcript-feed::-webkit-scrollbar-track { background: transparent; }
|
||||
#rtfc-transcript-feed::-webkit-scrollbar-thumb { background: #333; border-radius: 2px; }
|
||||
|
||||
.rtfc-transcript-word { display: inline; }
|
||||
|
||||
#rtfc-interim {
|
||||
font-size: 11px;
|
||||
color: #444;
|
||||
font-style: italic;
|
||||
padding: 0 14px;
|
||||
min-height: 16px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* ── Claims ── */
|
||||
|
||||
#rtfc-claims-section {
|
||||
border-bottom: 0.5px solid rgba(255,255,255,0.06);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#rtfc-claim-feed {
|
||||
list-style: disc;
|
||||
padding: 4px 14px 0 28px;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.rtfc-claim-bullet {
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
padding-bottom: 1px;
|
||||
transition: color 0.3s;
|
||||
}
|
||||
|
||||
.rtfc-claim-bullet--pending { color: #888; border-bottom: 1.5px solid #333; }
|
||||
.rtfc-claim-bullet--green { color: #e8e8e8; border-bottom: 2px solid #4ade80; }
|
||||
.rtfc-claim-bullet--teal { color: #e8e8e8; border-bottom: 2px solid #2dd4bf; }
|
||||
.rtfc-claim-bullet--red { color: #e8e8e8; border-bottom: 2px solid #f87171; }
|
||||
.rtfc-claim-bullet--yellow { color: #e8e8e8; border-bottom: 2px solid #fbbf24; }
|
||||
.rtfc-claim-bullet--grey { color: #777; border-bottom: 2px solid #555; }
|
||||
|
||||
/* ── Verdicts ── */
|
||||
|
||||
#rtfc-verdicts-section {
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
#rtfc-verdicts {
|
||||
padding: 4px 12px 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rtfc-empty {
|
||||
font-size: 11px;
|
||||
color: #444;
|
||||
text-align: center;
|
||||
padding: 12px 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rtfc-verdict {
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
background: rgba(255,255,255,0.04);
|
||||
border: 0.5px solid rgba(255,255,255,0.07);
|
||||
animation: rtfc-in 0.18s ease;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@keyframes rtfc-in {
|
||||
from { opacity: 0; transform: translateY(-4px); }
|
||||
to { opacity: 1; transform: translateY(0); }
|
||||
}
|
||||
|
||||
.rtfc-verdict--green { border-color: rgba(74,222,128,0.2); }
|
||||
.rtfc-verdict--teal { border-color: rgba(45,212,191,0.2); }
|
||||
.rtfc-verdict--red { border-color: rgba(248,113,113,0.2); }
|
||||
.rtfc-verdict--yellow { border-color: rgba(251,191,36,0.15); }
|
||||
.rtfc-verdict--grey { border-color: rgba(255,255,255,0.07); }
|
||||
|
||||
.rtfc-verdict--pending { opacity: 0.75; }
|
||||
|
||||
.rtfc-verdict-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.rtfc-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.rtfc-badge--green { color: #4ade80; }
|
||||
.rtfc-badge--teal { color: #2dd4bf; }
|
||||
.rtfc-badge--red { color: #f87171; }
|
||||
.rtfc-badge--yellow { color: #fbbf24; }
|
||||
.rtfc-badge--grey { color: #888; }
|
||||
|
||||
.rtfc-verifying {
|
||||
font-size: 9px;
|
||||
color: #444;
|
||||
font-style: italic;
|
||||
animation: rtfc-pulse 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes rtfc-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.3; }
|
||||
}
|
||||
|
||||
.rtfc-confidence-right {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
margin-left: auto;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.rtfc-timestamp {
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.rtfc-claim {
|
||||
font-size: 11.5px;
|
||||
color: #bbb;
|
||||
font-style: italic;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.rtfc-explanation {
|
||||
font-size: 11px;
|
||||
color: #777;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.rtfc-sources {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.rtfc-sources a {
|
||||
font-size: 10px;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rtfc-sources a:hover { text-decoration: underline; }
|
||||
|
||||
.rtfc-source-text {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Speaker confidence ── */
|
||||
|
||||
.rtfc-speaker-confidence {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.rtfc-speaker-toggle {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rtfc-speaker-toggle:hover { color: #aaa; }
|
||||
|
||||
.rtfc-speaker-dot {
|
||||
width: 5px;
|
||||
height: 5px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rtfc-speaker-dot--green { background: #4ade80; }
|
||||
.rtfc-speaker-dot--red { background: #f87171; }
|
||||
.rtfc-speaker-dot--yellow { background: #fbbf24; }
|
||||
|
||||
.rtfc-speaker-arrow { font-size: 9px; color: #444; }
|
||||
|
||||
.rtfc-speaker-explanation {
|
||||
font-size: 10.5px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-top: 4px;
|
||||
padding-left: 10px;
|
||||
border-left: 1.5px solid #333;
|
||||
}
|
||||
|
||||
/* ── Conviction breakdown ── */
|
||||
|
||||
.rtfc-conviction-row {
|
||||
font-size: 10.5px;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 3px;
|
||||
}
|
||||
|
||||
.rtfc-conviction-label {
|
||||
font-weight: 600;
|
||||
color: #555;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.rtfc-conviction-summary {
|
||||
font-size: 10.5px;
|
||||
color: #555;
|
||||
line-height: 1.5;
|
||||
margin-top: 6px;
|
||||
padding-top: 6px;
|
||||
border-top: 0.5px solid #222;
|
||||
}
|
||||
|
||||
/* ── Speaker tag ── */
|
||||
|
||||
.rtfc-speaker-tag {
|
||||
display: inline-block;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px 3px 0 0;
|
||||
margin-bottom: -1px;
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
/* ── Acoustic features ── */
|
||||
|
||||
.rtfc-acoustic {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.rtfc-acoustic-label {
|
||||
font-size: 10px;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.rtfc-acoustic-rate {
|
||||
font-size: 10px;
|
||||
color: #444;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/* ── Speaker editor chips ── */
|
||||
|
||||
#rtfc-speaker-editor {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.rtfc-speaker-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
}
|
||||
|
||||
.rtfc-speaker-chip-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
width: 52px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
cursor: text;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ── Speaker confirmation banner ── */
|
||||
|
||||
.rtfc-speaker-banner {
|
||||
margin: 6px 12px;
|
||||
padding: 10px 12px;
|
||||
background: rgba(59, 130, 246, 0.08);
|
||||
border: 0.5px solid rgba(59, 130, 246, 0.3);
|
||||
border-radius: 8px;
|
||||
animation: rtfc-in 0.18s ease;
|
||||
}
|
||||
|
||||
.rtfc-speaker-banner-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #aaa;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.rtfc-speaker-banner-sample {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
font-style: italic;
|
||||
margin-bottom: 8px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.rtfc-speaker-banner-buttons {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rtfc-speaker-banner-btn {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
background: rgba(255,255,255,0.06);
|
||||
color: #e8e8e8;
|
||||
cursor: pointer;
|
||||
font-family: 'Inter', sans-serif;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.rtfc-speaker-banner-btn:hover {
|
||||
background: rgba(255,255,255,0.12);
|
||||
}
|
||||
|
||||
.rtfc-speaker-banner-btn--skip {
|
||||
color: #555;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* ── Error toast ── */
|
||||
|
||||
.rtfc-error-toast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 8px 12px;
|
||||
background: rgba(248, 113, 113, 0.12);
|
||||
border-bottom: 0.5px solid rgba(248, 113, 113, 0.3);
|
||||
font-size: 11px;
|
||||
color: #f87171;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rtfc-error-icon { font-size: 12px; flex-shrink: 0; }
|
||||
|
||||
.rtfc-error-msg { flex: 1; line-height: 1.4; }
|
||||
|
||||
.rtfc-error-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #f87171;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
padding: 0;
|
||||
opacity: 0.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.rtfc-error-close:hover { opacity: 1; }
|
||||
@@ -0,0 +1,674 @@
|
||||
// overlay.js
|
||||
|
||||
console.log('[overlay] content script loaded');
|
||||
|
||||
let panel = null;
|
||||
let transcriptFeedEl = null;
|
||||
let interimEl = null;
|
||||
let claimFeedEl = null;
|
||||
let verdictListEl = null;
|
||||
let transcriptCollapsed = false;
|
||||
const pendingCards = new Map();
|
||||
const pendingCardTimes = new Map();
|
||||
|
||||
// expire pending cards after 90 seconds
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, time] of pendingCardTimes) {
|
||||
if (now - time > 90000) {
|
||||
const card = pendingCards.get(key);
|
||||
if (card) {
|
||||
card.classList.remove('rtfc-verdict--pending');
|
||||
const verifying = card.querySelector('.rtfc-verifying');
|
||||
if (verifying) verifying.textContent = '⚠ unverified';
|
||||
}
|
||||
pendingCards.delete(key);
|
||||
pendingCardTimes.delete(key);
|
||||
}
|
||||
}
|
||||
}, 15000);
|
||||
|
||||
let lastTranscriptTimestamp = '';
|
||||
const sentenceTimestamps = [];
|
||||
const MAX_TIMESTAMP_BUFFER = 10;
|
||||
|
||||
// ── Speaker state ────────────────────────────────────────────────────────────
|
||||
let speakers = [];
|
||||
|
||||
// ── Speaker colors ────────────────────────────────────────────────────────────
|
||||
const SPEAKER_COLORS = [
|
||||
'#3b82f6',
|
||||
'#ef4444',
|
||||
'#f59e0b',
|
||||
'#10b981',
|
||||
'#8b5cf6',
|
||||
'#f97316',
|
||||
];
|
||||
const speakerColorMap = new Map();
|
||||
|
||||
function getSpeakerColor(name) {
|
||||
if (!speakerColorMap.has(name)) {
|
||||
const idx = speakerColorMap.size % SPEAKER_COLORS.length;
|
||||
speakerColorMap.set(name, SPEAKER_COLORS[idx]);
|
||||
}
|
||||
return speakerColorMap.get(name);
|
||||
}
|
||||
|
||||
// ── Speaker parsing ───────────────────────────────────────────────────────────
|
||||
function parseSpeakersFromTitle(title) {
|
||||
if (!title) return [];
|
||||
const roleMatch = title.match(/(\d+)\s+([a-z]+(?:\s+[a-z]+)?)\s+(?:vs?\.?|versus)\s+(\d+)\s+([a-z]+(?:\s+[a-z]+)?)/i);
|
||||
if (roleMatch) {
|
||||
const cap = s => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
return [cap(roleMatch[2]), cap(roleMatch[4])];
|
||||
}
|
||||
// only match capitalized proper names (not lowercase words like "in", "the", etc.)
|
||||
const nameMatch = title.match(/([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)\s+(?:and|vs\.?|versus|&)\s+([A-Z][a-z]+(?:\s+[A-Z][a-z]+)?)/);
|
||||
if (nameMatch) {
|
||||
const clean = name => name.trim().split(' ').pop();
|
||||
return [clean(nameMatch[1]), clean(nameMatch[2])];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
let lastActiveSpeaker = null; // track most recently labeled speaker
|
||||
|
||||
function getClaimSpeaker(claimText) {
|
||||
if (!speakers.length) return 'Other';
|
||||
const lower = claimText.toLowerCase();
|
||||
|
||||
// direct name match
|
||||
for (const speaker of speakers) {
|
||||
if (lower.includes(speaker.toLowerCase())) return speaker;
|
||||
}
|
||||
|
||||
// partial name match (handles "Vice President Harris" → "Harris")
|
||||
for (const speaker of speakers) {
|
||||
const parts = speaker.toLowerCase().split(' ');
|
||||
if (parts.some(p => p.length > 3 && lower.includes(p))) return speaker;
|
||||
}
|
||||
|
||||
// fallback: use last active speaker for vague references
|
||||
if (lastActiveSpeaker) return lastActiveSpeaker;
|
||||
|
||||
return 'Other';
|
||||
}
|
||||
|
||||
// ── Speaker ID confirmation ──────────────────────────────────────────────────
|
||||
|
||||
const confirmedSpeakerMap = {}; // { speakerId: 'Harris' }
|
||||
const pendingSpeakerIds = new Set(); // IDs waiting for confirmation
|
||||
|
||||
function showSpeakerBanner(speakerId, sample) {
|
||||
if (pendingSpeakerIds.has(speakerId)) return;
|
||||
if (speakerId in confirmedSpeakerMap) return;
|
||||
// if speakers not yet parsed from title, retry once after 1s
|
||||
if (!speakers.length) {
|
||||
setTimeout(() => showSpeakerBanner(speakerId, sample), 1000);
|
||||
return;
|
||||
}
|
||||
pendingSpeakerIds.add(speakerId);
|
||||
|
||||
const banner = document.createElement('div');
|
||||
banner.className = 'rtfc-speaker-banner';
|
||||
banner.innerHTML =
|
||||
'<div class="rtfc-speaker-banner-text">New speaker detected — who is this?</div>' +
|
||||
'<div class="rtfc-speaker-banner-sample">"' + sample + '..."</div>' +
|
||||
'<div class="rtfc-speaker-banner-buttons">' +
|
||||
speakers.map(name =>
|
||||
'<button class="rtfc-speaker-banner-btn" data-name="' + name + '" data-id="' + speakerId + '">' + name + '</button>'
|
||||
).join('') +
|
||||
'<button class="rtfc-speaker-banner-btn rtfc-speaker-banner-btn--skip" data-id="' + speakerId + '">Skip</button>' +
|
||||
'</div>';
|
||||
|
||||
banner.querySelectorAll('.rtfc-speaker-banner-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const name = btn.dataset.name;
|
||||
const id = parseInt(btn.dataset.id);
|
||||
if (name) {
|
||||
confirmedSpeakerMap[id] = name;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'SPEAKER_NAMES',
|
||||
speakerIdToName: { [id]: name },
|
||||
});
|
||||
}
|
||||
pendingSpeakerIds.delete(id);
|
||||
if (!name) confirmedSpeakerMap[id] = null;
|
||||
banner.remove();
|
||||
// retroactively tag all existing grounded cards now that we have more info
|
||||
retryTagAllCards();
|
||||
});
|
||||
});
|
||||
|
||||
// insert above verdicts
|
||||
const verdictsSection = panel?.querySelector('#rtfc-verdicts-section');
|
||||
if (verdictsSection) verdictsSection.insertAdjacentElement('beforebegin', banner);
|
||||
}
|
||||
|
||||
// ── Speaker confirmation state ───────────────────────────────────────────────
|
||||
|
||||
function allSpeakersConfirmed() {
|
||||
// true when every speaker seen so far has been confirmed or skipped
|
||||
// and at least one real name has been confirmed
|
||||
const confirmedNames = Object.values(confirmedSpeakerMap).filter(v => v !== null);
|
||||
return confirmedNames.length >= Math.min(speakers.length, Object.keys(confirmedSpeakerMap).length)
|
||||
&& Object.keys(confirmedSpeakerMap).length > 0;
|
||||
}
|
||||
|
||||
function retryTagAllCards() {
|
||||
// retroactively tag all grounded cards once speakers are confirmed
|
||||
if (!verdictListEl) return;
|
||||
verdictListEl.querySelectorAll('.rtfc-verdict:not(.rtfc-verdict--pending)').forEach(card => {
|
||||
const sid = card.dataset.speakerid;
|
||||
if (sid === undefined) return;
|
||||
const rawName = confirmedSpeakerMap[sid];
|
||||
if (!rawName) return; // skipped or not confirmed
|
||||
const name = normalizeSpeakerName(rawName);
|
||||
// add or update tag
|
||||
let tag = card.querySelector('.rtfc-speaker-tag');
|
||||
if (tag) {
|
||||
tag.textContent = name;
|
||||
tag.style.background = getSpeakerColor(name);
|
||||
} else {
|
||||
const color = getSpeakerColor(name);
|
||||
tag = document.createElement('div');
|
||||
tag.className = 'rtfc-speaker-tag';
|
||||
tag.style.background = color;
|
||||
tag.textContent = name;
|
||||
card.insertBefore(tag, card.firstChild);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Speaker editor ───────────────────────────────────────────────────────────
|
||||
|
||||
function sendSpeakerMap() {
|
||||
// Deepgram speaker IDs are assigned in order of first appearance
|
||||
// We map ID 0 → speakers[0], ID 1 → speakers[1], etc.
|
||||
const speakerIdToName = {};
|
||||
speakers.forEach((name, i) => { speakerIdToName[i] = name; });
|
||||
chrome.runtime.sendMessage({ type: 'SPEAKER_NAMES', speakerIdToName });
|
||||
}
|
||||
|
||||
function renderSpeakerEditor() {
|
||||
const el = panel?.querySelector('#rtfc-speaker-editor');
|
||||
if (!el || !speakers.length) return;
|
||||
|
||||
el.innerHTML = speakers.map((name, i) => {
|
||||
const color = getSpeakerColor(name);
|
||||
return '<span class="rtfc-speaker-chip" style="border-color:' + color + ';color:' + color + '" data-idx="' + i + '">' +
|
||||
'<input class="rtfc-speaker-chip-input" value="' + name + '" data-idx="' + i + '" style="color:' + color + '" />' +
|
||||
'</span>';
|
||||
}).join('');
|
||||
|
||||
el.querySelectorAll('.rtfc-speaker-chip-input').forEach(input => {
|
||||
input.addEventListener('change', (e) => {
|
||||
const idx = parseInt(e.target.dataset.idx);
|
||||
const oldName = speakers[idx];
|
||||
const newName = e.target.value.trim() || oldName;
|
||||
if (newName === oldName) return;
|
||||
|
||||
// update color map
|
||||
if (speakerColorMap.has(oldName)) {
|
||||
speakerColorMap.set(newName, speakerColorMap.get(oldName));
|
||||
speakerColorMap.delete(oldName);
|
||||
}
|
||||
|
||||
speakers[idx] = newName;
|
||||
e.target.style.color = getSpeakerColor(newName);
|
||||
e.target.closest('.rtfc-speaker-chip').style.borderColor = getSpeakerColor(newName);
|
||||
e.target.closest('.rtfc-speaker-chip').style.color = getSpeakerColor(newName);
|
||||
sendSpeakerMap(); // update service worker with new names
|
||||
|
||||
// re-render all verdict cards to update speaker tags
|
||||
const cards = verdictListEl?.querySelectorAll('.rtfc-speaker-tag');
|
||||
if (cards) {
|
||||
cards.forEach(tag => {
|
||||
if (tag.textContent === oldName) {
|
||||
tag.textContent = newName;
|
||||
tag.style.background = getSpeakerColor(newName);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
// select all on focus for easy editing
|
||||
input.addEventListener('focus', e => e.target.select());
|
||||
});
|
||||
}
|
||||
|
||||
// ── Error toast ──────────────────────────────────────────────────────────────
|
||||
|
||||
function showError(message) {
|
||||
if (!panel) return;
|
||||
const existing = panel.querySelector('.rtfc-error-toast');
|
||||
if (existing) existing.remove();
|
||||
|
||||
const toast = document.createElement('div');
|
||||
toast.className = 'rtfc-error-toast';
|
||||
toast.innerHTML =
|
||||
'<span class="rtfc-error-icon">⚠</span>' +
|
||||
'<span class="rtfc-error-msg">' + message + '</span>' +
|
||||
'<button class="rtfc-error-close">✕</button>';
|
||||
|
||||
toast.querySelector('.rtfc-error-close').addEventListener('click', () => toast.remove());
|
||||
panel.querySelector('#rtfc-header').insertAdjacentElement('afterend', toast);
|
||||
|
||||
// auto-dismiss after 8 seconds unless it's a fatal error
|
||||
if (!message.includes('failed') && !message.includes('key')) {
|
||||
setTimeout(() => toast.remove(), 8000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Panel ─────────────────────────────────────────────────────────────────────
|
||||
function createPanel() {
|
||||
if (panel) return;
|
||||
|
||||
panel = document.createElement('div');
|
||||
panel.id = 'rtfc-panel';
|
||||
panel.innerHTML = [
|
||||
'<div id="rtfc-header">',
|
||||
'<span><span class="rtfc-dot"></span>InTruth</span>',
|
||||
'<div class="rtfc-header-actions">',
|
||||
'<button id="rtfc-export" title="Export session as PDF">↓ Export</button>',
|
||||
'<button id="rtfc-close">✕</button>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'<div id="rtfc-body">',
|
||||
'<div id="rtfc-transcript-section">',
|
||||
'<div class="rtfc-section-header">',
|
||||
'<span class="rtfc-section-label">Transcript</span>',
|
||||
'<button class="rtfc-toggle-btn" id="rtfc-transcript-toggle">▾</button>',
|
||||
'</div>',
|
||||
'<div id="rtfc-transcript-feed"></div>',
|
||||
'<p id="rtfc-interim"></p>',
|
||||
'</div>',
|
||||
'<div id="rtfc-claims-section">',
|
||||
'<div class="rtfc-section-header">',
|
||||
'<span class="rtfc-section-label">Claims</span>',
|
||||
'</div>',
|
||||
'<ul id="rtfc-claim-feed"></ul>',
|
||||
'</div>',
|
||||
'<div id="rtfc-verdicts-section">',
|
||||
'<div class="rtfc-section-header">',
|
||||
'<span class="rtfc-section-label">Verdicts</span>',
|
||||
'<div id="rtfc-speaker-editor"></div>',
|
||||
'</div>',
|
||||
'<div id="rtfc-verdicts">',
|
||||
'<p class="rtfc-empty">Verdicts will appear here...</p>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
'</div>',
|
||||
].join('');
|
||||
|
||||
document.body.appendChild(panel);
|
||||
|
||||
transcriptFeedEl = panel.querySelector('#rtfc-transcript-feed');
|
||||
interimEl = panel.querySelector('#rtfc-interim');
|
||||
claimFeedEl = panel.querySelector('#rtfc-claim-feed');
|
||||
verdictListEl = panel.querySelector('#rtfc-verdicts');
|
||||
|
||||
panel.querySelector('#rtfc-close').addEventListener('click', () => {
|
||||
chrome.runtime.sendMessage({ type: 'STOP_FACTCHECK' });
|
||||
removePanel();
|
||||
});
|
||||
|
||||
panel.querySelector('#rtfc-export').addEventListener('click', () => exportPDF());
|
||||
|
||||
makeDraggable(panel);
|
||||
|
||||
panel.querySelector('#rtfc-transcript-toggle').addEventListener('click', () => {
|
||||
transcriptCollapsed = !transcriptCollapsed;
|
||||
transcriptFeedEl.style.display = transcriptCollapsed ? 'none' : '';
|
||||
interimEl.style.display = transcriptCollapsed ? 'none' : '';
|
||||
panel.querySelector('#rtfc-transcript-toggle').textContent = transcriptCollapsed ? '▸' : '▾';
|
||||
});
|
||||
}
|
||||
|
||||
function removePanel() {
|
||||
panel?.remove();
|
||||
panel = null;
|
||||
transcriptFeedEl = null;
|
||||
interimEl = null;
|
||||
claimFeedEl = null;
|
||||
verdictListEl = null;
|
||||
transcriptCollapsed = false;
|
||||
pendingCards.clear();
|
||||
pendingCardTimes.clear();
|
||||
speakers = [];
|
||||
speakerColorMap.clear();
|
||||
sentenceTimestamps.length = 0;
|
||||
lastTranscriptTimestamp = '';
|
||||
lastActiveSpeaker = null;
|
||||
Object.keys(confirmedSpeakerMap).forEach(k => delete confirmedSpeakerMap[k]);
|
||||
pendingSpeakerIds.clear();
|
||||
}
|
||||
|
||||
// ── Transcript ────────────────────────────────────────────────────────────────
|
||||
function addTranscriptText(text) {
|
||||
if (!transcriptFeedEl) return;
|
||||
const span = document.createElement('span');
|
||||
span.textContent = text + ' ';
|
||||
span.className = 'rtfc-transcript-word';
|
||||
transcriptFeedEl.appendChild(span);
|
||||
transcriptFeedEl.scrollTop = transcriptFeedEl.scrollHeight;
|
||||
}
|
||||
|
||||
function updateInterim(text) {
|
||||
if (!interimEl) return;
|
||||
interimEl.textContent = text;
|
||||
}
|
||||
|
||||
function clearInterim() {
|
||||
if (!interimEl) return;
|
||||
interimEl.textContent = '';
|
||||
}
|
||||
|
||||
// ── Claims ────────────────────────────────────────────────────────────────────
|
||||
function addClaimBullet(claim) {
|
||||
if (!claimFeedEl) return;
|
||||
const li = document.createElement('li');
|
||||
li.className = 'rtfc-claim-bullet rtfc-claim-bullet--pending';
|
||||
li.dataset.claim = claim.toLowerCase().slice(0, 40);
|
||||
li.textContent = claim;
|
||||
claimFeedEl.appendChild(li);
|
||||
return li;
|
||||
}
|
||||
|
||||
function applyVerdictToBullet(claim, verdict, confidence) {
|
||||
if (!claimFeedEl) return;
|
||||
const color = colorForVerdict(verdict, confidence);
|
||||
const claimWords = new Set(claim.toLowerCase().split(/\s+/).filter(w => w.length >= 4));
|
||||
const bullets = claimFeedEl.querySelectorAll('.rtfc-claim-bullet');
|
||||
let bestLi = null, bestScore = 0;
|
||||
for (const li of bullets) {
|
||||
const bulletWords = (li.textContent || '').toLowerCase().split(/\s+/).filter(w => w.length >= 4);
|
||||
const overlap = bulletWords.filter(w => claimWords.has(w)).length;
|
||||
const score = overlap / Math.max(claimWords.size, bulletWords.length);
|
||||
if (score > bestScore) { bestScore = score; bestLi = li; }
|
||||
}
|
||||
if (bestLi && bestScore >= 0.3) {
|
||||
bestLi.className = 'rtfc-claim-bullet rtfc-claim-bullet--' + color;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Verdicts ──────────────────────────────────────────────────────────────────
|
||||
function colorForVerdict(verdict, confidence) {
|
||||
if (confidence === 'LOW') return 'yellow';
|
||||
if (verdict === 'TRUE') return 'green';
|
||||
if (verdict === 'SUBSTANTIALLY TRUE') return 'teal';
|
||||
if (verdict === 'FALSE') return 'red';
|
||||
if (verdict === 'MISLEADING') return 'yellow';
|
||||
if (verdict === 'UNVERIFIABLE') return 'grey';
|
||||
return 'grey';
|
||||
}
|
||||
|
||||
function buildLexicalRows(lexical) {
|
||||
if (!lexical) return '';
|
||||
const rows = [];
|
||||
const r = lexical.rates || {};
|
||||
if (r.hedging > 0)
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">Hedging language:</span> ' + r.hedging + '% rate — e.g. "I think", "maybe", "probably"</div>');
|
||||
if (r.certainty > 0)
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">Certainty markers:</span> ' + r.certainty + '% rate — e.g. "definitely", "always"</div>');
|
||||
if (r.filler > 0)
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">Filler words:</span> ' + r.filler + '% rate — e.g. "um", "like", "you know"</div>');
|
||||
if (r.emotional > 0)
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">Emotional language:</span> ' + r.emotional + '% rate</div>');
|
||||
if (r.exclusive > 0)
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">Qualifying words:</span> ' + r.exclusive + '% rate — e.g. "but", "except"</div>');
|
||||
if (r.firstPersonSg > 0)
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">First-person singular:</span> ' + r.firstPersonSg + '% rate</div>');
|
||||
if (lexical.wordsPerSecond != null) {
|
||||
const rateDesc = lexical.wordsPerSecond > 3.5 ? 'fast' : lexical.wordsPerSecond < 2 ? 'slow' : 'moderate';
|
||||
rows.push('<div class="rtfc-conviction-row"><span class="rtfc-conviction-label">Speech rate:</span> ' + lexical.wordsPerSecond + ' w/s (' + rateDesc + ')</div>');
|
||||
}
|
||||
return rows.join('');
|
||||
}
|
||||
|
||||
function buildCard(result) {
|
||||
const color = colorForVerdict(result.verdict, result.confidence);
|
||||
const convictionColor = result.speaker_confidence === 'HIGH' ? 'green'
|
||||
: result.speaker_confidence === 'LOW' ? 'red'
|
||||
: 'yellow';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'rtfc-verdict rtfc-verdict--' + color + (result.pending ? ' rtfc-verdict--pending' : '');
|
||||
card.dataset.claim = result.claim.toLowerCase().slice(0, 40);
|
||||
if (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined) {
|
||||
card.dataset.speakerid = String(result.dominantSpeakerId);
|
||||
}
|
||||
card._resultData = result;
|
||||
|
||||
const sourcesHTML = (result.sources ?? []).map((url, i) => {
|
||||
const isUrl = url.startsWith('http://') || url.startsWith('https://');
|
||||
return isUrl
|
||||
? '<a href="' + url + '" target="_blank" rel="noopener">Source ' + (i + 1) + '</a>'
|
||||
: '<span class="rtfc-source-text">' + url + '</span>';
|
||||
}).join('');
|
||||
|
||||
const lexicalRows = buildLexicalRows(result.lexical);
|
||||
|
||||
// speaker tag — only show on grounded cards AND only when all speakers confirmed
|
||||
// this prevents wrong tags from appearing before diarization stabilizes
|
||||
let speakerTag = '';
|
||||
if (!result.pending && allSpeakersConfirmed()) {
|
||||
const confirmedName = (result.dominantSpeakerId !== null && result.dominantSpeakerId !== undefined)
|
||||
? confirmedSpeakerMap[result.dominantSpeakerId]
|
||||
: undefined;
|
||||
const rawSpeaker = (confirmedName !== undefined && confirmedName !== null)
|
||||
? confirmedName
|
||||
: result.speaker || null;
|
||||
const normalizedName = rawSpeaker ? normalizeSpeakerName(rawSpeaker) : null;
|
||||
const speakerName = (normalizedName && !normalizedName.match(/^Speaker\s*\d+$/i)) ? normalizedName : null;
|
||||
const speakerColor = speakerName ? getSpeakerColor(speakerName) : null;
|
||||
if (speakerColor) {
|
||||
speakerTag = '<div class="rtfc-speaker-tag" style="background:' + speakerColor + '">' + speakerName + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
card.innerHTML = [
|
||||
speakerTag,
|
||||
'<div class="rtfc-verdict-header">',
|
||||
'<span class="rtfc-badge rtfc-badge--' + color + '">' + result.verdict + '</span>',
|
||||
result.pending ? '<span class="rtfc-verifying">⟳ verifying...</span>' : '',
|
||||
'<span class="rtfc-confidence-right">' + result.confidence + ' certainty</span>',
|
||||
'<span class="rtfc-timestamp">' + (result._timestamp || '') + '</span>',
|
||||
'</div>',
|
||||
'<p class="rtfc-claim">"' + result.claim + '"</p>',
|
||||
'<p class="rtfc-explanation">' + result.explanation + '</p>',
|
||||
'<div class="rtfc-speaker-confidence">',
|
||||
'<button class="rtfc-speaker-toggle">',
|
||||
'<span class="rtfc-speaker-dot rtfc-speaker-dot--' + convictionColor + '"></span>',
|
||||
'Speaker conviction: ' + (result.speaker_confidence || 'N/A'),
|
||||
'<span class="rtfc-speaker-arrow">▾</span>',
|
||||
'</button>',
|
||||
'<div class="rtfc-speaker-explanation" style="display:none">',
|
||||
lexicalRows,
|
||||
'</div>',
|
||||
'</div>',
|
||||
(sourcesHTML && sourcesHTML.trim()) ? '<div class="rtfc-sources">' + sourcesHTML + '</div>' : '',
|
||||
].join('');
|
||||
|
||||
const toggleBtn = card.querySelector('.rtfc-speaker-toggle');
|
||||
const reasons = card.querySelector('.rtfc-speaker-explanation');
|
||||
const arrow = card.querySelector('.rtfc-speaker-arrow');
|
||||
toggleBtn.addEventListener('click', () => {
|
||||
const open = reasons.style.display === 'none';
|
||||
reasons.style.display = open ? 'block' : 'none';
|
||||
arrow.textContent = open ? '▴' : '▾';
|
||||
});
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function findPendingCard(claim) {
|
||||
const key = claim.toLowerCase().slice(0, 40);
|
||||
if (pendingCards.has(key)) return pendingCards.get(key);
|
||||
|
||||
const claimWords = new Set(claim.toLowerCase().split(/\s+/).filter(w => w.length >= 4));
|
||||
let bestCard = null, bestScore = 0;
|
||||
for (const [cardKey, card] of pendingCards) {
|
||||
const cardWords = cardKey.split(/\s+/).filter(w => w.length >= 4);
|
||||
const overlap = cardWords.filter(w => claimWords.has(w)).length;
|
||||
const score = overlap / Math.max(claimWords.size, cardWords.length);
|
||||
if (score > bestScore) { bestScore = score; bestCard = card; }
|
||||
}
|
||||
if (bestScore >= 0.4) return bestCard;
|
||||
return verdictListEl?.querySelector('.rtfc-verdict--pending');
|
||||
}
|
||||
|
||||
function getVideoTimestamp() {
|
||||
const video = document.querySelector('video');
|
||||
if (!video) return '';
|
||||
const s = Math.floor(video.currentTime);
|
||||
const h = Math.floor(s / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
const sec = s % 60;
|
||||
if (h > 0) return h + ':' + String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
|
||||
return String(m).padStart(2,'0') + ':' + String(sec).padStart(2,'0');
|
||||
}
|
||||
|
||||
function getClaimTimestamp(claim) {
|
||||
if (!sentenceTimestamps.length) return lastTranscriptTimestamp || getVideoTimestamp();
|
||||
const claimWords = new Set(claim.toLowerCase().split(/\s+/).filter(w => w.length >= 4));
|
||||
let bestMatch = null, bestScore = 0;
|
||||
for (const entry of sentenceTimestamps) {
|
||||
const sentWords = entry.text.toLowerCase().split(/\s+/).filter(w => w.length >= 4);
|
||||
const overlap = sentWords.filter(w => claimWords.has(w)).length;
|
||||
const score = overlap / Math.max(claimWords.size, sentWords.length);
|
||||
if (score > bestScore) { bestScore = score; bestMatch = entry; }
|
||||
}
|
||||
return bestScore >= 0.3 ? bestMatch.timestamp : (lastTranscriptTimestamp || getVideoTimestamp());
|
||||
}
|
||||
|
||||
function addVerdict(result) {
|
||||
if (!verdictListEl) return;
|
||||
verdictListEl.querySelector('.rtfc-empty')?.remove();
|
||||
applyVerdictToBullet(result.claim, result.verdict, result.confidence);
|
||||
if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim);
|
||||
const card = buildCard(result);
|
||||
if (result.pending) {
|
||||
const key = result.claim.toLowerCase().slice(0, 40);
|
||||
pendingCards.set(key, card);
|
||||
pendingCardTimes.set(key, Date.now());
|
||||
} else {
|
||||
logVerdict(result);
|
||||
}
|
||||
verdictListEl.prepend(card);
|
||||
}
|
||||
|
||||
function updateVerdict(result) {
|
||||
const existing = findPendingCard(result.claim);
|
||||
if (!result._timestamp) result._timestamp = getClaimTimestamp(result.claim);
|
||||
// inherit dominantSpeakerId from pending card if grounded result doesn't have one
|
||||
if (existing && existing.dataset.speakerid && !result.dominantSpeakerId) {
|
||||
result.dominantSpeakerId = existing.dataset.speakerid;
|
||||
}
|
||||
const newCard = buildCard(result);
|
||||
if (existing) {
|
||||
existing.replaceWith(newCard);
|
||||
for (const [k, v] of pendingCards) {
|
||||
if (v === existing) { pendingCards.delete(k); pendingCardTimes.delete(k); break; }
|
||||
}
|
||||
} else {
|
||||
verdictListEl?.querySelector('.rtfc-empty')?.remove();
|
||||
verdictListEl?.prepend(newCard);
|
||||
}
|
||||
applyVerdictToBullet(result.claim, result.verdict, result.confidence);
|
||||
logVerdict(result);
|
||||
}
|
||||
|
||||
function makeDraggable(panel) {
|
||||
const header = panel.querySelector('#rtfc-header');
|
||||
let isDragging = false, startX, startY, startLeft, startTop;
|
||||
header.addEventListener('mousedown', (e) => {
|
||||
if (e.target.id === 'rtfc-close' || e.target.id === 'rtfc-export') return;
|
||||
isDragging = true;
|
||||
startX = e.clientX; startY = e.clientY;
|
||||
const rect = panel.getBoundingClientRect();
|
||||
startLeft = rect.left; startTop = rect.top;
|
||||
header.style.cursor = 'grabbing';
|
||||
e.preventDefault();
|
||||
});
|
||||
document.addEventListener('mousemove', (e) => {
|
||||
if (!isDragging) return;
|
||||
panel.style.right = 'unset';
|
||||
panel.style.left = Math.max(0, startLeft + e.clientX - startX) + 'px';
|
||||
panel.style.top = Math.max(0, startTop + e.clientY - startY) + 'px';
|
||||
});
|
||||
document.addEventListener('mouseup', () => { isDragging = false; header.style.cursor = 'grab'; });
|
||||
}
|
||||
|
||||
// ── Messages ──────────────────────────────────────────────────────────────────
|
||||
chrome.runtime.onMessage.addListener((msg) => {
|
||||
console.log('[overlay] message received:', msg.type);
|
||||
switch (msg.type) {
|
||||
|
||||
case 'START_FACTCHECK':
|
||||
createPanel();
|
||||
startSession();
|
||||
speakers = parseSpeakersFromTitle(document.title || '');
|
||||
speakerColorMap.clear();
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'PAGE_TITLE',
|
||||
title: document.title || '',
|
||||
date: (() => {
|
||||
const el = document.querySelector('meta[itemprop="uploadDate"]') ||
|
||||
document.querySelector('meta[property="og:updated_time"]');
|
||||
return el ? new Date(el.content).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' }) : '';
|
||||
})(),
|
||||
});
|
||||
renderSpeakerEditor();
|
||||
break;
|
||||
|
||||
case 'STOP_FACTCHECK':
|
||||
stopSession();
|
||||
removePanel();
|
||||
break;
|
||||
|
||||
case 'TRANSCRIPT_RESULT':
|
||||
if (msg.interim) {
|
||||
updateInterim(msg.text);
|
||||
} else if (msg.isFinal) {
|
||||
const ts = getVideoTimestamp();
|
||||
lastTranscriptTimestamp = ts;
|
||||
sentenceTimestamps.push({ text: msg.text, timestamp: ts });
|
||||
if (sentenceTimestamps.length > MAX_TIMESTAMP_BUFFER) sentenceTimestamps.shift();
|
||||
clearInterim();
|
||||
// strip [Speaker N] prefix before displaying
|
||||
const displayText = msg.text.replace(/^\[.*?\]\s*/, '');
|
||||
addTranscriptText(displayText);
|
||||
// track which speaker is active from label
|
||||
const labelMatch = msg.text.match(/^\[(.+?)\]/);
|
||||
if (labelMatch && speakers.includes(labelMatch[1])) {
|
||||
lastActiveSpeaker = labelMatch[1];
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'NEW_SPEAKER':
|
||||
if (panel) showSpeakerBanner(msg.speakerId, msg.sample || '');
|
||||
break;
|
||||
|
||||
case 'PIPELINE_ERROR':
|
||||
showError(msg.message || 'An error occurred in the fact-checking pipeline.');
|
||||
break;
|
||||
|
||||
case 'NEW_VERDICT':
|
||||
if (msg.results) {
|
||||
for (const result of msg.results) {
|
||||
addClaimBullet(result.claim);
|
||||
addVerdict(result);
|
||||
}
|
||||
}
|
||||
break;
|
||||
|
||||
case 'UPDATE_VERDICTS':
|
||||
if (msg.results) {
|
||||
for (const result of msg.results) {
|
||||
updateVerdict(result);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,169 @@
|
||||
// session-export.js
|
||||
// Handles session logging and PDF export.
|
||||
// Loaded after overlay.js — exposes logVerdict(), startSession(), stopSession(), exportPDF() as globals.
|
||||
|
||||
const sessionLog = [];
|
||||
let sessionStartTime = null;
|
||||
|
||||
function logVerdict(result) {
|
||||
sessionLog.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
secondsElapsed: sessionStartTime ? Math.round((Date.now() - sessionStartTime) / 1000) : 0,
|
||||
claim: result.claim,
|
||||
verdict: result.verdict,
|
||||
confidence: result.confidence,
|
||||
explanation: result.explanation,
|
||||
speakerConfidence: result.speaker_confidence,
|
||||
speakerExplanation: result.speaker_confidence_explanation,
|
||||
speakerName: result.speaker || null,
|
||||
sources: result.sources ?? [],
|
||||
});
|
||||
}
|
||||
|
||||
function startSession() {
|
||||
sessionLog.length = 0;
|
||||
sessionStartTime = Date.now();
|
||||
}
|
||||
|
||||
function stopSession() {
|
||||
sessionStartTime = null;
|
||||
}
|
||||
|
||||
function exportPDF() {
|
||||
if (!sessionLog.length) {
|
||||
alert('No claims detected in this session yet.');
|
||||
return;
|
||||
}
|
||||
|
||||
const pageTitle = document.title || 'Fact Check Session';
|
||||
const exportDate = new Date().toLocaleString();
|
||||
|
||||
const verdictColor = (v, c) => {
|
||||
if (c === 'LOW') return '#b45309';
|
||||
if (v === 'TRUE') return '#15803d';
|
||||
if (v === 'SUBSTANTIALLY TRUE') return '#0d9488';
|
||||
if (v === 'FALSE') return '#b91c1c';
|
||||
if (v === 'MISLEADING') return '#b45309';
|
||||
return '#6b7280';
|
||||
};
|
||||
|
||||
// group by speaker
|
||||
const speakerGroups = {};
|
||||
const speakerOrder = [];
|
||||
sessionLog.forEach((entry, i) => {
|
||||
// filter out unresolved Speaker N labels — group under Unknown
|
||||
const rawSpk = entry.speakerName;
|
||||
const spk = (rawSpk && !rawSpk.match(/^Speaker\s*\d+$/i) && rawSpk !== 'Other')
|
||||
? rawSpk
|
||||
: 'Unknown';
|
||||
if (!speakerGroups[spk]) { speakerGroups[spk] = []; speakerOrder.push(spk); }
|
||||
speakerGroups[spk].push({ entry, i });
|
||||
});
|
||||
|
||||
const speakerColors = ['#3b82f6', '#ef4444', '#f59e0b', '#10b981', '#8b5cf6', '#f97316'];
|
||||
|
||||
const claimsHTML = speakerOrder.map((spk, spkIdx) => {
|
||||
const color = spk !== 'Other' ? speakerColors[spkIdx % speakerColors.length] : '#888';
|
||||
const headerHTML = '<div class="speaker-section-header" style="border-left:3px solid ' + color + '">' +
|
||||
'<span class="speaker-section-name" style="color:' + color + '">' + spk + '</span>' +
|
||||
'<span class="speaker-section-count">' + speakerGroups[spk].length + ' claim' + (speakerGroups[spk].length !== 1 ? 's' : '') + '</span>' +
|
||||
'</div>';
|
||||
|
||||
const cardsHTML = speakerGroups[spk].map(({ entry, i }) => {
|
||||
const minutes = Math.floor(entry.secondsElapsed / 60);
|
||||
const seconds = entry.secondsElapsed % 60;
|
||||
const timestamp = String(minutes).padStart(2, '0') + ':' + String(seconds).padStart(2, '0');
|
||||
const vcolor = verdictColor(entry.verdict, entry.confidence);
|
||||
|
||||
const sourcesHTML = entry.sources.length
|
||||
? '<div class="sources"><span class="sources-label">Sources:</span>' +
|
||||
entry.sources.map((url, j) =>
|
||||
'<a href="' + url + '" class="source-link">Source ' + (j + 1) + '</a>'
|
||||
).join('') + '</div>'
|
||||
: '';
|
||||
|
||||
return '<div class="claim-card">' +
|
||||
'<div class="claim-header">' +
|
||||
'<span class="claim-number">#' + (i + 1) + '</span>' +
|
||||
'<span class="verdict" style="color:' + vcolor + '">' + entry.verdict + '</span>' +
|
||||
'<span class="confidence">' + entry.confidence + ' certainty</span>' +
|
||||
'<span class="timestamp">' + timestamp + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="claim-text">"' + entry.claim + '"</div>' +
|
||||
'<div class="explanation">' + entry.explanation + '</div>' +
|
||||
'<div class="speaker-row"><span class="speaker-label">Speaker conviction:</span> ' +
|
||||
(entry.speakerConfidence || 'N/A') +
|
||||
'</div>' +
|
||||
sourcesHTML +
|
||||
'</div>';
|
||||
}).join('');
|
||||
|
||||
return headerHTML + cardsHTML;
|
||||
}).join('');
|
||||
|
||||
const trueCount = sessionLog.filter(e => e.verdict === 'TRUE').length;
|
||||
const subTrueCount = sessionLog.filter(e => e.verdict === 'SUBSTANTIALLY TRUE').length;
|
||||
const falseCount = sessionLog.filter(e => e.verdict === 'FALSE').length;
|
||||
const misleadingCount = sessionLog.filter(e => e.verdict === 'MISLEADING').length;
|
||||
const unverifiableCount = sessionLog.filter(e => e.verdict === 'UNVERIFIABLE').length;
|
||||
|
||||
const html = '<!DOCTYPE html><html><head><meta charset="utf-8"/>' +
|
||||
'<title>Fact Check Report</title><style>' +
|
||||
'* { box-sizing: border-box; margin: 0; padding: 0; }' +
|
||||
'body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 13px; color: #111; padding: 40px; max-width: 800px; margin: 0 auto; line-height: 1.5; }' +
|
||||
'.report-header { border-bottom: 2px solid #111; padding-bottom: 16px; margin-bottom: 24px; }' +
|
||||
'.report-title { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 4px; }' +
|
||||
'.report-meta { font-size: 11px; color: #666; }' +
|
||||
'.report-meta span { margin-right: 16px; }' +
|
||||
'.summary { display: flex; gap: 16px; margin-bottom: 28px; padding: 16px; background: #f8f8f8; border-radius: 8px; }' +
|
||||
'.summary-item { display: flex; flex-direction: column; align-items: center; flex: 1; }' +
|
||||
'.summary-count { font-size: 24px; font-weight: 700; }' +
|
||||
'.summary-count.true { color: #15803d; } .summary-count.subtrue { color: #0d9488; } .summary-count.false { color: #b91c1c; } .summary-count.misleading { color: #b45309; } .summary-count.unverifiable { color: #6b7280; }' +
|
||||
'.summary-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; color: #888; margin-top: 2px; }' +
|
||||
'.claims-title { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; color: #888; margin-bottom: 12px; }' +
|
||||
'.claim-card { border: 1px solid #e5e5e5; border-radius: 8px; padding: 14px 16px; margin-bottom: 12px; page-break-inside: avoid; }' +
|
||||
'.claim-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }' +
|
||||
'.claim-number { font-size: 10px; color: #aaa; font-weight: 600; }' +
|
||||
'.verdict { font-size: 11px; font-weight: 700; letter-spacing: 0.05em; text-transform: uppercase; }' +
|
||||
'.confidence { font-size: 10px; color: #888; }' +
|
||||
'.timestamp { font-size: 10px; color: #aaa; margin-left: auto; }' +
|
||||
'.claim-text { font-size: 13px; font-style: italic; color: #333; margin-bottom: 6px; }' +
|
||||
'.explanation { font-size: 12px; color: #555; margin-bottom: 6px; }' +
|
||||
'.speaker-row { font-size: 11px; color: #888; margin-bottom: 4px; }' +
|
||||
'.speaker-label { font-weight: 600; }' +
|
||||
'.sources { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-top: 6px; }' +
|
||||
'.sources-label { font-size: 10px; color: #aaa; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }' +
|
||||
'.source-link { font-size: 10px; color: #1d4ed8; text-decoration: none; }' +
|
||||
'.speaker-section-header { display: flex; align-items: center; gap: 10px; padding: 8px 12px; margin: 20px 0 8px; background: #f8f8f8; border-radius: 6px; }' +
|
||||
'.speaker-section-name { font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; }' +
|
||||
'.speaker-section-count { font-size: 11px; color: #888; margin-left: auto; }' +
|
||||
'@media print { body { padding: 20px; } .claim-card { page-break-inside: avoid; } }' +
|
||||
'</style></head><body>' +
|
||||
'<div class="report-header">' +
|
||||
'<div class="report-title">Fact Check Report</div>' +
|
||||
'<div class="report-meta">' +
|
||||
'<span>📺 ' + pageTitle + '</span>' +
|
||||
'<span>🕐 ' + exportDate + '</span>' +
|
||||
'<span>📋 ' + sessionLog.length + ' claim' + (sessionLog.length !== 1 ? 's' : '') + ' detected</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="summary">' +
|
||||
'<div class="summary-item"><span class="summary-count true">' + trueCount + '</span><span class="summary-label">True</span></div>' +
|
||||
'<div class="summary-item"><span class="summary-count subtrue">' + subTrueCount + '</span><span class="summary-label">Substantially True</span></div>' +
|
||||
'<div class="summary-item"><span class="summary-count false">' + falseCount + '</span><span class="summary-label">False</span></div>' +
|
||||
'<div class="summary-item"><span class="summary-count misleading">' + misleadingCount + '</span><span class="summary-label">Misleading</span></div>' +
|
||||
'<div class="summary-item"><span class="summary-count unverifiable">' + unverifiableCount + '</span><span class="summary-label">Unverifiable</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="claims-title">Claims (' + sessionLog.length + ')</div>' +
|
||||
claimsHTML +
|
||||
'</body></html>';
|
||||
|
||||
// window.open is blocked in extensions — use blob URL instead
|
||||
const blob = new Blob([html], { type: 'text/html' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = 'factcheck-report.html';
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
// offscreen.js
|
||||
// Captures tab audio via tabCapture and streams to Deepgram WebSocket.
|
||||
// Deepgram handles transcription — no Web Speech API.
|
||||
|
||||
const DEEPGRAM_KEY = '';
|
||||
|
||||
let mediaStream = null;
|
||||
let audioContext = null;
|
||||
let processor = null;
|
||||
let socket = null;
|
||||
let active = false;
|
||||
|
||||
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
|
||||
if (msg.type === 'START_CAPTURE') {
|
||||
startCapture(msg.streamId)
|
||||
.then(() => sendResponse({ ok: true }))
|
||||
.catch(err => {
|
||||
console.error('[offscreen] error:', err);
|
||||
sendResponse({ ok: false, error: err.message });
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
if (msg.type === 'STOP_CAPTURE') {
|
||||
stopCapture();
|
||||
sendResponse({ ok: true });
|
||||
}
|
||||
});
|
||||
|
||||
let utteranceBuffer = '';
|
||||
|
||||
async function startCapture(streamId) {
|
||||
if (active) stopCapture();
|
||||
active = true;
|
||||
|
||||
// get tab audio stream
|
||||
mediaStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
mandatory: {
|
||||
chromeMediaSource: 'tab',
|
||||
chromeMediaSourceId: streamId,
|
||||
},
|
||||
},
|
||||
video: false,
|
||||
});
|
||||
|
||||
// connect deepgram websocket
|
||||
socket = new WebSocket(
|
||||
'wss://api.deepgram.com/v1/listen?' + [
|
||||
'encoding=linear16',
|
||||
'sample_rate=16000',
|
||||
'channels=1',
|
||||
'model=nova-2',
|
||||
'language=en-US',
|
||||
'punctuate=true',
|
||||
'interim_results=true',
|
||||
'utterance_end_ms=2500',
|
||||
'smart_format=true',
|
||||
'vad_events=true',
|
||||
'diarize=true',
|
||||
].join('&'),
|
||||
['token', DEEPGRAM_KEY]
|
||||
);
|
||||
|
||||
socket.onopen = () => {
|
||||
console.log('[offscreen] deepgram connected');
|
||||
startAudioPipeline();
|
||||
};
|
||||
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
// handle utterance end event
|
||||
if (data.type === 'UtteranceEnd') {
|
||||
chrome.runtime.sendMessage({ type: 'UTTERANCE_END' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = data.channel?.alternatives?.[0];
|
||||
if (!result || !result.transcript) return;
|
||||
|
||||
const text = result.transcript.trim();
|
||||
const isFinal = data.is_final;
|
||||
const speech = data.speech_final;
|
||||
const speaker = result.words?.[0]?.speaker ?? null;
|
||||
|
||||
if (!text) return;
|
||||
|
||||
if (isFinal && speech) {
|
||||
// speech_final = end of utterance — send full accumulated text as final
|
||||
const fullText = utteranceBuffer ? utteranceBuffer + ' ' + text : text;
|
||||
utteranceBuffer = '';
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'TRANSCRIPT_RESULT',
|
||||
text: fullText.trim(),
|
||||
isFinal: true,
|
||||
interim: false,
|
||||
speaker,
|
||||
});
|
||||
} else if (isFinal && !speech) {
|
||||
// is_final but not speech_final — accumulate and show as interim
|
||||
utteranceBuffer += (utteranceBuffer ? ' ' : '') + text;
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'TRANSCRIPT_RESULT',
|
||||
text: utteranceBuffer,
|
||||
isFinal: false,
|
||||
interim: true,
|
||||
speaker,
|
||||
});
|
||||
} else {
|
||||
// regular interim — show as-is
|
||||
chrome.runtime.sendMessage({
|
||||
type: 'TRANSCRIPT_RESULT',
|
||||
text,
|
||||
isFinal: false,
|
||||
interim: true,
|
||||
speaker,
|
||||
});
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('[offscreen] message parse error:', err);
|
||||
}
|
||||
};
|
||||
|
||||
socket.onerror = (err) => {
|
||||
console.error('[offscreen] deepgram error:', err);
|
||||
chrome.runtime.sendMessage({ type: 'PIPELINE_ERROR', message: 'Transcription error — check your Deepgram key.' }).catch(() => {});
|
||||
};
|
||||
socket.onclose = (e) => {
|
||||
console.log('[offscreen] deepgram closed:', e.code, e.reason);
|
||||
if (e.code === 1008 || e.code === 1011) {
|
||||
// auth error or server error — don't reconnect, notify user
|
||||
chrome.runtime.sendMessage({ type: 'PIPELINE_ERROR', message: 'Deepgram connection failed (code ' + e.code + '). Check your API key.' }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
if (active) {
|
||||
chrome.runtime.sendMessage({ type: 'PIPELINE_ERROR', message: 'Transcription disconnected — reconnecting...' }).catch(() => {});
|
||||
// request a fresh stream ID from service worker instead of reusing expired one
|
||||
setTimeout(() => {
|
||||
chrome.runtime.sendMessage({ type: 'REQUEST_NEW_STREAM' }).catch(() => {});
|
||||
}, 1000);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function startAudioPipeline() {
|
||||
audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
const source = audioContext.createMediaStreamSource(mediaStream);
|
||||
|
||||
// reconnect so user still hears video
|
||||
source.connect(audioContext.destination);
|
||||
|
||||
processor = audioContext.createScriptProcessor(4096, 1, 1);
|
||||
processor.onaudioprocess = (e) => {
|
||||
if (socket?.readyState !== WebSocket.OPEN) return;
|
||||
|
||||
const float32 = e.inputBuffer.getChannelData(0);
|
||||
|
||||
// convert float32 to int16 for Deepgram
|
||||
const int16 = new Int16Array(float32.length);
|
||||
for (let i = 0; i < float32.length; i++) {
|
||||
int16[i] = Math.max(-32768, Math.min(32767, float32[i] * 32768));
|
||||
}
|
||||
|
||||
socket.send(int16.buffer);
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
console.log('[offscreen] audio pipeline started');
|
||||
}
|
||||
|
||||
function stopCapture() {
|
||||
active = false;
|
||||
utteranceBuffer = '';
|
||||
|
||||
if (socket) {
|
||||
socket.close();
|
||||
socket = null;
|
||||
}
|
||||
|
||||
if (processor) {
|
||||
processor.disconnect();
|
||||
processor = null;
|
||||
}
|
||||
|
||||
if (mediaStream) {
|
||||
mediaStream.getTracks().forEach(t => t.stop());
|
||||
mediaStream = null;
|
||||
}
|
||||
|
||||
if (audioContext) {
|
||||
audioContext.close();
|
||||
audioContext = null;
|
||||
}
|
||||
|
||||
console.log('[offscreen] stopped');
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><meta charset="utf-8"/></head>
|
||||
<body><script src="offscreen.js"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,113 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap');
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
font-size: 13px;
|
||||
background: rgba(15, 15, 15, 0.82);
|
||||
color: #e8e8e8;
|
||||
width: 260px;
|
||||
}
|
||||
|
||||
.popup {
|
||||
padding: 14px 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
letter-spacing: -0.01em;
|
||||
color: #e8e8e8;
|
||||
}
|
||||
|
||||
.logo { font-size: 16px; opacity: 0.7; }
|
||||
|
||||
.status {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.status.active { color: #4ade80; }
|
||||
|
||||
/* ── Key fields ── */
|
||||
|
||||
.keys-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.key-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.key-label {
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.07em;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.key-input {
|
||||
background: rgba(255,255,255,0.05);
|
||||
border: 0.5px solid rgba(255,255,255,0.1);
|
||||
border-radius: 5px;
|
||||
color: #e8e8e8;
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 10px;
|
||||
padding: 5px 8px;
|
||||
width: 100%;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.key-input::placeholder { color: #444; }
|
||||
.key-input:focus { border-color: rgba(255,255,255,0.25); }
|
||||
|
||||
.key-input.saved {
|
||||
border-color: rgba(74, 222, 128, 0.3);
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.key-hint {
|
||||
font-size: 10px;
|
||||
color: #555;
|
||||
min-height: 14px;
|
||||
}
|
||||
|
||||
.key-hint.error { color: #f87171; }
|
||||
.key-hint.ok { color: #4ade80; }
|
||||
|
||||
/* ── Toggle button ── */
|
||||
|
||||
.toggle-btn {
|
||||
background: rgba(30,30,30,0.9);
|
||||
color: #f5f5f3;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 9px 14px;
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.01em;
|
||||
cursor: pointer;
|
||||
transition: opacity 0.15s;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.toggle-btn:hover { opacity: 0.85; }
|
||||
.toggle-btn.active { background: #166534; }
|
||||
.toggle-btn:disabled { opacity: 0.4; cursor: not-allowed; }
|
||||
@@ -0,0 +1,28 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<link rel="stylesheet" href="popup.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="popup">
|
||||
<div class="header">
|
||||
<span class="logo">⬡</span>
|
||||
<span class="title">InTruth</span>
|
||||
</div>
|
||||
|
||||
<div class="status" id="status">Inactive</div>
|
||||
|
||||
<div class="keys-section" id="keysSection">
|
||||
<div class="key-field">
|
||||
<label class="key-label">Anthropic API Key</label>
|
||||
<input class="key-input" id="anthropicKey" type="password" placeholder="sk-ant-..." autocomplete="off"/>
|
||||
</div>
|
||||
<div class="key-hint" id="keyHint"></div>
|
||||
</div>
|
||||
|
||||
<button class="toggle-btn" id="toggleBtn">Start Fact-Checking</button>
|
||||
</div>
|
||||
<script src="popup.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,87 @@
|
||||
// popup.js
|
||||
|
||||
const toggleBtn = document.getElementById('toggleBtn');
|
||||
const statusEl = document.getElementById('status');
|
||||
const anthropicEl = document.getElementById('anthropicKey');
|
||||
const keyHint = document.getElementById('keyHint');
|
||||
const keysSection = document.getElementById('keysSection');
|
||||
|
||||
let isActive = false;
|
||||
|
||||
// ── Load saved key ────────────────────────────────────────────────────────────
|
||||
|
||||
chrome.storage.local.get(['anthropicKey'], (data) => {
|
||||
if (data.anthropicKey) { anthropicEl.value = data.anthropicKey; anthropicEl.classList.add('saved'); }
|
||||
updateHint();
|
||||
});
|
||||
|
||||
// ── Save key on change ────────────────────────────────────────────────────────
|
||||
|
||||
anthropicEl.addEventListener('input', () => {
|
||||
anthropicEl.classList.remove('saved');
|
||||
updateHint();
|
||||
});
|
||||
anthropicEl.addEventListener('change', () => {
|
||||
chrome.storage.local.set({ anthropicKey: anthropicEl.value.trim() });
|
||||
anthropicEl.classList.add('saved');
|
||||
updateHint();
|
||||
});
|
||||
|
||||
function updateHint() {
|
||||
if (!anthropicEl.value.trim()) {
|
||||
keyHint.textContent = 'Enter your Anthropic API key to start.';
|
||||
keyHint.className = 'key-hint';
|
||||
toggleBtn.disabled = isActive ? false : true;
|
||||
} else {
|
||||
keyHint.textContent = 'Key saved.';
|
||||
keyHint.className = 'key-hint ok';
|
||||
toggleBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Status ────────────────────────────────────────────────────────────────────
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'GET_STATUS' }, (res) => {
|
||||
if (res?.isCapturing) setActive(true);
|
||||
});
|
||||
|
||||
function setActive(active) {
|
||||
isActive = active;
|
||||
toggleBtn.textContent = active ? 'Stop Fact-Checking' : 'Start Fact-Checking';
|
||||
toggleBtn.className = 'toggle-btn' + (active ? ' active' : '');
|
||||
statusEl.textContent = active ? 'Live • Fact-checking active' : 'Inactive';
|
||||
statusEl.className = 'status' + (active ? ' active' : '');
|
||||
// hide key fields while active
|
||||
keysSection.style.display = active ? 'none' : 'flex';
|
||||
if (!active) updateHint();
|
||||
}
|
||||
|
||||
// ── Toggle ────────────────────────────────────────────────────────────────────
|
||||
|
||||
toggleBtn.addEventListener('click', async () => {
|
||||
if (isActive) {
|
||||
chrome.runtime.sendMessage({ type: 'STOP_FACTCHECK' });
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const anthropicKey = anthropicEl.value.trim();
|
||||
|
||||
if (!anthropicKey) {
|
||||
keyHint.textContent = 'Please enter your Anthropic API key.';
|
||||
keyHint.className = 'key-hint error';
|
||||
return;
|
||||
}
|
||||
|
||||
// save key then start
|
||||
await new Promise(r => chrome.storage.local.set({ anthropicKey }, r));
|
||||
|
||||
chrome.runtime.sendMessage({ type: 'START_FACTCHECK' }, (res) => {
|
||||
if (res?.ok) {
|
||||
setActive(true);
|
||||
} else {
|
||||
keyHint.textContent = 'Failed to start: ' + (res?.error || 'unknown error');
|
||||
keyHint.className = 'key-hint error';
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user