UPDATE
This commit is contained in:
@@ -770,9 +770,11 @@
|
||||
"placeholder": "سوال مزرعهای خود را بنویسید..."
|
||||
},
|
||||
"recommendation": {
|
||||
"primaryAction": "اقدام اصلی",
|
||||
"frequency": "تناوب",
|
||||
"amount": "مقدار",
|
||||
"timing": "زمانبندی",
|
||||
"validityPeriod": "بازه اعتبار",
|
||||
"whyThis": "چرا این توصیه؟"
|
||||
},
|
||||
"errors": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Components Imports
|
||||
import SmartFertilizationRecommendation from '@views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation'
|
||||
|
||||
const FertilizationRecommendationPage = async () => {
|
||||
const FertilizationRecommendationPage = () => {
|
||||
return <SmartFertilizationRecommendation />
|
||||
}
|
||||
|
||||
|
||||
@@ -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<FarmContext>
|
||||
}
|
||||
|
||||
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<T> {
|
||||
data: T
|
||||
}
|
||||
|
||||
interface PendingChatTask {
|
||||
conversationId: string
|
||||
farmUuid?: string
|
||||
result?: ChatMessageResponse
|
||||
error?: string
|
||||
status: ChatTaskStatusResponse['status']
|
||||
}
|
||||
|
||||
const pendingChatTasks = new Map<string, PendingChatTask>()
|
||||
|
||||
function unwrap<T>(res: ApiResponse<T>): 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<ChatMessageResponse> | undefined,
|
||||
defaults: Pick<ChatMessageResponse, 'message_id' | 'conversation_id' | 'role' | 'content' | 'sections'>
|
||||
): 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<string, unknown> {
|
||||
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<FarmContextResponse> {
|
||||
return apiClient
|
||||
.get<ApiResponse<FarmContextResponse>>(
|
||||
`${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}`,
|
||||
`${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
)
|
||||
.then(unwrap)
|
||||
},
|
||||
|
||||
sendMessage(payload: ChatPayload): Promise<ChatResponse> {
|
||||
const requestBody = buildChatRequestBody(payload)
|
||||
const request = requestBody instanceof FormData
|
||||
? apiClient.postFormData<ApiResponse<any>>(`${PREFIX}/chat/`, requestBody)
|
||||
: apiClient.post<ApiResponse<any>>(`${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<ChatTaskInitResponse> {
|
||||
return apiClient.post<ApiResponse<ChatTaskInitResponse>>(`${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<ChatTaskStatusResponse> {
|
||||
return apiClient
|
||||
.get<ApiResponse<ChatTaskStatusResponse>>(
|
||||
`${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<ConversationSummary[]> {
|
||||
return apiClient
|
||||
.get<ApiResponse<ConversationSummary[]>>(
|
||||
`${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<ApiResponse<{ conversation_id: string; farm_uuid?: string }>>(
|
||||
`${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}`,
|
||||
`${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
)
|
||||
.then(unwrap)
|
||||
},
|
||||
|
||||
getConversationMessages(
|
||||
conversationId: string,
|
||||
farmUuid: string,
|
||||
farmUuid: string
|
||||
): Promise<ConversationMessagesResponse> {
|
||||
return apiClient
|
||||
.get<ApiResponse<ConversationMessagesResponse>>(
|
||||
`${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}`,
|
||||
`${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}`
|
||||
)
|
||||
.then(unwrap)
|
||||
}
|
||||
|
||||
@@ -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<FarmInfo>;
|
||||
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<FarmInfo>;
|
||||
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<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
|
||||
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<IrrigationMethod[]> {
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<IrrigationMethod[]>>(`${PREFIX}/`),
|
||||
);
|
||||
},
|
||||
|
||||
getConfig(farmUuid: string): Promise<IrrigationConfigResponse> {
|
||||
return unwrap(
|
||||
apiClient.get<ApiResponse<IrrigationConfigResponse>>(
|
||||
@@ -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<WaterStressResponse> {
|
||||
return unwrap(
|
||||
apiClient.post<ApiResponse<WaterStressResponse>>(
|
||||
`${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<RecommendationTaskStatusResponse<IrrigationRecommendationResult>> {
|
||||
return unwrap(
|
||||
apiClient.get<
|
||||
ApiResponse<
|
||||
RecommendationTaskStatusResponse<IrrigationRecommendationResult>
|
||||
>
|
||||
>(
|
||||
`${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,
|
||||
}));
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -50,12 +50,10 @@ export default function FarmAiAssistantChat() {
|
||||
const farmUuid = farmHub?.farm_uuid
|
||||
const [messages, setMessages] = useState<FarmAIMessage[]>([])
|
||||
const [inputValue, setInputValue] = useState('')
|
||||
const [isContextExpanded, setIsContextExpanded] = useState(true)
|
||||
const [isTyping, setIsTyping] = useState(false)
|
||||
const [selectedChip, setSelectedChip] = useState<string | null>(null)
|
||||
const [expandedExplanations, setExpandedExplanations] = useState<Set<string>>(new Set())
|
||||
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
|
||||
const [contextLoading, setContextLoading] = useState(true)
|
||||
const [conversationId, setConversationId] = useState<string | null>(null)
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([])
|
||||
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() {
|
||||
>
|
||||
<i className='tabler-menu-2 text-xl' />
|
||||
</IconButton>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
onClick={handleNewChat}
|
||||
startIcon={<i className='tabler-plus' />}
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
minWidth: 'fit-content',
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{t('sidebar.newChat')}
|
||||
</Button>
|
||||
|
||||
</Box>
|
||||
<Box
|
||||
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0'
|
||||
@@ -370,55 +350,25 @@ export default function FarmAiAssistantChat() {
|
||||
<Typography variant='caption' color='text.secondary' className='block truncate'>
|
||||
{t('header.subtitle')}
|
||||
</Typography>
|
||||
|
||||
</Box>
|
||||
<Button
|
||||
variant='contained'
|
||||
size='small'
|
||||
onClick={handleNewChat}
|
||||
startIcon={<i className='tabler-plus' />}
|
||||
sx={{
|
||||
borderRadius: '12px',
|
||||
minWidth: 'fit-content',
|
||||
textTransform: 'none',
|
||||
whiteSpace: 'nowrap'
|
||||
}}
|
||||
>
|
||||
{t('sidebar.newChat')}
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{/* 2) Expandable Farm Context Bar */}
|
||||
<Box
|
||||
className='mx-4 mt-3 flex-shrink-0 rounded-2xl overflow-hidden'
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${alpha(primary.main, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primary.main, 0.15)}`,
|
||||
boxShadow: theme.shadows[1]
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => 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'
|
||||
}}
|
||||
>
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.secondary'>
|
||||
{t('context.title')}
|
||||
</Typography>
|
||||
<i
|
||||
className={classnames('tabler-chevron-down text-xl transition-transform duration-300', {
|
||||
'rotate-180': isContextExpanded
|
||||
})}
|
||||
style={{ color: primary.main }}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={isContextExpanded}>
|
||||
<Box className='px-4 pb-4 grid grid-cols-2 gap-2'>
|
||||
<ContextBadge icon='tabler-seedling' label={t('context.soilType')} value={farmContext.soilType} />
|
||||
<ContextBadge icon='tabler-droplet' label={t('context.waterEC')} value={farmContext.waterEC} />
|
||||
<ContextBadge icon='tabler-plant-2' label={t('context.crop')} value={farmContext.selectedCrop} />
|
||||
<ContextBadge icon='tabler-flower' label={t('context.growthStage')} value={farmContext.growthStage} />
|
||||
<ContextBadge
|
||||
icon='tabler-calendar'
|
||||
label={t('context.lastIrrigation')}
|
||||
value={farmContext.lastIrrigationStatus}
|
||||
colSpan={2}
|
||||
/>
|
||||
</Box>
|
||||
</Collapse>
|
||||
</Box>
|
||||
|
||||
{/* 3) Chat Area */}
|
||||
{/* 2) Chat Area */}
|
||||
<Box
|
||||
ref={scrollRef}
|
||||
className='flex-1 overflow-y-auto overflow-x-hidden px-4 py-4 space-y-4'
|
||||
@@ -479,7 +429,7 @@ export default function FarmAiAssistantChat() {
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 4) Suggestion Chips */}
|
||||
{/* 3) Suggestion Chips */}
|
||||
<Box className='px-4 py-2 flex-shrink-0 overflow-x-auto scrollbar-hide'>
|
||||
<Box className='flex gap-2 pb-2'>
|
||||
{SUGGESTION_CHIPS.map(chip => (
|
||||
@@ -509,7 +459,7 @@ export default function FarmAiAssistantChat() {
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* 5) Input Area - Sticky Bottom */}
|
||||
{/* 4) Input Area - Sticky Bottom */}
|
||||
<Box
|
||||
className='px-4 py-3 pb-6 flex-shrink-0'
|
||||
sx={{
|
||||
@@ -602,41 +552,6 @@ export default function FarmAiAssistantChat() {
|
||||
|
||||
// ─── Sub-components ────────────────────────────────────────────────────────
|
||||
|
||||
function ContextBadge({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
colSpan = 1
|
||||
}: {
|
||||
icon: string
|
||||
label: string
|
||||
value: string
|
||||
colSpan?: number
|
||||
}) {
|
||||
const theme = useTheme()
|
||||
const primary = theme.palette.primary
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classnames('flex items-center gap-2 px-3 py-2 rounded-xl', colSpan === 2 ? 'col-span-2' : '')}
|
||||
sx={{
|
||||
background: alpha(primary.main, 0.06),
|
||||
border: `1px solid ${alpha(primary.main, 0.12)}`
|
||||
}}
|
||||
>
|
||||
<i className={`${icon} text-lg shrink-0`} style={{ color: primary.main }} />
|
||||
<Box className='min-w-0'>
|
||||
<Typography variant='caption' color='text.secondary' display='block' lineHeight={1.2}>
|
||||
{label}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} color='text.primary' noWrap>
|
||||
{value}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
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({
|
||||
<i className='tabler-leaf text-lg' style={{ color: 'inherit' }} />
|
||||
</Box>
|
||||
<Box className='flex-1 min-w-0 space-y-3'>
|
||||
{!message.sections?.length && message.content && (
|
||||
<Box
|
||||
className='px-4 py-3 rounded-2xl rounded-tl-md'
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${alpha(primary.main, 0.05)} 100%)`,
|
||||
border: `1px solid ${alpha(primary.main, 0.15)}`,
|
||||
boxShadow: theme.shadows[1]
|
||||
}}
|
||||
>
|
||||
<Typography variant='body2' color='text.primary'>
|
||||
{message.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{message.sections?.map((section, idx) => (
|
||||
<AISectionCard
|
||||
key={`${message.id}-${idx}`}
|
||||
section={section}
|
||||
recommendationUiMode={recommendationUiMode}
|
||||
expandedExplanations={expandedExplanations}
|
||||
onToggleExplanation={onToggleExplanation}
|
||||
messageId={message.id}
|
||||
@@ -701,6 +634,7 @@ function MessageBubble({
|
||||
|
||||
function AISectionCard({
|
||||
section,
|
||||
recommendationUiMode,
|
||||
expandedExplanations,
|
||||
onToggleExplanation,
|
||||
messageId,
|
||||
@@ -708,6 +642,7 @@ function AISectionCard({
|
||||
t
|
||||
}: {
|
||||
section: AIResponseSection
|
||||
recommendationUiMode: 'textOnly' | 'actionCard'
|
||||
expandedExplanations: Set<string>
|
||||
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 (
|
||||
<Box
|
||||
className='p-4 rounded-2xl'
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${alpha(primary.main, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primary.main, 0.15)}`,
|
||||
boxShadow: theme.shadows[1]
|
||||
}}
|
||||
>
|
||||
{section.title && (
|
||||
<Box className='flex items-center gap-2 mb-2'>
|
||||
<i className={`${iconClass} text-lg`} style={{ color: primary.main }} />
|
||||
<Typography variant='subtitle2' fontWeight={700} color='primary'>
|
||||
{section.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{section.content && (
|
||||
<Typography variant='body2' color='text.primary' lineHeight={1.8}>
|
||||
{section.content}
|
||||
</Typography>
|
||||
)}
|
||||
{section.expandableExplanation && (
|
||||
<Box className='mt-3'>
|
||||
<Box
|
||||
component='button'
|
||||
type='button'
|
||||
onClick={() => onToggleExplanation(expId)}
|
||||
className='flex items-center gap-1 text-sm font-medium'
|
||||
sx={{ color: 'primary.main', '&:hover': { color: 'primary.dark' } }}
|
||||
>
|
||||
{t('recommendation.whyThis')}
|
||||
<i
|
||||
className={classnames('tabler-chevron-down text-base transition-transform', {
|
||||
'rotate-180': expandedExplanations.has(expId)
|
||||
})}
|
||||
/>
|
||||
</Box>
|
||||
<Collapse in={expandedExplanations.has(expId)}>
|
||||
<Typography variant='body2' color='text.secondary' className='mt-2' lineHeight={1.8}>
|
||||
{section.expandableExplanation}
|
||||
</Typography>
|
||||
</Collapse>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
@@ -746,37 +733,62 @@ function AISectionCard({
|
||||
{section.title}
|
||||
</Typography>
|
||||
</Box>
|
||||
{section.content && (
|
||||
<Typography variant='body2' color='text.primary' className='mb-3' lineHeight={1.8}>
|
||||
{section.content}
|
||||
</Typography>
|
||||
)}
|
||||
<Box className='space-y-2'>
|
||||
{section.primaryAction && (
|
||||
<Box className='flex justify-between gap-3'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('recommendation.primaryAction')}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} className='text-end'>
|
||||
{section.primaryAction}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{section.frequency && (
|
||||
<Box className='flex justify-between'>
|
||||
<Box className='flex justify-between gap-3'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('recommendation.frequency')}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600}>
|
||||
<Typography variant='body2' fontWeight={600} className='text-end'>
|
||||
{section.frequency}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{section.amount && (
|
||||
<Box className='flex justify-between'>
|
||||
<Box className='flex justify-between gap-3'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('recommendation.amount')}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600}>
|
||||
<Typography variant='body2' fontWeight={600} className='text-end'>
|
||||
{section.amount}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{section.timing && (
|
||||
<Box className='flex justify-between'>
|
||||
<Box className='flex justify-between gap-3'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('recommendation.timing')}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600}>
|
||||
<Typography variant='body2' fontWeight={600} className='text-end'>
|
||||
{section.timing}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
{section.validityPeriod && (
|
||||
<Box className='flex justify-between gap-3'>
|
||||
<Typography variant='caption' color='text.secondary'>
|
||||
{t('recommendation.validityPeriod')}
|
||||
</Typography>
|
||||
<Typography variant='body2' fontWeight={600} className='text-end'>
|
||||
{section.validityPeriod}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
{section.expandableExplanation && (
|
||||
<Box className='mt-3'>
|
||||
@@ -865,6 +877,57 @@ function AISectionCard({
|
||||
)
|
||||
}
|
||||
|
||||
if (section.type === 'text') {
|
||||
return (
|
||||
<Box
|
||||
className='p-4 rounded-2xl'
|
||||
sx={{
|
||||
background: `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${alpha(primary.main, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primary.main, 0.15)}`
|
||||
}}
|
||||
>
|
||||
{section.title && (
|
||||
<Typography variant='subtitle2' fontWeight={600} color='text.primary' className='mb-2'>
|
||||
{section.title}
|
||||
</Typography>
|
||||
)}
|
||||
<Typography variant='body2' color='text.secondary' lineHeight={1.8}>
|
||||
{section.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
if (section.type === 'pureText') {
|
||||
return (
|
||||
<Card
|
||||
elevation={0}
|
||||
className='overflow-hidden'
|
||||
sx={{
|
||||
borderRadius: '20px',
|
||||
background: `linear-gradient(145deg, ${theme.palette.background.paper} 0%, ${alpha(primary.main, 0.04)} 100%)`,
|
||||
border: `1px solid ${alpha(primary.main, 0.14)}`,
|
||||
boxShadow: theme.shadows[1]
|
||||
}}
|
||||
>
|
||||
<Box className='p-4 flex items-start gap-3'>
|
||||
<Box
|
||||
className='w-10 h-10 rounded-xl flex items-center justify-center shrink-0'
|
||||
sx={{
|
||||
background: alpha(primary.main, 0.1),
|
||||
color: 'primary.main'
|
||||
}}
|
||||
>
|
||||
<i className='tabler-message-circle text-lg' style={{ color: 'inherit' }} />
|
||||
</Box>
|
||||
<Typography variant='body2' color='text.primary' lineHeight={1.9}>
|
||||
{section.content}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user