Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
343 changes: 343 additions & 0 deletions package-lock.json

Large diffs are not rendered by default.

16 changes: 12 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,24 @@
"web-build": "expo export --platform web"
},
"dependencies": {
"@ai-sdk/anthropic": "^1.2.12",
"@ai-sdk/deepseek": "^0.2.14",
"@ai-sdk/google": "^1.2.19",
"@ai-sdk/groq": "^1.2.9",
"@ai-sdk/openai": "^1.3.22",
"@react-native-async-storage/async-storage": "2.1.2",
"@react-navigation/drawer": "^7.3.9",
"ai": "^4.3.16",
"expo": "~53.0.10",
"expo-audio": "~0.4.6",
"expo-constants": "~17.1.6",
"expo-file-system": "~18.1.10",
"expo-image-picker": "~16.1.4",
"expo-linking": "~7.1.5",
"expo-router": "~5.0.7",
"expo-status-bar": "~2.2.3",
"nativewind": "^4.1.23",
"ollama-ai-provider": "^1.2.0",
"openai": "^5.1.0",
"react": "19.0.0",
"react-dom": "19.0.0",
Expand All @@ -29,10 +39,8 @@
"react-native-screens": "~4.11.1",
"react-native-web": "^0.20.0",
"tailwindcss": "^3.4.17",
"zustand": "^5.0.5",
"expo-image-picker": "~16.1.4",
"expo-audio": "~0.4.6",
"expo-file-system": "~18.1.10"
"zod": "^3.25.56",
"zustand": "^5.0.5"
},
"devDependencies": {
"@babel/core": "^7.25.2",
Expand Down
25 changes: 25 additions & 0 deletions src/app/api/chat/index+api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,34 @@ export async function POST(request: Request) {
...(previousResponseId && { previous_response_id: previousResponseId }),
});

let relatedQuestions: string[] | undefined = undefined;
try {
const questionsCompletion = await openai.chat.completions.create({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content:
`Provide 3 advanced follow-up questions related to: ${message}. ` +
'Respond in JSON format as { "questions": ["q1","q2","q3"] }.',
},
],
response_format: { type: 'json_object' },
});
const parsed = JSON.parse(
questionsCompletion.choices[0].message.content || '{}'
);
if (Array.isArray(parsed.questions)) {
relatedQuestions = parsed.questions;
}
} catch (err) {
console.error('Failed to generate related questions:', err);
}

return Response.json({
responseMessage: response.output_text,
responseId: response.id,
...(relatedQuestions && { relatedQuestions }),
});
} catch (error) {
console.error('OpenAI error:', error);
Expand Down
28 changes: 28 additions & 0 deletions src/app/api/chat/speech+api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,38 @@ export async function POST(request: Request) {
...(previousResponseId && { previous_response_id: previousResponseId }),
});

let relatedQuestions: string[] | undefined = undefined;
try {
const questionsCompletion = await openai.chat.completions.create({
model: 'gpt-4.1',
messages: [
{
role: 'user',
content:
`Based on the following input, generate 3 short and simple follow-up questions:\n\n` +
`"${transcription.text}"\n\n` +
`Each question should be clear, under 15 words, and focused.\n` +
`Respond only in JSON format as: { "questions": ["q1", "q2", "q3"] }`,
},
],
response_format: { type: 'json_object' },
});

const parsed = JSON.parse(
questionsCompletion.choices[0].message.content || '{}'
);
if (Array.isArray(parsed.questions)) {
relatedQuestions = parsed.questions;
}
} catch (err) {
console.error('Failed to generate related questions:', err);
}

