From ea36fcf7aefe336a50d0bbe7aff77304047732ff Mon Sep 17 00:00:00 2001 From: Mohammad Sajad Pourajam Date: Mon, 27 Apr 2026 23:31:33 +0330 Subject: [PATCH] UPDATE --- messages/fa.json | 2 + .../fertilization-recommendation/page.tsx | 2 +- .../api/services/farmAiAssistantService.ts | 266 ++++++++++++++-- .../irrigationRecommendationService.ts | 91 ++++-- .../farmAiAssistant/FarmAiAssistantChat.tsx | 283 +++++++++++------- .../farmAiAssistant/farmAiAssistantTypes.ts | 5 +- 6 files changed, 488 insertions(+), 161 deletions(-) diff --git a/messages/fa.json b/messages/fa.json index 4808070..27b3929 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -770,9 +770,11 @@ "placeholder": "سوال مزرعه‌ای خود را بنویسید..." }, "recommendation": { + "primaryAction": "اقدام اصلی", "frequency": "تناوب", "amount": "مقدار", "timing": "زمان‌بندی", + "validityPeriod": "بازه اعتبار", "whyThis": "چرا این توصیه؟" }, "errors": { diff --git a/src/app/(dashboard)/(private)/fertilization-recommendation/page.tsx b/src/app/(dashboard)/(private)/fertilization-recommendation/page.tsx index ed9a4f4..e6d5e73 100644 --- a/src/app/(dashboard)/(private)/fertilization-recommendation/page.tsx +++ b/src/app/(dashboard)/(private)/fertilization-recommendation/page.tsx @@ -1,7 +1,7 @@ // Components Imports import SmartFertilizationRecommendation from '@views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation' -const FertilizationRecommendationPage = async () => { +const FertilizationRecommendationPage = () => { return } diff --git a/src/libs/api/services/farmAiAssistantService.ts b/src/libs/api/services/farmAiAssistantService.ts index 5bde444..34a5277 100644 --- a/src/libs/api/services/farmAiAssistantService.ts +++ b/src/libs/api/services/farmAiAssistantService.ts @@ -1,5 +1,8 @@ import { apiClient } from '../client' -import type { ConversationSummary, FarmContext } from '@views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes' +import type { + ConversationSummary, + FarmContext +} from '@views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes' const PREFIX = '/api/farm-ai-assistant' @@ -13,26 +16,57 @@ export interface FarmContextResponse { } export interface ChatSection { - type: 'text' | 'list' | 'recommendation' | 'warning' + type: 'text' | 'list' | 'recommendation' | 'warning' | 'pureText' title?: string content?: string items?: string[] icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar' + uiMode?: 'textOnly' | 'actionCard' frequency?: string amount?: string timing?: string expandableExplanation?: string + primaryAction?: string + validityPeriod?: string +} + +export interface ChatHistoryMessage { + role: 'system' | 'user' | 'assistant' + content: string + images?: string[] } export interface ChatPayload { farm_uuid: string + query?: string content?: string + history?: ChatHistoryMessage[] + image_urls?: string[] images?: string[] + files?: File[] conversation_id?: string title?: string farm_context?: Partial } +export interface ChatMessageResponse { + message_id: string + conversation_id: string + farm_uuid?: string + role: 'user' | 'assistant' + content: string + sections: ChatSection[] + images?: string[] + created_at?: string +} + +export interface ChatResponse { + conversation_id: string + farm_uuid?: string + user_message?: ChatMessageResponse + assistant_response: ChatMessageResponse +} + export interface ChatTaskInitResponse { task_id: string status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE' @@ -54,17 +88,6 @@ export interface ChatTaskStatusResponse { error?: string } -export interface ChatMessageResponse { - message_id: string - conversation_id: string - farm_uuid?: string - role: 'user' | 'assistant' - content: string - sections: ChatSection[] - images?: string[] - created_at?: string -} - export interface ConversationMessagesResponse { conversation_id: string farm_uuid?: string @@ -90,35 +113,228 @@ interface ApiResponse { data: T } +interface PendingChatTask { + conversationId: string + farmUuid?: string + result?: ChatMessageResponse + error?: string + status: ChatTaskStatusResponse['status'] +} + +const pendingChatTasks = new Map() + function unwrap(res: ApiResponse): T { return res.data } +function getChatText(payload: ChatPayload): string { + return (payload.query ?? payload.content ?? '').trim() +} + +function getImageUrls(payload: ChatPayload): string[] | undefined { + if (payload.image_urls?.length) return payload.image_urls + if (payload.images?.length) return payload.images + + return undefined +} + +function isFile(value: unknown): value is File { + return typeof File !== 'undefined' && value instanceof File +} + +function createTaskId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID() + } + + return `chat-${Date.now()}-${Math.random().toString(36).slice(2, 10)}` +} + +function normalizeMessage( + message: Partial | undefined, + defaults: Pick +): ChatMessageResponse { + return { + message_id: message?.message_id ?? defaults.message_id, + conversation_id: message?.conversation_id ?? defaults.conversation_id, + farm_uuid: message?.farm_uuid, + role: message?.role ?? defaults.role, + content: message?.content ?? defaults.content, + sections: message?.sections ?? defaults.sections, + images: message?.images ?? [], + created_at: message?.created_at + } +} + +function normalizeChatResponse(response: any, fallbackConversationId?: string): ChatResponse { + const conversationId = + response?.conversation_id ?? + response?.conversation?.id ?? + response?.assistant_response?.conversation_id ?? + response?.assistantResponse?.conversation_id ?? + fallbackConversationId ?? + '' + + const assistantSource = + response?.assistant_response ?? + response?.assistantResponse ?? + response?.message ?? + response?.assistant ?? + response + + const userSource = response?.user_message ?? response?.userMessage + + return { + conversation_id: conversationId, + farm_uuid: response?.farm_uuid ?? assistantSource?.farm_uuid ?? userSource?.farm_uuid, + user_message: userSource + ? normalizeMessage(userSource, { + message_id: userSource?.message_id ?? `user-${Date.now()}`, + conversation_id: conversationId, + role: 'user', + content: userSource?.content ?? '', + sections: userSource?.sections ?? [] + }) + : undefined, + assistant_response: normalizeMessage(assistantSource, { + message_id: assistantSource?.message_id ?? `assistant-${Date.now()}`, + conversation_id: conversationId, + role: 'assistant', + content: assistantSource?.content ?? '', + sections: assistantSource?.sections ?? [] + }) + } +} + +function buildChatRequestBody(payload: ChatPayload): FormData | Record { + const query = getChatText(payload) + const imageUrls = getImageUrls(payload) + const files = (payload.files ?? []).filter(isFile) + + if (files.length > 0) { + const formData = new FormData() + + formData.append('farm_uuid', payload.farm_uuid) + formData.append('query', query) + + if (payload.conversation_id) { + formData.append('conversation_id', payload.conversation_id) + } + + if (payload.history?.length) { + formData.append('history', JSON.stringify(payload.history)) + } + + if (imageUrls?.length) { + formData.append('image_urls', JSON.stringify(imageUrls)) + } + + files.forEach(file => { + formData.append('images', file) + }) + + return formData + } + + return { + farm_uuid: payload.farm_uuid, + query, + ...(payload.conversation_id ? { conversation_id: payload.conversation_id } : {}), + ...(payload.history?.length ? { history: payload.history } : {}), + ...(imageUrls?.length ? { image_urls: imageUrls } : {}) + } +} + export const farmAiAssistantService = { getContext(farmUuid: string): Promise { return apiClient .get>( - `${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}`, + `${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}` ) .then(unwrap) }, + sendMessage(payload: ChatPayload): Promise { + const requestBody = buildChatRequestBody(payload) + const request = requestBody instanceof FormData + ? apiClient.postFormData>(`${PREFIX}/chat/`, requestBody) + : apiClient.post>(`${PREFIX}/chat/`, requestBody) + + return request.then(unwrap).then(response => + normalizeChatResponse(response, payload.conversation_id) + ) + }, + + // Backward-compatible shim for task-based callers while the UI is migrating. createChatTask(payload: ChatPayload): Promise { - return apiClient.post>(`${PREFIX}/chat/task/`, payload).then(unwrap) + const taskId = createTaskId() + const conversationId = payload.conversation_id ?? '' + + pendingChatTasks.set(taskId, { + conversationId, + farmUuid: payload.farm_uuid, + status: 'PENDING' + }) + + void this.sendMessage(payload) + .then(response => { + pendingChatTasks.set(taskId, { + conversationId: response.conversation_id, + farmUuid: response.farm_uuid, + result: response.assistant_response, + status: 'SUCCESS' + }) + }) + .catch((error: { message?: string }) => { + pendingChatTasks.set(taskId, { + conversationId, + farmUuid: payload.farm_uuid, + error: error?.message ?? 'chat-failed', + status: 'FAILURE' + }) + }) + + return Promise.resolve({ + task_id: taskId, + status: 'PENDING', + conversation_id: conversationId, + message_id: taskId, + farm_uuid: payload.farm_uuid + }) }, getChatTaskStatus(taskId: string, farmUuid: string): Promise { - return apiClient - .get>( - `${PREFIX}/chat/task/${taskId}/status/?farm_uuid=${encodeURIComponent(farmUuid)}`, - ) - .then(unwrap) + const task = pendingChatTasks.get(taskId) + + if (!task) { + return Promise.resolve({ + task_id: taskId, + status: 'FAILURE', + conversation_id: '', + farm_uuid: farmUuid, + error: 'chat-task-not-found' + }) + } + + if (task.status === 'SUCCESS' || task.status === 'FAILURE') { + pendingChatTasks.delete(taskId) + } + + return Promise.resolve({ + task_id: taskId, + status: task.status, + conversation_id: task.conversationId, + farm_uuid: task.farmUuid ?? farmUuid, + progress: task.status === 'PENDING' ? { message: 'Processing chat request' } : undefined, + result: task.result, + error: task.error + }) }, getConversations(farmUuid: string): Promise { return apiClient .get>( - `${PREFIX}/chats/?farm_uuid=${encodeURIComponent(farmUuid)}`, + `${PREFIX}/chats/?farm_uuid=${encodeURIComponent(farmUuid)}` ) .then(unwrap) }, @@ -129,22 +345,22 @@ export const farmAiAssistantService = { deleteConversation( conversationId: string, - farmUuid: string, + farmUuid: string ): Promise<{ conversation_id: string; farm_uuid?: string }> { return apiClient .delete>( - `${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}`, + `${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}` ) .then(unwrap) }, getConversationMessages( conversationId: string, - farmUuid: string, + farmUuid: string ): Promise { return apiClient .get>( - `${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}`, + `${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}` ) .then(unwrap) } diff --git a/src/libs/api/services/irrigationRecommendationService.ts b/src/libs/api/services/irrigationRecommendationService.ts index b87d4da..6950390 100644 --- a/src/libs/api/services/irrigationRecommendationService.ts +++ b/src/libs/api/services/irrigationRecommendationService.ts @@ -24,6 +24,13 @@ export interface CropOption { icon: string; } +export interface IrrigationMethod { + id: string; + name: string; + label?: string; + description?: string; +} + export interface IrrigationConfigResponse { farm_uuid?: string; farmInfo: FarmInfo; @@ -41,6 +48,7 @@ export interface IrrigationPlan { export interface IrrigationRecommendPayload { farm_uuid: string; crop_id?: string; + irrigation_method_id?: string; farm_data?: Partial; soilType?: string; waterQuality?: string; @@ -75,6 +83,34 @@ export interface IrrigationRecommendationResult { status?: string; } +export interface WaterStressPayload { + farm_uuid: string; + crop_id?: string; + irrigation_method_id?: string; + farm_data?: Partial; + soilType?: string; + waterQuality?: string; + climateZone?: string; +} + +export interface WaterStressDailyEntry { + date?: string; + forecast_date?: string; + stress_level?: string; + stress_index?: number; + recommendation?: string; +} + +export interface WaterStressResponse { + status?: string; + stress_level?: string; + stress_index?: number; + summary?: string; + recommendation?: string; + daily?: WaterStressDailyEntry[]; + raw_response?: string; +} + export type IrrigationRecommendResponse = | IrrigationRecommendationResult | RecommendationTaskInitResponse; @@ -89,15 +125,6 @@ async function unwrap(promise: Promise>): Promise { return res.data; } -function normalizeTaskInitResponse( - task: RecommendationTaskInitResponse, -): RecommendationTaskInitResponse { - return { - ...task, - status: normalizeRecommendationTaskStatus(task.status), - }; -} - function normalizeRecommendationResult( result: IrrigationRecommendationResult, ): IrrigationRecommendationResult { @@ -110,6 +137,12 @@ function normalizeRecommendationResult( } export const irrigationRecommendationService = { + listMethods(): Promise { + return unwrap( + apiClient.get>(`${PREFIX}/`), + ); + }, + getConfig(farmUuid: string): Promise { return unwrap( apiClient.get>( @@ -128,29 +161,39 @@ export const irrigationRecommendationService = { ), ).then((response) => "task_id" in response - ? normalizeTaskInitResponse(response) + ? { + ...response, + status: normalizeRecommendationTaskStatus(response.status), + } : normalizeRecommendationResult(response), ); }, + getWaterStress(payload?: WaterStressPayload): Promise { + return unwrap( + apiClient.post>( + `${PREFIX}/water-stress/`, + payload ?? {}, + ), + ).then((response) => + response.status + ? { + ...response, + status: normalizeRecommendationTaskStatus(response.status), + } + : response, + ); + }, + + // Deprecated: backend no longer exposes a status endpoint in the current API. getRecommendStatus( taskId: string, farmUuid: string, ): Promise> { - return unwrap( - apiClient.get< - ApiResponse< - RecommendationTaskStatusResponse - > - >( - `${PREFIX}/recommend/status/${taskId}/?farm_uuid=${encodeURIComponent(farmUuid)}`, + return Promise.reject( + new Error( + `Irrigation task status endpoint was removed from the API. Task ${taskId} for farm ${farmUuid} cannot be polled.`, ), - ).then((response) => ({ - ...response, - status: normalizeRecommendationTaskStatus(response.status), - result: response.result - ? normalizeRecommendationResult(response.result) - : undefined, - })); + ); }, }; diff --git a/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx b/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx index 9da144b..0107b60 100644 --- a/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx +++ b/src/views/dashboards/farm/farmAiAssistant/FarmAiAssistantChat.tsx @@ -50,12 +50,10 @@ export default function FarmAiAssistantChat() { const farmUuid = farmHub?.farm_uuid const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') - const [isContextExpanded, setIsContextExpanded] = useState(true) const [isTyping, setIsTyping] = useState(false) const [selectedChip, setSelectedChip] = useState(null) const [expandedExplanations, setExpandedExplanations] = useState>(new Set()) const [farmContext, setFarmContext] = useState(DEFAULT_FARM_CONTEXT) - const [contextLoading, setContextLoading] = useState(true) const [conversationId, setConversationId] = useState(null) const [conversations, setConversations] = useState([]) const [conversationLoading, setConversationLoading] = useState(false) @@ -110,14 +108,12 @@ export default function FarmAiAssistantChat() { setMessages([]) if (!farmUuid) { - setContextLoading(false) setConversations([]) return () => { cancelled = true } } - setContextLoading(true) farmAiAssistantService .getContext(farmUuid) .then(data => { @@ -136,9 +132,6 @@ export default function FarmAiAssistantChat() { toast.error(t('errors.contextLoad')) } }) - .finally(() => { - if (!cancelled) setContextLoading(false) - }) return () => { cancelled = true } @@ -328,20 +321,7 @@ export default function FarmAiAssistantChat() { > - + {t('header.subtitle')} + + - {/* 2) Expandable Farm Context Bar */} - - setIsContextExpanded(!isContextExpanded)} - className='w-full flex items-center justify-between px-4 py-3 text-start' - sx={{ - '&:hover': { bgcolor: alpha(primary.main, 0.04) }, - transition: 'background 0.2s' - }} - > - - {t('context.title')} - - - - - - - - - - - - - - - {/* 3) Chat Area */} + {/* 2) Chat Area */} - {/* 4) Suggestion Chips */} + {/* 3) Suggestion Chips */} {SUGGESTION_CHIPS.map(chip => ( @@ -509,7 +459,7 @@ export default function FarmAiAssistantChat() { - {/* 5) Input Area - Sticky Bottom */} + {/* 4) Input Area - Sticky Bottom */} - - - - {label} - - - {value} - - - - ) -} - function MessageBubble({ message, expandedExplanations, @@ -651,6 +566,9 @@ function MessageBubble({ const theme = useTheme() const { primary, info } = theme.palette const isUser = message.role === 'user' + const firstRecommendation = message.sections?.find(section => section.type === 'recommendation') + const recommendationUiMode = + firstRecommendation?.uiMode ?? (firstRecommendation?.primaryAction ? 'actionCard' : 'textOnly') if (isUser) { return ( @@ -683,10 +601,25 @@ function MessageBubble({ + {!message.sections?.length && message.content && ( + + + {message.content} + + + )} {message.sections?.map((section, idx) => ( onToggleExplanation: (id: string) => void messageId: string @@ -728,6 +663,58 @@ function AISectionCard({ const iconClass = section.icon ? iconMap[section.icon] : 'tabler-leaf' if (section.type === 'recommendation') { + const resolvedUiMode = section.uiMode ?? recommendationUiMode ?? (section.primaryAction ? 'actionCard' : 'textOnly') + + if (resolvedUiMode === 'textOnly') { + return ( + + {section.title && ( + + + + {section.title} + + + )} + {section.content && ( + + {section.content} + + )} + {section.expandableExplanation && ( + + onToggleExplanation(expId)} + className='flex items-center gap-1 text-sm font-medium' + sx={{ color: 'primary.main', '&:hover': { color: 'primary.dark' } }} + > + {t('recommendation.whyThis')} + + + + + {section.expandableExplanation} + + + + )} + + ) + } + return ( + {section.content && ( + + {section.content} + + )} + {section.primaryAction && ( + + + {t('recommendation.primaryAction')} + + + {section.primaryAction} + + + )} {section.frequency && ( - + {t('recommendation.frequency')} - + {section.frequency} )} {section.amount && ( - + {t('recommendation.amount')} - + {section.amount} )} {section.timing && ( - + {t('recommendation.timing')} - + {section.timing} )} + {section.validityPeriod && ( + + + {t('recommendation.validityPeriod')} + + + {section.validityPeriod} + + + )} {section.expandableExplanation && ( @@ -865,6 +877,57 @@ function AISectionCard({ ) } + if (section.type === 'text') { + return ( + + {section.title && ( + + {section.title} + + )} + + {section.content} + + + ) + } + + if (section.type === 'pureText') { + return ( + + + + + + + {section.content} + + + + ) + } + return null } diff --git a/src/views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes.ts b/src/views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes.ts index 2ba6649..a1134fe 100644 --- a/src/views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes.ts +++ b/src/views/dashboards/farm/farmAiAssistant/farmAiAssistantTypes.ts @@ -13,15 +13,18 @@ export interface SuggestionChip { // Structured AI response sections for card-based rendering export interface AIResponseSection { - type: 'text' | 'list' | 'recommendation' | 'warning' + type: 'text' | 'list' | 'recommendation' | 'warning' | 'pureText' title?: string content?: string items?: string[] icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar' + uiMode?: 'textOnly' | 'actionCard' // Recommendation-specific frequency?: string amount?: string timing?: string + primaryAction?: string + validityPeriod?: string expandableExplanation?: string }