feat: add Agrifine browser extension scaffold (Phase 1)

Manifest V3 Chrome extension with a persistent side-panel dashboard
for farm data management. Includes:

- Background service worker that proxies all Anthropic API calls
  (API key stored in chrome.storage.session, never in content scripts)
- Content script that extracts page text for reading-list summarisation
- Sidebar UI with bottom tab bar and settings panel (API key entry)
- Five module stubs wired to live storage:
    1. ReadingList — save pages with AI summary + topic tagging
    2. DataIngest — drag-and-drop CSV/Excel/PDF → AI-structured JSON
    3. FieldProfile — per-field cards with CLU, acres, soil, coordinates
    4. Dashboard — unified filterable view + natural-language AI query bar
    5. CarbonEstimator — Phase 7 stub with feature preview
- Shared storage schema (chrome.storage.local) with context-bundle
  builder for passing reading-list + field data as AI system context
- Tailwind CSS + Webpack 5 build pipeline; builds successfully

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KBD2dN2KEjzz3UQFa9hEpu
This commit is contained in:
Claude 2026-06-27 03:15:11 +00:00
parent 9c8a4503ec
commit 17e2ab8f1a
No known key found for this signature in database
24 changed files with 6785 additions and 0 deletions

View file

@ -0,0 +1,8 @@
{
"presets": [
["@babel/preset-env", {
"targets": { "chrome": "109" },
"modules": false
}]
]
}

3
agrifine-extension/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
node_modules/
dist/
*.map

View file

@ -0,0 +1,45 @@
{
"manifest_version": 3,
"name": "Agrifine",
"version": "0.1.0",
"description": "Farm data dashboard — reading list, data ingestion, field profiles, and AI-powered insights.",
"permissions": [
"storage",
"sidePanel",
"activeTab",
"scripting",
"tabs"
],
"host_permissions": [
"<all_urls>"
],
"background": {
"service_worker": "dist/background.js",
"type": "module"
},
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["dist/content.js"],
"run_at": "document_idle"
}
],
"side_panel": {
"default_path": "dist/sidebar.html"
},
"action": {
"default_title": "Open Agrifine",
"default_icon": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
},
"icons": {
"16": "icons/icon16.png",
"32": "icons/icon32.png",
"48": "icons/icon48.png",
"128": "icons/icon128.png"
}
}

5282
agrifine-extension/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,30 @@
{
"name": "agrifine-extension",
"version": "0.1.0",
"description": "Browser extension farm data dashboard",
"private": true,
"scripts": {
"build": "webpack --config webpack/webpack.config.js",
"watch": "webpack --config webpack/webpack.config.js --watch",
"build:prod": "NODE_ENV=production webpack --config webpack/webpack.config.js"
},
"devDependencies": {
"@babel/core": "^7.24.0",
"@babel/preset-env": "^7.24.0",
"autoprefixer": "^10.4.19",
"babel-loader": "^9.1.3",
"copy-webpack-plugin": "^12.0.2",
"css-loader": "^7.1.2",
"mini-css-extract-plugin": "^2.9.0",
"postcss": "^8.4.38",
"postcss-loader": "^8.1.1",
"tailwindcss": "^3.4.3",
"webpack": "^5.91.0",
"webpack-cli": "^5.1.4"
},
"dependencies": {
"papaparse": "^5.4.1",
"pdfjs-dist": "^4.2.67",
"xlsx": "^0.18.5"
}
}

View file

@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 B

View file

@ -0,0 +1,42 @@
import { sessionGet, sessionSet, KEYS } from '../utils/storage.js';
import { fetchAnthropic } from '../utils/api.js';
// Open the side panel when the action icon is clicked
chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }).catch(console.error);
// ── Message router ────────────────────────────────────────────────────────────
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
switch (message.type) {
case 'ANTHROPIC_REQUEST':
handleAnthropicRequest(message.payload).then(sendResponse).catch((err) =>
sendResponse({ error: err.message })
);
return true; // keep channel open for async response
case 'SET_API_KEY':
sessionSet(KEYS.API_KEY, message.payload.key)
.then(() => sendResponse({ ok: true }))
.catch((err) => sendResponse({ error: err.message }));
return true;
case 'GET_PAGE_CONTENT':
// Content script relays page text; background stores it temporarily
sendResponse({ ok: true });
return false;
default:
return false;
}
});
async function handleAnthropicRequest({ system, userMessage, maxTokens }) {
const text = await fetchAnthropic({ system, userMessage, maxTokens });
return { text };
}
// Keep service worker alive during active side-panel sessions
chrome.runtime.onConnect.addListener((port) => {
if (port.name === 'keepalive') {
port.onDisconnect.addListener(() => {});
}
});

View file

@ -0,0 +1,25 @@
// Content script — minimal surface. Relays page metadata to background on request.
chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message.type === 'GET_PAGE_INFO') {
sendResponse({
url: window.location.href,
title: document.title,
text: extractMainText(),
});
}
});
function extractMainText() {
const selectors = ['article', 'main', '[role="main"]', '.content', '#content', 'body'];
for (const sel of selectors) {
const el = document.querySelector(sel);
if (el) {
// Strip scripts and styles, return first 8000 chars
const clone = el.cloneNode(true);
clone.querySelectorAll('script,style,nav,header,footer,aside').forEach((n) => n.remove());
return clone.innerText.trim().slice(0, 8000);
}
}
return document.body?.innerText?.slice(0, 8000) ?? '';
}

View file