return Response.json({
responseId: response.id,
responseMessage: response.output_text,
transcribedMessage: transcription.text,
...(relatedQuestions && { relatedQuestions }),
});
} catch (error) {
console.log(error);
Expand Down
86 changes: 62 additions & 24 deletions src/app/chat/[id].tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { useLocalSearchParams } from 'expo-router';
import ChatInput from '@/components/ChatInput';
import MessageListItem from '@/components/MessageListItem';
import { useRef, useEffect } from 'react';
import { generateRelatedQuestions } from '@/lib/relatedQuestions';


import { useChatStore } from '@/store/chatStore';
import {
getTextResponse,
createAIImage,
getSpeechResponse,
type ChatResponse,
} from '@/services/chatService';
import type { Message } from '@/types/types';

export default function ChatScreen() {
const { id } = useLocalSearchParams();
Expand All @@ -29,6 +33,10 @@ export default function ChatScreen() {

const addNewMessage = useChatStore((state) => state.addNewMessage);

const handleQuestionPress = async (question: string) => {
await handleSend(question, null, false, null);
};

useEffect(() => {
const timeout = setTimeout(() => {
flatListRef.current?.scrollToEnd({ animated: true });
Expand All @@ -46,42 +54,67 @@ export default function ChatScreen() {
if (!chat) return;

setIsWaitingForResponse(true);
if (!audioBase64) {
addNewMessage(chat.id, {
id: Date.now().toString(),
role: 'user',
message,
...(imageBase64 && { image: imageBase64 }),
});
}

const previousResponseId = chat.messages.findLast(
(message) => message.responseId
)?.responseId;

try {
let data;
let data: ChatResponse | { image: string };

if (audioBase64) {
data = await getSpeechResponse(audioBase64, previousResponseId);
const myMessage = {
data = await getSpeechResponse(audioBase64, chat.messages.findLast((m) => m.responseId)?.responseId);
const myMessage: Message = {
id: Date.now().toString(),
role: 'user' as const,
role: 'user',
message: data.transcribedMessage,
};
addNewMessage(chat.id, myMessage);
} else if (isImageGeneration) {
data = await createAIImage(message);
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
message,
...(imageBase64 ? { image: imageBase64 } : {}),
};
addNewMessage(chat.id, userMessage);
} else {
// Normal text flow
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
message,
...(imageBase64 ? { image: imageBase64 } : {}),
};
addNewMessage(chat.id, userMessage);

const previousResponseId = chat.messages.findLast((m) => m.responseId)?.responseId;
data = await getTextResponse(message, imageBase64, previousResponseId);
}

const aiResponseMessage = {
id: Date.now().toString(),
message: data.responseMessage,
responseId: data.responseId,
image: data.image,
role: 'assistant' as const,
};
// Safely build assistant message
const aiResponseMessage: Message = {
id: Date.now().toString(),
role: 'assistant',
message: 'responseMessage' in data ? data.responseMessage : '',
responseId: 'responseId' in data ? data.responseId : undefined,
image: 'image' in data ? data.image : undefined,
};

// 👉 Generate related questions using model
try {
const questionsResult = await generateRelatedQuestions(
[
{ role: 'user', content: message },
{ role: 'assistant', content: aiResponseMessage.message || '' }
],
'openai:gpt-4o'
);

if ('data' in questionsResult && questionsResult.data.length) {
aiResponseMessage.relatedQuestions = questionsResult.data;
}
} catch (err) {
console.warn('❌ Related questions generation failed:', err);
}


addNewMessage(chat.id, aiResponseMessage);
} catch (error) {
Expand All @@ -104,7 +137,12 @@ export default function ChatScreen() {
<FlatList
ref={flatListRef}
data={chat.messages}
renderItem={({ item }) => <MessageListItem messageItem={item} />}
renderItem={({ item }) => (
<MessageListItem
messageItem={item}
onQuestionPress={handleQuestionPress}
/>
)}
ListFooterComponent={() =>
isWaitingForResponse && (
<Text className='text-gray-400 px-6 mb-4 animate-pulse'>
Expand Down
38 changes: 23 additions & 15 deletions src/app/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
createAIImage,
getSpeechResponse,
getTextResponse,
type ChatResponse,
} from '@/services/chatService';
import type { Message } from '@/types/types';

export default function HomeScreen() {
const createNewChat = useChatStore((state) => state.createNewChat);
Expand All @@ -23,40 +25,47 @@ export default function HomeScreen() {
isImageGeneration: boolean,
audioBase64: string | null
) => {
if (!message.trim() && !audioBase64 && !isImageGeneration) return;

setIsWaitingForResponse(true);
const newChatId = createNewChat(message.slice(0, 50) || 'New Chat');

if (!audioBase64) {
addNewMessage(newChatId, {
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
message,
...(imageBase64 && { image: imageBase64 }),
});
...(imageBase64 ? { image: imageBase64 } : {}),
};
addNewMessage(newChatId, userMessage);
}

router.push(`/chat/${newChatId}`);

try {
let data;
let data: ChatResponse | { image: string };

if (audioBase64) {
data = await getSpeechResponse(audioBase64);
const myMessage = {
const userVoiceMessage: Message = {
id: Date.now().toString(),
role: 'user' as const,
role: 'user',
message: data.transcribedMessage,
};
addNewMessage(newChatId, myMessage);
addNewMessage(newChatId, userVoiceMessage);
} else if (isImageGeneration) {
data = await createAIImage(message);
} else {
data = await getTextResponse(message, imageBase64);
}

const aiResponseMessage = {
const aiResponseMessage: Message = {
id: Date.now().toString(),
message: data.responseMessage,
responseId: data.responseId,
image: data.image,
role: 'assistant' as const,
role: 'assistant',
message: 'responseMessage' in data ? data.responseMessage : '',
responseId: 'responseId' in data ? data.responseId : undefined,
image: 'image' in data ? data.image : undefined,
relatedQuestions: 'relatedQuestions' in data ? data.relatedQuestions : undefined,
};

addNewMessage(newChatId, aiResponseMessage);
Expand All @@ -71,10 +80,9 @@ export default function HomeScreen() {

return (
<View className='flex-1 justify-center'>
<View className='flex-1'>
<Text className='text-3xl font-bold'>Home 123</Text>
<View className='flex-1 items-center justify-center'>
<Text className='text-3xl font-bold'>Home 123</Text>
</View>

<ChatInput onSend={handleSend} />
</View>
);
Expand Down
36 changes: 32 additions & 4 deletions src/components/MessageListItem.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import { Text, View, Image } from 'react-native';
import { Text, View, Image, Pressable } from 'react-native';
import Markdown from 'react-native-markdown-display';
import { Message } from '@/types/types';
import { markdownStyles } from '@/utils/markdown';

interface MessageListItemProps {
messageItem: Message;
onQuestionPress?: (question: string) => void;
}

export default function MessageListItem({ messageItem }: MessageListItemProps) {
const { message, role, image } = messageItem;
export default function MessageListItem({
messageItem,
onQuestionPress,
}: MessageListItemProps) {
const { message, role, image, relatedQuestions } = messageItem;
const isUser = role === 'user';

return (
Expand All @@ -26,12 +30,36 @@ export default function MessageListItem({ messageItem }: MessageListItemProps) {
{!!message && (
<View
className={`rounded-2xl p-4 py-1 ${
isUser && 'bg-[#262626] max-w-[70%]'
isUser ? 'bg-[#262626] max-w-[70%]' : 'bg-neutral-900 max-w-[85%]'
}`}
>
<Markdown style={markdownStyles}>{message}</Markdown>
</View>
)}

{!isUser && relatedQuestions?.length ? (
<View className="mt-3 w-full max-w-[90%]">
<Text className="text-sm text-gray-400 font-semibold mb-2">💡 Related questions</Text>
<View className="flex flex-col gap-2">
{relatedQuestions.map((q, idx) => {
const display = q.length > 90 ? `${q.slice(0, 87)}...` : q;
return (
<Pressable
key={idx}
onPress={() => onQuestionPress?.(q)}
className="bg-[#1f1f1f] border border-neutral-700 rounded-xl p-3 shadow-sm"
android_ripple={{ color: '#333' }}
>
<Text className="text-gray-100 text-[15px] leading-snug">
{display}
</Text>
</Pressable>
);
})}
</View>
</View>
) : null}

</View>
);
}
Loading