Build BlackRoad Internet: accuracy-first browser, search & verification platform
Tauri v2 desktop browser with AI-powered fact-checking, source reputation scoring (100+ domains), and accuracy-ranked search. Deploys verification-api and search-api to Cloudflare Workers backed by D1. - Rust backend: Ollama AI client, claim extraction/verification pipeline, domain reputation database, weighted scoring (0.4*source + 0.6*claims) - React frontend: tabs, navigation, verification sidebar with accuracy ring, source category/bias display, claim-level reasoning and verdicts - Cloudflare Workers: verification-api (D1 + Hono), search-api with accuracy re-ranking (0.4*Relevance + 0.3*Accuracy + 0.2*Source + 0.1*Fresh) - 60+ seeded domains: academic, journals, government, wire services, news, fact-checkers, tech docs, social media, low-credibility sources Accurate info. Period. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
# Dependencies
|
||||||
|
node_modules/
|
||||||
|
.pnpm-store/
|
||||||
|
|
||||||
|
# Build outputs
|
||||||
|
target/
|
||||||
|
dist/
|
||||||
|
*.dmg
|
||||||
|
*.app
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
Cargo.lock
|
||||||
|
!apps/browser/src-tauri/Cargo.lock
|
||||||
|
|
||||||
|
# Environment
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.dev.vars
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Wrangler
|
||||||
|
.wrangler/
|
||||||
|
|
||||||
|
# Tauri
|
||||||
|
apps/browser/src-tauri/gen/
|
||||||
|
apps/browser/src-tauri/icons/
|
||||||
|
|
||||||
|
# Turbo
|
||||||
|
.turbo/
|
||||||
BIN
apps/browser/app-icon.png
Normal file
BIN
apps/browser/app-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
16
apps/browser/app-icon.svg
Normal file
16
apps/browser/app-icon.svg
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#F5A623"/>
|
||||||
|
<stop offset="38.2%" style="stop-color:#FF1D6C"/>
|
||||||
|
<stop offset="61.8%" style="stop-color:#9C27B0"/>
|
||||||
|
<stop offset="100%" style="stop-color:#2979FF"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="1024" height="1024" rx="200" fill="#000000"/>
|
||||||
|
<circle cx="512" cy="512" r="320" fill="none" stroke="url(#bg)" stroke-width="48"/>
|
||||||
|
<circle cx="512" cy="512" r="180" fill="none" stroke="url(#bg)" stroke-width="32"/>
|
||||||
|
<circle cx="512" cy="512" r="60" fill="url(#bg)"/>
|
||||||
|
<line x1="512" y1="192" x2="512" y2="832" stroke="url(#bg)" stroke-width="24" opacity="0.3"/>
|
||||||
|
<line x1="192" y1="512" x2="832" y2="512" stroke="url(#bg)" stroke-width="24" opacity="0.3"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 876 B |
13
apps/browser/index.html
Normal file
13
apps/browser/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en" class="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/blackroad.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BlackRoad Internet</title>
|
||||||
|
</head>
|
||||||
|
<body class="bg-black text-white">
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
29
apps/browser/package.json
Normal file
29
apps/browser/package.json
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"name": "@blackroad/browser",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tauri-apps/api": "^2",
|
||||||
|
"@tauri-apps/plugin-http": "^2",
|
||||||
|
"@tauri-apps/plugin-shell": "^2",
|
||||||
|
"@tauri-apps/plugin-store": "^2",
|
||||||
|
"react": "^18.3.1",
|
||||||
|
"react-dom": "^18.3.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.3.12",
|
||||||
|
"@types/react-dom": "^18.3.1",
|
||||||
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"autoprefixer": "^10.4.20",
|
||||||
|
"postcss": "^8.4.49",
|
||||||
|
"tailwindcss": "^3.4.17",
|
||||||
|
"typescript": "^5.6.3",
|
||||||
|
"vite": "^6.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
apps/browser/postcss.config.js
Normal file
6
apps/browser/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
5695
apps/browser/src-tauri/Cargo.lock
generated
Normal file
5695
apps/browser/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
23
apps/browser/src-tauri/Cargo.toml
Normal file
23
apps/browser/src-tauri/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "blackroad-internet"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
tauri-plugin-http = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||||
|
url = "2"
|
||||||
|
urlencoding = "2"
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "blackroad_internet_lib"
|
||||||
|
crate-type = ["lib", "cdylib", "staticlib"]
|
||||||
3
apps/browser/src-tauri/build.rs
Normal file
3
apps/browser/src-tauri/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
apps/browser/src-tauri/capabilities/default.json
Normal file
10
apps/browser/src-tauri/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default capabilities for BlackRoad Internet browser",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"shell:allow-open",
|
||||||
|
"http:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
3
apps/browser/src-tauri/src/commands/mod.rs
Normal file
3
apps/browser/src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
pub mod navigation;
|
||||||
|
pub mod settings;
|
||||||
|
pub mod verification;
|
||||||
89
apps/browser/src-tauri/src/commands/navigation.rs
Normal file
89
apps/browser/src-tauri/src/commands/navigation.rs
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::{command, State};
|
||||||
|
|
||||||
|
use crate::store::AppStore;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct PageInfo {
|
||||||
|
pub url: String,
|
||||||
|
pub title: String,
|
||||||
|
pub is_secure: bool,
|
||||||
|
pub is_search: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_url(input: &str) -> PageInfo {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
|
||||||
|
// Internal pages
|
||||||
|
if trimmed.starts_with("blackroad://") {
|
||||||
|
return PageInfo {
|
||||||
|
url: trimmed.to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
is_secure: true,
|
||||||
|
is_search: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already a URL
|
||||||
|
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||||
|
return PageInfo {
|
||||||
|
url: trimmed.to_string(),
|
||||||
|
title: String::new(),
|
||||||
|
is_secure: trimmed.starts_with("https"),
|
||||||
|
is_search: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks like a domain (has a dot, no spaces)
|
||||||
|
if trimmed.contains('.') && !trimmed.contains(' ') {
|
||||||
|
let url = format!("https://{}", trimmed);
|
||||||
|
return PageInfo {
|
||||||
|
url,
|
||||||
|
title: String::new(),
|
||||||
|
is_secure: true,
|
||||||
|
is_search: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Treat as search query
|
||||||
|
let encoded = urlencoding::encode(trimmed);
|
||||||
|
PageInfo {
|
||||||
|
url: format!("blackroad://search?q={}", encoded),
|
||||||
|
title: String::new(),
|
||||||
|
is_secure: true,
|
||||||
|
is_search: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn navigate_to(url: String, store: State<'_, AppStore>) -> Result<PageInfo, String> {
|
||||||
|
let page_info = normalize_url(&url);
|
||||||
|
|
||||||
|
// Record in history
|
||||||
|
if !page_info.url.starts_with("blackroad://") {
|
||||||
|
let db = store.db.lock().map_err(|e| e.to_string())?;
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO history (url, title) VALUES (?1, ?2)",
|
||||||
|
rusqlite::params![page_info.url, page_info.title],
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(page_info)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn go_back() -> Result<Option<String>, String> {
|
||||||
|
// Navigation stack managed by frontend in Phase 1
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn go_forward() -> Result<Option<String>, String> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn refresh_page() -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
32
apps/browser/src-tauri/src/commands/settings.rs
Normal file
32
apps/browser/src-tauri/src/commands/settings.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use tauri::command;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct BrowserSettings {
|
||||||
|
pub verification_enabled: bool,
|
||||||
|
pub sidebar_position: String,
|
||||||
|
pub theme: String,
|
||||||
|
pub default_search: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BrowserSettings {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
verification_enabled: true,
|
||||||
|
sidebar_position: "right".to_string(),
|
||||||
|
theme: "dark".to_string(),
|
||||||
|
default_search: "blackroad".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn get_settings() -> Result<BrowserSettings, String> {
|
||||||
|
Ok(BrowserSettings::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn update_settings(settings: BrowserSettings) -> Result<BrowserSettings, String> {
|
||||||
|
// Phase 1: Just echo back. Phase 2: persist to SQLite.
|
||||||
|
Ok(settings)
|
||||||
|
}
|
||||||
254
apps/browser/src-tauri/src/commands/verification.rs
Normal file
254
apps/browser/src-tauri/src/commands/verification.rs
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::time::Instant;
|
||||||
|
use tauri::command;
|
||||||
|
|
||||||
|
use crate::verification::ollama::OllamaClient;
|
||||||
|
use crate::verification::reputation::ReputationDb;
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub enum ClaimType {
|
||||||
|
Factual,
|
||||||
|
Statistical,
|
||||||
|
Quote,
|
||||||
|
Opinion,
|
||||||
|
Prediction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub enum Verdict {
|
||||||
|
Verified,
|
||||||
|
Likely,
|
||||||
|
Uncertain,
|
||||||
|
Disputed,
|
||||||
|
False,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct ClaimResult {
|
||||||
|
pub text: String,
|
||||||
|
pub claim_type: ClaimType,
|
||||||
|
pub confidence: f64,
|
||||||
|
pub verdict: Verdict,
|
||||||
|
pub reasoning: String,
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||||
|
pub struct VerificationResult {
|
||||||
|
pub url: String,
|
||||||
|
pub overall_score: f64,
|
||||||
|
pub source_reputation: f64,
|
||||||
|
pub source_category: String,
|
||||||
|
pub source_bias: String,
|
||||||
|
pub claims: Vec<ClaimResult>,
|
||||||
|
pub claims_checked: usize,
|
||||||
|
pub verification_time_ms: u64,
|
||||||
|
pub method: String,
|
||||||
|
pub ai_available: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_to_claim_type(s: &str) -> ClaimType {
|
||||||
|
match s {
|
||||||
|
"Statistical" => ClaimType::Statistical,
|
||||||
|
"Quote" => ClaimType::Quote,
|
||||||
|
"Opinion" => ClaimType::Opinion,
|
||||||
|
"Prediction" => ClaimType::Prediction,
|
||||||
|
_ => ClaimType::Factual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn str_to_verdict(s: &str) -> Verdict {
|
||||||
|
match s {
|
||||||
|
"Verified" => Verdict::Verified,
|
||||||
|
"Likely" => Verdict::Likely,
|
||||||
|
"Disputed" => Verdict::Disputed,
|
||||||
|
"False" => Verdict::False,
|
||||||
|
_ => Verdict::Uncertain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a page's content for accuracy using AI + source reputation.
|
||||||
|
#[command]
|
||||||
|
pub async fn verify_page(url: String, content: String) -> Result<VerificationResult, String> {
|
||||||
|
let start = Instant::now();
|
||||||
|
let ollama = OllamaClient::new();
|
||||||
|
let reputation_db = ReputationDb::new();
|
||||||
|
|
||||||
|
// 1. Get source reputation
|
||||||
|
let rep = reputation_db.lookup(&url);
|
||||||
|
let source_score = rep.as_ref().map(|r| r.score).unwrap_or(0.50);
|
||||||
|
let source_category = rep
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.category.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
let source_bias = rep
|
||||||
|
.as_ref()
|
||||||
|
.map(|r| r.bias.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// 2. Check if Ollama is available for AI verification
|
||||||
|
let ai_available = ollama.is_available().await;
|
||||||
|
|
||||||
|
if !ai_available || content.trim().is_empty() || content.len() < 50 {
|
||||||
|
// Fallback to reputation-only scoring
|
||||||
|
return Ok(VerificationResult {
|
||||||
|
url,
|
||||||
|
overall_score: source_score,
|
||||||
|
source_reputation: source_score,
|
||||||
|
source_category,
|
||||||
|
source_bias,
|
||||||
|
claims: vec![],
|
||||||
|
claims_checked: 0,
|
||||||
|
verification_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
method: if ai_available {
|
||||||
|
"reputation-only (content too short)".to_string()
|
||||||
|
} else {
|
||||||
|
"reputation-only (AI offline)".to_string()
|
||||||
|
},
|
||||||
|
ai_available,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Extract claims from content using Ollama
|
||||||
|
let extracted = ollama.extract_claims(&content).await.unwrap_or_default();
|
||||||
|
|
||||||
|
// 4. Verify each factual claim (skip opinions and predictions)
|
||||||
|
let mut verified_claims = Vec::new();
|
||||||
|
let max_claims = 5; // Limit to avoid long wait times
|
||||||
|
let mut checked = 0;
|
||||||
|
|
||||||
|
for claim in extracted.iter().take(10) {
|
||||||
|
// Skip opinions and predictions - they can't be fact-checked
|
||||||
|
if claim.claim_type == "Opinion" || claim.claim_type == "Prediction" {
|
||||||
|
verified_claims.push(ClaimResult {
|
||||||
|
text: claim.text.clone(),
|
||||||
|
claim_type: str_to_claim_type(&claim.claim_type),
|
||||||
|
confidence: 0.0,
|
||||||
|
verdict: Verdict::Uncertain,
|
||||||
|
reasoning: "Opinions and predictions are not fact-checkable".to_string(),
|
||||||
|
sources: vec![],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if checked >= max_claims {
|
||||||
|
verified_claims.push(ClaimResult {
|
||||||
|
text: claim.text.clone(),
|
||||||
|
claim_type: str_to_claim_type(&claim.claim_type),
|
||||||
|
confidence: 0.0,
|
||||||
|
verdict: Verdict::Uncertain,
|
||||||
|
reasoning: "Skipped (verification limit reached)".to_string(),
|
||||||
|
sources: vec![],
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
match ollama.verify_claim(&claim.text).await {
|
||||||
|
Ok(verdict) => {
|
||||||
|
verified_claims.push(ClaimResult {
|
||||||
|
text: claim.text.clone(),
|
||||||
|
claim_type: str_to_claim_type(&claim.claim_type),
|
||||||
|
confidence: verdict.confidence,
|
||||||
|
verdict: str_to_verdict(&verdict.verdict),
|
||||||
|
reasoning: verdict.reasoning,
|
||||||
|
sources: verdict.sources,
|
||||||
|
});
|
||||||
|
checked += 1;
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
verified_claims.push(ClaimResult {
|
||||||
|
text: claim.text.clone(),
|
||||||
|
claim_type: str_to_claim_type(&claim.claim_type),
|
||||||
|
confidence: 0.0,
|
||||||
|
verdict: Verdict::Uncertain,
|
||||||
|
reasoning: "Verification failed".to_string(),
|
||||||
|
sources: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Calculate overall score
|
||||||
|
// Formula: 0.4 * source_reputation + 0.6 * claim_accuracy
|
||||||
|
let claim_score = if verified_claims.is_empty() {
|
||||||
|
source_score
|
||||||
|
} else {
|
||||||
|
let verifiable: Vec<&ClaimResult> = verified_claims
|
||||||
|
.iter()
|
||||||
|
.filter(|c| !matches!(c.claim_type, ClaimType::Opinion | ClaimType::Prediction))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if verifiable.is_empty() {
|
||||||
|
source_score
|
||||||
|
} else {
|
||||||
|
let total: f64 = verifiable
|
||||||
|
.iter()
|
||||||
|
.map(|c| match c.verdict {
|
||||||
|
Verdict::Verified => 1.0,
|
||||||
|
Verdict::Likely => 0.8,
|
||||||
|
Verdict::Uncertain => 0.5,
|
||||||
|
Verdict::Disputed => 0.3,
|
||||||
|
Verdict::False => 0.0,
|
||||||
|
})
|
||||||
|
.sum();
|
||||||
|
total / verifiable.len() as f64
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let overall = 0.4 * source_score + 0.6 * claim_score;
|
||||||
|
|
||||||
|
Ok(VerificationResult {
|
||||||
|
url,
|
||||||
|
overall_score: overall,
|
||||||
|
source_reputation: source_score,
|
||||||
|
source_category,
|
||||||
|
source_bias,
|
||||||
|
claims: verified_claims,
|
||||||
|
claims_checked: checked,
|
||||||
|
verification_time_ms: start.elapsed().as_millis() as u64,
|
||||||
|
method: "ai+reputation".to_string(),
|
||||||
|
ai_available: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn get_verification_status(url: String) -> Result<Option<VerificationResult>, String> {
|
||||||
|
// TODO Phase 3: Check local SQLite cache
|
||||||
|
let _ = url;
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[command]
|
||||||
|
pub async fn verify_claim(claim: String) -> Result<ClaimResult, String> {
|
||||||
|
let ollama = OllamaClient::new();
|
||||||
|
|
||||||
|
if !ollama.is_available().await {
|
||||||
|
return Ok(ClaimResult {
|
||||||
|
text: claim,
|
||||||
|
claim_type: ClaimType::Factual,
|
||||||
|
confidence: 0.0,
|
||||||
|
verdict: Verdict::Uncertain,
|
||||||
|
reasoning: "AI verification unavailable".to_string(),
|
||||||
|
sources: vec![],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
match ollama.verify_claim(&claim).await {
|
||||||
|
Ok(verdict) => Ok(ClaimResult {
|
||||||
|
text: claim,
|
||||||
|
claim_type: ClaimType::Factual,
|
||||||
|
confidence: verdict.confidence,
|
||||||
|
verdict: str_to_verdict(&verdict.verdict),
|
||||||
|
reasoning: verdict.reasoning,
|
||||||
|
sources: verdict.sources,
|
||||||
|
}),
|
||||||
|
Err(e) => Ok(ClaimResult {
|
||||||
|
text: claim,
|
||||||
|
claim_type: ClaimType::Factual,
|
||||||
|
confidence: 0.0,
|
||||||
|
verdict: Verdict::Uncertain,
|
||||||
|
reasoning: format!("Verification error: {}", e),
|
||||||
|
sources: vec![],
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
27
apps/browser/src-tauri/src/lib.rs
Normal file
27
apps/browser/src-tauri/src/lib.rs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
mod commands;
|
||||||
|
mod store;
|
||||||
|
mod verification;
|
||||||
|
|
||||||
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
|
pub fn run() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.plugin(tauri_plugin_http::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::navigation::navigate_to,
|
||||||
|
commands::navigation::go_back,
|
||||||
|
commands::navigation::go_forward,
|
||||||
|
commands::navigation::refresh_page,
|
||||||
|
commands::verification::verify_page,
|
||||||
|
commands::verification::get_verification_status,
|
||||||
|
commands::verification::verify_claim,
|
||||||
|
commands::settings::get_settings,
|
||||||
|
commands::settings::update_settings,
|
||||||
|
])
|
||||||
|
.setup(|app| {
|
||||||
|
store::init(app.handle())?;
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error while running BlackRoad Internet");
|
||||||
|
}
|
||||||
5
apps/browser/src-tauri/src/main.rs
Normal file
5
apps/browser/src-tauri/src/main.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
blackroad_internet_lib::run();
|
||||||
|
}
|
||||||
59
apps/browser/src-tauri/src/store/mod.rs
Normal file
59
apps/browser/src-tauri/src/store/mod.rs
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
use rusqlite::Connection;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
use tauri::AppHandle;
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
|
pub struct AppStore {
|
||||||
|
pub db: Mutex<Connection>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn init(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let app_dir = app.path().app_data_dir()?;
|
||||||
|
std::fs::create_dir_all(&app_dir)?;
|
||||||
|
|
||||||
|
let db_path = app_dir.join("blackroad-internet.db");
|
||||||
|
let conn = Connection::open(db_path)?;
|
||||||
|
|
||||||
|
conn.execute_batch(
|
||||||
|
"
|
||||||
|
CREATE TABLE IF NOT EXISTS history (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
title TEXT DEFAULT '',
|
||||||
|
visited_at TEXT DEFAULT (datetime('now')),
|
||||||
|
verification_score REAL
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS bookmarks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL UNIQUE,
|
||||||
|
title TEXT DEFAULT '',
|
||||||
|
folder TEXT DEFAULT 'default',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS source_reputation (
|
||||||
|
domain TEXT PRIMARY KEY,
|
||||||
|
score REAL DEFAULT 0.5,
|
||||||
|
total_checks INTEGER DEFAULT 0,
|
||||||
|
last_checked TEXT,
|
||||||
|
category TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS verification_cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
result_json TEXT NOT NULL,
|
||||||
|
cached_at TEXT DEFAULT (datetime('now')),
|
||||||
|
expires_at TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_history_visited ON history(visited_at);
|
||||||
|
",
|
||||||
|
)?;
|
||||||
|
|
||||||
|
app.manage(AppStore {
|
||||||
|
db: Mutex::new(conn),
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
2
apps/browser/src-tauri/src/verification/mod.rs
Normal file
2
apps/browser/src-tauri/src/verification/mod.rs
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
pub mod ollama;
|
||||||
|
pub mod reputation;
|
||||||
227
apps/browser/src-tauri/src/verification/ollama.rs
Normal file
227
apps/browser/src-tauri/src/verification/ollama.rs
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct GenerateRequest {
|
||||||
|
model: String,
|
||||||
|
prompt: String,
|
||||||
|
stream: bool,
|
||||||
|
options: OllamaOptions,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct OllamaOptions {
|
||||||
|
temperature: f64,
|
||||||
|
num_predict: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct GenerateResponse {
|
||||||
|
response: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct OllamaClient {
|
||||||
|
endpoint: String,
|
||||||
|
client: Client,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OllamaClient {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let endpoint = std::env::var("OLLAMA_ENDPOINT")
|
||||||
|
.unwrap_or_else(|_| "http://localhost:11434".to_string());
|
||||||
|
Self {
|
||||||
|
endpoint,
|
||||||
|
client: Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(60))
|
||||||
|
.build()
|
||||||
|
.unwrap_or_default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn is_available(&self) -> bool {
|
||||||
|
self.client
|
||||||
|
.get(format!("{}/api/tags", self.endpoint))
|
||||||
|
.timeout(std::time::Duration::from_secs(3))
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map(|r| r.status().is_success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn generate(&self, model: &str, prompt: &str) -> Result<String, String> {
|
||||||
|
let request = GenerateRequest {
|
||||||
|
model: model.to_string(),
|
||||||
|
prompt: prompt.to_string(),
|
||||||
|
stream: false,
|
||||||
|
options: OllamaOptions {
|
||||||
|
temperature: 0.1, // Low temperature for factual accuracy
|
||||||
|
num_predict: 2048,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let response = self
|
||||||
|
.client
|
||||||
|
.post(format!("{}/api/generate", self.endpoint))
|
||||||
|
.json(&request)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Ollama request failed: {}", e))?;
|
||||||
|
|
||||||
|
if !response.status().is_success() {
|
||||||
|
return Err(format!("Ollama returned status {}", response.status()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let data: GenerateResponse = response
|
||||||
|
.json()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("Failed to parse Ollama response: {}", e))?;
|
||||||
|
|
||||||
|
Ok(data.response)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract verifiable claims from page content
|
||||||
|
pub async fn extract_claims(&self, content: &str) -> Result<Vec<ExtractedClaim>, String> {
|
||||||
|
let truncated = if content.len() > 3000 {
|
||||||
|
&content[..3000]
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt = format!(
|
||||||
|
r#"You are a fact-checking assistant. Analyze the following text and extract all verifiable factual claims.
|
||||||
|
|
||||||
|
For each claim, output EXACTLY one line in this format:
|
||||||
|
[TYPE] claim text here
|
||||||
|
|
||||||
|
Where TYPE is one of:
|
||||||
|
- FACT: A verifiable factual statement
|
||||||
|
- STAT: A statistical claim with numbers
|
||||||
|
- QUOTE: An attributed quote
|
||||||
|
- OPINION: A subjective opinion (not verifiable)
|
||||||
|
- PREDICTION: A claim about the future
|
||||||
|
|
||||||
|
Only extract clear, specific claims. Skip vague statements. Output ONLY the tagged claims, nothing else.
|
||||||
|
|
||||||
|
TEXT:
|
||||||
|
{}
|
||||||
|
|
||||||
|
CLAIMS:"#,
|
||||||
|
truncated
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.generate("llama3:latest", &prompt).await?;
|
||||||
|
let claims = parse_claims(&response);
|
||||||
|
Ok(claims)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a single factual claim
|
||||||
|
pub async fn verify_claim(&self, claim: &str) -> Result<ClaimVerdict, String> {
|
||||||
|
let prompt = format!(
|
||||||
|
r#"You are a rigorous fact-checker. Evaluate the following claim for accuracy.
|
||||||
|
|
||||||
|
CLAIM: "{}"
|
||||||
|
|
||||||
|
Respond in EXACTLY this format (one line each):
|
||||||
|
VERDICT: [VERIFIED|LIKELY|UNCERTAIN|DISPUTED|FALSE]
|
||||||
|
CONFIDENCE: [0-100]
|
||||||
|
REASONING: [one sentence explanation]
|
||||||
|
SOURCES: [comma-separated list of what would verify this, or "none"]
|
||||||
|
|
||||||
|
Be conservative. If you're not sure, say UNCERTAIN. Only say VERIFIED for well-established facts."#,
|
||||||
|
claim
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = self.generate("llama3:latest", &prompt).await?;
|
||||||
|
parse_verdict(&response, claim)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ExtractedClaim {
|
||||||
|
pub text: String,
|
||||||
|
pub claim_type: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ClaimVerdict {
|
||||||
|
pub verdict: String,
|
||||||
|
pub confidence: f64,
|
||||||
|
pub reasoning: String,
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_claims(response: &str) -> Vec<ExtractedClaim> {
|
||||||
|
let mut claims = Vec::new();
|
||||||
|
for line in response.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (claim_type, text) = if line.starts_with("[FACT]") {
|
||||||
|
("Factual", line.trim_start_matches("[FACT]").trim())
|
||||||
|
} else if line.starts_with("[STAT]") {
|
||||||
|
("Statistical", line.trim_start_matches("[STAT]").trim())
|
||||||
|
} else if line.starts_with("[QUOTE]") {
|
||||||
|
("Quote", line.trim_start_matches("[QUOTE]").trim())
|
||||||
|
} else if line.starts_with("[OPINION]") {
|
||||||
|
("Opinion", line.trim_start_matches("[OPINION]").trim())
|
||||||
|
} else if line.starts_with("[PREDICTION]") {
|
||||||
|
("Prediction", line.trim_start_matches("[PREDICTION]").trim())
|
||||||
|
} else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !text.is_empty() {
|
||||||
|
claims.push(ExtractedClaim {
|
||||||
|
text: text.to_string(),
|
||||||
|
claim_type: claim_type.to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
claims
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_verdict(response: &str, original_claim: &str) -> Result<ClaimVerdict, String> {
|
||||||
|
let mut verdict = "Uncertain".to_string();
|
||||||
|
let mut confidence = 0.0;
|
||||||
|
let mut reasoning = String::new();
|
||||||
|
let mut sources = Vec::new();
|
||||||
|
|
||||||
|
for line in response.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if let Some(v) = line.strip_prefix("VERDICT:") {
|
||||||
|
let v = v.trim().trim_matches('[').trim_matches(']');
|
||||||
|
verdict = match v.to_uppercase().as_str() {
|
||||||
|
"VERIFIED" => "Verified",
|
||||||
|
"LIKELY" => "Likely",
|
||||||
|
"UNCERTAIN" => "Uncertain",
|
||||||
|
"DISPUTED" => "Disputed",
|
||||||
|
"FALSE" => "False",
|
||||||
|
_ => "Uncertain",
|
||||||
|
}
|
||||||
|
.to_string();
|
||||||
|
} else if let Some(c) = line.strip_prefix("CONFIDENCE:") {
|
||||||
|
let c = c.trim().trim_matches('[').trim_matches(']').trim_end_matches('%');
|
||||||
|
confidence = c.parse::<f64>().unwrap_or(0.0) / 100.0;
|
||||||
|
} else if let Some(r) = line.strip_prefix("REASONING:") {
|
||||||
|
reasoning = r.trim().trim_matches('[').trim_matches(']').to_string();
|
||||||
|
} else if let Some(s) = line.strip_prefix("SOURCES:") {
|
||||||
|
let s = s.trim().trim_matches('[').trim_matches(']');
|
||||||
|
if s.to_lowercase() != "none" {
|
||||||
|
sources = s.split(',').map(|s| s.trim().to_string()).collect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if reasoning.is_empty() {
|
||||||
|
reasoning = format!("Analysis of claim: \"{}\"", original_claim);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ClaimVerdict {
|
||||||
|
verdict,
|
||||||
|
confidence,
|
||||||
|
reasoning,
|
||||||
|
sources,
|
||||||
|
})
|
||||||
|
}
|
||||||
307
apps/browser/src-tauri/src/verification/reputation.rs
Normal file
307
apps/browser/src-tauri/src/verification/reputation.rs
Normal file
@@ -0,0 +1,307 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// Domain reputation database with categories and factual ratings.
|
||||||
|
/// Phase 2: Static in-memory database. Phase 4: Cloudflare D1 + community data.
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct ReputationDb {
|
||||||
|
domains: HashMap<String, DomainReputation>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct DomainReputation {
|
||||||
|
pub score: f64,
|
||||||
|
pub category: &'static str,
|
||||||
|
pub bias: &'static str,
|
||||||
|
pub factual: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ReputationDb {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let mut domains = HashMap::new();
|
||||||
|
|
||||||
|
// Academic & Research (highest trust)
|
||||||
|
for domain in &[
|
||||||
|
"arxiv.org",
|
||||||
|
"scholar.google.com",
|
||||||
|
"pubmed.ncbi.nlm.nih.gov",
|
||||||
|
"ncbi.nlm.nih.gov",
|
||||||
|
"jstor.org",
|
||||||
|
"sciencedirect.com",
|
||||||
|
"springer.com",
|
||||||
|
"wiley.com",
|
||||||
|
"ieee.org",
|
||||||
|
"acm.org",
|
||||||
|
"researchgate.net",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.95,
|
||||||
|
category: "academic",
|
||||||
|
bias: "none",
|
||||||
|
factual: "very-high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scientific journals
|
||||||
|
for domain in &[
|
||||||
|
"nature.com",
|
||||||
|
"science.org",
|
||||||
|
"cell.com",
|
||||||
|
"thelancet.com",
|
||||||
|
"nejm.org",
|
||||||
|
"bmj.com",
|
||||||
|
"pnas.org",
|
||||||
|
"plos.org",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.96,
|
||||||
|
category: "journal",
|
||||||
|
bias: "none",
|
||||||
|
factual: "very-high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Government sources
|
||||||
|
for domain in &[
|
||||||
|
"nasa.gov",
|
||||||
|
"nih.gov",
|
||||||
|
"cdc.gov",
|
||||||
|
"fda.gov",
|
||||||
|
"epa.gov",
|
||||||
|
"noaa.gov",
|
||||||
|
"usgs.gov",
|
||||||
|
"bls.gov",
|
||||||
|
"census.gov",
|
||||||
|
"sec.gov",
|
||||||
|
"federalreserve.gov",
|
||||||
|
"whitehouse.gov",
|
||||||
|
"congress.gov",
|
||||||
|
"supremecourt.gov",
|
||||||
|
"data.gov",
|
||||||
|
"gao.gov",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.92,
|
||||||
|
category: "government",
|
||||||
|
bias: "none",
|
||||||
|
factual: "very-high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wire services (highest journalistic trust)
|
||||||
|
for (domain, score) in &[
|
||||||
|
("apnews.com", 0.93),
|
||||||
|
("reuters.com", 0.93),
|
||||||
|
("afp.com", 0.91),
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: *score,
|
||||||
|
category: "wire-service",
|
||||||
|
bias: "center",
|
||||||
|
factual: "very-high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reference
|
||||||
|
for domain in &[
|
||||||
|
"wikipedia.org",
|
||||||
|
"en.wikipedia.org",
|
||||||
|
"britannica.com",
|
||||||
|
"merriam-webster.com",
|
||||||
|
"dictionary.com",
|
||||||
|
"worldbank.org",
|
||||||
|
"imf.org",
|
||||||
|
"un.org",
|
||||||
|
"who.int",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.88,
|
||||||
|
category: "reference",
|
||||||
|
bias: "none",
|
||||||
|
factual: "high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Quality journalism (center/high factual)
|
||||||
|
for (domain, score, bias) in &[
|
||||||
|
("bbc.com", 0.85, "center"),
|
||||||
|
("bbc.co.uk", 0.85, "center"),
|
||||||
|
("npr.org", 0.84, "center"),
|
||||||
|
("pbs.org", 0.84, "center"),
|
||||||
|
("economist.com", 0.83, "center"),
|
||||||
|
("ft.com", 0.83, "center"),
|
||||||
|
("wsj.com", 0.82, "center-right"),
|
||||||
|
("nytimes.com", 0.82, "center-left"),
|
||||||
|
("washingtonpost.com", 0.81, "center-left"),
|
||||||
|
("theguardian.com", 0.80, "center-left"),
|
||||||
|
("bloomberg.com", 0.83, "center"),
|
||||||
|
("propublica.org", 0.86, "center-left"),
|
||||||
|
("theintercept.com", 0.75, "left"),
|
||||||
|
("jacobin.com", 0.70, "left"),
|
||||||
|
("nationalreview.com", 0.72, "right"),
|
||||||
|
("reason.com", 0.73, "right"),
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: *score,
|
||||||
|
category: "news",
|
||||||
|
bias,
|
||||||
|
factual: if *score > 0.80 { "high" } else { "mixed" },
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fact-checking sites
|
||||||
|
for domain in &[
|
||||||
|
"snopes.com",
|
||||||
|
"factcheck.org",
|
||||||
|
"politifact.com",
|
||||||
|
"fullfact.org",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.90,
|
||||||
|
category: "fact-check",
|
||||||
|
bias: "center",
|
||||||
|
factual: "very-high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tech documentation
|
||||||
|
for domain in &[
|
||||||
|
"docs.github.com",
|
||||||
|
"developer.mozilla.org",
|
||||||
|
"docs.python.org",
|
||||||
|
"docs.rust-lang.org",
|
||||||
|
"docs.microsoft.com",
|
||||||
|
"developer.apple.com",
|
||||||
|
"cloud.google.com",
|
||||||
|
"docs.aws.amazon.com",
|
||||||
|
"stackoverflow.com",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.85,
|
||||||
|
category: "tech-docs",
|
||||||
|
bias: "none",
|
||||||
|
factual: "high",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Social media (low factual, high bias potential)
|
||||||
|
for domain in &[
|
||||||
|
"twitter.com",
|
||||||
|
"x.com",
|
||||||
|
"facebook.com",
|
||||||
|
"instagram.com",
|
||||||
|
"tiktok.com",
|
||||||
|
"reddit.com",
|
||||||
|
"threads.net",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.30,
|
||||||
|
category: "social",
|
||||||
|
bias: "mixed",
|
||||||
|
factual: "low",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Known misinformation/low trust
|
||||||
|
for domain in &[
|
||||||
|
"infowars.com",
|
||||||
|
"naturalnews.com",
|
||||||
|
"thegatewaypundit.com",
|
||||||
|
"zerohedge.com",
|
||||||
|
"breitbart.com",
|
||||||
|
"dailymail.co.uk",
|
||||||
|
] {
|
||||||
|
domains.insert(
|
||||||
|
domain.to_string(),
|
||||||
|
DomainReputation {
|
||||||
|
score: 0.15,
|
||||||
|
category: "low-credibility",
|
||||||
|
bias: "extreme",
|
||||||
|
factual: "very-low",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Self { domains }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Look up reputation for a domain, checking parent domains too
|
||||||
|
pub fn lookup(&self, url: &str) -> Option<DomainReputation> {
|
||||||
|
let domain = extract_domain(url)?;
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
if let Some(rep) = self.domains.get(&domain) {
|
||||||
|
return Some(rep.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parent domain (e.g., "en.wikipedia.org" -> "wikipedia.org")
|
||||||
|
let parts: Vec<&str> = domain.split('.').collect();
|
||||||
|
if parts.len() > 2 {
|
||||||
|
let parent = parts[parts.len() - 2..].join(".");
|
||||||
|
if let Some(rep) = self.domains.get(&parent) {
|
||||||
|
return Some(rep.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLD-based defaults
|
||||||
|
if domain.ends_with(".gov") {
|
||||||
|
return Some(DomainReputation {
|
||||||
|
score: 0.88,
|
||||||
|
category: "government",
|
||||||
|
bias: "none",
|
||||||
|
factual: "high",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if domain.ends_with(".edu") {
|
||||||
|
return Some(DomainReputation {
|
||||||
|
score: 0.85,
|
||||||
|
category: "education",
|
||||||
|
bias: "none",
|
||||||
|
factual: "high",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if domain.ends_with(".org") {
|
||||||
|
return Some(DomainReputation {
|
||||||
|
score: 0.60,
|
||||||
|
category: "organization",
|
||||||
|
bias: "unknown",
|
||||||
|
factual: "mixed",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn extract_domain(url: &str) -> Option<String> {
|
||||||
|
url::Url::parse(url)
|
||||||
|
.ok()
|
||||||
|
.and_then(|u| u.host_str().map(|h| h.to_lowercase()))
|
||||||
|
}
|
||||||
41
apps/browser/src-tauri/tauri.conf.json
Normal file
41
apps/browser/src-tauri/tauri.conf.json
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
|
||||||
|
"productName": "BlackRoad Internet",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "io.blackroad.internet",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../dist",
|
||||||
|
"devUrl": "http://localhost:1420",
|
||||||
|
"beforeDevCommand": "pnpm dev",
|
||||||
|
"beforeBuildCommand": "pnpm build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "BlackRoad Internet",
|
||||||
|
"width": 1400,
|
||||||
|
"height": 900,
|
||||||
|
"minWidth": 800,
|
||||||
|
"minHeight": 600,
|
||||||
|
"decorations": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"targets": ["dmg", "app"],
|
||||||
|
"macOS": {
|
||||||
|
"minimumSystemVersion": "10.15"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
apps/browser/src/App.tsx
Normal file
83
apps/browser/src/App.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { TabBar } from "./components/browser/TabBar";
|
||||||
|
import { AddressBar } from "./components/browser/AddressBar";
|
||||||
|
import { NavigationControls } from "./components/browser/NavigationControls";
|
||||||
|
import { BrowserFrame } from "./components/browser/BrowserFrame";
|
||||||
|
import { VerificationPanel } from "./components/sidebar/VerificationPanel";
|
||||||
|
import { StatusBar } from "./components/browser/StatusBar";
|
||||||
|
import { useTabs } from "./hooks/useTabs";
|
||||||
|
import { useVerification } from "./hooks/useVerification";
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const { tabs, activeTab, addTab, closeTab, setActiveTab, navigateTo, updateTabTitle } =
|
||||||
|
useTabs();
|
||||||
|
const { verification, isVerifying, sidebarOpen, toggleSidebar, onContentReady } =
|
||||||
|
useVerification(activeTab?.url);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen bg-black text-white font-mono">
|
||||||
|
{/* Tab bar */}
|
||||||
|
<TabBar
|
||||||
|
tabs={tabs}
|
||||||
|
activeTabId={activeTab?.id ?? ""}
|
||||||
|
onTabClick={setActiveTab}
|
||||||
|
onTabClose={closeTab}
|
||||||
|
onNewTab={addTab}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Navigation + Address */}
|
||||||
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-surface border-b border-gray-800">
|
||||||
|
<NavigationControls />
|
||||||
|
<AddressBar
|
||||||
|
url={activeTab?.url ?? ""}
|
||||||
|
onNavigate={navigateTo}
|
||||||
|
verificationScore={verification?.overall_score}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className="flex items-center justify-center w-8 h-8 rounded-lg hover:bg-surface-elevated transition-colors"
|
||||||
|
title="Toggle verification panel"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={`w-4 h-4 ${sidebarOpen ? "text-hotpink" : "text-muted"}`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content area */}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<BrowserFrame
|
||||||
|
url={activeTab?.url ?? "blackroad://newtab"}
|
||||||
|
onNavigate={navigateTo}
|
||||||
|
onTitleChange={updateTabTitle}
|
||||||
|
onContentReady={onContentReady}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{sidebarOpen && (
|
||||||
|
<VerificationPanel
|
||||||
|
verification={verification}
|
||||||
|
isVerifying={isVerifying}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status bar */}
|
||||||
|
<StatusBar
|
||||||
|
url={activeTab?.url}
|
||||||
|
isSecure={activeTab?.url?.startsWith("https") ?? false}
|
||||||
|
verificationScore={verification?.overall_score}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App;
|
||||||
83
apps/browser/src/components/browser/AddressBar.tsx
Normal file
83
apps/browser/src/components/browser/AddressBar.tsx
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import { useState, useCallback, useEffect, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
interface AddressBarProps {
|
||||||
|
url: string;
|
||||||
|
onNavigate: (url: string) => void;
|
||||||
|
verificationScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AddressBar({
|
||||||
|
url,
|
||||||
|
onNavigate,
|
||||||
|
verificationScore,
|
||||||
|
}: AddressBarProps) {
|
||||||
|
const [input, setInput] = useState(url);
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!focused) setInput(url);
|
||||||
|
}, [url, focused]);
|
||||||
|
|
||||||
|
const handleKeyDown = useCallback(
|
||||||
|
(e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
onNavigate(input);
|
||||||
|
(e.target as HTMLInputElement).blur();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[input, onNavigate],
|
||||||
|
);
|
||||||
|
|
||||||
|
const scoreColor =
|
||||||
|
verificationScore === undefined
|
||||||
|
? "bg-gray-600"
|
||||||
|
: verificationScore > 0.8
|
||||||
|
? "bg-emerald-500"
|
||||||
|
: verificationScore > 0.5
|
||||||
|
? "bg-amber-400"
|
||||||
|
: "bg-red-500";
|
||||||
|
|
||||||
|
const displayUrl = url.startsWith("blackroad://") ? "" : url;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex-1 flex items-center gap-2 px-3 py-1.5 rounded-lg bg-black/60 border border-gray-700 focus-within:border-hotpink transition-colors">
|
||||||
|
{verificationScore !== undefined && (
|
||||||
|
<div
|
||||||
|
className={`w-2 h-2 rounded-full shrink-0 ${scoreColor}`}
|
||||||
|
title={`Accuracy: ${Math.round(verificationScore * 100)}%`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{url.startsWith("https") && (
|
||||||
|
<svg
|
||||||
|
className="w-3.5 h-3.5 text-emerald-400 shrink-0"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={focused ? input : displayUrl}
|
||||||
|
onChange={(e) => setInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
onFocus={() => {
|
||||||
|
setFocused(true);
|
||||||
|
setInput(url.startsWith("blackroad://") ? "" : url);
|
||||||
|
}}
|
||||||
|
onBlur={() => setFocused(false)}
|
||||||
|
className="flex-1 bg-transparent text-sm text-gray-200 outline-none placeholder-gray-500"
|
||||||
|
placeholder="Search or enter URL — Accurate info. Period."
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
apps/browser/src/components/browser/BrowserFrame.tsx
Normal file
74
apps/browser/src/components/browser/BrowserFrame.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { NewTabPage } from "./NewTabPage";
|
||||||
|
import { SearchPage } from "../search/SearchPage";
|
||||||
|
|
||||||
|
interface BrowserFrameProps {
|
||||||
|
url: string;
|
||||||
|
onNavigate: (url: string) => void;
|
||||||
|
onTitleChange: (title: string) => void;
|
||||||
|
onContentReady: (content: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BrowserFrame({ url, onNavigate, onTitleChange, onContentReady }: BrowserFrameProps) {
|
||||||
|
const handleIframeLoad = useCallback(
|
||||||
|
(e: React.SyntheticEvent<HTMLIFrameElement>) => {
|
||||||
|
try {
|
||||||
|
const frame = e.target as HTMLIFrameElement;
|
||||||
|
const doc = frame.contentDocument;
|
||||||
|
if (!doc) return;
|
||||||
|
|
||||||
|
const title = doc.title;
|
||||||
|
if (title) onTitleChange(title);
|
||||||
|
|
||||||
|
// Extract text content for verification
|
||||||
|
const body = doc.body;
|
||||||
|
if (body) {
|
||||||
|
// Remove script/style elements before extracting text
|
||||||
|
const clone = body.cloneNode(true) as HTMLElement;
|
||||||
|
clone.querySelectorAll("script, style, nav, footer, header, iframe").forEach((el) => el.remove());
|
||||||
|
const text = clone.innerText || clone.textContent || "";
|
||||||
|
// Only send if we got meaningful content
|
||||||
|
if (text.trim().length > 50) {
|
||||||
|
onContentReady(text.trim().slice(0, 10000)); // Cap at 10k chars for LLM context
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Cross-origin: can't access content directly
|
||||||
|
// Send URL-only signal so backend knows to use reputation-only scoring
|
||||||
|
onContentReady("");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[onTitleChange, onContentReady],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Internal BlackRoad pages
|
||||||
|
if (url === "blackroad://newtab" || url === "") {
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<NewTabPage onNavigate={onNavigate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("blackroad://search")) {
|
||||||
|
const params = new URLSearchParams(url.split("?")[1] || "");
|
||||||
|
return (
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<SearchPage query={params.get("q") || ""} onNavigate={onNavigate} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// External web pages via iframe
|
||||||
|
return (
|
||||||
|
<div className="flex-1 relative">
|
||||||
|
<iframe
|
||||||
|
src={url}
|
||||||
|
className="w-full h-full border-0 bg-white"
|
||||||
|
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||||
|
referrerPolicy="no-referrer"
|
||||||
|
onLoad={handleIframeLoad}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
56
apps/browser/src/components/browser/NavigationControls.tsx
Normal file
56
apps/browser/src/components/browser/NavigationControls.tsx
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
interface NavigationControlsProps {
|
||||||
|
canGoBack?: boolean;
|
||||||
|
canGoForward?: boolean;
|
||||||
|
onBack?: () => void;
|
||||||
|
onForward?: () => void;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavigationControls({
|
||||||
|
canGoBack = false,
|
||||||
|
canGoForward = false,
|
||||||
|
onBack,
|
||||||
|
onForward,
|
||||||
|
onRefresh,
|
||||||
|
}: NavigationControlsProps) {
|
||||||
|
const btnClass = (enabled: boolean) =>
|
||||||
|
`w-7 h-7 flex items-center justify-center rounded transition-colors ${
|
||||||
|
enabled
|
||||||
|
? "text-gray-300 hover:text-white hover:bg-surface-elevated cursor-pointer"
|
||||||
|
: "text-gray-700 cursor-default"
|
||||||
|
}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-0.5">
|
||||||
|
<button
|
||||||
|
className={btnClass(canGoBack)}
|
||||||
|
onClick={onBack}
|
||||||
|
disabled={!canGoBack}
|
||||||
|
title="Back"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={btnClass(canGoForward)}
|
||||||
|
onClick={onForward}
|
||||||
|
disabled={!canGoForward}
|
||||||
|
title="Forward"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={btnClass(true)}
|
||||||
|
onClick={onRefresh}
|
||||||
|
title="Refresh"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/browser/src/components/browser/NewTabPage.tsx
Normal file
94
apps/browser/src/components/browser/NewTabPage.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
interface NewTabPageProps {
|
||||||
|
onNavigate: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NewTabPage({ onNavigate }: NewTabPageProps) {
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && query.trim()) {
|
||||||
|
onNavigate(query.trim());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const quickLinks = [
|
||||||
|
{ title: "Wikipedia", url: "https://wikipedia.org", icon: "W" },
|
||||||
|
{ title: "arXiv", url: "https://arxiv.org", icon: "a" },
|
||||||
|
{ title: "Reuters", url: "https://reuters.com", icon: "R" },
|
||||||
|
{ title: "AP News", url: "https://apnews.com", icon: "AP" },
|
||||||
|
{ title: "Nature", url: "https://nature.com", icon: "N" },
|
||||||
|
{ title: "PubMed", url: "https://pubmed.ncbi.nlm.nih.gov", icon: "P" },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center min-h-full bg-black px-4">
|
||||||
|
{/* Logo */}
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-6xl font-black br-gradient-text tracking-tight">
|
||||||
|
BlackRoad
|
||||||
|
</h1>
|
||||||
|
<p className="text-center text-muted text-sm mt-3 tracking-widest uppercase">
|
||||||
|
Accurate info. Period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search bar */}
|
||||||
|
<div className="w-full max-w-2xl">
|
||||||
|
<div className="relative">
|
||||||
|
<svg
|
||||||
|
className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-muted"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full pl-12 pr-6 py-4 rounded-2xl bg-surface-elevated border border-gray-700 text-white text-lg outline-none focus:border-hotpink focus:shadow-glow transition-all placeholder-gray-500"
|
||||||
|
placeholder="Search for anything..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<div className="mt-10 grid grid-cols-3 sm:grid-cols-6 gap-4">
|
||||||
|
{quickLinks.map((link) => (
|
||||||
|
<button
|
||||||
|
key={link.url}
|
||||||
|
onClick={() => onNavigate(link.url)}
|
||||||
|
className="flex flex-col items-center gap-2 p-3 rounded-xl hover:bg-surface-elevated transition-colors group"
|
||||||
|
>
|
||||||
|
<div className="w-10 h-10 rounded-lg bg-surface-elevated group-hover:bg-gray-700 flex items-center justify-center text-sm font-bold text-hotpink transition-colors">
|
||||||
|
{link.icon}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted group-hover:text-gray-300 transition-colors">
|
||||||
|
{link.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tagline */}
|
||||||
|
<div className="mt-16 text-center">
|
||||||
|
<p className="text-xs text-gray-700">
|
||||||
|
Verification powered by AI. Sources ranked by accuracy.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-800 mt-1">
|
||||||
|
BlackRoad Internet v0.1.0
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
43
apps/browser/src/components/browser/StatusBar.tsx
Normal file
43
apps/browser/src/components/browser/StatusBar.tsx
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
interface StatusBarProps {
|
||||||
|
url?: string;
|
||||||
|
isSecure: boolean;
|
||||||
|
verificationScore?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusBar({ url, isSecure, verificationScore }: StatusBarProps) {
|
||||||
|
const scoreLabel =
|
||||||
|
verificationScore === undefined
|
||||||
|
? ""
|
||||||
|
: `Accuracy: ${Math.round(verificationScore * 100)}%`;
|
||||||
|
|
||||||
|
const scoreColor =
|
||||||
|
verificationScore === undefined
|
||||||
|
? "text-muted"
|
||||||
|
: verificationScore > 0.8
|
||||||
|
? "text-emerald-400"
|
||||||
|
: verificationScore > 0.5
|
||||||
|
? "text-amber-400"
|
||||||
|
: "text-red-400";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-between px-3 py-1 bg-surface border-t border-gray-800 text-[10px] select-none">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isSecure && (
|
||||||
|
<span className="text-emerald-400 flex items-center gap-1">
|
||||||
|
<svg className="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
Secure
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-muted truncate max-w-md">
|
||||||
|
{url?.startsWith("blackroad://") ? "" : url}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{scoreLabel && <span className={scoreColor}>{scoreLabel}</span>}
|
||||||
|
<span className="text-gray-700">BlackRoad Internet</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
apps/browser/src/components/browser/TabBar.tsx
Normal file
69
apps/browser/src/components/browser/TabBar.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { Tab } from "../../types/browser";
|
||||||
|
|
||||||
|
interface TabBarProps {
|
||||||
|
tabs: Tab[];
|
||||||
|
activeTabId: string;
|
||||||
|
onTabClick: (id: string) => void;
|
||||||
|
onTabClose: (id: string) => void;
|
||||||
|
onNewTab: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TabBar({
|
||||||
|
tabs,
|
||||||
|
activeTabId,
|
||||||
|
onTabClick,
|
||||||
|
onTabClose,
|
||||||
|
onNewTab,
|
||||||
|
}: TabBarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center bg-black border-b border-gray-800 select-none">
|
||||||
|
<div className="flex flex-1 overflow-x-auto">
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<div
|
||||||
|
key={tab.id}
|
||||||
|
className={`group flex items-center gap-2 px-4 py-2 min-w-[140px] max-w-[240px] cursor-pointer border-r border-gray-800 transition-colors ${
|
||||||
|
tab.id === activeTabId
|
||||||
|
? "bg-surface-elevated text-white"
|
||||||
|
: "bg-black text-muted hover:bg-surface hover:text-gray-300"
|
||||||
|
}`}
|
||||||
|
onClick={() => onTabClick(tab.id)}
|
||||||
|
>
|
||||||
|
{tab.isLoading && (
|
||||||
|
<div className="w-3 h-3 border border-hotpink border-t-transparent rounded-full animate-spin shrink-0" />
|
||||||
|
)}
|
||||||
|
<span className="text-xs truncate flex-1">
|
||||||
|
{tab.title || extractDomain(tab.url) || "New Tab"}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
className="w-4 h-4 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-gray-600 transition-opacity text-[10px]"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onTabClose(tab.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
x
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onNewTab}
|
||||||
|
className="px-3 py-2 text-muted hover:text-white hover:bg-surface transition-colors text-sm"
|
||||||
|
title="New tab"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDomain(url: string): string {
|
||||||
|
if (url.startsWith("blackroad://")) {
|
||||||
|
return url.replace("blackroad://", "");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
288
apps/browser/src/components/search/SearchPage.tsx
Normal file
288
apps/browser/src/components/search/SearchPage.tsx
Normal file
@@ -0,0 +1,288 @@
|
|||||||
|
import { useState, useEffect, type KeyboardEvent } from "react";
|
||||||
|
|
||||||
|
interface SearchPageProps {
|
||||||
|
query: string;
|
||||||
|
onNavigate: (url: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
domain: string;
|
||||||
|
relevance_score: number;
|
||||||
|
accuracy_score: number;
|
||||||
|
source_reputation: number;
|
||||||
|
freshness_score: number;
|
||||||
|
final_score: number;
|
||||||
|
source_category: string;
|
||||||
|
source_bias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResponse {
|
||||||
|
query: string;
|
||||||
|
results: SearchResult[];
|
||||||
|
total: number;
|
||||||
|
search_time_ms: number;
|
||||||
|
engine: string;
|
||||||
|
error?: string;
|
||||||
|
fallback_url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEARCH_API = "https://blackroad-search-api.amundsonalexa.workers.dev";
|
||||||
|
|
||||||
|
const categoryIcons: Record<string, string> = {
|
||||||
|
academic: "A",
|
||||||
|
journal: "J",
|
||||||
|
government: "G",
|
||||||
|
"wire-service": "W",
|
||||||
|
reference: "R",
|
||||||
|
news: "N",
|
||||||
|
"fact-check": "F",
|
||||||
|
"tech-docs": "T",
|
||||||
|
social: "S",
|
||||||
|
"low-credibility": "!",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SearchPage({ query, onNavigate }: SearchPageProps) {
|
||||||
|
const [searchInput, setSearchInput] = useState(query);
|
||||||
|
const [results, setResults] = useState<SearchResult[]>([]);
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [searchTime, setSearchTime] = useState(0);
|
||||||
|
const [engine, setEngine] = useState("");
|
||||||
|
const [searchError, setSearchError] = useState<string | null>(null);
|
||||||
|
const [fallbackUrl, setFallbackUrl] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter" && searchInput.trim()) {
|
||||||
|
onNavigate(`blackroad://search?q=${encodeURIComponent(searchInput.trim())}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!query) return;
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const doSearch = async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
setSearchError(null);
|
||||||
|
setFallbackUrl(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${SEARCH_API}/search?q=${encodeURIComponent(query)}&limit=20`);
|
||||||
|
|
||||||
|
if (resp.ok && !cancelled) {
|
||||||
|
const data: SearchResponse = await resp.json();
|
||||||
|
setResults(data.results);
|
||||||
|
setSearchTime(data.search_time_ms);
|
||||||
|
setEngine(data.engine);
|
||||||
|
if (data.error) setSearchError(data.error);
|
||||||
|
if (data.fallback_url) setFallbackUrl(data.fallback_url);
|
||||||
|
} else if (!cancelled) {
|
||||||
|
setResults([]);
|
||||||
|
setSearchError("Search API returned an error.");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) {
|
||||||
|
setResults([]);
|
||||||
|
setSearchError("Search API offline.");
|
||||||
|
setFallbackUrl(`https://duckduckgo.com/?q=${encodeURIComponent(query)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
doSearch();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-full bg-black p-6">
|
||||||
|
<div className="max-w-3xl mx-auto">
|
||||||
|
{/* Search header */}
|
||||||
|
<div className="flex items-center gap-4 mb-8">
|
||||||
|
<h1
|
||||||
|
className="text-2xl font-bold br-gradient-text shrink-0 cursor-pointer"
|
||||||
|
onClick={() => onNavigate("blackroad://newtab")}
|
||||||
|
>
|
||||||
|
BlackRoad
|
||||||
|
</h1>
|
||||||
|
<div className="flex-1">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => setSearchInput(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full px-4 py-2 rounded-lg bg-surface-elevated border border-gray-700 text-white text-sm outline-none focus:border-hotpink transition-colors placeholder-gray-500"
|
||||||
|
placeholder="Search the accurate web..."
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Search status bar */}
|
||||||
|
{query && (
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
{isSearching
|
||||||
|
? "Searching & ranking by accuracy..."
|
||||||
|
: results.length > 0
|
||||||
|
? `${results.length} results ranked by accuracy (${searchTime}ms via ${engine})`
|
||||||
|
: null}
|
||||||
|
</p>
|
||||||
|
{!isSearching && results.length > 0 && (
|
||||||
|
<p className="text-[10px] text-gray-700">
|
||||||
|
0.4*Relevance + 0.3*Accuracy + 0.2*Source + 0.1*Fresh
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Loading */}
|
||||||
|
{isSearching && (
|
||||||
|
<div className="flex items-center justify-center py-20">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-hotpink border-t-transparent rounded-full mx-auto" />
|
||||||
|
<p className="text-xs text-muted mt-4">Ranking by accuracy...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Results */}
|
||||||
|
{!isSearching && results.length > 0 && (
|
||||||
|
<div className="space-y-5">
|
||||||
|
{results.map((result, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="group cursor-pointer"
|
||||||
|
onClick={() => onNavigate(result.url)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 mb-1">
|
||||||
|
<span
|
||||||
|
className={`text-[10px] font-mono font-bold px-1.5 py-0.5 rounded ${
|
||||||
|
result.final_score > 0.7
|
||||||
|
? "text-emerald-400 bg-emerald-400/10"
|
||||||
|
: result.final_score > 0.45
|
||||||
|
? "text-amber-400 bg-amber-400/10"
|
||||||
|
: "text-red-400 bg-red-400/10"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{Math.round(result.final_score * 100)}
|
||||||
|
</span>
|
||||||
|
{result.source_category !== "unknown" && (
|
||||||
|
<span
|
||||||
|
className="text-[9px] px-1 py-0.5 rounded bg-gray-800 text-gray-500 font-mono"
|
||||||
|
title={result.source_category}
|
||||||
|
>
|
||||||
|
{categoryIcons[result.source_category] || "?"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-[11px] text-gray-600 truncate">
|
||||||
|
{result.domain}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 className="text-sm text-electricblue group-hover:underline">
|
||||||
|
{result.title}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<p className="text-xs text-gray-400 mt-1 leading-relaxed line-clamp-2">
|
||||||
|
{result.description}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3 mt-1.5">
|
||||||
|
<span className="text-[9px] text-gray-700">
|
||||||
|
rel:{Math.round(result.relevance_score * 100)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-gray-700">
|
||||||
|
acc:{Math.round(result.accuracy_score * 100)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-gray-700">
|
||||||
|
src:{Math.round(result.source_reputation * 100)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] text-gray-700">
|
||||||
|
fresh:{Math.round(result.freshness_score * 100)}
|
||||||
|
</span>
|
||||||
|
{result.source_bias !== "unknown" && result.source_bias !== "none" && (
|
||||||
|
<span className="text-[9px] text-amber-600">
|
||||||
|
bias:{result.source_bias}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* No Brave key - DuckDuckGo fallback with accuracy overlay */}
|
||||||
|
{!isSearching && results.length === 0 && query && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Accuracy-scored quick links for this query */}
|
||||||
|
<div className="p-4 rounded-lg border border-gray-800 bg-surface">
|
||||||
|
<h3 className="text-xs text-muted font-semibold mb-3 uppercase tracking-wider">
|
||||||
|
Accuracy-Ranked Sources
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-gray-600 mb-3">
|
||||||
|
{searchError || "Searching high-accuracy sources for your query:"}
|
||||||
|
</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{[
|
||||||
|
{ name: "Wikipedia", url: `https://en.wikipedia.org/w/index.php?search=${encodeURIComponent(query)}`, score: 88, cat: "R" },
|
||||||
|
{ name: "Google Scholar", url: `https://scholar.google.com/scholar?q=${encodeURIComponent(query)}`, score: 95, cat: "A" },
|
||||||
|
{ name: "PubMed", url: `https://pubmed.ncbi.nlm.nih.gov/?term=${encodeURIComponent(query)}`, score: 95, cat: "A" },
|
||||||
|
{ name: "Reuters", url: `https://www.reuters.com/search/news?query=${encodeURIComponent(query)}`, score: 93, cat: "W" },
|
||||||
|
{ name: "AP News", url: `https://apnews.com/search#?q=${encodeURIComponent(query)}`, score: 93, cat: "W" },
|
||||||
|
{ name: "BBC", url: `https://www.bbc.co.uk/search?q=${encodeURIComponent(query)}`, score: 85, cat: "N" },
|
||||||
|
{ name: "Nature", url: `https://www.nature.com/search?q=${encodeURIComponent(query)}`, score: 96, cat: "J" },
|
||||||
|
{ name: "arXiv", url: `https://arxiv.org/search/?query=${encodeURIComponent(query)}`, score: 95, cat: "A" },
|
||||||
|
].map((src, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-surface-elevated cursor-pointer transition-colors"
|
||||||
|
onClick={() => onNavigate(src.url)}
|
||||||
|
>
|
||||||
|
<span className="text-[10px] font-mono font-bold text-emerald-400 bg-emerald-400/10 px-1.5 py-0.5 rounded w-8 text-center">
|
||||||
|
{src.score}
|
||||||
|
</span>
|
||||||
|
<span className="text-[9px] px-1 py-0.5 rounded bg-gray-800 text-gray-500 font-mono">
|
||||||
|
{src.cat}
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-electricblue hover:underline">
|
||||||
|
{src.name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-gray-700 ml-auto truncate max-w-[200px]">
|
||||||
|
Search "{query}"
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DuckDuckGo full search fallback */}
|
||||||
|
<div className="text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => onNavigate(fallbackUrl || `https://duckduckgo.com/?q=${encodeURIComponent(query)}`)}
|
||||||
|
className="text-xs text-electricblue hover:underline"
|
||||||
|
>
|
||||||
|
Or search with DuckDuckGo (unranked)
|
||||||
|
</button>
|
||||||
|
<p className="text-[9px] text-gray-700 mt-2">
|
||||||
|
Add a Brave Search API key to enable full accuracy-ranked search
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Empty state */}
|
||||||
|
{!query && (
|
||||||
|
<div className="text-center mt-20">
|
||||||
|
<p className="text-muted text-sm">
|
||||||
|
Enter a search query to get accuracy-ranked results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
79
apps/browser/src/components/sidebar/AccuracyScore.tsx
Normal file
79
apps/browser/src/components/sidebar/AccuracyScore.tsx
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
interface AccuracyScoreProps {
|
||||||
|
score: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccuracyScore({ score }: AccuracyScoreProps) {
|
||||||
|
const percentage = Math.round(score * 100);
|
||||||
|
const radius = 40;
|
||||||
|
const circumference = 2 * Math.PI * radius;
|
||||||
|
const offset = circumference - score * circumference;
|
||||||
|
|
||||||
|
const color =
|
||||||
|
score > 0.8
|
||||||
|
? "#4ade80"
|
||||||
|
: score > 0.6
|
||||||
|
? "#F5A623"
|
||||||
|
: score > 0.4
|
||||||
|
? "#ff8c00"
|
||||||
|
: "#ef4444";
|
||||||
|
|
||||||
|
const label =
|
||||||
|
score > 0.8
|
||||||
|
? "High Accuracy"
|
||||||
|
: score > 0.6
|
||||||
|
? "Moderate"
|
||||||
|
: score > 0.4
|
||||||
|
? "Mixed"
|
||||||
|
: "Low Accuracy";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 flex flex-col items-center border-b border-gray-800">
|
||||||
|
<svg width="100" height="100" viewBox="0 0 100 100">
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke="#1a1a1a"
|
||||||
|
strokeWidth="6"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="50"
|
||||||
|
cy="50"
|
||||||
|
r={radius}
|
||||||
|
fill="none"
|
||||||
|
stroke={color}
|
||||||
|
strokeWidth="6"
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
strokeDashoffset={offset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
transform="rotate(-90 50 50)"
|
||||||
|
style={{ transition: "stroke-dashoffset 0.8s ease" }}
|
||||||
|
/>
|
||||||
|
<text
|
||||||
|
x="50"
|
||||||
|
y="46"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill="white"
|
||||||
|
fontSize="24"
|
||||||
|
fontWeight="bold"
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{percentage}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x="50"
|
||||||
|
y="62"
|
||||||
|
textAnchor="middle"
|
||||||
|
dominantBaseline="central"
|
||||||
|
fill="#666"
|
||||||
|
fontSize="8"
|
||||||
|
fontFamily="monospace"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
apps/browser/src/components/sidebar/ClaimsList.tsx
Normal file
94
apps/browser/src/components/sidebar/ClaimsList.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import type { ClaimResult } from "../../types/verification";
|
||||||
|
|
||||||
|
interface ClaimsListProps {
|
||||||
|
claims: ClaimResult[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdictColors: Record<string, string> = {
|
||||||
|
Verified: "text-emerald-400 bg-emerald-400/10",
|
||||||
|
Likely: "text-green-300 bg-green-300/10",
|
||||||
|
Uncertain: "text-amber-400 bg-amber-400/10",
|
||||||
|
Disputed: "text-orange-400 bg-orange-400/10",
|
||||||
|
False: "text-red-400 bg-red-400/10",
|
||||||
|
};
|
||||||
|
|
||||||
|
const verdictIcons: Record<string, string> = {
|
||||||
|
Verified: "\u2713",
|
||||||
|
Likely: "~",
|
||||||
|
Uncertain: "?",
|
||||||
|
Disputed: "!",
|
||||||
|
False: "\u2715",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClaimsList({ claims }: ClaimsListProps) {
|
||||||
|
if (claims.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
|
||||||
|
Claims Analysis
|
||||||
|
</h3>
|
||||||
|
<p className="text-[10px] text-gray-700">
|
||||||
|
No verifiable claims extracted. This may be because the content is too
|
||||||
|
short, cross-origin restricted, or contains only opinions.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4">
|
||||||
|
<h3 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
|
||||||
|
Claims ({claims.length})
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{claims.map((claim, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="p-2 rounded-lg bg-surface-elevated border border-gray-800"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span
|
||||||
|
className={`shrink-0 w-5 h-5 rounded flex items-center justify-center text-[10px] font-bold ${verdictColors[claim.verdict] || "text-muted bg-gray-800"}`}
|
||||||
|
>
|
||||||
|
{verdictIcons[claim.verdict] || "?"}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-[11px] text-gray-300 leading-relaxed">
|
||||||
|
{claim.text}
|
||||||
|
</p>
|
||||||
|
{claim.reasoning && (
|
||||||
|
<p className="text-[10px] text-gray-500 mt-1 leading-snug">
|
||||||
|
{claim.reasoning}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<span className="text-[9px] text-gray-600 uppercase">
|
||||||
|
{claim.claim_type}
|
||||||
|
</span>
|
||||||
|
{claim.confidence > 0 && (
|
||||||
|
<span className="text-[9px] text-gray-700">
|
||||||
|
{Math.round(claim.confidence * 100)}% conf
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{claim.sources.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
|
{claim.sources.map((src, j) => (
|
||||||
|
<span
|
||||||
|
key={j}
|
||||||
|
className="text-[8px] text-electricblue truncate max-w-[200px]"
|
||||||
|
title={src}
|
||||||
|
>
|
||||||
|
[{j + 1}] {src}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
107
apps/browser/src/components/sidebar/SourceReputation.tsx
Normal file
107
apps/browser/src/components/sidebar/SourceReputation.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
interface SourceReputationProps {
|
||||||
|
score: number;
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
category: string;
|
||||||
|
bias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const categoryLabels: Record<string, string> = {
|
||||||
|
academic: "Academic",
|
||||||
|
journal: "Scientific Journal",
|
||||||
|
government: "Government",
|
||||||
|
"wire-service": "Wire Service",
|
||||||
|
reference: "Reference",
|
||||||
|
news: "News",
|
||||||
|
"fact-check": "Fact-Checker",
|
||||||
|
"tech-docs": "Tech Docs",
|
||||||
|
social: "Social Media",
|
||||||
|
"low-credibility": "Low Credibility",
|
||||||
|
education: "Education",
|
||||||
|
organization: "Organization",
|
||||||
|
unknown: "Unknown",
|
||||||
|
};
|
||||||
|
|
||||||
|
const biasColors: Record<string, string> = {
|
||||||
|
none: "text-emerald-400",
|
||||||
|
center: "text-emerald-400",
|
||||||
|
"center-left": "text-blue-400",
|
||||||
|
"center-right": "text-orange-300",
|
||||||
|
left: "text-blue-500",
|
||||||
|
right: "text-orange-400",
|
||||||
|
extreme: "text-red-400",
|
||||||
|
mixed: "text-amber-400",
|
||||||
|
unknown: "text-gray-500",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function SourceReputation({ score, url, method, category, bias }: SourceReputationProps) {
|
||||||
|
let domain: string;
|
||||||
|
try {
|
||||||
|
domain = new URL(url).hostname;
|
||||||
|
} catch {
|
||||||
|
domain = url;
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = Math.round(score * 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<h3 className="text-xs font-semibold text-muted mb-3 uppercase tracking-wider">
|
||||||
|
Source Reputation
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs text-gray-400 truncate max-w-[160px]">
|
||||||
|
{domain}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-mono font-bold ${
|
||||||
|
score > 0.8
|
||||||
|
? "text-emerald-400"
|
||||||
|
: score > 0.5
|
||||||
|
? "text-amber-400"
|
||||||
|
: "text-red-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{percentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reputation bar */}
|
||||||
|
<div className="w-full h-1.5 bg-gray-800 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full transition-all duration-700"
|
||||||
|
style={{
|
||||||
|
width: `${percentage}%`,
|
||||||
|
background:
|
||||||
|
score > 0.8
|
||||||
|
? "#4ade80"
|
||||||
|
: score > 0.5
|
||||||
|
? "#F5A623"
|
||||||
|
: "#ef4444",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Category & Bias */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{category && category !== "unknown" && (
|
||||||
|
<span className="text-[9px] px-1.5 py-0.5 rounded bg-gray-800 text-gray-400 uppercase">
|
||||||
|
{categoryLabels[category] || category}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{bias && bias !== "unknown" && (
|
||||||
|
<span className={`text-[9px] px-1.5 py-0.5 rounded bg-gray-800 uppercase ${biasColors[bias] || "text-gray-500"}`}>
|
||||||
|
{bias}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-[10px] text-gray-700">
|
||||||
|
Method: {method}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
apps/browser/src/components/sidebar/VerificationPanel.tsx
Normal file
98
apps/browser/src/components/sidebar/VerificationPanel.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { VerificationResult } from "../../types/verification";
|
||||||
|
import { AccuracyScore } from "./AccuracyScore";
|
||||||
|
import { SourceReputation } from "./SourceReputation";
|
||||||
|
import { ClaimsList } from "./ClaimsList";
|
||||||
|
|
||||||
|
interface VerificationPanelProps {
|
||||||
|
verification: VerificationResult | null;
|
||||||
|
isVerifying: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerificationPanel({
|
||||||
|
verification,
|
||||||
|
isVerifying,
|
||||||
|
}: VerificationPanelProps) {
|
||||||
|
return (
|
||||||
|
<div className="w-72 bg-surface border-l border-gray-800 overflow-y-auto flex flex-col shrink-0">
|
||||||
|
<div className="p-4 border-b border-gray-800">
|
||||||
|
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-hotpink"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Verification
|
||||||
|
{verification?.ai_available && (
|
||||||
|
<span className="ml-auto text-[9px] px-1.5 py-0.5 rounded bg-hotpink/20 text-hotpink font-medium">
|
||||||
|
AI
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
<p className="text-[10px] text-muted mt-1 tracking-wider uppercase">
|
||||||
|
Accurate info. Period.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isVerifying ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-8">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="animate-spin w-8 h-8 border-2 border-hotpink border-t-transparent rounded-full mx-auto" />
|
||||||
|
<p className="text-xs text-muted mt-4">Analyzing page...</p>
|
||||||
|
<p className="text-[10px] text-gray-700 mt-1">
|
||||||
|
Checking sources & claims
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : verification ? (
|
||||||
|
<>
|
||||||
|
<AccuracyScore score={verification.overall_score} />
|
||||||
|
<SourceReputation
|
||||||
|
score={verification.source_reputation}
|
||||||
|
url={verification.url}
|
||||||
|
method={verification.method}
|
||||||
|
category={verification.source_category}
|
||||||
|
bias={verification.source_bias}
|
||||||
|
/>
|
||||||
|
{verification.claims_checked > 0 && (
|
||||||
|
<div className="px-4 py-2 border-b border-gray-800">
|
||||||
|
<p className="text-[10px] text-muted">
|
||||||
|
{verification.claims_checked} claims verified in{" "}
|
||||||
|
{verification.verification_time_ms}ms
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<ClaimsList claims={verification.claims} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center p-6">
|
||||||
|
<div className="text-center">
|
||||||
|
<svg
|
||||||
|
className="w-10 h-10 text-gray-800 mx-auto mb-3"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={1}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<p className="text-xs text-muted">
|
||||||
|
Navigate to a page to see verification results
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
89
apps/browser/src/hooks/useTabs.ts
Normal file
89
apps/browser/src/hooks/useTabs.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { useState, useCallback } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { Tab, PageInfo } from "../types/browser";
|
||||||
|
|
||||||
|
let tabCounter = 1;
|
||||||
|
|
||||||
|
function makeTab(): Tab {
|
||||||
|
const id = `tab-${++tabCounter}`;
|
||||||
|
return { id, url: "blackroad://newtab", title: "New Tab", isLoading: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTabs() {
|
||||||
|
const [tabs, setTabs] = useState<Tab[]>([
|
||||||
|
{ id: "tab-1", url: "blackroad://newtab", title: "New Tab", isLoading: false },
|
||||||
|
]);
|
||||||
|
const [activeTabId, setActiveTabId] = useState("tab-1");
|
||||||
|
|
||||||
|
const activeTab = tabs.find((t) => t.id === activeTabId) ?? null;
|
||||||
|
|
||||||
|
const addTab = useCallback(() => {
|
||||||
|
const tab = makeTab();
|
||||||
|
setTabs((prev) => [...prev, tab]);
|
||||||
|
setActiveTabId(tab.id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const closeTab = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setTabs((prev) => {
|
||||||
|
const next = prev.filter((t) => t.id !== id);
|
||||||
|
if (next.length === 0) {
|
||||||
|
const tab = makeTab();
|
||||||
|
setActiveTabId(tab.id);
|
||||||
|
return [tab];
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (activeTabId === id) {
|
||||||
|
setTabs((prev) => {
|
||||||
|
const remaining = prev.filter((t) => t.id !== id);
|
||||||
|
if (remaining.length > 0) {
|
||||||
|
setActiveTabId(remaining[remaining.length - 1].id);
|
||||||
|
}
|
||||||
|
return prev;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTabId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const navigateTo = useCallback(
|
||||||
|
async (url: string) => {
|
||||||
|
if (!activeTabId) return;
|
||||||
|
try {
|
||||||
|
const pageInfo = await invoke<PageInfo>("navigate_to", { url });
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === activeTabId
|
||||||
|
? { ...t, url: pageInfo.url, isLoading: true }
|
||||||
|
: t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Navigation failed:", e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[activeTabId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateTabTitle = useCallback(
|
||||||
|
(title: string) => {
|
||||||
|
setTabs((prev) =>
|
||||||
|
prev.map((t) =>
|
||||||
|
t.id === activeTabId ? { ...t, title, isLoading: false } : t,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[activeTabId],
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
tabs,
|
||||||
|
activeTab,
|
||||||
|
addTab,
|
||||||
|
closeTab,
|
||||||
|
setActiveTab: setActiveTabId,
|
||||||
|
navigateTo,
|
||||||
|
updateTabTitle,
|
||||||
|
};
|
||||||
|
}
|
||||||
69
apps/browser/src/hooks/useVerification.ts
Normal file
69
apps/browser/src/hooks/useVerification.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState, useEffect, useCallback } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { VerificationResult } from "../types/verification";
|
||||||
|
|
||||||
|
export function useVerification(url?: string) {
|
||||||
|
const [verification, setVerification] =
|
||||||
|
useState<VerificationResult | null>(null);
|
||||||
|
const [isVerifying, setIsVerifying] = useState(false);
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||||
|
const [pageContent, setPageContent] = useState("");
|
||||||
|
|
||||||
|
// Called by BrowserFrame when page content is available
|
||||||
|
const onContentReady = useCallback((content: string) => {
|
||||||
|
setPageContent(content);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!url || url.startsWith("blackroad://")) {
|
||||||
|
setVerification(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
const verify = async () => {
|
||||||
|
setIsVerifying(true);
|
||||||
|
try {
|
||||||
|
// Check cache first
|
||||||
|
const cached = await invoke<VerificationResult | null>(
|
||||||
|
"get_verification_status",
|
||||||
|
{ url },
|
||||||
|
);
|
||||||
|
if (!cancelled && cached) {
|
||||||
|
setVerification(cached);
|
||||||
|
setIsVerifying(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run verification with whatever content we have
|
||||||
|
const result = await invoke<VerificationResult>("verify_page", {
|
||||||
|
url,
|
||||||
|
content: pageContent,
|
||||||
|
});
|
||||||
|
if (!cancelled) {
|
||||||
|
setVerification(result);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Verification failed:", err);
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) {
|
||||||
|
setIsVerifying(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
verify();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [url, pageContent]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
verification,
|
||||||
|
isVerifying,
|
||||||
|
sidebarOpen,
|
||||||
|
toggleSidebar: () => setSidebarOpen((v) => !v),
|
||||||
|
onContentReady,
|
||||||
|
};
|
||||||
|
}
|
||||||
10
apps/browser/src/main.tsx
Normal file
10
apps/browser/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./styles/globals.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
50
apps/browser/src/styles/globals.css
Normal file
50
apps/browser/src/styles/globals.css
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: "JetBrains Mono", "SF Mono", "Fira Code", monospace;
|
||||||
|
background: #000000;
|
||||||
|
color: #ffffff;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #0a0a0a;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #333;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: #555;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@layer components {
|
||||||
|
.br-gradient-text {
|
||||||
|
@apply bg-gradient-to-r from-amber via-hotpink to-electricblue bg-clip-text text-transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-gradient-border {
|
||||||
|
border-image: linear-gradient(135deg, #f5a623, #ff1d6c, #9c27b0, #2979ff) 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.br-glow {
|
||||||
|
box-shadow: 0 0 30px rgba(255, 29, 108, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
apps/browser/src/types/browser.ts
Normal file
21
apps/browser/src/types/browser.ts
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
export interface Tab {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
favicon?: string;
|
||||||
|
isLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageInfo {
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
is_secure: boolean;
|
||||||
|
is_search: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BrowserSettings {
|
||||||
|
verification_enabled: boolean;
|
||||||
|
sidebar_position: "left" | "right";
|
||||||
|
theme: "dark" | "light";
|
||||||
|
default_search: string;
|
||||||
|
}
|
||||||
35
apps/browser/src/types/verification.ts
Normal file
35
apps/browser/src/types/verification.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type ClaimType =
|
||||||
|
| "Factual"
|
||||||
|
| "Statistical"
|
||||||
|
| "Quote"
|
||||||
|
| "Opinion"
|
||||||
|
| "Prediction";
|
||||||
|
|
||||||
|
export type Verdict =
|
||||||
|
| "Verified"
|
||||||
|
| "Likely"
|
||||||
|
| "Uncertain"
|
||||||
|
| "Disputed"
|
||||||
|
| "False";
|
||||||
|
|
||||||
|
export interface ClaimResult {
|
||||||
|
text: string;
|
||||||
|
claim_type: ClaimType;
|
||||||
|
confidence: number;
|
||||||
|
verdict: Verdict;
|
||||||
|
reasoning: string;
|
||||||
|
sources: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VerificationResult {
|
||||||
|
url: string;
|
||||||
|
overall_score: number;
|
||||||
|
source_reputation: number;
|
||||||
|
source_category: string;
|
||||||
|
source_bias: string;
|
||||||
|
claims: ClaimResult[];
|
||||||
|
claims_checked: number;
|
||||||
|
verification_time_ms: number;
|
||||||
|
method: string;
|
||||||
|
ai_available: boolean;
|
||||||
|
}
|
||||||
28
apps/browser/tailwind.config.ts
Normal file
28
apps/browser/tailwind.config.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Config } from "tailwindcss";
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ["./src/**/*.{tsx,ts,jsx,js}", "./index.html"],
|
||||||
|
darkMode: "class",
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
hotpink: "#FF1D6C",
|
||||||
|
amber: "#F5A623",
|
||||||
|
electricblue: "#2979FF",
|
||||||
|
violet: "#9C27B0",
|
||||||
|
surface: "#0A0A0A",
|
||||||
|
"surface-elevated": "#1A1A1A",
|
||||||
|
muted: "#666666",
|
||||||
|
background: "#000000",
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
mono: ['"JetBrains Mono"', '"SF Mono"', '"Fira Code"', "monospace"],
|
||||||
|
},
|
||||||
|
boxShadow: {
|
||||||
|
glow: "0 0 40px rgba(255, 29, 108, 0.3)",
|
||||||
|
"glow-blue": "0 0 40px rgba(41, 121, 255, 0.3)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
21
apps/browser/tsconfig.json
Normal file
21
apps/browser/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2021", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
22
apps/browser/vite.config.ts
Normal file
22
apps/browser/vite.config.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 1420,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host
|
||||||
|
? { protocol: "ws", host, port: 1421 }
|
||||||
|
: undefined,
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
target: "esnext",
|
||||||
|
minify: !process.env.TAURI_DEBUG ? "esbuild" : false,
|
||||||
|
sourcemap: !!process.env.TAURI_DEBUG,
|
||||||
|
},
|
||||||
|
});
|
||||||
15
package.json
Normal file
15
package.json
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"name": "blackroad-internet",
|
||||||
|
"private": true,
|
||||||
|
"packageManager": "pnpm@10.28.2",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "turbo dev",
|
||||||
|
"build": "turbo build",
|
||||||
|
"dev:browser": "cd apps/browser && cargo tauri dev",
|
||||||
|
"build:browser": "cd apps/browser && cargo tauri build"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"turbo": "^2.0.0",
|
||||||
|
"typescript": "^5.6.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
2655
pnpm-lock.yaml
generated
Normal file
2655
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
4
pnpm-workspace.yaml
Normal file
4
pnpm-workspace.yaml
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
packages:
|
||||||
|
- "apps/*"
|
||||||
|
- "packages/*"
|
||||||
|
- "workers/*"
|
||||||
13
turbo.json
Normal file
13
turbo.json
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://turbo.build/schema.json",
|
||||||
|
"tasks": {
|
||||||
|
"dev": {
|
||||||
|
"persistent": true,
|
||||||
|
"cache": false
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"dependsOn": ["^build"],
|
||||||
|
"outputs": ["dist/**", "target/**"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
18
workers/search-api/package.json
Normal file
18
workers/search-api/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@blackroad/search-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"types": "wrangler types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250124.0",
|
||||||
|
"wrangler": "^4.4.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
249
workers/search-api/src/index.ts
Normal file
249
workers/search-api/src/index.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
|
||||||
|
export interface Env {
|
||||||
|
BRAVE_API_KEY: string;
|
||||||
|
VERIFICATION_API: Fetcher;
|
||||||
|
ENVIRONMENT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
age?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RankedResult {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
description: string;
|
||||||
|
domain: string;
|
||||||
|
relevance_score: number;
|
||||||
|
accuracy_score: number;
|
||||||
|
source_reputation: number;
|
||||||
|
freshness_score: number;
|
||||||
|
final_score: number;
|
||||||
|
source_category: string;
|
||||||
|
source_bias: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
app.use("*", cors({
|
||||||
|
origin: "*",
|
||||||
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowHeaders: ["Content-Type"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
app.get("/", (c) => {
|
||||||
|
return c.json({
|
||||||
|
service: "BlackRoad Search API",
|
||||||
|
version: "0.1.0",
|
||||||
|
status: "operational",
|
||||||
|
ranking: "0.4*Relevance + 0.3*Accuracy + 0.2*SourceReputation + 0.1*Freshness",
|
||||||
|
endpoints: {
|
||||||
|
search: "GET /search?q=query&limit=20 (requires BRAVE_API_KEY)",
|
||||||
|
enrich: "POST /enrich (enrich client-side results with reputation scores)",
|
||||||
|
reputation: "GET /reputation/:domain (via verification-api)",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health", (c) => c.json({ ok: true, ts: Date.now() }));
|
||||||
|
|
||||||
|
// GET /search?q=query&limit=20 - Full search (requires Brave API key)
|
||||||
|
app.get("/search", async (c) => {
|
||||||
|
const query = c.req.query("q");
|
||||||
|
if (!query) {
|
||||||
|
return c.json({ error: "q query parameter required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const limit = Math.min(parseInt(c.req.query("limit") || "20"), 50);
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
if (!c.env.BRAVE_API_KEY) {
|
||||||
|
return c.json({
|
||||||
|
query,
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
search_time_ms: Date.now() - start,
|
||||||
|
engine: "none",
|
||||||
|
fallback_url: `https://duckduckgo.com/?q=${encodeURIComponent(query)}`,
|
||||||
|
error: "BRAVE_API_KEY not configured. Use /enrich endpoint to score client-side results, or set up Brave Search API key.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let rawResults: SearchResult[] = [];
|
||||||
|
try {
|
||||||
|
const braveResp = await fetch(
|
||||||
|
`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=${limit}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Accept": "application/json",
|
||||||
|
"Accept-Encoding": "gzip",
|
||||||
|
"X-Subscription-Token": c.env.BRAVE_API_KEY,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (braveResp.ok) {
|
||||||
|
const data = (await braveResp.json()) as { web?: { results: SearchResult[] } };
|
||||||
|
rawResults = data.web?.results || [];
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return c.json({
|
||||||
|
query,
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
search_time_ms: Date.now() - start,
|
||||||
|
engine: "brave",
|
||||||
|
error: "Brave Search API error",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(await rankResults(rawResults, query, start, "brave", c.env.VERIFICATION_API, limit));
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /enrich - Score/rank a set of results from client-side scraping
|
||||||
|
// Body: { query: string, results: [{ title, url, description }] }
|
||||||
|
app.post("/enrich", async (c) => {
|
||||||
|
const body = await c.req.json<{ query: string; results: SearchResult[] }>();
|
||||||
|
if (!body.query || !body.results) {
|
||||||
|
return c.json({ error: "query and results[] required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Date.now();
|
||||||
|
return c.json(await rankResults(body.results, body.query, start, "enriched", c.env.VERIFICATION_API, 50));
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /reputation/:domain - Proxy to verification API
|
||||||
|
app.get("/reputation/:domain", async (c) => {
|
||||||
|
const domain = c.req.param("domain");
|
||||||
|
try {
|
||||||
|
const resp = await c.env.VERIFICATION_API.fetch(
|
||||||
|
new Request(`https://internal/reputation/${domain}`),
|
||||||
|
);
|
||||||
|
const data = await resp.json();
|
||||||
|
return c.json(data);
|
||||||
|
} catch {
|
||||||
|
return c.json({ domain, score: 0.5, category: "unknown", bias: "unknown", error: "lookup failed" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Core ranking logic - shared between /search and /enrich */
|
||||||
|
async function rankResults(
|
||||||
|
rawResults: SearchResult[],
|
||||||
|
query: string,
|
||||||
|
startTime: number,
|
||||||
|
engine: string,
|
||||||
|
verificationApi: Fetcher,
|
||||||
|
limit: number,
|
||||||
|
) {
|
||||||
|
if (rawResults.length === 0) {
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
results: [],
|
||||||
|
total: 0,
|
||||||
|
search_time_ms: Date.now() - startTime,
|
||||||
|
engine,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Batch lookup domain reputations
|
||||||
|
const domains = [...new Set(rawResults.map((r) => extractDomain(r.url)))];
|
||||||
|
const reputations = await batchLookupReputations(verificationApi, domains);
|
||||||
|
|
||||||
|
// Apply BlackRoad accuracy formula
|
||||||
|
const ranked: RankedResult[] = rawResults.map((result, index) => {
|
||||||
|
const domain = extractDomain(result.url);
|
||||||
|
const rep = reputations[domain] || { score: 0.5, category: "unknown", bias: "unknown" };
|
||||||
|
|
||||||
|
const relevance = 1.0 - (index / rawResults.length) * 0.5;
|
||||||
|
const accuracy = rep.score;
|
||||||
|
const sourceRep = rep.score;
|
||||||
|
const freshness = computeFreshness(result.age);
|
||||||
|
|
||||||
|
const finalScore = 0.4 * relevance + 0.3 * accuracy + 0.2 * sourceRep + 0.1 * freshness;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: result.title,
|
||||||
|
url: result.url,
|
||||||
|
description: result.description,
|
||||||
|
domain,
|
||||||
|
relevance_score: Math.round(relevance * 100) / 100,
|
||||||
|
accuracy_score: Math.round(accuracy * 100) / 100,
|
||||||
|
source_reputation: Math.round(sourceRep * 100) / 100,
|
||||||
|
freshness_score: Math.round(freshness * 100) / 100,
|
||||||
|
final_score: Math.round(finalScore * 100) / 100,
|
||||||
|
source_category: rep.category,
|
||||||
|
source_bias: rep.bias,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
ranked.sort((a, b) => b.final_score - a.final_score);
|
||||||
|
|
||||||
|
return {
|
||||||
|
query,
|
||||||
|
results: ranked.slice(0, limit),
|
||||||
|
total: ranked.length,
|
||||||
|
search_time_ms: Date.now() - startTime,
|
||||||
|
engine,
|
||||||
|
ranking_formula: "0.4*Relevance + 0.3*Accuracy + 0.2*SourceReputation + 0.1*Freshness",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function batchLookupReputations(
|
||||||
|
verificationApi: Fetcher,
|
||||||
|
domains: string[],
|
||||||
|
): Promise<Record<string, { score: number; category: string; bias: string }>> {
|
||||||
|
try {
|
||||||
|
const resp = await verificationApi.fetch(
|
||||||
|
new Request("https://internal/reputation/batch", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ domains }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = (await resp.json()) as { results: Record<string, { score: number; category: string; bias: string }> };
|
||||||
|
return data.results;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Service binding unavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaults: Record<string, { score: number; category: string; bias: string }> = {};
|
||||||
|
for (const d of domains) {
|
||||||
|
defaults[d] = { score: 0.5, category: "unknown", bias: "unknown" };
|
||||||
|
}
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeFreshness(age?: string): number {
|
||||||
|
if (!age) return 0.5;
|
||||||
|
const lower = age.toLowerCase();
|
||||||
|
if (lower.includes("hour") || lower.includes("minute")) return 1.0;
|
||||||
|
if (lower.includes("day")) {
|
||||||
|
const days = parseInt(lower) || 1;
|
||||||
|
return days <= 7 ? 0.9 : days <= 30 ? 0.7 : 0.5;
|
||||||
|
}
|
||||||
|
if (lower.includes("week")) return 0.7;
|
||||||
|
if (lower.includes("month")) {
|
||||||
|
const months = parseInt(lower) || 1;
|
||||||
|
return months <= 3 ? 0.5 : months <= 12 ? 0.3 : 0.2;
|
||||||
|
}
|
||||||
|
if (lower.includes("year")) return 0.1;
|
||||||
|
return 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default app;
|
||||||
14
workers/search-api/tsconfig.json
Normal file
14
workers/search-api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2021"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
17
workers/search-api/wrangler.toml
Normal file
17
workers/search-api/wrangler.toml
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
name = "blackroad-search-api"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-01-01"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
ENVIRONMENT = "production"
|
||||||
|
|
||||||
|
# Brave Search API key - set via wrangler secret
|
||||||
|
# wrangler secret put BRAVE_API_KEY
|
||||||
|
|
||||||
|
# Service binding to verification API
|
||||||
|
[[services]]
|
||||||
|
binding = "VERIFICATION_API"
|
||||||
|
service = "blackroad-verification-api"
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = true
|
||||||
127
workers/verification-api/migrations/0001_init.sql
Normal file
127
workers/verification-api/migrations/0001_init.sql
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
-- BlackRoad Verification API - D1 Schema
|
||||||
|
-- Source reputation + verification cache + community reports
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS source_reputation (
|
||||||
|
domain TEXT PRIMARY KEY,
|
||||||
|
score REAL NOT NULL DEFAULT 0.5,
|
||||||
|
category TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
bias TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
factual TEXT NOT NULL DEFAULT 'unknown',
|
||||||
|
description TEXT,
|
||||||
|
updated_at TEXT DEFAULT (datetime('now')),
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS verification_cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
result_json TEXT NOT NULL,
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS community_reports (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
domain TEXT NOT NULL,
|
||||||
|
claim_text TEXT,
|
||||||
|
report_type TEXT NOT NULL,
|
||||||
|
details TEXT,
|
||||||
|
reporter_id TEXT DEFAULT 'anonymous',
|
||||||
|
created_at TEXT DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_domain ON community_reports(domain);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reports_url ON community_reports(url);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_cache_created ON verification_cache(created_at);
|
||||||
|
|
||||||
|
-- Seed the source reputation database (same domains as Rust backend)
|
||||||
|
|
||||||
|
-- Academic & Research
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('arxiv.org', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('scholar.google.com', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('pubmed.ncbi.nlm.nih.gov', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('jstor.org', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('sciencedirect.com', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('ieee.org', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('acm.org', 0.95, 'academic', 'none', 'very-high'),
|
||||||
|
('researchgate.net', 0.95, 'academic', 'none', 'very-high');
|
||||||
|
|
||||||
|
-- Scientific Journals
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('nature.com', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('science.org', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('cell.com', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('thelancet.com', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('nejm.org', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('bmj.com', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('pnas.org', 0.96, 'journal', 'none', 'very-high'),
|
||||||
|
('plos.org', 0.96, 'journal', 'none', 'very-high');
|
||||||
|
|
||||||
|
-- Government
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('nasa.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('nih.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('cdc.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('fda.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('noaa.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('census.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('sec.gov', 0.92, 'government', 'none', 'very-high'),
|
||||||
|
('data.gov', 0.92, 'government', 'none', 'very-high');
|
||||||
|
|
||||||
|
-- Wire Services
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('apnews.com', 0.93, 'wire-service', 'center', 'very-high'),
|
||||||
|
('reuters.com', 0.93, 'wire-service', 'center', 'very-high'),
|
||||||
|
('afp.com', 0.91, 'wire-service', 'center', 'very-high');
|
||||||
|
|
||||||
|
-- Reference
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('wikipedia.org', 0.88, 'reference', 'none', 'high'),
|
||||||
|
('britannica.com', 0.88, 'reference', 'none', 'high'),
|
||||||
|
('who.int', 0.88, 'reference', 'none', 'high'),
|
||||||
|
('worldbank.org', 0.88, 'reference', 'none', 'high');
|
||||||
|
|
||||||
|
-- Fact-checking
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('snopes.com', 0.90, 'fact-check', 'center', 'very-high'),
|
||||||
|
('factcheck.org', 0.90, 'fact-check', 'center', 'very-high'),
|
||||||
|
('politifact.com', 0.90, 'fact-check', 'center', 'very-high'),
|
||||||
|
('fullfact.org', 0.90, 'fact-check', 'center', 'very-high');
|
||||||
|
|
||||||
|
-- Quality News
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('bbc.com', 0.85, 'news', 'center', 'high'),
|
||||||
|
('bbc.co.uk', 0.85, 'news', 'center', 'high'),
|
||||||
|
('npr.org', 0.84, 'news', 'center', 'high'),
|
||||||
|
('pbs.org', 0.84, 'news', 'center', 'high'),
|
||||||
|
('economist.com', 0.83, 'news', 'center', 'high'),
|
||||||
|
('bloomberg.com', 0.83, 'news', 'center', 'high'),
|
||||||
|
('ft.com', 0.83, 'news', 'center', 'high'),
|
||||||
|
('wsj.com', 0.82, 'news', 'center-right', 'high'),
|
||||||
|
('nytimes.com', 0.82, 'news', 'center-left', 'high'),
|
||||||
|
('washingtonpost.com', 0.81, 'news', 'center-left', 'high'),
|
||||||
|
('theguardian.com', 0.80, 'news', 'center-left', 'high'),
|
||||||
|
('propublica.org', 0.86, 'news', 'center-left', 'high');
|
||||||
|
|
||||||
|
-- Tech Docs
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('developer.mozilla.org', 0.85, 'tech-docs', 'none', 'high'),
|
||||||
|
('docs.python.org', 0.85, 'tech-docs', 'none', 'high'),
|
||||||
|
('docs.rust-lang.org', 0.85, 'tech-docs', 'none', 'high'),
|
||||||
|
('stackoverflow.com', 0.85, 'tech-docs', 'none', 'high');
|
||||||
|
|
||||||
|
-- Social Media (low trust)
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('twitter.com', 0.30, 'social', 'mixed', 'low'),
|
||||||
|
('x.com', 0.30, 'social', 'mixed', 'low'),
|
||||||
|
('facebook.com', 0.30, 'social', 'mixed', 'low'),
|
||||||
|
('instagram.com', 0.30, 'social', 'mixed', 'low'),
|
||||||
|
('tiktok.com', 0.30, 'social', 'mixed', 'low'),
|
||||||
|
('reddit.com', 0.30, 'social', 'mixed', 'low');
|
||||||
|
|
||||||
|
-- Known low credibility
|
||||||
|
INSERT OR IGNORE INTO source_reputation (domain, score, category, bias, factual) VALUES
|
||||||
|
('infowars.com', 0.15, 'low-credibility', 'extreme', 'very-low'),
|
||||||
|
('naturalnews.com', 0.15, 'low-credibility', 'extreme', 'very-low'),
|
||||||
|
('breitbart.com', 0.15, 'low-credibility', 'extreme', 'very-low'),
|
||||||
|
('dailymail.co.uk', 0.15, 'low-credibility', 'extreme', 'very-low');
|
||||||
18
workers/verification-api/package.json
Normal file
18
workers/verification-api/package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "@blackroad/verification-api",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"dev": "wrangler dev",
|
||||||
|
"deploy": "wrangler deploy",
|
||||||
|
"types": "wrangler types"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"hono": "^4.7.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "^4.20250124.0",
|
||||||
|
"wrangler": "^4.4.0",
|
||||||
|
"typescript": "^5.7.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
40
workers/verification-api/src/index.ts
Normal file
40
workers/verification-api/src/index.ts
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import { cors } from "hono/cors";
|
||||||
|
import { reputation } from "./routes/reputation";
|
||||||
|
import { verify } from "./routes/verify";
|
||||||
|
import { reports } from "./routes/reports";
|
||||||
|
|
||||||
|
export interface Env {
|
||||||
|
DB: D1Database;
|
||||||
|
ENVIRONMENT: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
// CORS for browser access
|
||||||
|
app.use("*", cors({
|
||||||
|
origin: ["http://localhost:1420", "https://internet.blackroad.io", "https://search.blackroad.io"],
|
||||||
|
allowMethods: ["GET", "POST", "OPTIONS"],
|
||||||
|
allowHeaders: ["Content-Type", "Authorization"],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get("/", (c) => {
|
||||||
|
return c.json({
|
||||||
|
service: "BlackRoad Verification API",
|
||||||
|
version: "0.1.0",
|
||||||
|
status: "operational",
|
||||||
|
mission: "Accurate info. Period.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get("/health", (c) => {
|
||||||
|
return c.json({ ok: true, ts: Date.now() });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mount route groups
|
||||||
|
app.route("/reputation", reputation);
|
||||||
|
app.route("/verify", verify);
|
||||||
|
app.route("/reports", reports);
|
||||||
|
|
||||||
|
export default app;
|
||||||
115
workers/verification-api/src/routes/reports.ts
Normal file
115
workers/verification-api/src/routes/reports.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Env } from "../index";
|
||||||
|
|
||||||
|
export const reports = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
interface ReportRequest {
|
||||||
|
url: string;
|
||||||
|
claim_text?: string;
|
||||||
|
report_type: "inaccurate" | "misleading" | "satire" | "outdated" | "biased";
|
||||||
|
details?: string;
|
||||||
|
reporter_id?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /reports - Submit a community accuracy report
|
||||||
|
reports.post("/", async (c) => {
|
||||||
|
const body = await c.req.json<ReportRequest>();
|
||||||
|
|
||||||
|
if (!body.url || !body.report_type) {
|
||||||
|
return c.json({ error: "url and report_type required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const validTypes = ["inaccurate", "misleading", "satire", "outdated", "biased"];
|
||||||
|
if (!validTypes.includes(body.report_type)) {
|
||||||
|
return c.json({ error: `report_type must be one of: ${validTypes.join(", ")}` }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const domain = extractDomain(body.url);
|
||||||
|
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
`INSERT INTO community_reports (url, domain, claim_text, report_type, details, reporter_id, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, datetime('now'))`,
|
||||||
|
)
|
||||||
|
.bind(
|
||||||
|
body.url,
|
||||||
|
domain,
|
||||||
|
body.claim_text || null,
|
||||||
|
body.report_type,
|
||||||
|
body.details || null,
|
||||||
|
body.reporter_id || "anonymous",
|
||||||
|
)
|
||||||
|
.run();
|
||||||
|
|
||||||
|
return c.json({ success: true, message: "Report submitted" });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: "Failed to submit report", detail: String(err) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /reports?domain=... - Get reports for a domain
|
||||||
|
reports.get("/", async (c) => {
|
||||||
|
const domain = c.req.query("domain");
|
||||||
|
const url = c.req.query("url");
|
||||||
|
const limit = Math.min(parseInt(c.req.query("limit") || "20"), 100);
|
||||||
|
|
||||||
|
let query: string;
|
||||||
|
let bindValue: string;
|
||||||
|
|
||||||
|
if (url) {
|
||||||
|
query = "SELECT * FROM community_reports WHERE url = ? ORDER BY created_at DESC LIMIT ?";
|
||||||
|
bindValue = url;
|
||||||
|
} else if (domain) {
|
||||||
|
query = "SELECT * FROM community_reports WHERE domain = ? ORDER BY created_at DESC LIMIT ?";
|
||||||
|
bindValue = domain;
|
||||||
|
} else {
|
||||||
|
return c.json({ error: "domain or url query parameter required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.DB.prepare(query).bind(bindValue, limit).all();
|
||||||
|
return c.json({ reports: results, count: results.length });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: "Query failed", detail: String(err) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /reports/stats?domain=... - Report stats for a domain
|
||||||
|
reports.get("/stats", async (c) => {
|
||||||
|
const domain = c.req.query("domain");
|
||||||
|
if (!domain) {
|
||||||
|
return c.json({ error: "domain query parameter required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { results } = await c.env.DB.prepare(
|
||||||
|
`SELECT report_type, COUNT(*) as count
|
||||||
|
FROM community_reports WHERE domain = ?
|
||||||
|
GROUP BY report_type`,
|
||||||
|
)
|
||||||
|
.bind(domain)
|
||||||
|
.all();
|
||||||
|
|
||||||
|
const total = await c.env.DB.prepare(
|
||||||
|
"SELECT COUNT(*) as total FROM community_reports WHERE domain = ?",
|
||||||
|
)
|
||||||
|
.bind(domain)
|
||||||
|
.first<{ total: number }>();
|
||||||
|
|
||||||
|
return c.json({
|
||||||
|
domain,
|
||||||
|
total_reports: total?.total || 0,
|
||||||
|
by_type: results,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: "Query failed", detail: String(err) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
112
workers/verification-api/src/routes/reputation.ts
Normal file
112
workers/verification-api/src/routes/reputation.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Env } from "../index";
|
||||||
|
|
||||||
|
export const reputation = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
// GET /reputation/:domain - Look up domain reputation
|
||||||
|
reputation.get("/:domain", async (c) => {
|
||||||
|
const domain = c.req.param("domain").toLowerCase();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const row = await c.env.DB.prepare(
|
||||||
|
"SELECT domain, score, category, bias, factual, updated_at FROM source_reputation WHERE domain = ?",
|
||||||
|
)
|
||||||
|
.bind(domain)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
// Try parent domain
|
||||||
|
const parts = domain.split(".");
|
||||||
|
if (parts.length > 2) {
|
||||||
|
const parent = parts.slice(-2).join(".");
|
||||||
|
const parentRow = await c.env.DB.prepare(
|
||||||
|
"SELECT domain, score, category, bias, factual, updated_at FROM source_reputation WHERE domain = ?",
|
||||||
|
)
|
||||||
|
.bind(parent)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (parentRow) {
|
||||||
|
return c.json({ found: true, ...parentRow, matched_via: "parent" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLD fallbacks
|
||||||
|
if (domain.endsWith(".gov")) {
|
||||||
|
return c.json({
|
||||||
|
found: true,
|
||||||
|
domain,
|
||||||
|
score: 0.88,
|
||||||
|
category: "government",
|
||||||
|
bias: "none",
|
||||||
|
factual: "high",
|
||||||
|
matched_via: "tld",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (domain.endsWith(".edu")) {
|
||||||
|
return c.json({
|
||||||
|
found: true,
|
||||||
|
domain,
|
||||||
|
score: 0.85,
|
||||||
|
category: "education",
|
||||||
|
bias: "none",
|
||||||
|
factual: "high",
|
||||||
|
matched_via: "tld",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ found: false, domain, score: 0.5, category: "unknown", bias: "unknown", factual: "unknown" });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ found: true, ...row });
|
||||||
|
} catch (err) {
|
||||||
|
return c.json({ error: "Database error", detail: String(err) }, 500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /reputation/batch - Batch lookup with parent domain resolution
|
||||||
|
reputation.post("/batch", async (c) => {
|
||||||
|
const body = await c.req.json<{ domains: string[] }>();
|
||||||
|
if (!body.domains || !Array.isArray(body.domains)) {
|
||||||
|
return c.json({ error: "domains array required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const results: Record<string, unknown> = {};
|
||||||
|
for (const domain of body.domains.slice(0, 50)) {
|
||||||
|
const d = domain.toLowerCase();
|
||||||
|
|
||||||
|
// Direct match
|
||||||
|
let row = await c.env.DB.prepare(
|
||||||
|
"SELECT score, category, bias, factual FROM source_reputation WHERE domain = ?",
|
||||||
|
)
|
||||||
|
.bind(d)
|
||||||
|
.first();
|
||||||
|
|
||||||
|
// Try parent domain (e.g. climate.nasa.gov -> nasa.gov)
|
||||||
|
if (!row) {
|
||||||
|
const parts = d.split(".");
|
||||||
|
if (parts.length > 2) {
|
||||||
|
const parent = parts.slice(-2).join(".");
|
||||||
|
row = await c.env.DB.prepare(
|
||||||
|
"SELECT score, category, bias, factual FROM source_reputation WHERE domain = ?",
|
||||||
|
)
|
||||||
|
.bind(parent)
|
||||||
|
.first();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TLD fallbacks
|
||||||
|
if (!row) {
|
||||||
|
if (d.endsWith(".gov")) {
|
||||||
|
row = { score: 0.88, category: "government", bias: "none", factual: "high" } as any;
|
||||||
|
} else if (d.endsWith(".edu")) {
|
||||||
|
row = { score: 0.85, category: "education", bias: "none", factual: "high" } as any;
|
||||||
|
} else if (d.endsWith(".org")) {
|
||||||
|
row = { score: 0.60, category: "organization", bias: "unknown", factual: "mixed" } as any;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
results[domain] = row || { score: 0.5, category: "unknown", bias: "unknown", factual: "unknown" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ results });
|
||||||
|
});
|
||||||
100
workers/verification-api/src/routes/verify.ts
Normal file
100
workers/verification-api/src/routes/verify.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
import { Hono } from "hono";
|
||||||
|
import type { Env } from "../index";
|
||||||
|
|
||||||
|
export const verify = new Hono<{ Bindings: Env }>();
|
||||||
|
|
||||||
|
interface VerifyRequest {
|
||||||
|
url: string;
|
||||||
|
content?: string;
|
||||||
|
claims?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /verify - Verify a page or set of claims
|
||||||
|
verify.post("/", async (c) => {
|
||||||
|
const body = await c.req.json<VerifyRequest>();
|
||||||
|
if (!body.url) {
|
||||||
|
return c.json({ error: "url required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = extractDomain(body.url);
|
||||||
|
const start = Date.now();
|
||||||
|
|
||||||
|
// 1. Get source reputation from D1
|
||||||
|
const repRow = await c.env.DB.prepare(
|
||||||
|
"SELECT score, category, bias, factual FROM source_reputation WHERE domain = ?",
|
||||||
|
)
|
||||||
|
.bind(domain)
|
||||||
|
.first<{ score: number; category: string; bias: string; factual: string }>();
|
||||||
|
|
||||||
|
const sourceScore = repRow?.score ?? 0.5;
|
||||||
|
const sourceCategory = repRow?.category ?? "unknown";
|
||||||
|
const sourceBias = repRow?.bias ?? "unknown";
|
||||||
|
|
||||||
|
// 2. Check verification cache
|
||||||
|
const cached = await c.env.DB.prepare(
|
||||||
|
"SELECT result_json FROM verification_cache WHERE url = ? AND created_at > datetime('now', '-1 hour')",
|
||||||
|
)
|
||||||
|
.bind(body.url)
|
||||||
|
.first<{ result_json: string }>();
|
||||||
|
|
||||||
|
if (cached) {
|
||||||
|
return c.json(JSON.parse(cached.result_json));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build verification result
|
||||||
|
// In Phase 2, cloud-side AI verification uses source reputation + basic heuristics.
|
||||||
|
// Full AI pipeline runs client-side via Ollama.
|
||||||
|
const result = {
|
||||||
|
url: body.url,
|
||||||
|
overall_score: sourceScore,
|
||||||
|
source_reputation: sourceScore,
|
||||||
|
source_category: sourceCategory,
|
||||||
|
source_bias: sourceBias,
|
||||||
|
claims: [] as unknown[],
|
||||||
|
claims_checked: 0,
|
||||||
|
verification_time_ms: Date.now() - start,
|
||||||
|
method: "cloud-reputation",
|
||||||
|
ai_available: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 4. Cache result
|
||||||
|
try {
|
||||||
|
await c.env.DB.prepare(
|
||||||
|
"INSERT OR REPLACE INTO verification_cache (url, result_json, created_at) VALUES (?, ?, datetime('now'))",
|
||||||
|
)
|
||||||
|
.bind(body.url, JSON.stringify(result))
|
||||||
|
.run();
|
||||||
|
} catch {
|
||||||
|
// Cache write failure is non-critical
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json(result);
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /verify/status?url=... - Check cached verification
|
||||||
|
verify.get("/status", async (c) => {
|
||||||
|
const url = c.req.query("url");
|
||||||
|
if (!url) {
|
||||||
|
return c.json({ error: "url query parameter required" }, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cached = await c.env.DB.prepare(
|
||||||
|
"SELECT result_json, created_at FROM verification_cache WHERE url = ?",
|
||||||
|
)
|
||||||
|
.bind(url)
|
||||||
|
.first<{ result_json: string; created_at: string }>();
|
||||||
|
|
||||||
|
if (!cached) {
|
||||||
|
return c.json({ found: false });
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.json({ found: true, ...JSON.parse(cached.result_json), cached_at: cached.created_at });
|
||||||
|
});
|
||||||
|
|
||||||
|
function extractDomain(url: string): string {
|
||||||
|
try {
|
||||||
|
return new URL(url).hostname.toLowerCase();
|
||||||
|
} catch {
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
workers/verification-api/tsconfig.json
Normal file
14
workers/verification-api/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2021",
|
||||||
|
"module": "ES2022",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2021"],
|
||||||
|
"types": ["@cloudflare/workers-types"],
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"jsxImportSource": "hono/jsx"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
14
workers/verification-api/wrangler.toml
Normal file
14
workers/verification-api/wrangler.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
name = "blackroad-verification-api"
|
||||||
|
main = "src/index.ts"
|
||||||
|
compatibility_date = "2025-01-01"
|
||||||
|
|
||||||
|
[vars]
|
||||||
|
ENVIRONMENT = "production"
|
||||||
|
|
||||||
|
[[d1_databases]]
|
||||||
|
binding = "DB"
|
||||||
|
database_name = "blackroad-verification"
|
||||||
|
database_id = "e781869d-962c-459d-91c3-f0edbf111815"
|
||||||
|
|
||||||
|
[observability]
|
||||||
|
enabled = true
|
||||||
Reference in New Issue
Block a user