This commit is contained in:
1 parent
208e11e4f8
commit
a8b49374f1
44 files changed
+119956
-552
No files matched your search
@@ -565,6 +565,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
const [reportType, setReportType] = useState<'daily' | 'weekly' | 'end_cycle'>('daily');
|
||||
const [reportPeriod, setReportPeriod] = useState<string>('Minggu 1');
|
||||
const insightInFlightRef = useRef(false);
|
||||
|
||||
const applyInsightText = useCallback((text: string) => {
|
||||
@@ -590,6 +592,17 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
const margin = 20;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
|
||||
// Helper to strip non-ASCII to prevent corrupted PDF in jsPDF standard fonts
|
||||
const safeText = (text: string) => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/–|—/g, '-')
|
||||
.replace(/•/g, '-')
|
||||
.replace(/[^\x20-\x7E\n]/g, '');
|
||||
};
|
||||
|
||||
// Helper to wrap and add text
|
||||
let y = 20;
|
||||
|
||||
@@ -629,8 +642,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
(k: any) => k.id?.toString() === kandangId?.toString()
|
||||
);
|
||||
const kandangName = activeKandang?.name || 'Semua Kandang';
|
||||
const rawCtxDay = input?.currentDay ?? context.cycleData?.currentDay;
|
||||
const maxDays = input?.totalDays ?? context.cycleData?.totalDays;
|
||||
const rawCtxDay = context.cycleData?.currentDay;
|
||||
const maxDays = context.cycleData?.totalDays;
|
||||
const currentDayVal = maxDays && rawCtxDay > maxDays ? maxDays : rawCtxDay;
|
||||
const dayAge =
|
||||
currentDayVal !== undefined && currentDayVal !== null ? `Hari ${currentDayVal}` : '-';
|
||||
@@ -658,9 +671,10 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]);
|
||||
|
||||
const headline =
|
||||
const headline = safeText(
|
||||
structuredInsight.summary?.headline ||
|
||||
'Ringkasan operasional dan analisis kecerdasan buatan untuk farm broiler.';
|
||||
'Ringkasan operasional dan analisis kecerdasan buatan untuk farm broiler.'
|
||||
);
|
||||
const headlineLines = doc.splitTextToSize(headline, contentWidth - 38);
|
||||
doc.text(headlineLines, margin + 34, y + 7.8);
|
||||
|
||||
@@ -702,7 +716,8 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
items.forEach((rawItem, index) => {
|
||||
const item = safeText(rawItem);
|
||||
const textLines = doc.splitTextToSize(item, contentWidth - 12);
|
||||
const textHeight = textLines.length * 4.5;
|
||||
|
||||
@@ -1031,7 +1046,9 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
kandangId,
|
||||
insightData.contextPack,
|
||||
CACHE_VERSION,
|
||||
forceRefresh
|
||||
forceRefresh,
|
||||
reportType,
|
||||
reportPeriod
|
||||
);
|
||||
|
||||
if (!result || !result.success || !result.data?.insightText) {
|
||||
@@ -1062,7 +1079,15 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
insightInFlightRef.current = false;
|
||||
}
|
||||
},
|
||||
[applyInsightText, context.cycleData, kandangId, prepareInsightData, saveToCache]
|
||||
[
|
||||
applyInsightText,
|
||||
context.cycleData,
|
||||
kandangId,
|
||||
prepareInsightData,
|
||||
saveToCache,
|
||||
reportType,
|
||||
reportPeriod,
|
||||
]
|
||||
);
|
||||
|
||||
// Monitor when KPIs become available
|
||||
@@ -1081,7 +1106,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
|
||||
// The scope key already captures cycle/kandang changes. Avoid retriggering on unrelated context updates.
|
||||
return () => clearTimeout(timer);
|
||||
}, [kpisReady, kandangId, context.cycleData?.id, generateInsight]); // Regenerate when KPIs become ready or the insight scope changes
|
||||
}, [kpisReady, kandangId, context.cycleData?.id, generateInsight, reportType, reportPeriod]); // Regenerate when KPIs become ready or the insight scope changes
|
||||
|
||||
// Auto-refresh every 6 hours
|
||||
useEffect(() => {
|
||||
@@ -1343,12 +1368,47 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
<div className="bg-gradient-to-br from-blue-50 to-purple-50 border border-blue-100 rounded-2xl shadow-lg overflow-hidden">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between mb-4 gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-6 h-6 text-purple-600" />
|
||||
<h2 className="text-xl font-bold text-gray-900">AI Insight</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{/* Filter controls only visible on dashboard overview */}
|
||||
{(page === 'dashboard' || !page) && (
|
||||
<>
|
||||
<select
|
||||
value={reportType}
|
||||
onChange={(e) => setReportType(e.target.value as any)}
|
||||
className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
>
|
||||
<option value="daily">Harian</option>
|
||||
<option value="weekly">Mingguan</option>
|
||||
{((context.kpis?.umur?.value || 0) >= 35 ||
|
||||
context.cycleData?.status === 'finished') && (
|
||||
<option value="end_cycle">Akhir Siklus</option>
|
||||
)}
|
||||
</select>
|
||||
|
||||
{reportType === 'weekly' && (
|
||||
<select
|
||||
value={reportPeriod}
|
||||
onChange={(e) => setReportPeriod(e.target.value)}
|
||||
className="text-sm border-gray-300 rounded-lg shadow-sm focus:border-purple-500 focus:ring-purple-500"
|
||||
>
|
||||
{Array.from(
|
||||
{ length: Math.max(1, Math.ceil((context.kpis?.umur?.value || 0) / 7)) },
|
||||
(_, i) => i + 1
|
||||
).map((w) => (
|
||||
<option key={w} value={`Minggu ${w}`}>
|
||||
Minggu {w}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{structuredInsight && (
|
||||
<button
|
||||
onClick={downloadPdf}
|
||||
@@ -1357,7 +1417,7 @@ export const AiInsightCard: React.FC<AiInsightCardProps> = ({ kandangId, page =
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-slate-800 bg-slate-900 text-white hover:bg-slate-800 active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed transition-all text-xs font-semibold shadow-sm"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Unduh PDF</span>
|
||||
<span className="hidden sm:inline">Unduh PDF</span>
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
|
||||
@@ -104,6 +104,11 @@ const Dashboard: React.FC<DashboardProps> = ({
|
||||
const cycleStart = new Date(cycleData.startDate);
|
||||
cycleStart.setHours(0, 0, 0, 0);
|
||||
|
||||
const getLocalDateString = (d: Date) => {
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
};
|
||||
|
||||
const dailyTotals = new Map<string, number>();
|
||||
for (const entry of feedSackHistory) {
|
||||
if (!usageCameraNames.includes(entry.cameraName)) continue;
|
||||
@@ -112,7 +117,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
||||
entryDate.setHours(0, 0, 0, 0);
|
||||
if (entryDate < cycleStart) continue;
|
||||
|
||||
const dateKey = entryDate.toISOString().split('T')[0] || '';
|
||||
const dateKey = getLocalDateString(entryDate);
|
||||
if (!dateKey) continue;
|
||||
dailyTotals.set(dateKey, (dailyTotals.get(dateKey) || 0) + (entry.count || 0));
|
||||
}
|
||||
@@ -121,7 +126,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
||||
for (let dayIndex = 0; dayIndex <= reportedDay; dayIndex += 1) {
|
||||
const date = new Date(cycleStart);
|
||||
date.setDate(date.getDate() + dayIndex);
|
||||
const dateKey = date.toISOString().split('T')[0] || '';
|
||||
const dateKey = getLocalDateString(date);
|
||||
if (!dateKey) {
|
||||
history.push(0);
|
||||
continue;
|
||||
@@ -131,7 +136,7 @@ const Dashboard: React.FC<DashboardProps> = ({
|
||||
|
||||
const targetDate = new Date(cycleStart);
|
||||
targetDate.setDate(targetDate.getDate() + reportedDay);
|
||||
const targetDateKey = targetDate.toISOString().split('T')[0] || '';
|
||||
const targetDateKey = getLocalDateString(targetDate);
|
||||
const totalKarung = dailyTotals.get(targetDateKey) || 0;
|
||||
|
||||
return {
|
||||
|
||||
@@ -214,7 +214,11 @@ const PAGE_FOCUSES: Record<InsightTopic, string[]> = {
|
||||
};
|
||||
|
||||
// ─── Prompt builders ─────────────────────────────────────────────────────────
|
||||
const buildPrompt = (topic: InsightTopic, data: Record<string, unknown>): string => {
|
||||
const buildPrompt = (
|
||||
topic: InsightTopic,
|
||||
data: Record<string, unknown>,
|
||||
timeframe: 'harian' | 'mingguan' = 'harian'
|
||||
): string => {
|
||||
// Limit data to 2000 chars to avoid oversized prompts
|
||||
const fullJson = JSON.stringify(data, null, 2);
|
||||
const dataStr = fullJson.length > 2000 ? fullJson.slice(0, 2000) + '\n...' : fullJson;
|
||||
@@ -230,6 +234,7 @@ Pedoman mortalitas strain CP 707: di bawah 5 persen = normal (ok), 5-7 persen =
|
||||
|
||||
Konteks & Aturan Variasi:
|
||||
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
||||
Lakukan analisis secara ${timeframe.toUpperCase()}.
|
||||
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
||||
|
||||
Data kandang:
|
||||
@@ -241,6 +246,7 @@ Pedoman: bobot di bawah target standar CP 707 lebih dari 5 persen = warning. ADG
|
||||
|
||||
Konteks & Aturan Variasi:
|
||||
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
||||
Lakukan analisis secara ${timeframe.toUpperCase()}.
|
||||
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
||||
|
||||
Data bobot:
|
||||
@@ -255,6 +261,7 @@ Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
||||
|
||||
Konteks & Aturan Variasi:
|
||||
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
||||
Lakukan analisis secara ${timeframe.toUpperCase()}.
|
||||
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
||||
|
||||
Data FCR:
|
||||
@@ -268,6 +275,7 @@ Berikan insight singkat, profesional, actionable dalam Bahasa Indonesia.
|
||||
|
||||
Konteks & Aturan Variasi:
|
||||
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
||||
Lakukan analisis secara ${timeframe.toUpperCase()}.
|
||||
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
||||
|
||||
Data EEF:
|
||||
@@ -280,6 +288,7 @@ Pedoman: Suhu ideal 20-30 derajat Celsius. Kelembapan ideal 60-70 persen. Amonia
|
||||
|
||||
Konteks & Aturan Variasi:
|
||||
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
||||
Lakukan analisis secara ${timeframe.toUpperCase()}.
|
||||
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
||||
|
||||
Data sensor IoT:
|
||||
@@ -292,6 +301,7 @@ Pedoman: konsumsi pakan harian wajar strain CP 707 sekitar 170 gram per ekor per
|
||||
|
||||
Konteks & Aturan Variasi:
|
||||
Fokus utama analisis Anda hari ini adalah: "${selectedFocus}".
|
||||
Lakukan analisis secara ${timeframe.toUpperCase()}.
|
||||
Jadikan ini sebagai sudut pandang utama. Jangan sebutkan nama fokus secara eksplisit di hasil akhir.
|
||||
|
||||
Data karung pakan:
|
||||
@@ -725,6 +735,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
const [result, setResult] = useState<AiResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [timeframe, setTimeframe] = useState<'harian' | 'mingguan'>('harian');
|
||||
const inFlightRef = useRef(false);
|
||||
const lastCacheKeyRef = useRef<string | null>(null);
|
||||
|
||||
@@ -787,7 +798,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
const generate = useCallback(
|
||||
async (force = false) => {
|
||||
const storageKey = `page-insight-v5-${cacheKey}`;
|
||||
const storageKey = `page-insight-v5-${cacheKey}-${timeframe}`;
|
||||
|
||||
// ── On force refresh, evict all caches first ──────────────────────────────
|
||||
if (force) {
|
||||
@@ -815,7 +826,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
const memCached = _insightMemoryCache.get(storageKey);
|
||||
if (memCached) {
|
||||
setResult(memCached);
|
||||
lastCacheKeyRef.current = cacheKey;
|
||||
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -829,7 +840,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
if (parsed) {
|
||||
_insightMemoryCache.set(storageKey, parsed); // promote to memory
|
||||
setResult(parsed);
|
||||
lastCacheKeyRef.current = cacheKey;
|
||||
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
|
||||
return;
|
||||
}
|
||||
sessionStorage.removeItem(storageKey);
|
||||
@@ -853,18 +864,18 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
if (backendResult) {
|
||||
_saveToCache(storageKey, backendResult);
|
||||
setResult(backendResult);
|
||||
lastCacheKeyRef.current = cacheKey;
|
||||
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 5. Generate via LM Studio proxy with 4000ms timeout ───────────────
|
||||
const prompt = buildPrompt(topic, contextData);
|
||||
const prompt = buildPrompt(topic, contextData, timeframe);
|
||||
const system_prompt =
|
||||
'Anda adalah analis peternakan ayam broiler berpengalaman. Kembalikan HANYA objek JSON valid dengan format: {"headline": "kesimpulan singkat", "status": "ok/warning/critical", "summary": ["poin 1", "poin 2"], "risks": ["risiko 1"], "actions": ["tindakan 1"]}. Dilarang memberikan teks penjelasan di luar objek JSON.';
|
||||
|
||||
// Build a short semantic query for RAG (describe what we're analyzing)
|
||||
const context_query = `Analisis ${topic.replace(/_/g, ' ')} hari ke-${contextData.currentDay || contextData.hari_ke || contextData.hari_terakhir || 0} broiler CP707`;
|
||||
const context_query = `Analisis ${timeframe} ${topic.replace(/_/g, ' ')} hari ke-${contextData.currentDay || contextData.hari_ke || contextData.hari_terakhir || 0} broiler CP707`;
|
||||
|
||||
const fetchPromiseCall = apiClient.proxyLmStudio(
|
||||
prompt,
|
||||
@@ -895,7 +906,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
|
||||
_saveToCache(storageKey, parsed);
|
||||
setResult(parsed);
|
||||
lastCacheKeyRef.current = cacheKey;
|
||||
lastCacheKeyRef.current = `${cacheKey}-${timeframe}`;
|
||||
} catch (err) {
|
||||
console.error(`[PageAiInsight:${topic}] LLM request failed/timed out:`, err);
|
||||
const errMsg = err instanceof Error ? err.message : 'Gagal menghasilkan AI Insight.';
|
||||
@@ -913,14 +924,14 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
// eslint-disable-next-line react-hooks/preserve-manual-memoization
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
},
|
||||
[topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result]
|
||||
[topic, cacheKey, contextData, cycleData?.id, fetchFromBackend, result, timeframe]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastCacheKeyRef.current === cacheKey) return;
|
||||
if (lastCacheKeyRef.current === `${cacheKey}-${timeframe}`) return;
|
||||
const timer = setTimeout(() => generate(false), 800);
|
||||
return () => clearTimeout(timer);
|
||||
}, [cacheKey, generate]);
|
||||
}, [cacheKey, timeframe, generate]);
|
||||
|
||||
const downloadPdf = () => {
|
||||
if (!result) return;
|
||||
@@ -940,6 +951,17 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
const margin = 20;
|
||||
const contentWidth = pageWidth - margin * 2;
|
||||
|
||||
// Helper to strip non-ASCII to prevent corrupted PDF in jsPDF standard fonts
|
||||
const safeText = (text: string) => {
|
||||
if (!text) return '';
|
||||
return text
|
||||
.replace(/[“”]/g, '"')
|
||||
.replace(/[‘’]/g, "'")
|
||||
.replace(/–|—/g, '-')
|
||||
.replace(/•/g, '-')
|
||||
.replace(/[^\x20-\x7E\n]/g, '');
|
||||
};
|
||||
|
||||
// Helper to wrap and add text
|
||||
let y = 20;
|
||||
|
||||
@@ -1024,7 +1046,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
doc.setFontSize(9);
|
||||
doc.setTextColor(secondaryColor[0], secondaryColor[1], secondaryColor[2]);
|
||||
|
||||
const headlineLines = doc.splitTextToSize(result.headline, contentWidth - 38);
|
||||
const headline = safeText(result.headline);
|
||||
const headlineLines = doc.splitTextToSize(headline, contentWidth - 38);
|
||||
doc.text(headlineLines, margin + 34, y + 7.8);
|
||||
|
||||
y += 26;
|
||||
@@ -1066,7 +1089,8 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
return;
|
||||
}
|
||||
|
||||
items.forEach((item, index) => {
|
||||
items.forEach((rawItem, index) => {
|
||||
const item = safeText(rawItem);
|
||||
const textLines = doc.splitTextToSize(item, contentWidth - 12);
|
||||
const textHeight = textLines.length * 4.5;
|
||||
|
||||
@@ -1149,10 +1173,25 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
<span className="text-sm font-bold text-gray-900">
|
||||
{meta.emoji} {meta.title}
|
||||
</span>
|
||||
<p className="text-xs text-gray-400">Analisis berbasis kecerdasan buatan DeepSeek R1</p>
|
||||
<p className="text-xs text-gray-400">Analisis berbasis kecerdasan buatan Qwen 2.5</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* TIMEFRAME TOGGLE */}
|
||||
<div className="flex bg-slate-100 rounded-lg p-0.5 border border-slate-200 mr-1">
|
||||
<button
|
||||
onClick={() => setTimeframe('harian')}
|
||||
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'harian' ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Harian
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTimeframe('mingguan')}
|
||||
className={`px-2 py-1 text-[11px] font-semibold rounded-md transition-all ${timeframe === 'mingguan' ? 'bg-white shadow-sm text-slate-800' : 'text-slate-500 hover:text-slate-700'}`}
|
||||
>
|
||||
Mingguan
|
||||
</button>
|
||||
</div>
|
||||
{result && (
|
||||
<button
|
||||
onClick={downloadPdf}
|
||||
@@ -1185,7 +1224,7 @@ export const PageAiInsight: React.FC<PageAiInsightProps> = ({ topic, contextData
|
||||
className={`w-8 h-8 rounded-full border-4 border-slate-100 border-t-current ${colors.sparkText} animate-spin`}
|
||||
/>
|
||||
<p className="text-sm text-gray-400 animate-pulse">
|
||||
Menganalisis data dengan DeepSeek R1...
|
||||
Menganalisis data dengan Qwen 2.5...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user