@ -0,0 +1,56 @@
// Carbon Estimator — Phase 7 stub
// Full implementation: soil organic matter, Scope 3 emissions, USDA NRCS eFOTG API, PDF export
export function CarbonEstimatorModule() {
return {
id: 'carbon-estimator',
label: 'Carbon',
render(container) {
container.innerHTML = `
<div class="section-heading">Carbon Estimator</div>
<div class="px-4">
<!-- Phase 7 preview card -->
<div class="agri-card border-earth-200">
<div class="flex items-center gap-3 mb-3">
<div class="w-10 h-10 rounded-full bg-earth-100 flex items-center justify-center text-xl">🌿</div>
<div>
<h3 class="text-sm font-bold text-gray-800">Carbon Estimator</h3>
<span class="coming-soon">Coming in Phase 7</span>
</div>
</div>
<p class="text-xs text-gray-500 leading-relaxed">
The Carbon Estimator will calculate your operation's Scope 3 emissions profile
and estimate carbon sequestration potential per field using USDA emission factors.
</p>
</div>
<!-- Feature preview list -->
<div class="space-y-2 mt-2">
${[
['📊', 'Scope 3 Emissions Profile', 'Based on fuel use, crop type, and animal operations'],
['🌱', 'Sequestration Potential', 'Per-field estimate using soil type and land cover'],
['🏛️', 'USDA Program Matcher', 'Match your practices to EQIP, CSP, and CRP programs'],
['📄', 'Carbon Credit PDF', 'Downloadable eligibility summary for carbon marketplaces'],
['📡', 'Marketplace Handoff', 'Send your credit profile to Nori, Pachama, or others (Phase 8)'],
].map(([icon, title, desc]) => `
<div class="flex items-start gap-3 py-2.5 border-b border-gray-100 last:border-0">
<span class="text-base">${icon}</span>
<div>
<p class="text-xs font-semibold text-gray-700">${title}</p>
<p class="text-xs text-gray-400">${desc}</p>
</div>
</div>`).join('')}
</div>
<!-- Notify placeholder -->
<div class="mt-4 bg-agri-50 rounded-xl p-3 text-center">
<p class="text-xs text-agri-700 font-medium">Your field profile data is already being collected.</p>
<p class="text-xs text-agri-600 mt-0.5">Carbon estimates will populate automatically when Phase 7 lands.</p>
</div>
</div>
`;
},
};
}

View file

@ -0,0 +1,183 @@
import {
getReadingList, getIngestedFiles, getFieldProfiles, buildContextBundle,
} from '../../utils/storage.js';
import { callAnthropic } from '../../utils/api.js';
const CATEGORIES = ['all', 'land', 'equipment', 'harvest', 'finance', 'carbon', 'weather'];
function tagCategory(item) {
const tags = item.tags ?? [];
const data = JSON.stringify(item.structuredData ?? {}).toLowerCase();
if (tags.includes('land') || data.includes('field') || data.includes('acre')) return 'land';
if (tags.includes('equipment') || data.includes('equipment')) return 'equipment';
if (data.includes('harvest') || data.includes('yield')) return 'harvest';
if (tags.includes('finance') || data.includes('financ') || data.includes('expense')) return 'finance';
if (tags.includes('carbon') || data.includes('carbon')) return 'carbon';
if (tags.includes('weather') || data.includes('weather')) return 'weather';
return 'other';
}
export function DashboardModule() {
let activeCategory = 'all';
let keyword = '';
let aiAnswer = '';
let aiLoading = false;
return {
id: 'dashboard',
label: 'Dashboard',
async render(container) {
container.innerHTML = `
<div class="section-heading">Farm Dashboard</div>
<!-- AI Query bar -->
<div class="px-4 mb-3">
<div class="flex gap-2">
<input id="dash-ai-input" type="text" placeholder="Ask anything… e.g. highest yield field?"
class="flex-1 text-sm border border-gray-300 rounded-xl px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
<button id="dash-ai-btn"
class="bg-agri-600 hover:bg-agri-700 text-white text-sm px-4 py-2 rounded-xl transition flex-shrink-0">
Ask
</button>
</div>
<div id="dash-ai-answer" class="hidden mt-2 bg-white border border-agri-200 rounded-xl p-3 text-sm text-gray-700 leading-relaxed shadow-sm"></div>
</div>
<!-- Filters -->
<div class="px-4 mb-3">
<div class="flex gap-1.5 flex-wrap mb-2">
${CATEGORIES.map((c) => `
<button data-cat="${c}" class="cat-btn text-xs px-2.5 py-1 rounded-full border transition
${c === activeCategory ? 'bg-agri-600 text-white border-agri-600' : 'border-gray-300 text-gray-600 hover:border-agri-400'}">
${c.charAt(0).toUpperCase() + c.slice(1)}
</button>`).join('')}
</div>
<input id="dash-search" type="text" placeholder="Search by keyword or field name…"
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-agri-400" />
</div>
<!-- Unified list -->
<div id="dash-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderDashboard(container);
},
_bindEvents(container) {
container.querySelectorAll('.cat-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
activeCategory = btn.dataset.cat;
container.querySelectorAll('.cat-btn').forEach((b) => {
b.className = `cat-btn text-xs px-2.5 py-1 rounded-full border transition border-gray-300 text-gray-600 hover:border-agri-400`;
});
btn.className = `cat-btn text-xs px-2.5 py-1 rounded-full border transition bg-agri-600 text-white border-agri-600`;
await this._renderDashboard(container);
});
});
container.querySelector('#dash-search').addEventListener('input', async (e) => {
keyword = e.target.value.toLowerCase();
await this._renderDashboard(container);
});
container.querySelector('#dash-ai-btn').addEventListener('click', () => this._runAIQuery(container));
container.querySelector('#dash-ai-input').addEventListener('keydown', (e) => {
if (e.key === 'Enter') this._runAIQuery(container);
});
},
async _runAIQuery(container) {
if (aiLoading) return;
const input = container.querySelector('#dash-ai-input');
const question = input.value.trim();
if (!question) return;
aiLoading = true;
const answerEl = container.querySelector('#dash-ai-answer');
answerEl.classList.remove('hidden');
answerEl.innerHTML = '<span class="spinner"></span> Thinking…';
try {
const contextBundle = await buildContextBundle();
const [readingList, ingestedFiles, fieldProfiles] = await Promise.all([
getReadingList(), getIngestedFiles(), getFieldProfiles(),
]);
const dataContext = [
contextBundle,
'',
'INGESTED FILES:',
ingestedFiles.map((f) => `${f.filename}: ${JSON.stringify(f.structuredData ?? {}).slice(0, 400)}`).join('\n') || '(none)',
].join('\n');
const answer = await callAnthropic({
system: `You are a farm management AI assistant with access to this farm's data:\n\n${dataContext}`,
userMessage: question,
maxTokens: 512,
});
answerEl.innerHTML = `<p class="font-medium text-agri-700 mb-1">Answer</p>${answer}`;
} catch (err) {
answerEl.textContent = `Error: ${err.message}`;
} finally {
aiLoading = false;
}
},
async _renderDashboard(container) {
const [readingList, ingestedFiles, fieldProfiles] = await Promise.all([
getReadingList(), getIngestedFiles(), getFieldProfiles(),
]);
const allItems = [
...readingList.map((i) => ({ ...i, _source: 'reading', _category: tagCategory(i) })),
...ingestedFiles.map((f) => ({ ...f, _source: 'file', _category: tagCategory(f) })),
...fieldProfiles.map((p) => ({
...p, _source: 'field', _category: 'land', tags: ['land'],
title: `Field: ${p.name}`, summary: `${p.acres ?? '?'} ac — ${p.soilType ?? 'unknown soil'}`,
})),
];
const filtered = allItems.filter((item) => {
const catMatch = activeCategory === 'all' || item._category === activeCategory;
const kwMatch = !keyword ||
(item.title ?? '').toLowerCase().includes(keyword) ||
(item.summary ?? '').toLowerCase().includes(keyword) ||
(item.filename ?? '').toLowerCase().includes(keyword) ||
(item.name ?? '').toLowerCase().includes(keyword);
return catMatch && kwMatch;
});
const listEl = container.querySelector('#dash-list');
if (filtered.length === 0) {
listEl.innerHTML = `<div class="empty-state"><p>No data matches your filters.</p></div>`;
return;
}
listEl.innerHTML = filtered.map((item) => {
const sourceIcon = { reading: '📖', file: '📄', field: '🌱' }[item._source] ?? '•';
const title = item.title ?? item.filename ?? item.name ?? 'Untitled';
const sub = item.summary ?? item.preview?.slice(0, 120) ?? '';
const date = item.savedAt ?? item.uploadedAt ?? item.createdAt ?? '';
return `
<div class="agri-card">
<div class="flex items-start gap-2">
<span class="text-lg flex-shrink-0">${sourceIcon}</span>
<div class="flex-1 min-w-0">
<p class="text-sm font-semibold text-gray-800 truncate">${title}</p>
${sub ? `<p class="text-xs text-gray-500 mt-0.5 leading-relaxed line-clamp-2">${sub}</p>` : ''}
<div class="flex items-center gap-2 mt-1.5">
<span class="tag-pill bg-earth-100 text-earth-700">${item._category}</span>
${(item.tags ?? []).filter((t) => t !== item._category).slice(0, 2).map((t) => `<span class="tag-pill">${t}</span>`).join('')}
${date ? `<span class="text-xs text-gray-300">${new Date(date).toLocaleDateString()}</span>` : ''}
</div>
</div>
</div>
</div>`;
}).join('');
},
};
}

