Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
3d3e036
feat: add AI provider types and interfaces
MatiasOS Feb 10, 2026
c99bd0e
feat: add AI provider configuration
MatiasOS Feb 10, 2026
c17759a
feat: add AI prompt templates and caching utilities
MatiasOS Feb 10, 2026
b3cc8a6
feat: implement useAIAnalysis hook
MatiasOS Feb 10, 2026
a3647a5
feat: integrate AI provider settings into settings UI
MatiasOS Feb 10, 2026
7b61faa
feat: add AI analysis i18n translations
MatiasOS Feb 10, 2026
0cc45ad
feat: integrate AIAnalysis component into main app
MatiasOS Feb 10, 2026
5053443
chore: update dependencies for AI analysis
MatiasOS Feb 10, 2026
6c25795
style: update settings UI styling and layout\n\n- Refine settings pag…
MatiasOS Feb 10, 2026
c002066
feat(i18n): add AI analysis translation keys for address pages
MatiasOS Feb 10, 2026
f08799c
feat(address): integrate AI analysis panel into address display pages
MatiasOS Feb 10, 2026
3849bf5
feat(ai): add language-aware AI analysis responses
MatiasOS Feb 10, 2026
89639e1
feat(ai): add ERC-7730 transaction pre-analysis hook
MatiasOS Feb 11, 2026
d0fc2a6
feat(ai): enrich transaction prompt with ERC-7730 pre-analysis context
MatiasOS Feb 11, 2026
3370326
feat(tx): integrate AI analysis panel into transaction display
MatiasOS Feb 11, 2026
7204844
feat(ai): add analysis to block page
MatiasOS Feb 11, 2026
198a730
feat(ui): move ai analysis into expandable panel
MatiasOS Feb 11, 2026
2c9f211
style(ui): update ai analysis panel styling
MatiasOS Feb 11, 2026
75c4234
i18n(ai): update ai analysis button labels
MatiasOS Feb 11, 2026
75c8491
chore(ts): Update target to modern browsers
MatiasOS Feb 12, 2026
40584f3
feat(AI): Improve prompting
MatiasOS Feb 12, 2026
91fc28c
Merge branch 'release/v1.2.1-a' into feat/ai-analysis-foundation
MatiasOS Feb 13, 2026
d9b08ec
refactor(utils): Unify logic to format units
MatiasOS Feb 13, 2026
f0c4132
refactor(ai): Keep AI functionality closer
MatiasOS Feb 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"@noble/curves": "^1.8.0"
},
"dependencies": {
"@erc7730/sdk": "^0.1.3",
"@openscan/network-connectors": "^1.3.0",
"@rainbow-me/rainbowkit": "^2.2.8",
"@react-native-async-storage/async-storage": "^1.24.0",
Expand Down
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import "./styles/tables.css";
import "./styles/forms.css";
import "./styles/rainbowkit.css";
import "./styles/responsive.css";
import "./styles/ai-analysis.css";

import Loading from "./components/common/Loading";
import {
Expand Down
162 changes: 162 additions & 0 deletions src/components/common/AIAnalysis/AIAnalysisPanel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import type React from "react";
import { useEffect, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import Markdown from "react-markdown";
import { Link } from "react-router-dom";
import { useAIAnalysis } from "../../../hooks/useAIAnalysis";
import type { AIAnalysisType } from "../../../types";

interface AIAnalysisProps {
analysisType: AIAnalysisType;
context: Record<string, unknown>;
networkName: string;
networkCurrency: string;
cacheKey: string;
}

const AIAnalysisPanel: React.FC<AIAnalysisProps> = ({
analysisType,
context,
networkName,
networkCurrency,
cacheKey,
}) => {
const { t, i18n } = useTranslation("common");
const [isOpen, setIsOpen] = useState(false);
const panelId = useId();
const { result, loading, error, errorType, analyze, refresh } = useAIAnalysis(
analysisType,
context,
networkName,
networkCurrency,
cacheKey,
i18n.language,
);

useEffect(() => {
if (result || error) {
setIsOpen(true);
}
}, [result, error]);

const handleAnalyze = () => {
setIsOpen(true);
void analyze();
};

return (
<section className="block-display-card ai-analysis-panel" aria-label={t("aiAnalysis.title")}>
<div className="ai-analysis-header">
<div className="ai-analysis-actions">
<button
type="button"
className="more-details-toggle ai-analysis-button"
onClick={handleAnalyze}
disabled={loading}
>
{loading ? (
<>
<span className="ai-analysis-spinner" />
{t("aiAnalysis.analyzing")}
</>
) : (
t("aiAnalysis.analyzeButton")
)}
</button>
{result && (
<button
type="button"
className="more-details-toggle ai-analysis-toggle"
aria-expanded={isOpen}
aria-controls={panelId}
onClick={() => setIsOpen((open) => !open)}
>
{isOpen ? t("aiAnalysis.collapse") : t("aiAnalysis.expand")}
</button>
)}
</div>
</div>

{result && !isOpen && (
<div className="ai-analysis-preview" aria-hidden="true">
<p className="ai-analysis-preview-text">{result.summary}</p>
</div>
)}

<section
id={panelId}
className="ai-analysis-content"
aria-live="polite"
aria-hidden={!isOpen}
style={{ display: isOpen ? "flex" : "none" }}
>
{error && <AIAnalysisError errorType={errorType} onRetry={analyze} />}

{result && (
<>
<div className="ai-analysis-result">
<Markdown>{result.summary}</Markdown>
</div>
<div className="ai-analysis-footer">
<div className="ai-analysis-meta">
<span>
{t("aiAnalysis.generatedBy", { model: result.model })}
{result.cached && (
<span className="ai-analysis-cached">{t("aiAnalysis.cachedResult")}</span>
)}
</span>
<button type="button" className="ai-analysis-refresh" onClick={refresh}>
{t("aiAnalysis.refreshButton")}
</button>
</div>
<div className="ai-analysis-disclaimer">{t("aiAnalysis.disclaimer")}</div>
</div>
</>
)}
</section>
</section>
);
};

const ERROR_MESSAGE_KEYS = {
rate_limited: "aiAnalysis.errors.rateLimited",
invalid_key: "aiAnalysis.errors.invalidKey",
no_api_key: "aiAnalysis.errors.no_api_key",
network_error: "aiAnalysis.errors.networkError",
service_unavailable: "aiAnalysis.errors.serviceUnavailable",
parse_error: "aiAnalysis.errors.parseError",
generic: "aiAnalysis.errors.generic",
} as const;

interface AIAnalysisErrorProps {
errorType: string | null;
onRetry: () => void;
}

const AIAnalysisError: React.FC<AIAnalysisErrorProps> = ({ errorType, onRetry }) => {
const { t } = useTranslation("common");

const messageKey =
errorType && errorType in ERROR_MESSAGE_KEYS
? ERROR_MESSAGE_KEYS[errorType as keyof typeof ERROR_MESSAGE_KEYS]
: ERROR_MESSAGE_KEYS.generic;
const showSettingsLink = errorType === "no_api_key" || errorType === "invalid_key";

return (
<div className="ai-analysis-error">
<div className="ai-analysis-error-message">{t(messageKey)}</div>
<div className="ai-analysis-error-action">
<button type="button" className="ai-analysis-retry" onClick={onRetry}>
{t("aiAnalysis.errors.tryAgain")}
</button>
{showSettingsLink && (
<Link to="/settings" className="ai-analysis-settings-link">
{t("aiAnalysis.errors.goToSettings")}
</Link>
)}
</div>
</div>
);
};

export default AIAnalysisPanel;
153 changes: 153 additions & 0 deletions src/components/common/AIAnalysis/aiCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
import type { AIAnalysisResult } from "../../../types";
import { logger } from "../../../utils/logger";

const CACHE_PREFIX = "openscan_ai_";
const CACHE_VERSION = 1;
const MAX_CACHE_SIZE_BYTES = 5 * 1024 * 1024; // 5MB

interface CachedAnalysis {
result: AIAnalysisResult;
contextHash: string;
version: number;
storedAt: number;
}

/**
* Fast string hash (djb2 algorithm).
* Used to hash serialized context objects for cache invalidation.
*/
export function hashContext(context: Record<string, unknown>): string {
const str = JSON.stringify(context, (_key, value) =>
typeof value === "bigint" ? value.toString() : value,
);
let hash = 5381;
for (let i = 0; i < str.length; i++) {
hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
}
return (hash >>> 0).toString(36);
}

/**
* Build a cache key from analysis type, network ID, and identifier.
*/
export function buildCacheKey(type: string, networkId: string, identifier: string): string {
return `${CACHE_PREFIX}${type}_${networkId}_${identifier}`;
}

/**
* Get a cached analysis result if it exists and the context hash matches.
* Returns null if cache miss, hash mismatch, or version mismatch.
*/
export function getCachedAnalysis(key: string, contextHash: string): AIAnalysisResult | null {
try {
const raw = localStorage.getItem(key);
if (!raw) return null;

const cached: CachedAnalysis = JSON.parse(raw);
if (cached.version !== CACHE_VERSION) {
localStorage.removeItem(key);
return null;
}

if (cached.contextHash !== contextHash) {
return null;
}

return { ...cached.result, cached: true };
} catch {
logger.warn("Failed to read AI cache entry:", key);
return null;
}
}

/**
* Store an analysis result in the cache.
* Evicts oldest entries if total AI cache exceeds size limit.
*/
export function setCachedAnalysis(
key: string,
contextHash: string,
result: AIAnalysisResult,
): void {
try {
const entry: CachedAnalysis = {
result,
contextHash,
version: CACHE_VERSION,
storedAt: Date.now(),
};

evictIfNeeded();
localStorage.setItem(key, JSON.stringify(entry));
} catch {
logger.warn("Failed to write AI cache entry:", key);
}
}

/**
* Clear all AI analysis cache entries from localStorage.
*/
export function clearAICache(): void {
const keysToRemove: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
keysToRemove.push(key);
}
}
for (const key of keysToRemove) {
localStorage.removeItem(key);
}
logger.info(`Cleared ${keysToRemove.length} AI cache entries`);
}

/**
* Get the total size of AI cache entries in bytes.
*/
export function getAICacheSize(): number {
let totalSize = 0;
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.startsWith(CACHE_PREFIX)) {
const value = localStorage.getItem(key);
if (value) {
totalSize += key.length + value.length;
}
}
}
return totalSize;
}

/**
* Evict oldest AI cache entries if total cache size exceeds limit.
*/
function evictIfNeeded(): void {
if (getAICacheSize() <= MAX_CACHE_SIZE_BYTES) return;

const entries: Array<{ key: string; storedAt: number }> = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(CACHE_PREFIX)) continue;

try {
const raw = localStorage.getItem(key);
if (raw) {
const cached: CachedAnalysis = JSON.parse(raw);
entries.push({ key, storedAt: cached.storedAt });
}
} catch {
// Remove invalid entries
if (key) localStorage.removeItem(key);
}
}

// Sort oldest first
entries.sort((a, b) => a.storedAt - b.storedAt);

// Remove oldest entries until under the size limit
for (const entry of entries) {
if (getAICacheSize() <= MAX_CACHE_SIZE_BYTES) break;
localStorage.removeItem(entry.key);
logger.debug("Evicted AI cache entry:", entry.key);
}
}
Loading