This commit is contained in:
2026-04-27 23:31:33 +03:30
parent 80ba238713
commit ea36fcf7ae
6 changed files with 488 additions and 161 deletions
+2
View File
@@ -770,9 +770,11 @@
"placeholder": "سوال مزرعه‌ای خود را بنویسید..." "placeholder": "سوال مزرعه‌ای خود را بنویسید..."
}, },
"recommendation": { "recommendation": {
"primaryAction": "اقدام اصلی",
"frequency": "تناوب", "frequency": "تناوب",
"amount": "مقدار", "amount": "مقدار",
"timing": "زمان‌بندی", "timing": "زمان‌بندی",
"validityPeriod": "بازه اعتبار",
"whyThis": "چرا این توصیه؟" "whyThis": "چرا این توصیه؟"
}, },
"errors": { "errors": {
@@ -1,7 +1,7 @@
// Components Imports // Components Imports
import SmartFertilizationRecommendation from '@views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation' import SmartFertilizationRecommendation from '@views/dashboards/farm/smartFertilization/SmartFertilizationRecommendation'
const FertilizationRecommendationPage = async () => { const FertilizationRecommendationPage = () => {
return <SmartFertilizationRecommendation /> return <SmartFertilizationRecommendation />
} }
+241 -25
View File
@@ -1,5 +1,8 @@
import { apiClient } from '../client' 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' const PREFIX = '/api/farm-ai-assistant'
@@ -13,26 +16,57 @@ export interface FarmContextResponse {
} }
export interface ChatSection { export interface ChatSection {
type: 'text' | 'list' | 'recommendation' | 'warning' type: 'text' | 'list' | 'recommendation' | 'warning' | 'pureText'
title?: string title?: string
content?: string content?: string
items?: string[] items?: string[]
icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar' icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar'
uiMode?: 'textOnly' | 'actionCard'
frequency?: string frequency?: string
amount?: string amount?: string
timing?: string timing?: string
expandableExplanation?: string expandableExplanation?: string
primaryAction?: string
validityPeriod?: string
}
export interface ChatHistoryMessage {
role: 'system' | 'user' | 'assistant'
content: string
images?: string[]
} }
export interface ChatPayload { export interface ChatPayload {
farm_uuid: string farm_uuid: string
query?: string
content?: string content?: string
history?: ChatHistoryMessage[]
image_urls?: string[]
images?: string[] images?: string[]
files?: File[]
conversation_id?: string conversation_id?: string
title?: string title?: string
farm_context?: Partial<FarmContext> 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 { export interface ChatTaskInitResponse {
task_id: string task_id: string
status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE' status: 'PENDING' | 'STARTED' | 'SUCCESS' | 'FAILURE'
@@ -54,17 +88,6 @@ export interface ChatTaskStatusResponse {
error?: string 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 { export interface ConversationMessagesResponse {
conversation_id: string conversation_id: string
farm_uuid?: string farm_uuid?: string
@@ -90,35 +113,228 @@ interface ApiResponse<T> {
data: 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 { function unwrap<T>(res: ApiResponse<T>): T {
return res.data 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 = { export const farmAiAssistantService = {
getContext(farmUuid: string): Promise<FarmContextResponse> { getContext(farmUuid: string): Promise<FarmContextResponse> {
return apiClient return apiClient
.get<ApiResponse<FarmContextResponse>>( .get<ApiResponse<FarmContextResponse>>(
`${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}`, `${PREFIX}/context/?farm_uuid=${encodeURIComponent(farmUuid)}`
) )
.then(unwrap) .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> { 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> { getChatTaskStatus(taskId: string, farmUuid: string): Promise<ChatTaskStatusResponse> {
return apiClient const task = pendingChatTasks.get(taskId)
.get<ApiResponse<ChatTaskStatusResponse>>(
`${PREFIX}/chat/task/${taskId}/status/?farm_uuid=${encodeURIComponent(farmUuid)}`, if (!task) {
) return Promise.resolve({
.then(unwrap) 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[]> { getConversations(farmUuid: string): Promise<ConversationSummary[]> {
return apiClient return apiClient
.get<ApiResponse<ConversationSummary[]>>( .get<ApiResponse<ConversationSummary[]>>(
`${PREFIX}/chats/?farm_uuid=${encodeURIComponent(farmUuid)}`, `${PREFIX}/chats/?farm_uuid=${encodeURIComponent(farmUuid)}`
) )
.then(unwrap) .then(unwrap)
}, },
@@ -129,22 +345,22 @@ export const farmAiAssistantService = {
deleteConversation( deleteConversation(
conversationId: string, conversationId: string,
farmUuid: string, farmUuid: string
): Promise<{ conversation_id: string; farm_uuid?: string }> { ): Promise<{ conversation_id: string; farm_uuid?: string }> {
return apiClient return apiClient
.delete<ApiResponse<{ conversation_id: string; farm_uuid?: string }>>( .delete<ApiResponse<{ conversation_id: string; farm_uuid?: string }>>(
`${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}`, `${PREFIX}/chats/${conversationId}/?farm_uuid=${encodeURIComponent(farmUuid)}`
) )
.then(unwrap) .then(unwrap)
}, },
getConversationMessages( getConversationMessages(
conversationId: string, conversationId: string,
farmUuid: string, farmUuid: string
): Promise<ConversationMessagesResponse> { ): Promise<ConversationMessagesResponse> {
return apiClient return apiClient
.get<ApiResponse<ConversationMessagesResponse>>( .get<ApiResponse<ConversationMessagesResponse>>(
`${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}`, `${PREFIX}/chats/${conversationId}/messages/?farm_uuid=${encodeURIComponent(farmUuid)}`
) )
.then(unwrap) .then(unwrap)
} }
@@ -24,6 +24,13 @@ export interface CropOption {
icon: string; icon: string;
} }
export interface IrrigationMethod {
id: string;
name: string;
label?: string;
description?: string;
}
export interface IrrigationConfigResponse { export interface IrrigationConfigResponse {
farm_uuid?: string; farm_uuid?: string;
farmInfo: FarmInfo; farmInfo: FarmInfo;
@@ -41,6 +48,7 @@ export interface IrrigationPlan {
export interface IrrigationRecommendPayload { export interface IrrigationRecommendPayload {
farm_uuid: string; farm_uuid: string;
crop_id?: string; crop_id?: string;
irrigation_method_id?: string;
farm_data?: Partial<FarmInfo>; farm_data?: Partial<FarmInfo>;
soilType?: string; soilType?: string;
waterQuality?: string; waterQuality?: string;
@@ -75,6 +83,34 @@ export interface IrrigationRecommendationResult {
status?: string; 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 = export type IrrigationRecommendResponse =
| IrrigationRecommendationResult | IrrigationRecommendationResult
| RecommendationTaskInitResponse; | RecommendationTaskInitResponse;
@@ -89,15 +125,6 @@ async function unwrap<T>(promise: Promise<ApiResponse<T>>): Promise<T> {
return res.data; return res.data;
} }
function normalizeTaskInitResponse(
task: RecommendationTaskInitResponse,
): RecommendationTaskInitResponse {
return {
...task,
status: normalizeRecommendationTaskStatus(task.status),
};
}
function normalizeRecommendationResult( function normalizeRecommendationResult(
result: IrrigationRecommendationResult, result: IrrigationRecommendationResult,
): IrrigationRecommendationResult { ): IrrigationRecommendationResult {
@@ -110,6 +137,12 @@ function normalizeRecommendationResult(
} }
export const irrigationRecommendationService = { export const irrigationRecommendationService = {
listMethods(): Promise<IrrigationMethod[]> {
return unwrap(
apiClient.get<ApiResponse<IrrigationMethod[]>>(`${PREFIX}/`),
);
},
getConfig(farmUuid: string): Promise<IrrigationConfigResponse> { getConfig(farmUuid: string): Promise<IrrigationConfigResponse> {
return unwrap( return unwrap(
apiClient.get<ApiResponse<IrrigationConfigResponse>>( apiClient.get<ApiResponse<IrrigationConfigResponse>>(
@@ -128,29 +161,39 @@ export const irrigationRecommendationService = {
), ),
).then((response) => ).then((response) =>
"task_id" in response "task_id" in response
? normalizeTaskInitResponse(response) ? {
...response,
status: normalizeRecommendationTaskStatus(response.status),
}
: normalizeRecommendationResult(response), : 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( getRecommendStatus(
taskId: string, taskId: string,
farmUuid: string, farmUuid: string,
): Promise<RecommendationTaskStatusResponse<IrrigationRecommendationResult>> { ): Promise<RecommendationTaskStatusResponse<IrrigationRecommendationResult>> {
return unwrap( return Promise.reject(
apiClient.get< new Error(
ApiResponse< `Irrigation task status endpoint was removed from the API. Task ${taskId} for farm ${farmUuid} cannot be polled.`,
RecommendationTaskStatusResponse<IrrigationRecommendationResult>
>
>(
`${PREFIX}/recommend/status/${taskId}/?farm_uuid=${encodeURIComponent(farmUuid)}`,
), ),
).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 farmUuid = farmHub?.farm_uuid
const [messages, setMessages] = useState<FarmAIMessage[]>([]) const [messages, setMessages] = useState<FarmAIMessage[]>([])
const [inputValue, setInputValue] = useState('') const [inputValue, setInputValue] = useState('')
const [isContextExpanded, setIsContextExpanded] = useState(true)
const [isTyping, setIsTyping] = useState(false) const [isTyping, setIsTyping] = useState(false)
const [selectedChip, setSelectedChip] = useState<string | null>(null) const [selectedChip, setSelectedChip] = useState<string | null>(null)
const [expandedExplanations, setExpandedExplanations] = useState<Set<string>>(new Set()) const [expandedExplanations, setExpandedExplanations] = useState<Set<string>>(new Set())
const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT) const [farmContext, setFarmContext] = useState<FarmContext>(DEFAULT_FARM_CONTEXT)
const [contextLoading, setContextLoading] = useState(true)
const [conversationId, setConversationId] = useState<string | null>(null) const [conversationId, setConversationId] = useState<string | null>(null)
const [conversations, setConversations] = useState<ConversationSummary[]>([]) const [conversations, setConversations] = useState<ConversationSummary[]>([])
const [conversationLoading, setConversationLoading] = useState(false) const [conversationLoading, setConversationLoading] = useState(false)
@@ -110,14 +108,12 @@ export default function FarmAiAssistantChat() {
setMessages([]) setMessages([])
if (!farmUuid) { if (!farmUuid) {
setContextLoading(false)
setConversations([]) setConversations([])
return () => { return () => {
cancelled = true cancelled = true
} }
} }
setContextLoading(true)
farmAiAssistantService farmAiAssistantService
.getContext(farmUuid) .getContext(farmUuid)
.then(data => { .then(data => {
@@ -136,9 +132,6 @@ export default function FarmAiAssistantChat() {
toast.error(t('errors.contextLoad')) toast.error(t('errors.contextLoad'))
} }
}) })
.finally(() => {
if (!cancelled) setContextLoading(false)
})
return () => { return () => {
cancelled = true cancelled = true
} }
@@ -328,20 +321,7 @@ export default function FarmAiAssistantChat() {
> >
<i className='tabler-menu-2 text-xl' /> <i className='tabler-menu-2 text-xl' />
</IconButton> </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>
<Box <Box
className='w-12 h-12 rounded-2xl flex items-center justify-center shrink-0' 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'> <Typography variant='caption' color='text.secondary' className='block truncate'>
{t('header.subtitle')} {t('header.subtitle')}
</Typography> </Typography>
</Box> </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> </Box>
{/* 2) Expandable Farm Context Bar */} {/* 2) Chat Area */}
<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 */}
<Box <Box
ref={scrollRef} ref={scrollRef}
className='flex-1 overflow-y-auto overflow-x-hidden px-4 py-4 space-y-4' 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> </Box>
{/* 4) Suggestion Chips */} {/* 3) Suggestion Chips */}
<Box className='px-4 py-2 flex-shrink-0 overflow-x-auto scrollbar-hide'> <Box className='px-4 py-2 flex-shrink-0 overflow-x-auto scrollbar-hide'>
<Box className='flex gap-2 pb-2'> <Box className='flex gap-2 pb-2'>
{SUGGESTION_CHIPS.map(chip => ( {SUGGESTION_CHIPS.map(chip => (
@@ -509,7 +459,7 @@ export default function FarmAiAssistantChat() {
</Box> </Box>
</Box> </Box>
{/* 5) Input Area - Sticky Bottom */} {/* 4) Input Area - Sticky Bottom */}
<Box <Box
className='px-4 py-3 pb-6 flex-shrink-0' className='px-4 py-3 pb-6 flex-shrink-0'
sx={{ sx={{
@@ -602,41 +552,6 @@ export default function FarmAiAssistantChat() {
// ─── Sub-components ──────────────────────────────────────────────────────── // ─── 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({ function MessageBubble({
message, message,
expandedExplanations, expandedExplanations,
@@ -651,6 +566,9 @@ function MessageBubble({
const theme = useTheme() const theme = useTheme()
const { primary, info } = theme.palette const { primary, info } = theme.palette
const isUser = message.role === 'user' const isUser = message.role === 'user'
const firstRecommendation = message.sections?.find(section => section.type === 'recommendation')
const recommendationUiMode =
firstRecommendation?.uiMode ?? (firstRecommendation?.primaryAction ? 'actionCard' : 'textOnly')
if (isUser) { if (isUser) {
return ( return (
@@ -683,10 +601,25 @@ function MessageBubble({
<i className='tabler-leaf text-lg' style={{ color: 'inherit' }} /> <i className='tabler-leaf text-lg' style={{ color: 'inherit' }} />
</Box> </Box>
<Box className='flex-1 min-w-0 space-y-3'> <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) => ( {message.sections?.map((section, idx) => (
<AISectionCard <AISectionCard
key={`${message.id}-${idx}`} key={`${message.id}-${idx}`}
section={section} section={section}
recommendationUiMode={recommendationUiMode}
expandedExplanations={expandedExplanations} expandedExplanations={expandedExplanations}
onToggleExplanation={onToggleExplanation} onToggleExplanation={onToggleExplanation}
messageId={message.id} messageId={message.id}
@@ -701,6 +634,7 @@ function MessageBubble({
function AISectionCard({ function AISectionCard({
section, section,
recommendationUiMode,
expandedExplanations, expandedExplanations,
onToggleExplanation, onToggleExplanation,
messageId, messageId,
@@ -708,6 +642,7 @@ function AISectionCard({
t t
}: { }: {
section: AIResponseSection section: AIResponseSection
recommendationUiMode: 'textOnly' | 'actionCard'
expandedExplanations: Set<string> expandedExplanations: Set<string>
onToggleExplanation: (id: string) => void onToggleExplanation: (id: string) => void
messageId: string messageId: string
@@ -728,6 +663,58 @@ function AISectionCard({
const iconClass = section.icon ? iconMap[section.icon] : 'tabler-leaf' const iconClass = section.icon ? iconMap[section.icon] : 'tabler-leaf'
if (section.type === 'recommendation') { 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 ( return (
<Card <Card
elevation={0} elevation={0}
@@ -746,37 +733,62 @@ function AISectionCard({
{section.title} {section.title}
</Typography> </Typography>
</Box> </Box>
{section.content && (
<Typography variant='body2' color='text.primary' className='mb-3' lineHeight={1.8}>
{section.content}
</Typography>
)}
<Box className='space-y-2'> <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 && ( {section.frequency && (
<Box className='flex justify-between'> <Box className='flex justify-between gap-3'>
<Typography variant='caption' color='text.secondary'> <Typography variant='caption' color='text.secondary'>
{t('recommendation.frequency')} {t('recommendation.frequency')}
</Typography> </Typography>
<Typography variant='body2' fontWeight={600}> <Typography variant='body2' fontWeight={600} className='text-end'>
{section.frequency} {section.frequency}
</Typography> </Typography>
</Box> </Box>
)} )}
{section.amount && ( {section.amount && (
<Box className='flex justify-between'> <Box className='flex justify-between gap-3'>
<Typography variant='caption' color='text.secondary'> <Typography variant='caption' color='text.secondary'>
{t('recommendation.amount')} {t('recommendation.amount')}
</Typography> </Typography>
<Typography variant='body2' fontWeight={600}> <Typography variant='body2' fontWeight={600} className='text-end'>
{section.amount} {section.amount}
</Typography> </Typography>
</Box> </Box>
)} )}
{section.timing && ( {section.timing && (
<Box className='flex justify-between'> <Box className='flex justify-between gap-3'>
<Typography variant='caption' color='text.secondary'> <Typography variant='caption' color='text.secondary'>
{t('recommendation.timing')} {t('recommendation.timing')}
</Typography> </Typography>
<Typography variant='body2' fontWeight={600}> <Typography variant='body2' fontWeight={600} className='text-end'>
{section.timing} {section.timing}
</Typography> </Typography>
</Box> </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> </Box>
{section.expandableExplanation && ( {section.expandableExplanation && (
<Box className='mt-3'> <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 return null
} }
@@ -13,15 +13,18 @@ export interface SuggestionChip {
// Structured AI response sections for card-based rendering // Structured AI response sections for card-based rendering
export interface AIResponseSection { export interface AIResponseSection {
type: 'text' | 'list' | 'recommendation' | 'warning' type: 'text' | 'list' | 'recommendation' | 'warning' | 'pureText'
title?: string title?: string
content?: string content?: string
items?: string[] items?: string[]
icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar' icon?: 'droplet' | 'leaf' | 'warning' | 'fertilizer' | 'calendar'
uiMode?: 'textOnly' | 'actionCard'
// Recommendation-specific // Recommendation-specific
frequency?: string frequency?: string
amount?: string amount?: string
timing?: string timing?: string
primaryAction?: string
validityPeriod?: string
expandableExplanation?: string expandableExplanation?: string
} }