View file

@ -0,0 +1,234 @@
import { getIngestedFiles, saveIngestedFile, deleteIngestedFile } from '../../utils/storage.js';
import { callAnthropic } from '../../utils/api.js';
const SUPPORTED_TYPES = {
'text/csv': 'CSV',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel',
'application/vnd.ms-excel': 'Excel',
'application/pdf': 'PDF',
};
export function DataIngestModule() {
return {
id: 'data-ingest',
label: 'Data Ingest',
async render(container) {
container.innerHTML = `
<div class="section-heading">Data Ingest</div>
<!-- Drop zone -->
<div class="px-4 mb-4">
<div id="drop-zone"
class="border-2 border-dashed border-agri-300 rounded-xl p-6 text-center cursor-pointer hover:border-agri-500 hover:bg-agri-50 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-8 w-8 mx-auto text-agri-400 mb-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<p class="text-sm font-medium text-gray-600">Drop CSV, Excel, or PDF here</p>
<p class="text-xs text-gray-400 mt-1">or click to select a file</p>
<input id="file-input" type="file" accept=".csv,.xlsx,.xls,.pdf" class="hidden" />
</div>
<div id="ingest-status" class="text-xs text-center text-gray-400 mt-2 min-h-[1rem]"></div>
</div>
<!-- File list -->
<div id="file-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderFileList(container);
},
_bindEvents(container) {
const dropZone = container.querySelector('#drop-zone');
const fileInput = container.querySelector('#file-input');
dropZone.addEventListener('click', () => fileInput.click());
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('border-agri-600', 'bg-agri-50');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('border-agri-600', 'bg-agri-50');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('border-agri-600', 'bg-agri-50');
const file = e.dataTransfer.files[0];
if (file) this._processFile(file, container);
});
fileInput.addEventListener('change', () => {
if (fileInput.files[0]) this._processFile(fileInput.files[0], container);
});
},
async _processFile(file, container) {
const status = container.querySelector('#ingest-status');
const typeName = SUPPORTED_TYPES[file.type] ?? (file.name.endsWith('.csv') ? 'CSV' : null);
if (!typeName) {
status.textContent = 'Unsupported file type.';
return;
}
status.textContent = `Parsing ${typeName}`;
let extractedText = '';
try {
if (typeName === 'CSV') {
extractedText = await this._parseCSV(file);
} else if (typeName === 'Excel') {
extractedText = await this._parseExcel(file);
} else if (typeName === 'PDF') {
extractedText = await this._parsePDF(file);
}
} catch (err) {
status.textContent = `Parse error: ${err.message}`;
return;
}
status.textContent = 'Extracting structured data with AI…';
let structuredData = null;
try {
const raw = await callAnthropic({
system: 'You are an agricultural data analyst. Extract and return structured JSON from this document. Identify: operation type, field names, dates, quantities, equipment, crop types, financial figures, and any carbon or emissions data. Return only valid JSON.',
userMessage: extractedText.slice(0, 6000),
maxTokens: 1024,
});
structuredData = JSON.parse(raw);
} catch (_) {
structuredData = { raw_preview: extractedText.slice(0, 500), parse_error: 'AI extraction unavailable' };
}
const record = {
id: `file_${Date.now()}`,
filename: file.name,
type: typeName,
uploadedAt: new Date().toISOString(),
structuredData,
preview: Object.entries(structuredData ?? {})
.filter(([k]) => k !== 'raw_preview')
.slice(0, 5)
.map(([k, v]) => `${k}: ${JSON.stringify(v).slice(0, 80)}`)
.join('\n'),
};
await saveIngestedFile(record);
status.textContent = 'File processed!';
setTimeout(() => { status.textContent = ''; }, 2000);
await this._renderFileList(container);
},
_parseCSV(file) {
return new Promise((resolve, reject) => {
// PapaParse is loaded dynamically to keep the background bundle lean
import('papaparse').then(({ default: Papa }) => {
Papa.parse(file, {
complete: (results) => {
const rows = results.data.slice(0, 200);
resolve(rows.map((r) => r.join(',')).join('\n'));
},
error: reject,
});
});
});
},
_parseExcel(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const { read, utils } = await import('xlsx');
const wb = read(e.target.result, { type: 'array' });
const lines = [];
wb.SheetNames.slice(0, 3).forEach((name) => {
const ws = wb.Sheets[name];
lines.push(`Sheet: ${name}`);
lines.push(utils.sheet_to_csv(ws).split('\n').slice(0, 100).join('\n'));
});
resolve(lines.join('\n'));
} catch (err) {
reject(err);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
},
_parsePDF(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = async (e) => {
try {
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = chrome.runtime.getURL('pdf.worker.js');
const pdf = await pdfjsLib.getDocument({ data: e.target.result }).promise;
const pages = Math.min(pdf.numPages, 10);
const texts = [];
for (let i = 1; i <= pages; i++) {
const page = await pdf.getPage(i);
const content = await page.getTextContent();
texts.push(content.items.map((s) => s.str).join(' '));
}
resolve(texts.join('\n'));
} catch (err) {
reject(err);
}
};
reader.onerror = reject;
reader.readAsArrayBuffer(file);
});
},
async _renderFileList(container) {
const files = await getIngestedFiles();
const listEl = container.querySelector('#file-list');
if (files.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
<p>No files ingested yet.</p>
<p class="mt-1 text-xs">Upload a CSV, Excel, or PDF file above.</p>
</div>`;
return;
}
listEl.innerHTML = files.map((f) => `
<div class="agri-card" data-id="${f.id}">
<div class="flex items-start justify-between gap-2">
<div class="flex-1">
<span class="text-xs font-bold uppercase tracking-wide text-earth-600">${f.type}</span>
<p class="text-sm font-semibold text-gray-800 leading-snug mt-0.5">${f.filename}</p>
<p class="text-xs text-gray-400 mt-0.5">${new Date(f.uploadedAt).toLocaleDateString()}</p>
</div>
<button class="file-delete-btn text-gray-300 hover:text-red-400 transition flex-shrink-0" data-id="${f.id}" title="Remove">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
${f.preview ? `<pre class="text-xs text-gray-500 mt-2 whitespace-pre-wrap bg-gray-50 rounded p-2 overflow-hidden max-h-20">${f.preview}</pre>` : ''}
</div>
`).join('');
listEl.querySelectorAll('.file-delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteIngestedFile(btn.dataset.id);
await this._renderFileList(container);
});
});
},
};
}

