From 0682c19e15241511a5079240282d89005821dbf0 Mon Sep 17 00:00:00 2001 From: Pankaj Sharma <8818473+iamphoenix310@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:12:32 +0530 Subject: [PATCH 1/3] feat: surface follow-up questions --- src/app/api/chat/index+api.ts | 25 +++++++++++++++++++++++++ src/app/api/chat/speech+api.ts | 25 +++++++++++++++++++++++++ src/app/chat/[id].tsx | 12 +++++++++++- src/app/index.tsx | 1 + src/components/MessageListItem.tsx | 23 ++++++++++++++++++++--- src/services/chatService.ts | 18 +++++++++++++----- src/types/types.ts | 1 + 7 files changed, 96 insertions(+), 9 deletions(-) diff --git a/src/app/api/chat/index+api.ts b/src/app/api/chat/index+api.ts index 54d4145..4693d2e 100644 --- a/src/app/api/chat/index+api.ts +++ b/src/app/api/chat/index+api.ts @@ -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); diff --git a/src/app/api/chat/speech+api.ts b/src/app/api/chat/speech+api.ts index 3ce2091..7b808a9 100644 --- a/src/app/api/chat/speech+api.ts +++ b/src/app/api/chat/speech+api.ts @@ -21,10 +21,35 @@ 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: ${transcription.text}. ` + + '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({ responseId: response.id, responseMessage: response.output_text, transcribedMessage: transcription.text, + ...(relatedQuestions && { relatedQuestions }), }); } catch (error) { console.log(error); diff --git a/src/app/chat/[id].tsx b/src/app/chat/[id].tsx index 7bb92f7..1004146 100644 --- a/src/app/chat/[id].tsx +++ b/src/app/chat/[id].tsx @@ -29,6 +29,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 }); @@ -80,6 +84,7 @@ export default function ChatScreen() { message: data.responseMessage, responseId: data.responseId, image: data.image, + relatedQuestions: data.relatedQuestions, role: 'assistant' as const, }; @@ -104,7 +109,12 @@ export default function ChatScreen() { } + renderItem={({ item }) => ( + + )} ListFooterComponent={() => isWaitingForResponse && ( diff --git a/src/app/index.tsx b/src/app/index.tsx index 9efbb90..417fe9d 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -56,6 +56,7 @@ export default function HomeScreen() { message: data.responseMessage, responseId: data.responseId, image: data.image, + relatedQuestions: data.relatedQuestions, role: 'assistant' as const, }; diff --git a/src/components/MessageListItem.tsx b/src/components/MessageListItem.tsx index bdb7ec0..a876d23 100644 --- a/src/components/MessageListItem.tsx +++ b/src/components/MessageListItem.tsx @@ -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 ( @@ -32,6 +36,19 @@ export default function MessageListItem({ messageItem }: MessageListItemProps) { {message} )} + {!isUser && relatedQuestions?.length ? ( + + {relatedQuestions.map((q, idx) => ( + onQuestionPress && onQuestionPress(q)} + className='bg-[#262626] rounded-lg p-2' + > + {q} + + ))} + + ) : null} ); } diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 0e4bfd4..586de99 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -1,4 +1,12 @@ -export async function createAIImage(prompt: string) { +export interface ChatResponse { + responseMessage: string; + responseId: string; + image?: string; + transcribedMessage?: string; + relatedQuestions?: string[]; +} + +export async function createAIImage(prompt: string): Promise<{ image: string }> { const res = await fetch('/api/chat/createImage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -14,7 +22,7 @@ export const getTextResponse = async ( message: string, imageBase64: string | null, previousResponseId?: string -) => { +): Promise => { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -23,13 +31,13 @@ export const getTextResponse = async ( const data = await res.json(); if (!res.ok) throw new Error(data.error); - return data; + return data as ChatResponse; }; export const getSpeechResponse = async ( audioBase64: string, previousResponseId?: string -) => { +): Promise => { const res = await fetch('/api/chat/speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -38,5 +46,5 @@ export const getSpeechResponse = async ( const data = await res.json(); if (!res.ok) throw new Error(data.error); - return data; + return data as ChatResponse; }; diff --git a/src/types/types.ts b/src/types/types.ts index 522ea6d..d44919c 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,6 +4,7 @@ export interface Message { message?: string; responseId?: string; image?: string; + relatedQuestions?: string[]; } export interface Chat { From f37f1e575550602e5c7feb3238de0521ac132425 Mon Sep 17 00:00:00 2001 From: Pankaj Sharma <8818473+iamphoenix310@users.noreply.github.com> Date: Mon, 9 Jun 2025 10:55:50 +0530 Subject: [PATCH 2/3] Style related questions list --- src/app/api/chat/index+api.ts | 25 ++++++++++++++++++++++++ src/app/api/chat/speech+api.ts | 25 ++++++++++++++++++++++++ src/app/chat/[id].tsx | 30 +++++++++++++++++++++-------- src/app/index.tsx | 19 +++++++++++------- src/components/MessageListItem.tsx | 31 +++++++++++++++++++++++++++--- src/services/chatService.ts | 18 ++++++++++++----- src/types/types.ts | 1 + 7 files changed, 126 insertions(+), 23 deletions(-) diff --git a/src/app/api/chat/index+api.ts b/src/app/api/chat/index+api.ts index 54d4145..4693d2e 100644 --- a/src/app/api/chat/index+api.ts +++ b/src/app/api/chat/index+api.ts @@ -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); diff --git a/src/app/api/chat/speech+api.ts b/src/app/api/chat/speech+api.ts index 3ce2091..7b808a9 100644 --- a/src/app/api/chat/speech+api.ts +++ b/src/app/api/chat/speech+api.ts @@ -21,10 +21,35 @@ 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: ${transcription.text}. ` + + '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({ responseId: response.id, responseMessage: response.output_text, transcribedMessage: transcription.text, + ...(relatedQuestions && { relatedQuestions }), }); } catch (error) { console.log(error); diff --git a/src/app/chat/[id].tsx b/src/app/chat/[id].tsx index 7bb92f7..e56bcdf 100644 --- a/src/app/chat/[id].tsx +++ b/src/app/chat/[id].tsx @@ -9,7 +9,9 @@ import { getTextResponse, createAIImage, getSpeechResponse, + type ChatResponse, } from '@/services/chatService'; +import type { Message } from '@/types/types'; export default function ChatScreen() { const { id } = useLocalSearchParams(); @@ -29,6 +31,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 }); @@ -60,7 +66,7 @@ export default function ChatScreen() { )?.responseId; try { - let data; + let data: ChatResponse | { image: string }; if (audioBase64) { data = await getSpeechResponse(audioBase64, previousResponseId); const myMessage = { @@ -75,13 +81,16 @@ export default function ChatScreen() { data = await getTextResponse(message, imageBase64, previousResponseId); } - const aiResponseMessage = { + const aiResponseMessage: Message = { id: Date.now().toString(), - message: data.responseMessage, - responseId: data.responseId, - image: data.image, - role: 'assistant' as const, - }; + role: 'assistant', + ...("responseMessage" in data && { + message: data.responseMessage, + responseId: data.responseId, + relatedQuestions: data.relatedQuestions, + }), + ...("image" in data && { image: data.image }), + } as Message; addNewMessage(chat.id, aiResponseMessage); } catch (error) { @@ -104,7 +113,12 @@ export default function ChatScreen() { } + renderItem={({ item }) => ( + + )} ListFooterComponent={() => isWaitingForResponse && ( diff --git a/src/app/index.tsx b/src/app/index.tsx index 9efbb90..3540e7f 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -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); @@ -36,7 +38,7 @@ export default function HomeScreen() { router.push(`/chat/${newChatId}`); try { - let data; + let data: ChatResponse | { image: string }; if (audioBase64) { data = await getSpeechResponse(audioBase64); const myMessage = { @@ -51,13 +53,16 @@ export default function HomeScreen() { 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', + ...("responseMessage" in data && { + message: data.responseMessage, + responseId: data.responseId, + relatedQuestions: data.relatedQuestions, + }), + ...("image" in data && { image: data.image }), + } as Message; addNewMessage(newChatId, aiResponseMessage); diff --git a/src/components/MessageListItem.tsx b/src/components/MessageListItem.tsx index bdb7ec0..90ed824 100644 --- a/src/components/MessageListItem.tsx +++ b/src/components/MessageListItem.tsx @@ -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 ( @@ -32,6 +36,27 @@ export default function MessageListItem({ messageItem }: MessageListItemProps) { {message} )} + {!isUser && relatedQuestions?.length ? ( + + + Related questions + + + {relatedQuestions.map((q, idx) => { + const display = q.length > 80 ? `${q.slice(0, 77)}...` : q; + return ( + onQuestionPress?.(q)} + className='bg-[#262626] rounded-lg p-2' + > + {display} + + ); + })} + + + ) : null} ); } diff --git a/src/services/chatService.ts b/src/services/chatService.ts index 0e4bfd4..586de99 100644 --- a/src/services/chatService.ts +++ b/src/services/chatService.ts @@ -1,4 +1,12 @@ -export async function createAIImage(prompt: string) { +export interface ChatResponse { + responseMessage: string; + responseId: string; + image?: string; + transcribedMessage?: string; + relatedQuestions?: string[]; +} + +export async function createAIImage(prompt: string): Promise<{ image: string }> { const res = await fetch('/api/chat/createImage', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -14,7 +22,7 @@ export const getTextResponse = async ( message: string, imageBase64: string | null, previousResponseId?: string -) => { +): Promise => { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -23,13 +31,13 @@ export const getTextResponse = async ( const data = await res.json(); if (!res.ok) throw new Error(data.error); - return data; + return data as ChatResponse; }; export const getSpeechResponse = async ( audioBase64: string, previousResponseId?: string -) => { +): Promise => { const res = await fetch('/api/chat/speech', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -38,5 +46,5 @@ export const getSpeechResponse = async ( const data = await res.json(); if (!res.ok) throw new Error(data.error); - return data; + return data as ChatResponse; }; diff --git a/src/types/types.ts b/src/types/types.ts index 522ea6d..d44919c 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -4,6 +4,7 @@ export interface Message { message?: string; responseId?: string; image?: string; + relatedQuestions?: string[]; } export interface Chat { From e2074eb4ed3a33f7524a81423cd675cfacd635f7 Mon Sep 17 00:00:00 2001 From: Pankaj Sharma <8818473+iamphoenix310@users.noreply.github.com> Date: Mon, 9 Jun 2025 11:19:43 +0530 Subject: [PATCH 3/3] hi related --- package-lock.json | 343 +++++++++++++++++++++++++++++ package.json | 16 +- src/app/api/chat/speech+api.ts | 25 ++- src/app/chat/[id].tsx | 35 ++- src/components/MessageListItem.tsx | 40 ++-- src/lib/registry.ts | 78 +++++++ src/lib/relatedQuestions.ts | 48 ++++ src/lib/schema/related.ts | 15 ++ src/lib/schema/relatedSchema.ts | 7 + 9 files changed, 565 insertions(+), 42 deletions(-) create mode 100644 src/lib/registry.ts create mode 100644 src/lib/relatedQuestions.ts create mode 100644 src/lib/schema/related.ts create mode 100644 src/lib/schema/relatedSchema.ts diff --git a/package-lock.json b/package-lock.json index 37bed90..cb792bc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,8 +8,14 @@ "name": "aichatclone", "version": "1.0.0", "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", @@ -19,6 +25,7 @@ "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", @@ -30,6 +37,7 @@ "react-native-screens": "~4.11.1", "react-native-web": "^0.20.0", "tailwindcss": "^3.4.17", + "zod": "^3.25.56", "zustand": "^5.0.5" }, "devDependencies": { @@ -52,6 +60,173 @@ } } }, + "node_modules/@ai-sdk/anthropic": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/anthropic/-/anthropic-1.2.12.tgz", + "integrity": "sha512-YSzjlko7JvuiyQFmI9RN1tNZdEiZxc+6xld/0tq/VkJaHpEzGAb1yiNxxvmYVcjvfu/PcvCxAAYXmTYQQ63IHQ==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/deepseek": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/deepseek/-/deepseek-0.2.14.tgz", + "integrity": "sha512-TISD1FzBWuQkHEHoVustoJILV33ZNgfYxeTkq1xU2vHEZuWTGZV7/IlXixyFsfqDCdVgrbLeIABk5FuCw7niLg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/openai-compatible": "0.2.14", + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/google": { + "version": "1.2.19", + "resolved": "https://registry.npmjs.org/@ai-sdk/google/-/google-1.2.19.tgz", + "integrity": "sha512-Xgl6eftIRQ4srUdCzxM112JuewVMij5q4JLcNmHcB68Bxn9dpr3MVUSPlJwmameuiQuISIA8lMB+iRiRbFsaqA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/groq": { + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/@ai-sdk/groq/-/groq-1.2.9.tgz", + "integrity": "sha512-7MoDaxm8yWtiRbD1LipYZG0kBl+Xe0sv/EeyxnHnGPZappXdlgtdOgTZVjjXkT3nWP30jjZi9A45zoVrBMb3Xg==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/openai": { + "version": "1.3.22", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai/-/openai-1.3.22.tgz", + "integrity": "sha512-QwA+2EkG0QyjVR+7h6FE7iOu2ivNqAVMm9UJZkVxxTk5OIq5fFJDTEI/zICEMuHImTTXR2JjsL6EirJ28Jc4cw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/openai-compatible": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/@ai-sdk/openai-compatible/-/openai-compatible-0.2.14.tgz", + "integrity": "sha512-icjObfMCHKSIbywijaoLdZ1nSnuRnWgMEMLgwoxPJgxsUHMx0aVORnsLUid4SPtdhHI3X2masrt6iaEQLvOSFw==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + } + }, + "node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@ai-sdk/react/-/react-1.2.12.tgz", + "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/ui-utils": "1.2.11", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.2.11", + "resolved": "https://registry.npmjs.org/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -2422,6 +2597,15 @@ "node": ">= 8" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -2967,6 +3151,12 @@ "@babel/types": "^7.20.7" } }, + "node_modules/@types/diff-match-patch": { + "version": "1.0.36", + "resolved": "https://registry.npmjs.org/@types/diff-match-patch/-/diff-match-patch-1.0.36.tgz", + "integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==", + "license": "MIT" + }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", @@ -3130,6 +3320,32 @@ "node": ">= 14" } }, + "node_modules/ai": { + "version": "4.3.16", + "resolved": "https://registry.npmjs.org/ai/-/ai-4.3.16.tgz", + "integrity": "sha512-KUDwlThJ5tr2Vw0A1ZkbDKNME3wzWhuVfAOwIvFUzl1TPVDFAXDFTXio3p+jaKneB+dKNCvFFlolYmmgHttG1g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/react": "1.2.12", + "@ai-sdk/ui-utils": "1.2.11", + "@opentelemetry/api": "1.9.0", + "jsondiffpatch": "0.6.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + } + }, "node_modules/ajv": { "version": "8.17.1", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", @@ -4330,6 +4546,15 @@ "node": ">= 0.8" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/destroy": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", @@ -4358,6 +4583,12 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "license": "Apache-2.0" }, + "node_modules/diff-match-patch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/diff-match-patch/-/diff-match-patch-1.0.5.tgz", + "integrity": "sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==", + "license": "Apache-2.0" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -5741,6 +5972,12 @@ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "license": "MIT" }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -5759,6 +5996,35 @@ "node": ">=6" } }, + "node_modules/jsondiffpatch": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/jsondiffpatch/-/jsondiffpatch-0.6.0.tgz", + "integrity": "sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==", + "license": "MIT", + "dependencies": { + "@types/diff-match-patch": "^1.0.36", + "chalk": "^5.3.0", + "diff-match-patch": "^1.0.5" + }, + "bin": { + "jsondiffpatch": "bin/jsondiffpatch.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/jsondiffpatch/node_modules/chalk": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.4.1.tgz", + "integrity": "sha512-zgVZuo2WcZgfUEmsn6eO3kINexW8RAE4maiQ8QNs8CtpPCSyMiYsULR3HQYkm3w8FIA3SberyMJMSldGsW+U3w==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, "node_modules/kleur": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", @@ -6915,6 +7181,28 @@ "node": ">= 6" } }, + "node_modules/ollama-ai-provider": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/ollama-ai-provider/-/ollama-ai-provider-1.2.0.tgz", + "integrity": "sha512-jTNFruwe3O/ruJeppI/quoOUxG7NA6blG3ZyQj3lei4+NnJo7bi3eIRWqlVpRlu/mbzbFXeJSBuYQWF6pzGKww==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "^1.0.0", + "@ai-sdk/provider-utils": "^2.0.0", + "partial-json": "0.1.7" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, "node_modules/on-finished": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.3.0.tgz", @@ -7182,6 +7470,12 @@ "node": ">= 0.8" } }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -8404,6 +8698,12 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -9012,6 +9312,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swr": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.3.3.tgz", + "integrity": "sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwindcss": { "version": "3.4.17", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", @@ -9223,6 +9536,18 @@ "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", "license": "MIT" }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -9875,6 +10200,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.56", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.56.tgz", + "integrity": "sha512-rd6eEF3BTNvQnR2e2wwolfTmUTnp70aUTqr0oaGbHifzC3BKJsoV+Gat8vxUMR1hwOKBs6El+qWehrHbCpW6SQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.24.5", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz", + "integrity": "sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.24.1" + } + }, "node_modules/zustand": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz", diff --git a/package.json b/package.json index 0e185fa..fd35ed7 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/app/api/chat/speech+api.ts b/src/app/api/chat/speech+api.ts index 7b808a9..ec8c221 100644 --- a/src/app/api/chat/speech+api.ts +++ b/src/app/api/chat/speech+api.ts @@ -24,17 +24,20 @@ export async function POST(request: Request) { 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: ${transcription.text}. ` + - 'Respond in JSON format as { "questions": ["q1","q2","q3"] }.', - }, - ], - response_format: { type: 'json_object' }, - }); + 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 || '{}' ); diff --git a/src/app/chat/[id].tsx b/src/app/chat/[id].tsx index 52e84da..b212691 100644 --- a/src/app/chat/[id].tsx +++ b/src/app/chat/[id].tsx @@ -3,6 +3,8 @@ 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 { @@ -88,14 +90,31 @@ export default function ChatScreen() { } // 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, - relatedQuestions: 'relatedQuestions' in data ? data.relatedQuestions : undefined, - image: 'image' in data ? data.image : undefined, - }; + 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) { diff --git a/src/components/MessageListItem.tsx b/src/components/MessageListItem.tsx index 4245893..3f9ac6b 100644 --- a/src/components/MessageListItem.tsx +++ b/src/components/MessageListItem.tsx @@ -38,26 +38,28 @@ export default function MessageListItem({ )} {!isUser && relatedQuestions?.length ? ( - - - Related questions - - - {relatedQuestions.map((q, idx) => { - const display = q.length > 80 ? `${q.slice(0, 77)}...` : q; - return ( - onQuestionPress?.(q)} - className='bg-[#262626] rounded-lg p-2' - > - {display} - - ); - })} - + + 💡 Related questions + + {relatedQuestions.map((q, idx) => { + const display = q.length > 90 ? `${q.slice(0, 87)}...` : q; + return ( + onQuestionPress?.(q)} + className="bg-[#1f1f1f] border border-neutral-700 rounded-xl p-3 shadow-sm" + android_ripple={{ color: '#333' }} + > + + {display} + + + ); + })} - ) : null} + + ) : null} + ); } diff --git a/src/lib/registry.ts b/src/lib/registry.ts new file mode 100644 index 0000000..9f7a184 --- /dev/null +++ b/src/lib/registry.ts @@ -0,0 +1,78 @@ +// lib/registry.ts +import { createOpenAI, openai } from '@ai-sdk/openai'; +import { google } from '@ai-sdk/google'; +import { anthropic } from '@ai-sdk/anthropic'; +import { groq } from '@ai-sdk/groq'; +import { deepseek } from '@ai-sdk/deepseek'; +import { createOllama } from 'ollama-ai-provider'; +import { + createProviderRegistry, + extractReasoningMiddleware, + wrapLanguageModel +} from 'ai'; + +export const registry = createProviderRegistry({ + openai, + google, + anthropic, + groq, + deepseek, + ollama: createOllama({ + baseURL: `${process.env.OLLAMA_BASE_URL}/api` + }) +}); + +export function getModel(model: string) { + const [provider, ...modelNameParts] = model.split(':'); + const modelName = modelNameParts.join(':'); + + if (model.includes('ollama')) { + const ollama = createOllama({ baseURL: `${process.env.OLLAMA_BASE_URL}/api` }); + + if (model.includes('deepseek-r1')) { + return wrapLanguageModel({ + model: ollama(modelName), + middleware: extractReasoningMiddleware({ tagName: 'think' }) + }); + } + + return ollama(modelName, { simulateStreaming: true }); + } + + if (provider === 'groq' && model.includes('deepseek-r1')) { + return wrapLanguageModel({ + model: groq(modelName), + middleware: extractReasoningMiddleware({ tagName: 'think' }) + }); + } + + return registry.languageModel(model as Parameters[0]); +} + +export function getToolCallModel(model?: string) { + const [provider, ...modelNameParts] = model?.split(':') ?? []; + const modelName = modelNameParts.join(':'); + + switch (provider) { + case 'deepseek': + return getModel('deepseek:deepseek-chat'); + case 'groq': + return getModel('groq:llama-3.1-8b-instant'); + case 'google': + return getModel('google:gemini-2.0-flash'); + case 'ollama': + return getModel(`ollama:${process.env.NEXT_PUBLIC_OLLAMA_TOOL_CALL_MODEL || modelName}`); + default: + return getModel('openai:gpt-4o-mini'); + } +} + +export function isToolCallSupported(model?: string) { + const [provider, ...modelNameParts] = model?.split(':') ?? []; + const modelName = modelNameParts.join(':'); + + if (provider === 'ollama' || provider === 'google') return false; + if (modelName?.includes('deepseek')) return false; + + return true; +} diff --git a/src/lib/relatedQuestions.ts b/src/lib/relatedQuestions.ts new file mode 100644 index 0000000..c747baa --- /dev/null +++ b/src/lib/relatedQuestions.ts @@ -0,0 +1,48 @@ +import { generateObject, generateText } from 'ai'; +import { relatedSchema } from './schema/related'; +import { getModel, getToolCallModel, isToolCallSupported } from './registry'; // you will reuse these +import { type CoreMessage } from 'ai'; + +export async function generateRelatedQuestions(messages: CoreMessage[], model: string) { + const lastUserMessages = messages.slice(-1).map(m => ({ + ...m, + role: 'user' + })) as CoreMessage[]; + + const systemPrompt = `You are a helpful assistant. Based on the user's input, generate exactly 3 short, focused follow-up questions. Keep them under 15 words each. Respond in JSON format as { "object": "related", "data": ["q1", "q2", "q3"] }`; + + const supportsToolCall = isToolCallSupported(model); + const currentModel = supportsToolCall ? getModel(model) : getToolCallModel(model); + + try { + if (supportsToolCall) { + return await generateObject({ + model: currentModel, + system: systemPrompt, + messages: lastUserMessages, + schema: relatedSchema + }); + } + } catch (err) { + console.warn(`Tool call failed for ${model}, falling back.`); + } + + // fallback to raw text + const result = await generateText({ + model: currentModel, + system: systemPrompt, + messages: lastUserMessages + }); + + const text = result.text || ''; + const lines = text + .split('\n') + .filter(line => line.trim().startsWith('-')) + .map(line => line.replace(/^- /, '').trim()) + .filter(Boolean); + + return { + object: 'related', + data: lines.slice(0, 3) + }; +} diff --git a/src/lib/schema/related.ts b/src/lib/schema/related.ts new file mode 100644 index 0000000..77fc383 --- /dev/null +++ b/src/lib/schema/related.ts @@ -0,0 +1,15 @@ +import { DeepPartial } from 'ai' +import { z } from 'zod' + +export const relatedSchema = z.object({ + items: z + .array( + z.object({ + query: z.string() + }) + ) + .length(3) +}) +export type PartialRelated = DeepPartial + +export type Related = z.infer diff --git a/src/lib/schema/relatedSchema.ts b/src/lib/schema/relatedSchema.ts new file mode 100644 index 0000000..2114bf0 --- /dev/null +++ b/src/lib/schema/relatedSchema.ts @@ -0,0 +1,7 @@ +// lib/schema/related.ts +import { z } from 'zod'; + +export const relatedSchema = z.object({ + object: z.literal('related'), + data: z.array(z.string()).length(3) +});