UPDATE
This commit is contained in:
@@ -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 />
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user