View file

@ -0,0 +1,179 @@
import { getFieldProfiles, saveFieldProfile, deleteFieldProfile } from '../../utils/storage.js';
export function FieldProfileModule() {
let showForm = false;
let expandedId = null;
return {
id: 'field-profile',
label: 'Field Profiles',
async render(container) {
container.innerHTML = `
<div class="section-heading">Field Profiles</div>
<div class="px-4 mb-3">
<button id="fp-new-btn"
class="w-full flex items-center justify-center gap-2 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2.5 rounded-xl transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New Field Profile
</button>
</div>
<!-- Create form -->
<div id="fp-form" class="hidden px-4 mb-4 bg-white border border-gray-200 rounded-xl mx-4 p-4 shadow-sm">
<h3 class="text-sm font-semibold text-gray-700 mb-3">New Field</h3>
<div class="space-y-2">
<input id="fp-name" type="text" placeholder="Field name *"
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
<input id="fp-clu" type="text" placeholder="CLU ID (optional)"
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
<div class="flex gap-2">
<input id="fp-acres" type="number" placeholder="Acres"
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
<input id="fp-soil" type="text" placeholder="Soil type"
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
</div>
<div class="flex gap-2">
<input id="fp-lat" type="number" step="any" placeholder="Latitude"
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
<input id="fp-lon" type="number" step="any" placeholder="Longitude"
class="w-1/2 text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400" />
</div>
<textarea id="fp-notes" rows="2" placeholder="Notes (AI-queryable)"
class="w-full text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-agri-400 resize-none"></textarea>
</div>
<div class="flex gap-2 mt-3">
<button id="fp-save-btn"
class="flex-1 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2 rounded-lg transition">
Save
</button>
<button id="fp-cancel-btn"
class="flex-1 border border-gray-300 text-gray-600 text-sm font-medium py-2 rounded-lg hover:bg-gray-50 transition">
Cancel
</button>
</div>
</div>
<!-- Profiles list -->
<div id="fp-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderList(container);
},
_bindEvents(container) {
container.querySelector('#fp-new-btn').addEventListener('click', () => {
showForm = !showForm;
container.querySelector('#fp-form').classList.toggle('hidden', !showForm);
});
container.querySelector('#fp-cancel-btn').addEventListener('click', () => {
showForm = false;
container.querySelector('#fp-form').classList.add('hidden');
});
container.querySelector('#fp-save-btn').addEventListener('click', async () => {
const name = container.querySelector('#fp-name').value.trim();
if (!name) return;
const profile = {
id: `fp_${Date.now()}`,
name,
cluId: container.querySelector('#fp-clu').value.trim() || null,
acres: parseFloat(container.querySelector('#fp-acres').value) || null,
soilType: container.querySelector('#fp-soil').value.trim() || null,
coordinates: {
lat: parseFloat(container.querySelector('#fp-lat').value) || null,
lon: parseFloat(container.querySelector('#fp-lon').value) || null,
},
notes: container.querySelector('#fp-notes').value.trim() || null,
cropHistory: [], // populated from ingested data in Phase 3
harvestRecords: [], // populated from ingested CSVs in Phase 3
weatherData: null, // Phase 6
carbonPotential: null, // Phase 7
createdAt: new Date().toISOString(),
};
await saveFieldProfile(profile);
showForm = false;
container.querySelector('#fp-form').classList.add('hidden');
await this._renderList(container);
});
},
async _renderList(container) {
const profiles = await getFieldProfiles();
const listEl = container.querySelector('#fp-list');
if (profiles.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p>No field profiles yet.</p>
<p class="mt-1 text-xs">Create a profile for each field in your operation.</p>
</div>`;
return;
}
listEl.innerHTML = profiles.map((p) => `
<div class="agri-card cursor-pointer" data-id="${p.id}">
<div class="flex items-center justify-between">
<div class="flex-1">
<h3 class="text-sm font-bold text-gray-800">${p.name}</h3>
<div class="flex flex-wrap gap-x-3 gap-y-0.5 mt-1">
${p.acres ? `<span class="text-xs text-gray-500">${p.acres} ac</span>` : ''}
${p.soilType ? `<span class="text-xs text-gray-500">${p.soilType}</span>` : ''}
${p.cluId ? `<span class="text-xs text-agri-600">CLU ${p.cluId}</span>` : ''}
</div>
</div>
<div class="flex items-center gap-2">
<button class="fp-delete-btn text-gray-300 hover:text-red-400 transition" data-id="${p.id}" title="Delete">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<svg class="fp-chevron h-4 w-4 text-gray-400 transition-transform ${expandedId === p.id ? 'rotate-90' : ''}"
xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</div>
</div>
<!-- Expanded detail -->
<div class="fp-detail ${expandedId === p.id ? '' : 'hidden'} mt-3 pt-3 border-t border-gray-100 text-xs text-gray-600 space-y-1">
${p.coordinates?.lat ? `<p>📍 ${p.coordinates.lat.toFixed(4)}, ${p.coordinates.lon.toFixed(4)}</p>` : ''}
${p.notes ? `<p>📝 ${p.notes}</p>` : ''}
<p class="text-gray-300">Weather data: <span class="coming-soon">Phase 6</span></p>
<p class="text-gray-300">Carbon potential: <span class="coming-soon">Phase 7</span></p>
<p class="text-gray-300">Added ${new Date(p.createdAt).toLocaleDateString()}</p>
</div>
</div>
`).join('');
listEl.querySelectorAll('.agri-card').forEach((card) => {
card.addEventListener('click', async (e) => {
if (e.target.closest('.fp-delete-btn')) return;
const id = card.dataset.id;
expandedId = expandedId === id ? null : id;
await this._renderList(container);
});
});
listEl.querySelectorAll('.fp-delete-btn').forEach((btn) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await deleteFieldProfile(btn.dataset.id);
if (expandedId === btn.dataset.id) expandedId = null;
await this._renderList(container);
});
});
},
};
}

