mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-01-11 20:10:25 +00:00
AI initial commit
This commit is contained in:
parent
0a853e7460
commit
e0ca614893
9 changed files with 1763 additions and 3 deletions
5
App.tsx
5
App.tsx
|
|
@ -33,6 +33,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage';
|
|||
import * as Sentry from '@sentry/react-native';
|
||||
import UpdateService from './src/services/updateService';
|
||||
import { memoryMonitorService } from './src/services/memoryMonitorService';
|
||||
import { aiService } from './src/services/aiService';
|
||||
|
||||
Sentry.init({
|
||||
dsn: 'https://1a58bf436454d346e5852b7bfd3c95e8@o4509536317276160.ingest.de.sentry.io/4509536317734992',
|
||||
|
|
@ -97,6 +98,10 @@ const ThemedApp = () => {
|
|||
memoryMonitorService; // Just accessing it starts the monitoring
|
||||
console.log('Memory monitoring service initialized');
|
||||
|
||||
// Initialize AI service
|
||||
await aiService.initialize();
|
||||
console.log('AI service initialized');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error initializing app:', error);
|
||||
// Default to showing onboarding if we can't check
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 5c3e2d8bccf7d076e392fdc397233f392e2a1563
|
||||
Subproject commit 5c020cca433f0400e23eb553f3e4de09f65b66d3
|
||||
|
|
@ -94,7 +94,8 @@ const ActionButtons = memo(({
|
|||
animatedStyle,
|
||||
isWatched,
|
||||
watchProgress,
|
||||
groupedEpisodes
|
||||
groupedEpisodes,
|
||||
metadata
|
||||
}: {
|
||||
handleShowStreams: () => void;
|
||||
toggleLibrary: () => void;
|
||||
|
|
@ -107,6 +108,7 @@ const ActionButtons = memo(({
|
|||
isWatched: boolean;
|
||||
watchProgress: any;
|
||||
groupedEpisodes?: { [seasonNumber: number]: any[] };
|
||||
metadata: any;
|
||||
}) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
|
|
@ -287,6 +289,45 @@ const ActionButtons = memo(({
|
|||
</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* AI Chat Button */}
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
||||
onPress={() => {
|
||||
// Extract episode info if it's a series
|
||||
let episodeData = null;
|
||||
if (type === 'series' && watchProgress?.episodeId) {
|
||||
const parts = watchProgress.episodeId.split(':');
|
||||
if (parts.length >= 3) {
|
||||
episodeData = {
|
||||
seasonNumber: parseInt(parts[1], 10),
|
||||
episodeNumber: parseInt(parts[2], 10)
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
navigation.navigate('AIChat', {
|
||||
contentId: id,
|
||||
contentType: type,
|
||||
episodeId: episodeData ? watchProgress.episodeId : undefined,
|
||||
seasonNumber: episodeData?.seasonNumber,
|
||||
episodeNumber: episodeData?.episodeNumber,
|
||||
title: metadata?.name || metadata?.title || 'Unknown'
|
||||
});
|
||||
}}
|
||||
activeOpacity={0.85}
|
||||
>
|
||||
{Platform.OS === 'ios' ? (
|
||||
<ExpoBlurView intensity={80} style={styles.blurBackgroundRound} tint="dark" />
|
||||
) : (
|
||||
<View style={styles.androidFallbackBlurRound} />
|
||||
)}
|
||||
<MaterialIcons
|
||||
name="smart-toy"
|
||||
size={isTablet ? 28 : 24}
|
||||
color={currentTheme.colors.white}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{type === 'series' && (
|
||||
<TouchableOpacity
|
||||
style={[styles.iconButton, isTablet && styles.tabletIconButton]}
|
||||
|
|
@ -1423,6 +1464,7 @@ const HeroSection: React.FC<HeroSectionProps> = memo(({
|
|||
isWatched={isWatched}
|
||||
watchProgress={watchProgress}
|
||||
groupedEpisodes={groupedEpisodes}
|
||||
metadata={metadata}
|
||||
/>
|
||||
</View>
|
||||
</LinearGradient>
|
||||
|
|
|
|||
|
|
@ -48,6 +48,8 @@ import { LoadingProvider, useLoading } from '../contexts/LoadingContext';
|
|||
import PluginsScreen from '../screens/PluginsScreen';
|
||||
import CastMoviesScreen from '../screens/CastMoviesScreen';
|
||||
import UpdateScreen from '../screens/UpdateScreen';
|
||||
import AISettingsScreen from '../screens/AISettingsScreen';
|
||||
import AIChatScreen from '../screens/AIChatScreen';
|
||||
|
||||
// Stack navigator types
|
||||
export type RootStackParamList = {
|
||||
|
|
@ -127,6 +129,15 @@ export type RootStackParamList = {
|
|||
character?: string;
|
||||
};
|
||||
};
|
||||
AISettings: undefined;
|
||||
AIChat: {
|
||||
contentId: string;
|
||||
contentType: 'movie' | 'series';
|
||||
episodeId?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RootStackNavigationProp = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
|
@ -1224,6 +1235,36 @@ const InnerNavigator = ({ initialRouteName }: { initialRouteName?: keyof RootSta
|
|||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AISettings"
|
||||
component={AISettingsScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_right' : 'slide_from_right',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
presentation: 'card',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'horizontal',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="AIChat"
|
||||
component={AIChatScreen}
|
||||
options={{
|
||||
animation: Platform.OS === 'android' ? 'slide_from_bottom' : 'slide_from_bottom',
|
||||
animationDuration: Platform.OS === 'android' ? 250 : 300,
|
||||
presentation: 'modal',
|
||||
gestureEnabled: true,
|
||||
gestureDirection: 'vertical',
|
||||
headerShown: false,
|
||||
contentStyle: {
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
</View>
|
||||
</PaperProvider>
|
||||
|
|
|
|||
773
src/screens/AIChatScreen.tsx
Normal file
773
src/screens/AIChatScreen.tsx
Normal file
|
|
@ -0,0 +1,773 @@
|
|||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
KeyboardAvoidingView,
|
||||
Platform,
|
||||
Dimensions,
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
} from 'react-native';
|
||||
import { useRoute, useNavigation, RouteProp } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { aiService, ChatMessage, ContentContext, createMovieContext, createEpisodeContext, generateConversationStarters } from '../services/aiService';
|
||||
import { tmdbService } from '../services/tmdbService';
|
||||
import Animated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
withSpring,
|
||||
withTiming,
|
||||
interpolate,
|
||||
Extrapolate
|
||||
} from 'react-native-reanimated';
|
||||
|
||||
const { width, height } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
type AIChatRouteParams = {
|
||||
AIChat: {
|
||||
contentId: string;
|
||||
contentType: 'movie' | 'series';
|
||||
episodeId?: string;
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
title: string;
|
||||
};
|
||||
};
|
||||
|
||||
type AIChatScreenRouteProp = RouteProp<AIChatRouteParams, 'AIChat'>;
|
||||
|
||||
interface ChatBubbleProps {
|
||||
message: ChatMessage;
|
||||
isLast: boolean;
|
||||
}
|
||||
|
||||
const ChatBubble: React.FC<ChatBubbleProps> = ({ message, isLast }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
const bubbleAnimation = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
bubbleAnimation.value = withSpring(1, { damping: 15, stiffness: 120 });
|
||||
}, []);
|
||||
|
||||
const animatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: bubbleAnimation.value,
|
||||
transform: [
|
||||
{
|
||||
translateY: interpolate(
|
||||
bubbleAnimation.value,
|
||||
[0, 1],
|
||||
[20, 0],
|
||||
Extrapolate.CLAMP
|
||||
)
|
||||
},
|
||||
{
|
||||
scale: interpolate(
|
||||
bubbleAnimation.value,
|
||||
[0, 1],
|
||||
[0.8, 1],
|
||||
Extrapolate.CLAMP
|
||||
)
|
||||
}
|
||||
]
|
||||
}));
|
||||
|
||||
return (
|
||||
<Animated.View style={[
|
||||
styles.messageContainer,
|
||||
isUser ? styles.userMessageContainer : styles.assistantMessageContainer,
|
||||
isLast && styles.lastMessageContainer,
|
||||
animatedStyle
|
||||
]}>
|
||||
{!isUser && (
|
||||
<View style={[styles.avatarContainer, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<MaterialIcons name="smart-toy" size={16} color="white" />
|
||||
</View>
|
||||
)}
|
||||
|
||||
<View style={[
|
||||
styles.messageBubble,
|
||||
isUser ? [
|
||||
styles.userBubble,
|
||||
{ backgroundColor: currentTheme.colors.primary }
|
||||
] : [
|
||||
styles.assistantBubble,
|
||||
{ backgroundColor: currentTheme.colors.elevation2 }
|
||||
]
|
||||
]}>
|
||||
<Text style={[
|
||||
styles.messageText,
|
||||
{ color: isUser ? 'white' : currentTheme.colors.highEmphasis }
|
||||
]}>
|
||||
{message.content}
|
||||
</Text>
|
||||
<Text style={[
|
||||
styles.messageTime,
|
||||
{ color: isUser ? 'rgba(255,255,255,0.7)' : currentTheme.colors.mediumEmphasis }
|
||||
]}>
|
||||
{new Date(message.timestamp).toLocaleTimeString([], {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{isUser && (
|
||||
<View style={[styles.userAvatarContainer, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<MaterialIcons name="person" size={16} color={currentTheme.colors.primary} />
|
||||
</View>
|
||||
)}
|
||||
</Animated.View>
|
||||
);
|
||||
};
|
||||
|
||||
interface SuggestionChipProps {
|
||||
text: string;
|
||||
onPress: () => void;
|
||||
}
|
||||
|
||||
const SuggestionChip: React.FC<SuggestionChipProps> = ({ text, onPress }) => {
|
||||
const { currentTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<TouchableOpacity
|
||||
style={[styles.suggestionChip, { backgroundColor: currentTheme.colors.elevation1 }]}
|
||||
onPress={onPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={[styles.suggestionText, { color: currentTheme.colors.primary }]}>
|
||||
{text}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
};
|
||||
|
||||
const AIChatScreen: React.FC = () => {
|
||||
const route = useRoute<AIChatScreenRouteProp>();
|
||||
const navigation = useNavigation();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const { contentId, contentType, episodeId, seasonNumber, episodeNumber, title } = route.params;
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [inputText, setInputText] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [context, setContext] = useState<ContentContext | null>(null);
|
||||
const [isLoadingContext, setIsLoadingContext] = useState(true);
|
||||
const [suggestions, setSuggestions] = useState<string[]>([]);
|
||||
|
||||
const scrollViewRef = useRef<ScrollView>(null);
|
||||
const inputRef = useRef<TextInput>(null);
|
||||
|
||||
// Animation values
|
||||
const headerOpacity = useSharedValue(1);
|
||||
const inputContainerY = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadContext();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (context && messages.length === 0) {
|
||||
// Generate conversation starters
|
||||
const starters = generateConversationStarters(context);
|
||||
setSuggestions(starters);
|
||||
}
|
||||
}, [context, messages.length]);
|
||||
|
||||
const loadContext = async () => {
|
||||
try {
|
||||
setIsLoadingContext(true);
|
||||
|
||||
if (contentType === 'movie') {
|
||||
// Movies: contentId may be TMDB id string or IMDb id (tt...)
|
||||
let movieData = await tmdbService.getMovieDetails(contentId);
|
||||
if (!movieData) {
|
||||
// Try resolve TMDB id from IMDb id
|
||||
const tmdbId = await tmdbService.findTMDBIdByIMDB(contentId);
|
||||
if (tmdbId) {
|
||||
movieData = await tmdbService.getMovieDetails(String(tmdbId));
|
||||
}
|
||||
}
|
||||
|
||||
if (!movieData) throw new Error('Unable to load movie details');
|
||||
|
||||
const movieContext = createMovieContext(movieData);
|
||||
setContext(movieContext);
|
||||
} else {
|
||||
// Series: resolve TMDB numeric id first (contentId may be IMDb/stremio id)
|
||||
let tmdbNumericId: number | null = null;
|
||||
if (/^\d+$/.test(contentId)) {
|
||||
tmdbNumericId = parseInt(contentId, 10);
|
||||
} else {
|
||||
// Try to resolve from IMDb id or stremio-like id
|
||||
tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId);
|
||||
if (!tmdbNumericId && episodeId) {
|
||||
tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId);
|
||||
}
|
||||
}
|
||||
|
||||
if (!tmdbNumericId) throw new Error('Unable to resolve TMDB ID for series');
|
||||
|
||||
const [showData, episodeData] = await Promise.all([
|
||||
tmdbService.getTVShowDetails(tmdbNumericId),
|
||||
episodeId && seasonNumber && episodeNumber ?
|
||||
tmdbService.getEpisodeDetails(tmdbNumericId, seasonNumber, episodeNumber) :
|
||||
null
|
||||
]);
|
||||
|
||||
if (!showData) throw new Error('Unable to load TV show details');
|
||||
|
||||
if (episodeData && seasonNumber && episodeNumber) {
|
||||
const episodeContext = createEpisodeContext(
|
||||
episodeData,
|
||||
showData,
|
||||
seasonNumber,
|
||||
episodeNumber
|
||||
);
|
||||
setContext(episodeContext);
|
||||
} else {
|
||||
// Fallback: synthesize a show-level episode-like context so AI treats it as a series
|
||||
const syntheticEpisode: any = {
|
||||
id: `${showData?.id ?? ''}-overview`,
|
||||
name: 'Series Overview',
|
||||
overview: showData?.overview ?? '',
|
||||
air_date: showData?.first_air_date ?? '',
|
||||
runtime: undefined,
|
||||
credits: {
|
||||
guest_stars: [],
|
||||
crew: [],
|
||||
},
|
||||
};
|
||||
const episodeContext = createEpisodeContext(
|
||||
syntheticEpisode,
|
||||
showData,
|
||||
0,
|
||||
0
|
||||
);
|
||||
setContext(episodeContext);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error loading context:', error);
|
||||
Alert.alert('Error', 'Failed to load content details for AI chat');
|
||||
} finally {
|
||||
setIsLoadingContext(false);
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = useCallback(async (messageText: string) => {
|
||||
if (!messageText.trim() || !context || isLoading) return;
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: Date.now().toString(),
|
||||
role: 'user',
|
||||
content: messageText.trim(),
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, userMessage]);
|
||||
setInputText('');
|
||||
setIsLoading(true);
|
||||
setSuggestions([]); // Hide suggestions after first message
|
||||
|
||||
try {
|
||||
// If series overview is loaded, parse user query for specific episode and fetch on-demand
|
||||
let requestContext = context;
|
||||
if ('showTitle' in context) {
|
||||
const sxe = messageText.match(/s(\d+)e(\d+)/i);
|
||||
const words = messageText.match(/season\s+(\d+)[^\d]+episode\s+(\d+)/i);
|
||||
const season = sxe ? parseInt(sxe[1], 10) : (words ? parseInt(words[1], 10) : undefined);
|
||||
const episode = sxe ? parseInt(sxe[2], 10) : (words ? parseInt(words[2], 10) : undefined);
|
||||
|
||||
if (season && episode) {
|
||||
try {
|
||||
// Resolve TMDB id for the show
|
||||
let tmdbNumericId: number | null = null;
|
||||
if (/^\d+$/.test(contentId)) {
|
||||
tmdbNumericId = parseInt(contentId, 10);
|
||||
} else {
|
||||
tmdbNumericId = await tmdbService.findTMDBIdByIMDB(contentId);
|
||||
if (!tmdbNumericId && episodeId) {
|
||||
tmdbNumericId = await tmdbService.extractTMDBIdFromStremioId(episodeId);
|
||||
}
|
||||
}
|
||||
if (tmdbNumericId) {
|
||||
const [showData, episodeData] = await Promise.all([
|
||||
tmdbService.getTVShowDetails(tmdbNumericId),
|
||||
tmdbService.getEpisodeDetails(tmdbNumericId, season, episode)
|
||||
]);
|
||||
if (showData && episodeData) {
|
||||
requestContext = createEpisodeContext(episodeData, showData, season, episode);
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
|
||||
const response = await aiService.sendMessage(
|
||||
messageText.trim(),
|
||||
requestContext,
|
||||
messages
|
||||
);
|
||||
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: response,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, assistantMessage]);
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error sending message:', error);
|
||||
|
||||
let errorMessage = 'Sorry, I encountered an error. Please try again.';
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes('not configured')) {
|
||||
errorMessage = 'Please configure your OpenRouter API key in Settings > AI Assistant.';
|
||||
} else if (error.message.includes('API request failed')) {
|
||||
errorMessage = 'Failed to connect to AI service. Please check your internet connection and API key.';
|
||||
}
|
||||
}
|
||||
|
||||
const errorResponse: ChatMessage = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: errorMessage,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
setMessages(prev => [...prev, errorResponse]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [context, messages, isLoading]);
|
||||
|
||||
const handleSendPress = useCallback(() => {
|
||||
sendMessage(inputText);
|
||||
}, [inputText, sendMessage]);
|
||||
|
||||
const handleSuggestionPress = useCallback((suggestion: string) => {
|
||||
sendMessage(suggestion);
|
||||
}, [sendMessage]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
setTimeout(() => {
|
||||
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length > 0) {
|
||||
scrollToBottom();
|
||||
}
|
||||
}, [messages, scrollToBottom]);
|
||||
|
||||
const getDisplayTitle = () => {
|
||||
if (!context) return title;
|
||||
|
||||
if ('showTitle' in context) {
|
||||
const ep = context as any;
|
||||
// For series overview (synthetic S0E0), show just the show title
|
||||
if (ep.seasonNumber === 0 && ep.episodeNumber === 0) {
|
||||
return ep.showTitle;
|
||||
}
|
||||
return `${ep.showTitle} S${ep.seasonNumber}E${ep.episodeNumber}`;
|
||||
}
|
||||
return context.title || title;
|
||||
};
|
||||
|
||||
const headerAnimatedStyle = useAnimatedStyle(() => ({
|
||||
opacity: headerOpacity.value,
|
||||
}));
|
||||
|
||||
const inputAnimatedStyle = useAnimatedStyle(() => ({
|
||||
transform: [{ translateY: inputContainerY.value }],
|
||||
}));
|
||||
|
||||
if (isLoadingContext) {
|
||||
return (
|
||||
<View style={[styles.loadingContainer, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
<ActivityIndicator size="large" color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.loadingText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Loading AI context...
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
<Animated.View style={[
|
||||
styles.header,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.darkBackground,
|
||||
paddingTop: insets.top
|
||||
},
|
||||
headerAnimatedStyle
|
||||
]}>
|
||||
<View style={styles.headerContent}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<MaterialIcons name="arrow-back" size={24} color={currentTheme.colors.text} />
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerInfo}>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
AI Chat
|
||||
</Text>
|
||||
<Text style={[styles.headerSubtitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
{getDisplayTitle()}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[styles.aiIndicator, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<MaterialIcons name="smart-toy" size={20} color="white" />
|
||||
</View>
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Chat Messages */}
|
||||
<KeyboardAvoidingView
|
||||
style={styles.chatContainer}
|
||||
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||
keyboardVerticalOffset={insets.top + 60}
|
||||
>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.messagesContainer}
|
||||
contentContainerStyle={styles.messagesContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
keyboardShouldPersistTaps="handled"
|
||||
>
|
||||
{messages.length === 0 && suggestions.length > 0 && (
|
||||
<View style={styles.welcomeContainer}>
|
||||
<View style={[styles.welcomeIcon, { backgroundColor: currentTheme.colors.primary }]}>
|
||||
<MaterialIcons name="smart-toy" size={32} color="white" />
|
||||
</View>
|
||||
<Text style={[styles.welcomeTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
Ask me anything about
|
||||
</Text>
|
||||
<Text style={[styles.welcomeSubtitle, { color: currentTheme.colors.primary }]}>
|
||||
{getDisplayTitle()}
|
||||
</Text>
|
||||
<Text style={[styles.welcomeDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
I have detailed knowledge about this content and can answer questions about plot, characters, themes, and more.
|
||||
</Text>
|
||||
|
||||
<View style={styles.suggestionsContainer}>
|
||||
<Text style={[styles.suggestionsTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Try asking:
|
||||
</Text>
|
||||
<View style={styles.suggestionsGrid}>
|
||||
{suggestions.map((suggestion, index) => (
|
||||
<SuggestionChip
|
||||
key={index}
|
||||
text={suggestion}
|
||||
onPress={() => handleSuggestionPress(suggestion)}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{messages.map((message, index) => (
|
||||
<ChatBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLast={index === messages.length - 1}
|
||||
/>
|
||||
))}
|
||||
|
||||
{isLoading && (
|
||||
<View style={styles.typingIndicator}>
|
||||
<View style={[styles.typingBubble, { backgroundColor: currentTheme.colors.elevation2 }]}>
|
||||
<View style={styles.typingDots}>
|
||||
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
|
||||
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
|
||||
<View style={[styles.typingDot, { backgroundColor: currentTheme.colors.mediumEmphasis }]} />
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
{/* Input Container */}
|
||||
<Animated.View style={[
|
||||
styles.inputContainer,
|
||||
{ backgroundColor: currentTheme.colors.darkBackground },
|
||||
inputAnimatedStyle
|
||||
]}>
|
||||
<View style={[styles.inputWrapper, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={[
|
||||
styles.textInput,
|
||||
{ color: currentTheme.colors.highEmphasis }
|
||||
]}
|
||||
value={inputText}
|
||||
onChangeText={setInputText}
|
||||
placeholder="Ask about this content..."
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
multiline
|
||||
maxLength={500}
|
||||
editable={!isLoading}
|
||||
onSubmitEditing={handleSendPress}
|
||||
blurOnSubmit={false}
|
||||
/>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.sendButton,
|
||||
{
|
||||
backgroundColor: inputText.trim() ? currentTheme.colors.primary : currentTheme.colors.elevation2
|
||||
}
|
||||
]}
|
||||
onPress={handleSendPress}
|
||||
disabled={!inputText.trim() || isLoading}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="send"
|
||||
size={20}
|
||||
color={inputText.trim() ? 'white' : currentTheme.colors.mediumEmphasis}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Animated.View>
|
||||
</KeyboardAvoidingView>
|
||||
</SafeAreaView>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
loadingContainer: {
|
||||
flex: 1,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
gap: 16,
|
||||
},
|
||||
loadingText: {
|
||||
fontSize: 16,
|
||||
textAlign: 'center',
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: 16,
|
||||
paddingBottom: 12,
|
||||
borderBottomWidth: 1,
|
||||
borderBottomColor: 'rgba(255,255,255,0.1)',
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 12,
|
||||
},
|
||||
backButton: {
|
||||
padding: 8,
|
||||
},
|
||||
headerInfo: {
|
||||
flex: 1,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
},
|
||||
headerSubtitle: {
|
||||
fontSize: 14,
|
||||
marginTop: 2,
|
||||
},
|
||||
aiIndicator: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
chatContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
messagesContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
messagesContent: {
|
||||
padding: 16,
|
||||
paddingBottom: 8,
|
||||
},
|
||||
welcomeContainer: {
|
||||
alignItems: 'center',
|
||||
paddingVertical: 32,
|
||||
paddingHorizontal: 24,
|
||||
},
|
||||
welcomeIcon: {
|
||||
width: 64,
|
||||
height: 64,
|
||||
borderRadius: 32,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginBottom: 16,
|
||||
},
|
||||
welcomeTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '600',
|
||||
textAlign: 'center',
|
||||
},
|
||||
welcomeSubtitle: {
|
||||
fontSize: 22,
|
||||
fontWeight: '700',
|
||||
textAlign: 'center',
|
||||
marginTop: 4,
|
||||
marginBottom: 12,
|
||||
},
|
||||
welcomeDescription: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
textAlign: 'center',
|
||||
marginBottom: 32,
|
||||
},
|
||||
suggestionsContainer: {
|
||||
width: '100%',
|
||||
},
|
||||
suggestionsTitle: {
|
||||
fontSize: 14,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
textAlign: 'center',
|
||||
},
|
||||
suggestionsGrid: {
|
||||
gap: 8,
|
||||
},
|
||||
suggestionChip: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
alignSelf: 'flex-start',
|
||||
marginBottom: 8,
|
||||
},
|
||||
suggestionText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
messageContainer: {
|
||||
marginBottom: 16,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
},
|
||||
userMessageContainer: {
|
||||
justifyContent: 'flex-end',
|
||||
},
|
||||
assistantMessageContainer: {
|
||||
justifyContent: 'flex-start',
|
||||
},
|
||||
lastMessageContainer: {
|
||||
marginBottom: 8,
|
||||
},
|
||||
avatarContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginRight: 8,
|
||||
},
|
||||
userAvatarContainer: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
marginLeft: 8,
|
||||
},
|
||||
messageBubble: {
|
||||
maxWidth: width * 0.75,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
},
|
||||
userBubble: {
|
||||
borderBottomRightRadius: 4,
|
||||
},
|
||||
assistantBubble: {
|
||||
borderBottomLeftRadius: 4,
|
||||
},
|
||||
messageText: {
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
messageTime: {
|
||||
fontSize: 12,
|
||||
marginTop: 4,
|
||||
opacity: 0.8,
|
||||
},
|
||||
typingIndicator: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
marginBottom: 16,
|
||||
},
|
||||
typingBubble: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
borderRadius: 20,
|
||||
borderBottomLeftRadius: 4,
|
||||
marginLeft: 40,
|
||||
},
|
||||
typingDots: {
|
||||
flexDirection: 'row',
|
||||
gap: 4,
|
||||
},
|
||||
typingDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: 4,
|
||||
},
|
||||
inputContainer: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
paddingBottom: 16,
|
||||
},
|
||||
inputWrapper: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'flex-end',
|
||||
borderRadius: 24,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 8,
|
||||
gap: 12,
|
||||
},
|
||||
textInput: {
|
||||
flex: 1,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
maxHeight: 100,
|
||||
paddingVertical: 8,
|
||||
},
|
||||
sendButton: {
|
||||
width: 40,
|
||||
height: 40,
|
||||
borderRadius: 20,
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
|
||||
export default AIChatScreen;
|
||||
500
src/screens/AISettingsScreen.tsx
Normal file
500
src/screens/AISettingsScreen.tsx
Normal file
|
|
@ -0,0 +1,500 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
ScrollView,
|
||||
SafeAreaView,
|
||||
StatusBar,
|
||||
TextInput,
|
||||
TouchableOpacity,
|
||||
Alert,
|
||||
Linking,
|
||||
Platform,
|
||||
Dimensions,
|
||||
} from 'react-native';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { MaterialIcons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../contexts/ThemeContext';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
|
||||
const { width } = Dimensions.get('window');
|
||||
const isTablet = width >= 768;
|
||||
|
||||
const AISettingsScreen: React.FC = () => {
|
||||
const navigation = useNavigation();
|
||||
const { currentTheme } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const [apiKey, setApiKey] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isKeySet, setIsKeySet] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadApiKey();
|
||||
}, []);
|
||||
|
||||
const loadApiKey = async () => {
|
||||
try {
|
||||
const savedKey = await AsyncStorage.getItem('openrouter_api_key');
|
||||
if (savedKey) {
|
||||
setApiKey(savedKey);
|
||||
setIsKeySet(true);
|
||||
}
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error loading OpenRouter API key:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveApiKey = async () => {
|
||||
if (!apiKey.trim()) {
|
||||
Alert.alert('Error', 'Please enter a valid API key');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!apiKey.startsWith('sk-or-')) {
|
||||
Alert.alert('Error', 'OpenRouter API keys should start with "sk-or-"');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await AsyncStorage.setItem('openrouter_api_key', apiKey.trim());
|
||||
setIsKeySet(true);
|
||||
Alert.alert('Success', 'OpenRouter API key saved successfully!');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to save API key');
|
||||
if (__DEV__) console.error('Error saving OpenRouter API key:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveApiKey = () => {
|
||||
Alert.alert(
|
||||
'Remove API Key',
|
||||
'Are you sure you want to remove your OpenRouter API key? This will disable AI chat features.',
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await AsyncStorage.removeItem('openrouter_api_key');
|
||||
setApiKey('');
|
||||
setIsKeySet(false);
|
||||
Alert.alert('Success', 'API key removed successfully');
|
||||
} catch (error) {
|
||||
Alert.alert('Error', 'Failed to remove API key');
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
);
|
||||
};
|
||||
|
||||
const handleGetApiKey = () => {
|
||||
Linking.openURL('https://openrouter.ai/keys');
|
||||
};
|
||||
|
||||
const headerBaseHeight = Platform.OS === 'android' ? 80 : 60;
|
||||
const topSpacing = Platform.OS === 'android' ? (StatusBar.currentHeight || 0) : insets.top;
|
||||
const headerHeight = headerBaseHeight + topSpacing;
|
||||
|
||||
return (
|
||||
<View style={[styles.container, { backgroundColor: currentTheme.colors.darkBackground }]}>
|
||||
<StatusBar barStyle="light-content" />
|
||||
|
||||
{/* Header */}
|
||||
<View style={[styles.header, { height: headerHeight, paddingTop: topSpacing }]}>
|
||||
<View style={styles.headerContent}>
|
||||
<TouchableOpacity
|
||||
onPress={() => navigation.goBack()}
|
||||
style={styles.backButton}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="arrow-back"
|
||||
size={24}
|
||||
color={currentTheme.colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
<Text style={[styles.headerTitle, { color: currentTheme.colors.text }]}>
|
||||
AI Assistant
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
style={styles.scrollView}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
{/* Info Card */}
|
||||
<View style={[styles.infoCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={styles.infoHeader}>
|
||||
<MaterialIcons
|
||||
name="smart-toy"
|
||||
size={24}
|
||||
color={currentTheme.colors.primary}
|
||||
/>
|
||||
<Text style={[styles.infoTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
AI-Powered Chat
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.infoDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Ask questions about any movie or TV show episode using advanced AI. Get insights about plot, characters, themes, trivia, and more - all powered by comprehensive TMDB data.
|
||||
</Text>
|
||||
|
||||
<View style={styles.featureList}>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Episode-specific context and analysis
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Plot explanations and character insights
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Behind-the-scenes trivia and facts
|
||||
</Text>
|
||||
</View>
|
||||
<View style={styles.featureItem}>
|
||||
<MaterialIcons name="check-circle" size={16} color={currentTheme.colors.primary} />
|
||||
<Text style={[styles.featureText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Your own free OpenRouter API key
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* API Key Configuration */}
|
||||
<View style={[styles.card, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<Text style={[styles.cardTitle, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
OPENROUTER API KEY
|
||||
</Text>
|
||||
|
||||
<View style={styles.apiKeySection}>
|
||||
<Text style={[styles.label, { color: currentTheme.colors.highEmphasis }]}>
|
||||
API Key
|
||||
</Text>
|
||||
<Text style={[styles.description, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
Enter your OpenRouter API key to enable AI chat features
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
style={[
|
||||
styles.input,
|
||||
{
|
||||
backgroundColor: currentTheme.colors.elevation2,
|
||||
color: currentTheme.colors.highEmphasis,
|
||||
borderColor: currentTheme.colors.elevation2
|
||||
}
|
||||
]}
|
||||
value={apiKey}
|
||||
onChangeText={setApiKey}
|
||||
placeholder="sk-or-v1-xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
placeholderTextColor={currentTheme.colors.mediumEmphasis}
|
||||
secureTextEntry={true}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
/>
|
||||
|
||||
<View style={styles.buttonContainer}>
|
||||
{!isKeySet ? (
|
||||
<TouchableOpacity
|
||||
style={[styles.saveButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleSaveApiKey}
|
||||
disabled={loading}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="save"
|
||||
size={20}
|
||||
color={currentTheme.colors.white}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.saveButtonText}>
|
||||
{loading ? 'Saving...' : 'Save API Key'}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
) : (
|
||||
<View style={styles.buttonRow}>
|
||||
<TouchableOpacity
|
||||
style={[styles.updateButton, { backgroundColor: currentTheme.colors.primary }]}
|
||||
onPress={handleSaveApiKey}
|
||||
disabled={loading}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="update"
|
||||
size={20}
|
||||
color={currentTheme.colors.white}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={styles.updateButtonText}>Update</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.removeButton, { borderColor: currentTheme.colors.error }]}
|
||||
onPress={handleRemoveApiKey}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="delete"
|
||||
size={20}
|
||||
color={currentTheme.colors.error}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={[styles.removeButtonText, { color: currentTheme.colors.error }]}>
|
||||
Remove
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
<TouchableOpacity
|
||||
style={[styles.getKeyButton, { backgroundColor: currentTheme.colors.elevation2 }]}
|
||||
onPress={handleGetApiKey}
|
||||
>
|
||||
<MaterialIcons
|
||||
name="open-in-new"
|
||||
size={20}
|
||||
color={currentTheme.colors.primary}
|
||||
style={{ marginRight: 8 }}
|
||||
/>
|
||||
<Text style={[styles.getKeyButtonText, { color: currentTheme.colors.primary }]}>
|
||||
Get Free API Key from OpenRouter
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
{/* Status Card */}
|
||||
{isKeySet && (
|
||||
<View style={[styles.statusCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<View style={styles.statusHeader}>
|
||||
<MaterialIcons
|
||||
name="check-circle"
|
||||
size={24}
|
||||
color={currentTheme.colors.success || '#4CAF50'}
|
||||
/>
|
||||
<Text style={[styles.statusTitle, { color: currentTheme.colors.success || '#4CAF50' }]}>
|
||||
AI Chat Enabled
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={[styles.statusDescription, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
You can now ask questions about movies and TV shows. Look for the "Ask AI" button on content pages!
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* Usage Info */}
|
||||
<View style={[styles.usageCard, { backgroundColor: currentTheme.colors.elevation1 }]}>
|
||||
<Text style={[styles.usageTitle, { color: currentTheme.colors.highEmphasis }]}>
|
||||
How it works
|
||||
</Text>
|
||||
<Text style={[styles.usageText, { color: currentTheme.colors.mediumEmphasis }]}>
|
||||
• OpenRouter provides access to multiple AI models{'\n'}
|
||||
• Your API key stays private and secure{'\n'}
|
||||
• Free tier includes generous usage limits{'\n'}
|
||||
• Chat with context about specific episodes/movies{'\n'}
|
||||
• Get detailed analysis and explanations
|
||||
</Text>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</View>
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
container: {
|
||||
flex: 1,
|
||||
},
|
||||
header: {
|
||||
paddingHorizontal: Math.max(16, width * 0.05),
|
||||
justifyContent: 'flex-end',
|
||||
paddingBottom: 8,
|
||||
},
|
||||
headerContent: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
backButton: {
|
||||
marginRight: 16,
|
||||
padding: 8,
|
||||
},
|
||||
headerTitle: {
|
||||
fontSize: Math.min(28, width * 0.07),
|
||||
fontWeight: '800',
|
||||
letterSpacing: 0.3,
|
||||
},
|
||||
scrollView: {
|
||||
flex: 1,
|
||||
},
|
||||
scrollContent: {
|
||||
padding: Math.max(16, width * 0.05),
|
||||
paddingBottom: 40,
|
||||
},
|
||||
infoCard: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
infoHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 12,
|
||||
},
|
||||
infoTitle: {
|
||||
fontSize: 20,
|
||||
fontWeight: '700',
|
||||
marginLeft: 12,
|
||||
},
|
||||
infoDescription: {
|
||||
fontSize: 16,
|
||||
lineHeight: 24,
|
||||
marginBottom: 16,
|
||||
},
|
||||
featureList: {
|
||||
gap: 8,
|
||||
},
|
||||
featureItem: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
},
|
||||
featureText: {
|
||||
fontSize: 15,
|
||||
marginLeft: 8,
|
||||
flex: 1,
|
||||
},
|
||||
card: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
cardTitle: {
|
||||
fontSize: 13,
|
||||
fontWeight: '600',
|
||||
letterSpacing: 0.8,
|
||||
marginBottom: 16,
|
||||
},
|
||||
apiKeySection: {
|
||||
gap: 12,
|
||||
},
|
||||
label: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
description: {
|
||||
fontSize: 14,
|
||||
lineHeight: 20,
|
||||
},
|
||||
input: {
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
fontSize: 16,
|
||||
fontFamily: Platform.OS === 'ios' ? 'Menlo' : 'monospace',
|
||||
borderWidth: 1,
|
||||
},
|
||||
buttonContainer: {
|
||||
marginTop: 8,
|
||||
},
|
||||
buttonRow: {
|
||||
flexDirection: 'row',
|
||||
gap: 12,
|
||||
},
|
||||
saveButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
},
|
||||
saveButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
updateButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
flex: 1,
|
||||
},
|
||||
updateButtonText: {
|
||||
color: 'white',
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
removeButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 14,
|
||||
paddingHorizontal: 20,
|
||||
borderRadius: 12,
|
||||
borderWidth: 2,
|
||||
flex: 1,
|
||||
},
|
||||
removeButtonText: {
|
||||
fontSize: 16,
|
||||
fontWeight: '600',
|
||||
},
|
||||
getKeyButton: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
paddingVertical: 12,
|
||||
paddingHorizontal: 16,
|
||||
borderRadius: 12,
|
||||
marginTop: 8,
|
||||
},
|
||||
getKeyButtonText: {
|
||||
fontSize: 15,
|
||||
fontWeight: '500',
|
||||
},
|
||||
statusCard: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
marginBottom: 20,
|
||||
},
|
||||
statusHeader: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
marginBottom: 8,
|
||||
},
|
||||
statusTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginLeft: 12,
|
||||
},
|
||||
statusDescription: {
|
||||
fontSize: 15,
|
||||
lineHeight: 22,
|
||||
},
|
||||
usageCard: {
|
||||
borderRadius: 16,
|
||||
padding: 20,
|
||||
},
|
||||
usageTitle: {
|
||||
fontSize: 18,
|
||||
fontWeight: '600',
|
||||
marginBottom: 12,
|
||||
},
|
||||
usageText: {
|
||||
fontSize: 15,
|
||||
lineHeight: 24,
|
||||
},
|
||||
});
|
||||
|
||||
export default AISettingsScreen;
|
||||
|
|
@ -43,6 +43,7 @@ const SETTINGS_CATEGORIES = [
|
|||
{ id: 'content', title: 'Content & Discovery', icon: 'explore' as keyof typeof MaterialIcons.glyphMap },
|
||||
{ id: 'appearance', title: 'Appearance', icon: 'palette' as keyof typeof MaterialIcons.glyphMap },
|
||||
{ id: 'integrations', title: 'Integrations', icon: 'extension' as keyof typeof MaterialIcons.glyphMap },
|
||||
{ id: 'ai', title: 'AI Assistant', icon: 'smart-toy' as keyof typeof MaterialIcons.glyphMap },
|
||||
{ id: 'playback', title: 'Playback', icon: 'play-circle-outline' as keyof typeof MaterialIcons.glyphMap },
|
||||
{ id: 'updates', title: 'Updates', icon: 'system-update' as keyof typeof MaterialIcons.glyphMap },
|
||||
{ id: 'about', title: 'About', icon: 'info-outline' as keyof typeof MaterialIcons.glyphMap },
|
||||
|
|
@ -256,6 +257,7 @@ const SettingsScreen: React.FC = () => {
|
|||
const [addonCount, setAddonCount] = useState<number>(0);
|
||||
const [catalogCount, setCatalogCount] = useState<number>(0);
|
||||
const [mdblistKeySet, setMdblistKeySet] = useState<boolean>(false);
|
||||
const [openRouterKeySet, setOpenRouterKeySet] = useState<boolean>(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
try {
|
||||
|
|
@ -289,6 +291,10 @@ const SettingsScreen: React.FC = () => {
|
|||
// Check MDBList API key status
|
||||
const mdblistKey = await AsyncStorage.getItem('mdblist_api_key');
|
||||
setMdblistKeySet(!!mdblistKey);
|
||||
|
||||
// Check OpenRouter API key status
|
||||
const openRouterKey = await AsyncStorage.getItem('openrouter_api_key');
|
||||
setOpenRouterKeySet(!!openRouterKey);
|
||||
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Error loading settings data:', error);
|
||||
|
|
@ -526,6 +532,21 @@ const SettingsScreen: React.FC = () => {
|
|||
</SettingsCard>
|
||||
);
|
||||
|
||||
case 'ai':
|
||||
return (
|
||||
<SettingsCard title="AI ASSISTANT" isTablet={isTablet}>
|
||||
<SettingItem
|
||||
title="OpenRouter API"
|
||||
description={openRouterKeySet ? "Connected" : "Add your API key to enable AI chat"}
|
||||
icon="smart-toy"
|
||||
renderControl={ChevronRight}
|
||||
onPress={() => navigation.navigate('AISettings')}
|
||||
isLast={true}
|
||||
isTablet={isTablet}
|
||||
/>
|
||||
</SettingsCard>
|
||||
);
|
||||
|
||||
case 'playback':
|
||||
return (
|
||||
<SettingsCard title="PLAYBACK" isTablet={isTablet}>
|
||||
|
|
@ -765,6 +786,7 @@ const SettingsScreen: React.FC = () => {
|
|||
{renderCategoryContent('content')}
|
||||
{renderCategoryContent('appearance')}
|
||||
{renderCategoryContent('integrations')}
|
||||
{renderCategoryContent('ai')}
|
||||
{renderCategoryContent('playback')}
|
||||
{renderCategoryContent('updates')}
|
||||
{renderCategoryContent('about')}
|
||||
|
|
|
|||
375
src/services/aiService.ts
Normal file
375
src/services/aiService.ts
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
export interface MovieContext {
|
||||
id: string;
|
||||
title: string;
|
||||
overview: string;
|
||||
releaseDate: string;
|
||||
genres: string[];
|
||||
cast: Array<{
|
||||
name: string;
|
||||
character: string;
|
||||
}>;
|
||||
crew: Array<{
|
||||
name: string;
|
||||
job: string;
|
||||
}>;
|
||||
runtime?: number;
|
||||
tagline?: string;
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export interface EpisodeContext {
|
||||
id: string;
|
||||
showId: string;
|
||||
showTitle: string;
|
||||
episodeTitle: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
overview: string;
|
||||
airDate: string;
|
||||
released: boolean;
|
||||
runtime?: number;
|
||||
cast: Array<{
|
||||
name: string;
|
||||
character: string;
|
||||
}>;
|
||||
crew: Array<{
|
||||
name: string;
|
||||
job: string;
|
||||
}>;
|
||||
guestStars?: Array<{
|
||||
name: string;
|
||||
character: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export type ContentContext = MovieContext | EpisodeContext;
|
||||
|
||||
interface OpenRouterResponse {
|
||||
choices: Array<{
|
||||
message: {
|
||||
content: string;
|
||||
role: string;
|
||||
};
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage?: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
class AIService {
|
||||
private static instance: AIService;
|
||||
private apiKey: string | null = null;
|
||||
private baseUrl = 'https://openrouter.ai/api/v1';
|
||||
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): AIService {
|
||||
if (!AIService.instance) {
|
||||
AIService.instance = new AIService();
|
||||
}
|
||||
return AIService.instance;
|
||||
}
|
||||
|
||||
async initialize(): Promise<boolean> {
|
||||
try {
|
||||
this.apiKey = await AsyncStorage.getItem('openrouter_api_key');
|
||||
return !!this.apiKey;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('Failed to initialize AI service:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async isConfigured(): Promise<boolean> {
|
||||
if (!this.apiKey) {
|
||||
await this.initialize();
|
||||
}
|
||||
return !!this.apiKey;
|
||||
}
|
||||
|
||||
private createSystemPrompt(context: ContentContext): string {
|
||||
const isEpisode = 'showTitle' in context;
|
||||
|
||||
if (isEpisode) {
|
||||
const ep = context as EpisodeContext;
|
||||
return `You are an AI assistant specialized in TV shows and episodes. You have detailed knowledge about "${ep.showTitle}" Season ${ep.seasonNumber}, Episode ${ep.episodeNumber}: "${ep.episodeTitle}".
|
||||
|
||||
Episode Details:
|
||||
- Show: ${ep.showTitle}
|
||||
- Episode: S${ep.seasonNumber}E${ep.episodeNumber} - "${ep.episodeTitle}"
|
||||
- Air Date: ${ep.airDate}
|
||||
- Release Status: ${ep.released ? 'Released' : 'Unreleased'}
|
||||
- Runtime: ${ep.runtime ? `${ep.runtime} minutes` : 'Unknown'}
|
||||
- Synopsis: ${ep.overview}
|
||||
|
||||
Cast:
|
||||
${ep.cast.map(c => `- ${c.name} as ${c.character}`).join('\n')}
|
||||
|
||||
${ep.guestStars && ep.guestStars.length > 0 ? `Guest Stars:\n${ep.guestStars.map(g => `- ${g.name} as ${g.character}`).join('\n')}` : ''}
|
||||
|
||||
Crew:
|
||||
${ep.crew.map(c => `- ${c.name} (${c.job})`).join('\n')}
|
||||
|
||||
Guidance:
|
||||
- Never provide spoilers under any circumstances. Always keep responses spoiler-safe.
|
||||
- If Release Status is Released, do not claim the episode is unreleased. Provide specific, accurate details.
|
||||
- If Release Status is Unreleased, avoid spoilers and focus on official information only.
|
||||
- Be specific to this episode and provide detailed, informative responses. If asked about other episodes or seasons, politely redirect the conversation back to this specific episode while acknowledging the broader context of the show.`;
|
||||
} else {
|
||||
const movie = context as MovieContext;
|
||||
return `You are an AI assistant specialized in movies and cinema. You have detailed knowledge about the movie "${movie.title}".
|
||||
|
||||
Movie Details:
|
||||
- Title: ${movie.title}
|
||||
- Release Date: ${movie.releaseDate}
|
||||
- Runtime: ${movie.runtime ? `${movie.runtime} minutes` : 'Unknown'}
|
||||
- Genres: ${movie.genres.join(', ')}
|
||||
- Tagline: ${movie.tagline || 'N/A'}
|
||||
- Synopsis: ${movie.overview}
|
||||
|
||||
Cast:
|
||||
${movie.cast.map(c => `- ${c.name} as ${c.character}`).join('\n')}
|
||||
|
||||
Crew:
|
||||
${movie.crew.map(c => `- ${c.name} (${c.job})`).join('\n')}
|
||||
|
||||
${movie.keywords && movie.keywords.length > 0 ? `Keywords: ${movie.keywords.join(', ')}` : ''}
|
||||
|
||||
Guidance:
|
||||
- Never provide spoilers under any circumstances. Always keep responses spoiler-safe.
|
||||
- You can discuss themes, production, performances, and high-level plot setup without revealing twists, surprises, or outcomes.
|
||||
- If users explicitly request spoilers, refuse gently and offer a spoiler-safe summary or analysis instead.
|
||||
|
||||
You should answer questions about this movie, including plot analysis, character development, themes, cinematography, production notes, trivia, and critical analysis. Provide detailed, informative responses that demonstrate deep knowledge of the film while remaining spoiler-safe. Be specific and focus on this particular movie.`;
|
||||
}
|
||||
}
|
||||
|
||||
async sendMessage(
|
||||
message: string,
|
||||
context: ContentContext,
|
||||
conversationHistory: ChatMessage[] = []
|
||||
): Promise<string> {
|
||||
if (!await this.isConfigured()) {
|
||||
throw new Error('AI service not configured. Please add your OpenRouter API key in settings.');
|
||||
}
|
||||
|
||||
try {
|
||||
const systemPrompt = this.createSystemPrompt(context);
|
||||
|
||||
// Prepare messages for API
|
||||
const messages = [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
...conversationHistory
|
||||
.filter(msg => msg.role !== 'system')
|
||||
.slice(-10) // Keep last 10 messages for context
|
||||
.map(msg => ({
|
||||
role: msg.role,
|
||||
content: msg.content
|
||||
})),
|
||||
{ role: 'user', content: message }
|
||||
];
|
||||
|
||||
if (__DEV__) {
|
||||
console.log('[AIService] Sending request to OpenRouter with context:', {
|
||||
contentType: 'showTitle' in context ? 'episode' : 'movie',
|
||||
title: 'showTitle' in context ?
|
||||
`${(context as EpisodeContext).showTitle} S${(context as EpisodeContext).seasonNumber}E${(context as EpisodeContext).episodeNumber}` :
|
||||
(context as MovieContext).title,
|
||||
messageCount: messages.length
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}/chat/completions`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
'HTTP-Referer': 'https://nuvio.app',
|
||||
'X-Title': 'Nuvio - AI Chat',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: 'anthropic/claude-3.5-sonnet', // Using Claude for better analysis
|
||||
messages,
|
||||
max_tokens: 1000,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
frequency_penalty: 0.1,
|
||||
presence_penalty: 0.1,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
if (__DEV__) console.error('[AIService] API Error:', response.status, errorText);
|
||||
throw new Error(`API request failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: OpenRouterResponse = await response.json();
|
||||
|
||||
if (!data.choices || data.choices.length === 0) {
|
||||
throw new Error('No response received from AI service');
|
||||
}
|
||||
|
||||
const responseContent = data.choices[0].message.content;
|
||||
|
||||
if (__DEV__ && data.usage) {
|
||||
console.log('[AIService] Token usage:', data.usage);
|
||||
}
|
||||
|
||||
return responseContent;
|
||||
} catch (error) {
|
||||
if (__DEV__) console.error('[AIService] Error sending message:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to create context from TMDB movie data
|
||||
static createMovieContext(movieData: any): MovieContext {
|
||||
if (__DEV__) {
|
||||
console.log('[AIService] Creating movie context from TMDB data:', {
|
||||
id: movieData.id,
|
||||
title: movieData.title || movieData.name,
|
||||
hasCredits: !!movieData.credits,
|
||||
castCount: movieData.credits?.cast?.length || 0,
|
||||
crewCount: movieData.credits?.crew?.length || 0,
|
||||
hasKeywords: !!(movieData.keywords?.keywords || movieData.keywords?.results),
|
||||
keywordCount: (movieData.keywords?.keywords || movieData.keywords?.results)?.length || 0,
|
||||
genreCount: movieData.genres?.length || 0
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: movieData.id?.toString() || '',
|
||||
title: movieData.title || movieData.name || '',
|
||||
overview: movieData.overview || '',
|
||||
releaseDate: movieData.release_date || movieData.first_air_date || '',
|
||||
genres: movieData.genres?.map((g: any) => g.name) || [],
|
||||
cast: movieData.credits?.cast?.slice(0, 10).map((c: any) => ({
|
||||
name: c.name,
|
||||
character: c.character
|
||||
})) || [],
|
||||
crew: movieData.credits?.crew?.slice(0, 5).map((c: any) => ({
|
||||
name: c.name,
|
||||
job: c.job
|
||||
})) || [],
|
||||
runtime: movieData.runtime,
|
||||
tagline: movieData.tagline,
|
||||
keywords: movieData.keywords?.keywords?.map((k: any) => k.name) ||
|
||||
movieData.keywords?.results?.map((k: any) => k.name) || []
|
||||
};
|
||||
}
|
||||
|
||||
// Helper method to create context from TMDB episode data
|
||||
static createEpisodeContext(
|
||||
episodeData: any,
|
||||
showData: any,
|
||||
seasonNumber: number,
|
||||
episodeNumber: number
|
||||
): EpisodeContext {
|
||||
// Compute release status from TMDB air date
|
||||
const airDate: string = episodeData.air_date || '';
|
||||
let released = false;
|
||||
try {
|
||||
if (airDate) {
|
||||
const parsed = new Date(airDate);
|
||||
if (!isNaN(parsed.getTime())) released = parsed.getTime() <= Date.now();
|
||||
}
|
||||
} catch {}
|
||||
// Heuristics: if TMDB provides meaningful content, treat as released
|
||||
if (!released) {
|
||||
const hasOverview = typeof episodeData.overview === 'string' && episodeData.overview.trim().length > 40;
|
||||
const hasRuntime = typeof episodeData.runtime === 'number' && episodeData.runtime > 0;
|
||||
const hasVotes = typeof episodeData.vote_average === 'number' && episodeData.vote_average > 0;
|
||||
if (hasOverview || hasRuntime || hasVotes) {
|
||||
released = true;
|
||||
}
|
||||
}
|
||||
if (__DEV__) {
|
||||
console.log('[AIService] Creating episode context from TMDB data:', {
|
||||
showId: showData.id,
|
||||
showTitle: showData.name || showData.title,
|
||||
episodeId: episodeData.id,
|
||||
episodeTitle: episodeData.name,
|
||||
season: seasonNumber,
|
||||
episode: episodeNumber,
|
||||
hasShowCredits: !!showData.credits,
|
||||
showCastCount: showData.credits?.cast?.length || 0,
|
||||
hasEpisodeCredits: !!episodeData.credits,
|
||||
episodeGuestStarsCount: episodeData.credits?.guest_stars?.length || 0,
|
||||
episodeCrewCount: episodeData.credits?.crew?.length || 0
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
id: episodeData.id?.toString() || '',
|
||||
showId: showData.id?.toString() || '',
|
||||
showTitle: showData.name || showData.title || '',
|
||||
episodeTitle: episodeData.name || `Episode ${episodeNumber}`,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
overview: episodeData.overview || '',
|
||||
airDate,
|
||||
released,
|
||||
runtime: episodeData.runtime,
|
||||
cast: showData.credits?.cast?.slice(0, 8).map((c: any) => ({
|
||||
name: c.name,
|
||||
character: c.character
|
||||
})) || [],
|
||||
crew: episodeData.credits?.crew?.slice(0, 5).map((c: any) => ({
|
||||
name: c.name,
|
||||
job: c.job
|
||||
})) || showData.credits?.crew?.slice(0, 5).map((c: any) => ({
|
||||
name: c.name,
|
||||
job: c.job
|
||||
})) || [],
|
||||
guestStars: episodeData.credits?.guest_stars?.map((g: any) => ({
|
||||
name: g.name,
|
||||
character: g.character
|
||||
})) || []
|
||||
};
|
||||
}
|
||||
|
||||
// Generate conversation starter suggestions
|
||||
static generateConversationStarters(context: ContentContext): string[] {
|
||||
const isEpisode = 'showTitle' in context;
|
||||
|
||||
if (isEpisode) {
|
||||
const ep = context as EpisodeContext;
|
||||
return [
|
||||
`What happened in this episode of ${ep.showTitle}?`,
|
||||
`Explain the main plot points of "${ep.episodeTitle}"`,
|
||||
`What character development occurred in this episode?`,
|
||||
`Are there any hidden details or easter eggs I might have missed?`,
|
||||
`How does this episode connect to the overall story arc?`
|
||||
];
|
||||
} else {
|
||||
const movie = context as MovieContext;
|
||||
return [
|
||||
`What is ${movie.title} about?`,
|
||||
`Explain the themes in this movie`,
|
||||
`What's the significance of the ending?`,
|
||||
`Tell me about the main characters and their development`,
|
||||
`Are there any interesting production facts about this film?`
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const aiService = AIService.getInstance();
|
||||
|
||||
// Export static methods for easier access
|
||||
export const createMovieContext = AIService.createMovieContext;
|
||||
export const createEpisodeContext = AIService.createEpisodeContext;
|
||||
export const generateConversationStarters = AIService.generateConversationStarters;
|
||||
|
|
@ -166,6 +166,7 @@ export class TMDBService {
|
|||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
append_to_response: 'external_ids,credits,keywords' // Append external IDs, cast/crew, and keywords for AI context
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
|
|
@ -300,6 +301,7 @@ export class TMDBService {
|
|||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
append_to_response: 'credits' // Include guest stars and crew for episode context
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
|
@ -585,7 +587,7 @@ export class TMDBService {
|
|||
headers: await this.getHeaders(),
|
||||
params: await this.getParams({
|
||||
language: 'en-US',
|
||||
append_to_response: 'external_ids' // Append external IDs
|
||||
append_to_response: 'external_ids,credits,keywords' // Append external IDs, cast/crew, and keywords for AI context
|
||||
}),
|
||||
});
|
||||
return response.data;
|
||||
|
|
|
|||
Loading…
Reference in a new issue