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() {
>
- }
- sx={{
- borderRadius: '12px',
- minWidth: 'fit-content',
- textTransform: 'none',
- whiteSpace: 'nowrap'
- }}
- >
- {t('sidebar.newChat')}
-
+
{t('header.subtitle')}
+
+ }
+ sx={{
+ borderRadius: '12px',
+ minWidth: 'fit-content',
+ textTransform: 'none',
+ whiteSpace: 'nowrap'
+ }}
+ >
+ {t('sidebar.newChat')}
+
- {/* 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
}