View file

@ -0,0 +1,154 @@
import { getReadingList, saveReadingItem, deleteReadingItem } from '../../utils/storage.js';
import { callAnthropic, AGRICULTURE_TAGS } from '../../utils/api.js';
export function ReadingListModule() {
let currentTag = 'all';
return {
id: 'reading-list',
label: 'Reading List',
async render(container) {
container.innerHTML = `
<div class="section-heading">Reading List</div>
<!-- Save current page -->
<div class="px-4 mb-3">
<button id="rl-save-btn"
class="w-full flex items-center justify-center gap-2 bg-agri-600 hover:bg-agri-700 text-white text-sm font-medium py-2.5 rounded-xl transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
Save current page
</button>
<div id="rl-save-status" class="text-xs text-center text-gray-400 mt-1 min-h-[1rem]"></div>
</div>
<!-- Tag filter -->
<div class="px-4 mb-3 flex gap-1.5 flex-wrap">
<button data-tag="all" class="tag-filter-btn tag-pill bg-agri-600 text-white">All</button>
${AGRICULTURE_TAGS.map((t) => `<button data-tag="${t}" class="tag-filter-btn tag-pill">${t}</button>`).join('')}
</div>
<!-- List -->
<div id="rl-list" class="px-4 pb-4"></div>
`;
this._bindEvents(container);
await this._renderList(container);
},
_bindEvents(container) {
container.querySelector('#rl-save-btn').addEventListener('click', () => this._savePage(container));
container.querySelectorAll('.tag-filter-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
currentTag = btn.dataset.tag;
container.querySelectorAll('.tag-filter-btn').forEach((b) => {
b.classList.remove('bg-agri-600', 'text-white');
b.classList.add('bg-agri-100', 'text-agri-800');
});
btn.classList.add('bg-agri-600', 'text-white');
btn.classList.remove('bg-agri-100', 'text-agri-800');
await this._renderList(container);
});
});
},
async _savePage(container) {
const status = container.querySelector('#rl-save-status');
status.textContent = 'Fetching page info…';
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
if (!tab) {
status.textContent = 'No active tab found.';
return;
}
let pageText = '';
try {
const resp = await chrome.tabs.sendMessage(tab.id, { type: 'GET_PAGE_INFO' });
pageText = resp?.text ?? '';
} catch (_) {
pageText = '';
}
status.textContent = 'Summarising with AI…';
let summary = '';
let tags = [];
try {
const rawResponse = await callAnthropic({
system: 'You are an agricultural research assistant. Given web page text, return a JSON object with two fields: "summary" (2-3 sentence plain English summary focused on agricultural relevance) and "tags" (array of relevant tags from: agriculture, equipment, land, carbon, USDA, dairy, finance, weather). Return only valid JSON.',
userMessage: `Title: ${tab.title}\nURL: ${tab.url}\n\nContent:\n${pageText.slice(0, 4000)}`,
maxTokens: 256,
});
const parsed = JSON.parse(rawResponse);
summary = parsed.summary ?? '';
tags = parsed.tags ?? [];
} catch (_) {
summary = '(AI summary unavailable)';
tags = ['agriculture'];
}
const item = {
id: `rl_${Date.now()}`,
url: tab.url,
title: tab.title,
savedAt: new Date().toISOString(),
summary,
tags,
};
await saveReadingItem(item);
status.textContent = 'Saved!';
setTimeout(() => { status.textContent = ''; }, 2000);
await this._renderList(container);
},
async _renderList(container) {
const list = await getReadingList();
const filtered = currentTag === 'all' ? list : list.filter((i) => i.tags?.includes(currentTag));
const listEl = container.querySelector('#rl-list');
if (filtered.length === 0) {
listEl.innerHTML = `
<div class="empty-state">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 mb-3 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<p>No saved pages yet.</p>
<p class="mt-1 text-xs">Browse to a page and click "Save current page".</p>
</div>`;
return;
}
listEl.innerHTML = filtered.map((item) => `
<div class="agri-card" data-id="${item.id}">
<div class="flex items-start justify-between gap-2">
<a href="${item.url}" target="_blank" class="text-sm font-semibold text-agri-700 hover:underline leading-snug flex-1">${item.title}</a>
<button class="rl-delete-btn text-gray-300 hover:text-red-400 transition flex-shrink-0" data-id="${item.id}" title="Remove">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
${item.summary ? `<p class="text-xs text-gray-500 mt-1.5 leading-relaxed">${item.summary}</p>` : ''}
<div class="mt-2">
${(item.tags ?? []).map((t) => `<span class="tag-pill">${t}</span>`).join('')}
</div>
<p class="text-xs text-gray-300 mt-2">${new Date(item.savedAt).toLocaleDateString()}</p>
</div>
`).join('');
listEl.querySelectorAll('.rl-delete-btn').forEach((btn) => {
btn.addEventListener('click', async () => {
await deleteReadingItem(btn.dataset.id);
await this._renderList(container);
});
});
},
};
}

