From ae9b997eb15a6f0f9013c4576dfd9c588b068c4a Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Thu, 29 Jan 2026 21:58:21 -1000 Subject: [PATCH 1/2] AI plant chat --- apps/api/src/endpoints/trpc/plants/chat.ts | 121 +++++++++ apps/api/src/routers/trpc/plants.ts | 2 + apps/mobile/src/app/plants/[id].tsx | 21 +- apps/mobile/src/components/Chat.tsx | 291 +++++++++++++++++++++ apps/mobile/src/components/ChatMessage.tsx | 64 +++++ apps/mobile/src/components/Fab.tsx | 36 ++- 6 files changed, 527 insertions(+), 8 deletions(-) create mode 100644 apps/api/src/endpoints/trpc/plants/chat.ts create mode 100644 apps/mobile/src/components/Chat.tsx create mode 100644 apps/mobile/src/components/ChatMessage.tsx diff --git a/apps/api/src/endpoints/trpc/plants/chat.ts b/apps/api/src/endpoints/trpc/plants/chat.ts new file mode 100644 index 0000000..7156566 --- /dev/null +++ b/apps/api/src/endpoints/trpc/plants/chat.ts @@ -0,0 +1,121 @@ +import { TRPCError } from '@trpc/server' +import { z } from 'zod' + +import { + Fertilizer, + Plant, + type IChore, + type IFertilizer, + type ISpecies, +} from '../../../models' + +import { authProcedure } from '../../../procedures/authProcedure' + +import { createClient } from '../../../services/openAi' + +const messageSchema = z.object({ + role: z.enum(['user', 'assistant']), + content: z.string(), +}) + +export const chat = authProcedure + .input(z.object({ + plantId: z.string(), + messages: z.array(messageSchema).min(1), + })) + .mutation(async ({ ctx, input }) => { + const plant = await Plant + .findOne({ _id: input.plantId, user: ctx.userId }) + .populate<{ species: ISpecies }>('species') + .populate<{ chores: (Omit & { fertilizers: Array<{ fertilizer: IFertilizer, amount: string }> })[] }>({ + path: 'chores', + populate: { path: 'fertilizers.fertilizer' }, + match: { deletedAt: null }, + }) + .lean() + + if (!plant) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'Plant not found' }) + } + + const fertilizers = await Fertilizer + .find({ user: ctx.userId, deletedAt: null }) + .sort({ name: 1 }) + .lean() + + const systemParts: string[] = [ + 'You are an expert on growing plants. The user is chatting about a specific plant in their care.', + '', + '**This plant**', + `- Name: ${(plant as { name: string }).name}`, + `- Lifecycle stage: ${(plant as { lifecycle: string | null }).lifecycle ?? 'not set'}`, + (plant as { plantedAt: Date | null }).plantedAt + ? `- Planted on: ${new Date((plant as { plantedAt: Date }).plantedAt).toLocaleDateString('en-US')}` + : '', + (plant as { notes: string | null }).notes?.trim() + ? `- Notes: ${(plant as { notes: string }).notes}` + : '', + ].filter(Boolean) + + const species = (plant as { species: ISpecies | null }).species + if (species) { + systemParts.push('', '**Species**', `- Common name: ${species.commonName}`) + if (species.scientificName) systemParts.push(`- Scientific name: ${species.scientificName}`) + if (species.familyCommonName) systemParts.push(`- Family: ${species.familyCommonName}`) + if (species.genus) systemParts.push(`- Genus: ${species.genus}`) + if (species.fertilizerRecommendations?.text) { + systemParts.push('', 'Fertilizer guidance for this species:', species.fertilizerRecommendations.text) + } + } + + const chores = (plant as { chores: Array<{ + description: string | null, + recurAmount: number | null, + recurUnit: string | null, + fertilizers: Array<{ fertilizer: IFertilizer, amount: string }>, + }> }).chores ?? [] + if (chores.length > 0) { + systemParts.push('', '**Chores assigned to this plant**') + chores.forEach((chore, i) => { + const desc = chore.fertilizers?.length + ? chore.fertilizers.map(f => `${(f.fertilizer as IFertilizer).name}${f.amount ? ` (${f.amount})` : ''}`).join(', ') + : chore.description || 'Task' + const recur = chore.recurAmount && chore.recurUnit + ? ` every ${chore.recurAmount} ${chore.recurUnit}${chore.recurAmount === 1 ? '' : 's'}` + : '' + systemParts.push(`${i + 1}. ${desc}${recur}`) + }) + } + + if (fertilizers.length > 0) { + systemParts.push('', '**Fertilizers in the user\'s inventory**') + fertilizers.forEach((f: IFertilizer) => { + const npk = [f.nitrogen, f.phosphorus, f.potassium].map(n => n ?? '—').join('-') + systemParts.push(`- ${f.name}: type ${f.type}, organic: ${f.isOrganic}, N-P-K: ${npk}`) + }) + } + + systemParts.push( + '', + 'Answer in a helpful, concise way. Reference their plant, chores, and fertilizers when relevant. Keep responses focused and practical.', + ) + + const openai = createClient() + const completion = await openai.chat.completions.create({ + model: 'gpt-4o-mini', + messages: [ + { role: 'system', content: systemParts.join('\n') }, + ...input.messages.map(m => ({ role: m.role as 'user' | 'assistant', content: m.content })), + ], + }) + + const choice = completion.choices[0] + const content = choice?.message?.content ?? '' + + return { + message: { + role: 'assistant' as const, + content, + }, + } + }) diff --git a/apps/api/src/routers/trpc/plants.ts b/apps/api/src/routers/trpc/plants.ts index 49a6f18..ea00e6c 100644 --- a/apps/api/src/routers/trpc/plants.ts +++ b/apps/api/src/routers/trpc/plants.ts @@ -1,6 +1,7 @@ import { router } from '../../trpc' import { archivePlant } from '../../endpoints/trpc/plants/archivePlant' +import { chat } from '../../endpoints/trpc/plants/chat' import { createPlant } from '../../endpoints/trpc/plants/createPlant' import { deletePlant } from '../../endpoints/trpc/plants/deletePlant' import { listPlantLifecycleEvents } from '../../endpoints/trpc/plants/listPlantLifecycleEvents' @@ -10,6 +11,7 @@ import { updatePlant } from '../../endpoints/trpc/plants/updatePlant' export const plantsRouter = router({ archive: archivePlant, + chat, create: createPlant, delete: deletePlant, list: listPlants, diff --git a/apps/mobile/src/app/plants/[id].tsx b/apps/mobile/src/app/plants/[id].tsx index c337c92..5b7cf6b 100644 --- a/apps/mobile/src/app/plants/[id].tsx +++ b/apps/mobile/src/app/plants/[id].tsx @@ -7,7 +7,9 @@ import type { ISpecies } from '@plannting/api/dist/models/Species' import { AddEditChoreModal } from '../../components/AddEditChoreModal' import { AddEditPlantModal } from '../../components/AddEditPlantModal' +import { Chat } from '../../components/Chat' import { DateTimePicker } from '../../components/DateTimePicker' +import { Fab } from '../../components/Fab' import { FertilizerRecommendations } from '../../components/FertilizerRecommendations' import { LoadingSkeleton, LoadingSkeletonLine } from '../../components/LoadingSkeleton' import { PlantHistogram } from '../../components/PlantHistogram' @@ -55,6 +57,7 @@ export default function PlantDetailScreen() { const [showLifecycleChangeDatePicker, setShowLifecycleChangeDatePicker] = React.useState(false) const [pendingLifecycleChange, setPendingLifecycleChange] = React.useState<{ plantId: string, data: EditFormData } | null>(null) + const [showChat, setShowChat] = React.useState(false) const [showChoreForm, setShowChoreForm] = React.useState(false) const [choreFormData, setChoreFormData] = React.useState<{ description: string, @@ -227,7 +230,8 @@ export default function PlantDetailScreen() { } return ( - + <> + {plant?.name || } @@ -324,6 +328,17 @@ export default function PlantDetailScreen() { + {plant && ( + setShowChat(false)} + title={plant.name} + description="Ask anything about growing this plant. I know its species, your chores, and your fertilizer inventory." + /> + )} + )} - + + {plant && setShowChat(true)} />} + ) } diff --git a/apps/mobile/src/components/Chat.tsx b/apps/mobile/src/components/Chat.tsx new file mode 100644 index 0000000..7f95025 --- /dev/null +++ b/apps/mobile/src/components/Chat.tsx @@ -0,0 +1,291 @@ +import React, { useRef, useEffect } from 'react' +import { + ActivityIndicator, + Keyboard, + KeyboardAvoidingView, + Modal, + Platform, + ScrollView, + StyleSheet, + Text, + TextInput, + TouchableOpacity, + View, +} from 'react-native' +import { useSafeAreaInsets } from 'react-native-safe-area-context' +import type { MutationLike } from '@trpc/react-query/shared' + +import { useToast } from '../contexts/ToastContext' + +import { ChatMessage } from './ChatMessage' + +import { palette } from '../styles' + +export type ChatMessageItem = { + role: 'user' | 'assistant', + content: string, +} + +/** Endpoint must be a mutation procedure whose output includes { message: { role: 'assistant', content: string } }. endpointProps is merged with { messages } when calling mutate. */ +export interface ChatProps { + endpoint: MutationLike, + endpointProps: Record, + isVisible: boolean, + onClose: () => void, + title?: string, + description?: string, +} + +const HEADER_HEIGHT = 76 // paddingTop 60 + paddingBottom 16 + +const DEFAULT_DESCRIPTION = 'Ask a question to get started.' + +export function Chat({ + endpoint, + endpointProps, + isVisible, + onClose, + title, + description = DEFAULT_DESCRIPTION, +}: ChatProps) { + const [messages, setMessages] = React.useState([]) + const [inputText, setInputText] = React.useState('') + const scrollRef = useRef(null) + const { showToast } = useToast() + const insets = useSafeAreaInsets() + + const chatMutation = endpoint.useMutation({ + onSuccess: (data) => { + setMessages((prev) => [...prev, data.message]) + }, + onError: (err) => { + showToast(err.message || 'Something went wrong. Try again.') + }, + }) + + const prevVisible = useRef(false) + useEffect(() => { + if (isVisible && !prevVisible.current) { + setMessages([]) + setInputText('') + } + prevVisible.current = isVisible + }, [isVisible]) + + useEffect(() => { + if (messages.length > 0 && scrollRef.current) { + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }) + }, 100) + } + }, [messages]) + + const handleSend = () => { + const trimmed = inputText.trim() + if (!trimmed || chatMutation.isPending) return + + Keyboard.dismiss() + const newUserMessage: ChatMessageItem = { role: 'user', content: trimmed } + const nextMessages = [...messages, newUserMessage] + setMessages(nextMessages) + setInputText('') + chatMutation.mutate({ + ...endpointProps, + messages: nextMessages, + }) + } + + const canSend = inputText.trim().length > 0 && !chatMutation.isPending + + return ( + + + + + {title != null ? `Chat – ${title}` : 'Chat'} + + + + + + + scrollRef.current?.scrollToEnd({ animated: true })} + > + {messages.length === 0 && !chatMutation.isPending && ( + + + + {description} + + + )} + {messages.map((msg, i) => ( + + ))} + {chatMutation.isError && chatMutation.error && ( + + {chatMutation.error.message} + + )} + {chatMutation.isPending && ( + + + Thinking… + + )} + + + + + + Send + + + + + ) +} + +const localStyles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: palette.background, + }, + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: palette.border, + backgroundColor: palette.surface, + }, + title: { + fontSize: 18, + fontWeight: '700', + color: palette.textPrimary, + flex: 1, + marginRight: 12, + }, + closeButton: { + fontSize: 24, + color: palette.textPrimary, + }, + scroll: { + flex: 1, + }, + scrollContent: { + padding: 16, + paddingBottom: 24, + }, + empty: { + paddingVertical: 32, + paddingHorizontal: 20, + alignItems: 'center', + }, + emptyIcon: { + fontSize: 40, + marginBottom: 12, + }, + emptyText: { + fontSize: 15, + color: palette.textSecondary, + textAlign: 'center', + lineHeight: 22, + }, + errorWrap: { + padding: 12, + marginBottom: 12, + backgroundColor: '#fdecea', + borderRadius: 12, + borderWidth: 1, + borderColor: palette.danger, + alignSelf: 'stretch', + }, + errorText: { + fontSize: 14, + color: palette.danger, + }, + loadingWrap: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + marginBottom: 12, + alignSelf: 'flex-start', + }, + loadingText: { + fontSize: 15, + color: palette.textSecondary, + }, + inputRow: { + flexDirection: 'row', + alignItems: 'flex-end', + padding: 12, + paddingBottom: Platform.OS === 'ios' ? 28 : 12, + gap: 10, + borderTopWidth: 1, + borderTopColor: palette.border, + backgroundColor: palette.surface, + }, + input: { + flex: 1, + minHeight: 44, + maxHeight: 120, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 22, + fontSize: 15, + backgroundColor: palette.surfaceMuted, + color: palette.textPrimary, + borderWidth: 1, + borderColor: palette.border, + }, + sendButton: { + paddingHorizontal: 20, + paddingVertical: 12, + borderRadius: 22, + justifyContent: 'center', + }, + sendButtonActive: { + backgroundColor: palette.primary, + }, + sendButtonDisabled: { + backgroundColor: palette.muted, + opacity: 0.7, + }, + sendButtonText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, +}) diff --git a/apps/mobile/src/components/ChatMessage.tsx b/apps/mobile/src/components/ChatMessage.tsx new file mode 100644 index 0000000..38c7429 --- /dev/null +++ b/apps/mobile/src/components/ChatMessage.tsx @@ -0,0 +1,64 @@ +import React from 'react' +import { StyleSheet, Text, View } from 'react-native' + +import { palette } from '../styles' + +export type ChatMessageRole = 'user' | 'assistant' + +export interface ChatMessageProps { + role: ChatMessageRole, + content: string, +} + +export function ChatMessage({ role, content }: ChatMessageProps) { + const isUser = role === 'user' + + return ( + + + + {content} + + + + ) +} + +const localStyles = StyleSheet.create({ + bubbleWrap: { + marginBottom: 12, + maxWidth: '85%', + }, + userWrap: { + alignSelf: 'flex-end', + }, + assistantWrap: { + alignSelf: 'flex-start', + }, + bubble: { + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 18, + maxWidth: '100%', + }, + userBubble: { + backgroundColor: palette.primary, + borderBottomRightRadius: 4, + }, + assistantBubble: { + backgroundColor: palette.surfaceMuted, + borderBottomLeftRadius: 4, + borderWidth: 1, + borderColor: palette.border, + }, + text: { + fontSize: 15, + lineHeight: 22, + }, + userText: { + color: '#fff', + }, + assistantText: { + color: palette.textPrimary, + }, +}) diff --git a/apps/mobile/src/components/Fab.tsx b/apps/mobile/src/components/Fab.tsx index bdf0d8e..3794a5d 100644 --- a/apps/mobile/src/components/Fab.tsx +++ b/apps/mobile/src/components/Fab.tsx @@ -1,5 +1,5 @@ import React from 'react' -import { StyleSheet, TouchableOpacity } from 'react-native' +import { StyleSheet, Text, TouchableOpacity, View } from 'react-native' import { Ionicons } from '@expo/vector-icons' import { palette } from '../styles' @@ -8,19 +8,27 @@ export function Fab({ type, onPress, }: { - type: 'add' | 'cancel', + type: 'add' | 'cancel' | 'chat', onPress: () => void, }) { - const iconName = type === 'add' ? 'add' : 'close' - const backgroundColor = type === 'add' ? palette.primary : palette.muted + const iconName = (type === 'add' && 'add') || (type === 'cancel' && 'close') || (type === 'chat' && 'sparkles') + const backgroundColor = type === 'cancel' ? palette.muted : palette.primary + const isChat = type === 'chat' return ( - + {(isChat && ( + + {iconName && } + Chat + + )) + || (iconName && ) + || null} ) } @@ -42,4 +50,20 @@ const styles = StyleSheet.create({ shadowRadius: 4, zIndex: 10, }, + fabOval: { + width: undefined, + minWidth: 56, + paddingHorizontal: 16, + borderRadius: 28, + }, + chatContent: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + chatLabel: { + color: '#fff', + fontSize: 16, + fontWeight: '600', + }, }) From d54549e3373bc1cf020a24e55e3012b124432c8e Mon Sep 17 00:00:00 2001 From: Matt Rabe Date: Fri, 30 Jan 2026 07:20:20 -1000 Subject: [PATCH 2/2] ai chat clean up --- apps/api/src/endpoints/trpc/plants/chat.ts | 30 ++++++++++------------ apps/mobile/src/app/plants/[id].tsx | 4 +-- 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/apps/api/src/endpoints/trpc/plants/chat.ts b/apps/api/src/endpoints/trpc/plants/chat.ts index 7156566..aa35eb6 100644 --- a/apps/api/src/endpoints/trpc/plants/chat.ts +++ b/apps/api/src/endpoints/trpc/plants/chat.ts @@ -35,7 +35,10 @@ export const chat = authProcedure .lean() if (!plant) { - throw new TRPCError({ code: 'NOT_FOUND', message: 'Plant not found' }) + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Plant not found', + }) } const fertilizers = await Fertilizer @@ -47,17 +50,17 @@ export const chat = authProcedure 'You are an expert on growing plants. The user is chatting about a specific plant in their care.', '', '**This plant**', - `- Name: ${(plant as { name: string }).name}`, - `- Lifecycle stage: ${(plant as { lifecycle: string | null }).lifecycle ?? 'not set'}`, - (plant as { plantedAt: Date | null }).plantedAt - ? `- Planted on: ${new Date((plant as { plantedAt: Date }).plantedAt).toLocaleDateString('en-US')}` + `- Name: ${plant.name}`, + `- Lifecycle stage: ${plant.lifecycle ?? 'not set'}`, + plant.plantedAt + ? `- Planted on: ${new Date(plant.plantedAt).toLocaleDateString('en-US')}` : '', - (plant as { notes: string | null }).notes?.trim() - ? `- Notes: ${(plant as { notes: string }).notes}` + plant.notes?.trim() + ? `- Notes: ${plant.notes}` : '', ].filter(Boolean) - const species = (plant as { species: ISpecies | null }).species + const species = plant.species if (species) { systemParts.push('', '**Species**', `- Common name: ${species.commonName}`) if (species.scientificName) systemParts.push(`- Scientific name: ${species.scientificName}`) @@ -68,17 +71,12 @@ export const chat = authProcedure } } - const chores = (plant as { chores: Array<{ - description: string | null, - recurAmount: number | null, - recurUnit: string | null, - fertilizers: Array<{ fertilizer: IFertilizer, amount: string }>, - }> }).chores ?? [] + const chores = plant.chores ?? [] if (chores.length > 0) { systemParts.push('', '**Chores assigned to this plant**') chores.forEach((chore, i) => { const desc = chore.fertilizers?.length - ? chore.fertilizers.map(f => `${(f.fertilizer as IFertilizer).name}${f.amount ? ` (${f.amount})` : ''}`).join(', ') + ? chore.fertilizers.map(f => `${f.fertilizer.name}${f.amount ? ` (${f.amount})` : ''}`).join(', ') : chore.description || 'Task' const recur = chore.recurAmount && chore.recurUnit ? ` every ${chore.recurAmount} ${chore.recurUnit}${chore.recurAmount === 1 ? '' : 's'}` @@ -114,7 +112,7 @@ export const chat = authProcedure return { message: { - role: 'assistant' as const, + role: 'assistant', content, }, } diff --git a/apps/mobile/src/app/plants/[id].tsx b/apps/mobile/src/app/plants/[id].tsx index 5b7cf6b..29de58a 100644 --- a/apps/mobile/src/app/plants/[id].tsx +++ b/apps/mobile/src/app/plants/[id].tsx @@ -335,7 +335,7 @@ export default function PlantDetailScreen() { isVisible={showChat} onClose={() => setShowChat(false)} title={plant.name} - description="Ask anything about growing this plant. I know its species, your chores, and your fertilizer inventory." + description='Ask anything about growing this plant. I know its species, your chores, and your fertilizer inventory.' /> )} @@ -463,7 +463,7 @@ export default function PlantDetailScreen() { )} - {plant && setShowChat(true)} />} + {plant && setShowChat(true)} />} ) }