View file

@ -0,0 +1,90 @@
import './sidebar.css';
import { ReadingListModule } from '../modules/reading-list/index.js';
import { DataIngestModule } from '../modules/data-ingest/index.js';
import { FieldProfileModule } from '../modules/field-profile/index.js';
import { DashboardModule } from '../modules/dashboard/index.js';
import { CarbonEstimatorModule } from '../modules/carbon-estimator/index.js';
import { sessionSet, sessionGet, KEYS } from '../utils/storage.js';
// ── Module registry ───────────────────────────────────────────────────────────
const MODULES = [
ReadingListModule(),
DataIngestModule(),
FieldProfileModule(),
DashboardModule(),
CarbonEstimatorModule(),
];
const moduleMap = Object.fromEntries(MODULES.map((m) => [m.id, m]));
let activeModuleId = 'reading-list';
// ── Tab navigation ────────────────────────────────────────────────────────────
function setupTabs() {
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.addEventListener('click', () => activateTab(btn.dataset.tab));
});
}
async function activateTab(id) {
if (!moduleMap[id]) return;
activeModuleId = id;
document.querySelectorAll('.tab-btn').forEach((btn) => {
btn.classList.toggle('active-tab', btn.dataset.tab === id);
});
const main = document.getElementById('main-content');
main.innerHTML = '';
await moduleMap[id].render(main);
}
// ── Settings panel ────────────────────────────────────────────────────────────
function setupSettings() {
const btn = document.getElementById('btn-settings');
const panel = document.getElementById('settings-panel');
const saveBtn = document.getElementById('btn-save-key');
const input = document.getElementById('api-key-input');
const status = document.getElementById('api-key-status');
btn.addEventListener('click', async () => {
panel.classList.toggle('hidden');
if (!panel.classList.contains('hidden')) {
const existing = await sessionGet(KEYS.API_KEY);
if (existing) {
input.value = '';
input.placeholder = 'Key set — enter new key to replace';
status.textContent = '✓ API key is active this session';
}
}
});
saveBtn.addEventListener('click', async () => {
const key = input.value.trim();
if (!key.startsWith('sk-ant-')) {
status.textContent = 'Key must start with sk-ant-';
return;
}
await chrome.runtime.sendMessage({ type: 'SET_API_KEY', payload: { key } });
input.value = '';
input.placeholder = 'Key set — enter new key to replace';
status.textContent = '✓ Saved for this session';
});
}
// ── Keepalive port (prevents service worker from being killed) ────────────────
function keepAlive() {
try {
const port = chrome.runtime.connect({ name: 'keepalive' });
port.onDisconnect.addListener(() => {
setTimeout(keepAlive, 5000);
});
} catch (_) {}
}
// ── Init ──────────────────────────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', async () => {
setupTabs();
setupSettings();
keepAlive();
await activateTab(activeModuleId);
});

View file

@ -0,0 +1,60 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
/* Active tab highlight */
.tab-btn.active-tab {
@apply text-agri-700;
}
.tab-btn.active-tab svg {
@apply stroke-agri-700;
}
/* Card styles shared across modules */
.agri-card {
@apply bg-white rounded-xl shadow-sm border border-gray-100 p-4 mb-3;
}
.agri-card:hover {
@apply shadow-md;
}
/* Tag pill */
.tag-pill {
@apply inline-block text-xs px-2 py-0.5 rounded-full bg-agri-100 text-agri-800 font-medium mr-1 mb-1;
}
/* Section heading */
.section-heading {
@apply text-xs uppercase tracking-widest font-semibold text-gray-400 mb-2 px-4 pt-4;
}
/* Empty state */
.empty-state {
@apply flex flex-col items-center justify-center py-16 text-gray-400 text-sm text-center px-6;
}
/* Spinner */
.spinner {
@apply inline-block w-5 h-5 border-2 border-agri-300 border-t-agri-700 rounded-full animate-spin;
}
/* Coming soon badge */
.coming-soon {
@apply inline-block text-xs px-2 py-0.5 rounded-full bg-earth-100 text-earth-700 font-semibold;
}
/* Scrollbar styling */
::-webkit-scrollbar {
width: 4px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #bbf7d0;
border-radius: 2px;
}

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Agrifine</title>
<link rel="stylesheet" href="sidebar.css" />
</head>
<body class="bg-agri-50 text-gray-900 h-screen flex flex-col overflow-hidden font-sans">
<!-- Header -->
<header class="flex items-center justify-between px-4 py-3 bg-agri-700 text-white shadow-md flex-shrink-0">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight">🌾 Agrifine</span>
</div>
<button id="btn-settings" class="text-agri-200 hover:text-white transition" title="Settings">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
</button>
</header>
<!-- Settings panel (hidden by default) -->
<div id="settings-panel" class="hidden flex-shrink-0 bg-white border-b border-gray-200 px-4 py-3 shadow-sm">
<label class="block text-xs font-semibold text-gray-600 mb-1">Anthropic API Key</label>
<div class="flex gap-2">
<input id="api-key-input" type="password" placeholder="sk-ant-..."
class="flex-1 text-sm border border-gray-300 rounded px-2 py-1 focus:outline-none focus:ring-2 focus:ring-agri-400" />
<button id="btn-save-key"
class="text-xs bg-agri-600 text-white px-3 py-1 rounded hover:bg-agri-700 transition">Save</button>
</div>
<p id="api-key-status" class="text-xs text-gray-400 mt-1"></p>
</div>
<!-- Main content area -->
<main id="main-content" class="flex-1 overflow-y-auto">
<!-- Modules render here -->
</main>
<!-- Bottom tab bar -->
<nav class="flex-shrink-0 bg-white border-t border-gray-200 shadow-lg">
<div class="flex">
<button data-tab="reading-list"
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition active-tab">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" />
</svg>
<span>Reading</span>
</button>
<button data-tab="data-ingest"
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
</svg>
<span>Ingest</span>
</button>
<button data-tab="field-profile"
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>Fields</span>
</button>
<button data-tab="dashboard"
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
</svg>
<span>Dashboard</span>
</button>
<button data-tab="carbon-estimator"
class="tab-btn flex-1 flex flex-col items-center py-2 px-1 text-xs font-medium text-gray-500 hover:text-agri-600 transition">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mb-0.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
</svg>
<span>Carbon</span>
</button>
</div>
</nav>
<script src="sidebar.js"></script>
</body>
</html>

View file

@ -0,0 +1,66 @@
import { sessionGet, KEYS } from './storage.js';
const ANTHROPIC_URL = 'https://api.anthropic.com/v1/messages';
const MODEL = 'claude-sonnet-4-6';
/**
* Send a message to the Anthropic API via the background service worker.
* Content scripts and sidebar cannot call external APIs directly due to CSP,
* so all API calls are proxied through the background worker.
*/
export async function callAnthropic({ system, userMessage, maxTokens = 1024 }) {
return new Promise((resolve, reject) => {
chrome.runtime.sendMessage(
{ type: 'ANTHROPIC_REQUEST', payload: { system, userMessage, maxTokens } },
(response) => {
if (chrome.runtime.lastError) {
reject(new Error(chrome.runtime.lastError.message));
return;
}
if (response?.error) {
reject(new Error(response.error));
return;
}
resolve(response?.text ?? '');
}
);
});
}
/**
* Direct fetch from background worker keeps API key off content scripts.
*/
export async function fetchAnthropic({ system, userMessage, maxTokens = 1024 }) {
const apiKey = await sessionGet(KEYS.API_KEY);
if (!apiKey) throw new Error('No API key set. Open Agrifine settings to add your key.');
const body = {
model: MODEL,
max_tokens: maxTokens,
system,
messages: [{ role: 'user', content: userMessage }],
};
const res = await fetch(ANTHROPIC_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-api-key': apiKey,
'anthropic-version': '2023-06-01',
},
body: JSON.stringify(body),
});
if (!res.ok) {
const errText = await res.text();
throw new Error(`Anthropic API error ${res.status}: ${errText}`);
}
const data = await res.json();
return data.content?.[0]?.text ?? '';
}
export const AGRICULTURE_TAGS = [
'agriculture', 'equipment', 'land', 'carbon',
'USDA', 'dairy', 'finance', 'weather',
];

View file

@ -0,0 +1,136 @@
/**
* Agrifine storage schema
*
* chrome.storage.local keys:
* agrifine_reading_list Array<ReadingItem>
* agrifine_ingested_files Array<IngestedFile>
* agrifine_field_profiles Array<FieldProfile>
* agrifine_settings Settings
*
* chrome.storage.session keys:
* agrifine_api_key string (never persisted to local)
*/
export const KEYS = {
READING_LIST: 'agrifine_reading_list',
INGESTED_FILES: 'agrifine_ingested_files',
FIELD_PROFILES: 'agrifine_field_profiles',
SETTINGS: 'agrifine_settings',
API_KEY: 'agrifine_api_key', // session only
};
// ── Generic helpers ──────────────────────────────────────────────────────────
export async function localGet(key) {
return new Promise((resolve) => {
chrome.storage.local.get(key, (result) => resolve(result[key] ?? null));
});
}
export async function localSet(key, value) {
return new Promise((resolve) => {
chrome.storage.local.set({ [key]: value }, resolve);
});
}
export async function sessionGet(key) {
return new Promise((resolve) => {
chrome.storage.session.get(key, (result) => resolve(result[key] ?? null));
});
}
export async function sessionSet(key, value) {
return new Promise((resolve) => {
chrome.storage.session.set({ [key]: value }, resolve);
});
}
// ── Reading List ─────────────────────────────────────────────────────────────
export async function getReadingList() {
return (await localGet(KEYS.READING_LIST)) ?? [];
}
export async function saveReadingItem(item) {
const list = await getReadingList();
list.unshift(item);
await localSet(KEYS.READING_LIST, list);
}
export async function deleteReadingItem(id) {
const list = await getReadingList();
await localSet(KEYS.READING_LIST, list.filter((i) => i.id !== id));
}
// ── Ingested Files ───────────────────────────────────────────────────────────
export async function getIngestedFiles() {
return (await localGet(KEYS.INGESTED_FILES)) ?? [];
}
export async function saveIngestedFile(file) {
const files = await getIngestedFiles();
files.unshift(file);
await localSet(KEYS.INGESTED_FILES, files);
}
export async function deleteIngestedFile(id) {
const files = await getIngestedFiles();
await localSet(KEYS.INGESTED_FILES, files.filter((f) => f.id !== id));
}
// ── Field Profiles ───────────────────────────────────────────────────────────
export async function getFieldProfiles() {
return (await localGet(KEYS.FIELD_PROFILES)) ?? [];
}
export async function saveFieldProfile(profile) {
const profiles = await getFieldProfiles();
const idx = profiles.findIndex((p) => p.id === profile.id);
if (idx >= 0) {
profiles[idx] = profile;
} else {
profiles.unshift(profile);
}
await localSet(KEYS.FIELD_PROFILES, profiles);
}
export async function deleteFieldProfile(id) {
const profiles = await getFieldProfiles();
await localSet(KEYS.FIELD_PROFILES, profiles.filter((p) => p.id !== id));
}
// ── Settings ─────────────────────────────────────────────────────────────────
export async function getSettings() {
return (await localGet(KEYS.SETTINGS)) ?? { theme: 'light', defaultState: 'all' };
}
export async function saveSettings(patch) {
const current = await getSettings();
await localSet(KEYS.SETTINGS, { ...current, ...patch });
}
// ── Context bundle (used as AI system context) ───────────────────────────────
export async function buildContextBundle() {
const list = await getReadingList();
const profiles = await getFieldProfiles();
const readingCtx = list.slice(0, 20).map((item) =>
`[${item.tags?.join(', ') ?? 'general'}] ${item.title}: ${item.summary ?? ''}`
).join('\n');
const fieldCtx = profiles.map((p) =>
`Field "${p.name}" (${p.acres ?? '?'} ac, ${p.soilType ?? 'unknown soil'}): ${p.notes ?? ''}`
).join('\n');
return [
'USER READING LIST CONTEXT:',
readingCtx || '(none)',
'',
'FIELD PROFILES:',
fieldCtx || '(none)',
].join('\n');
}

View file

@ -0,0 +1,35 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ['./src/**/*.{js,html}'],
theme: {
extend: {
colors: {
agri: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
earth: {
50: '#fdf8f0',
100: '#fbefd8',
200: '#f5d9a8',
300: '#eebb6a',
400: '#e59b38',
500: '#d97f1a',
600: '#c06212',
700: '#9f4b12',
800: '#813c15',
900: '#6a3214',
},
},
},
},
plugins: [],
};

View file

@ -0,0 +1,57 @@
const path = require('path');
const CopyPlugin = require('copy-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const rootDir = path.resolve(__dirname, '..');
const srcDir = path.join(rootDir, 'src');
const distDir = path.join(rootDir, 'dist');
const publicDir = path.join(rootDir, 'public');
module.exports = {
mode: process.env.NODE_ENV === 'production' ? 'production' : 'development',
devtool: process.env.NODE_ENV === 'production' ? false : 'cheap-module-source-map',
entry: {
background: path.join(srcDir, 'background', 'index.js'),
content: path.join(srcDir, 'content', 'index.js'),
sidebar: path.join(srcDir, 'sidebar', 'index.js'),
},
output: {
path: distDir,
filename: '[name].js',
clean: true,
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env'],
},
},
},
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'postcss-loader'],
},
],
},
plugins: [
new MiniCssExtractPlugin({ filename: '[name].css' }),
new CopyPlugin({
patterns: [
{ from: path.join(publicDir, 'icons'), to: path.join(distDir, 'icons') },
{ from: path.join(rootDir, 'manifest.json'), to: distDir },
{ from: path.join(srcDir, 'sidebar', 'sidebar.html'), to: path.join(distDir, 'sidebar.html') },
],
}),
],
resolve: {
extensions: ['.js'],
alias: {
'@': srcDir,
},
},
};