Spaces:
Sleeping
Sleeping
Commit ·
d93d789
1
Parent(s): 58ac475
Updated app
Browse files- .modified +0 -0
- docs/blueprint.md +21 -0
- src/ai/dev.ts +5 -1
- src/ai/flows/generate-mcq.ts +54 -0
- src/ai/flows/summarize-image.ts +51 -0
- src/app/globals.css +49 -49
- src/app/journey/[journeyId]/page.tsx +210 -0
- src/app/layout.tsx +11 -6
- src/app/page.tsx +102 -2
- src/app/select-journey/page.tsx +33 -0
- src/components/chatbot/chat-message.tsx +85 -0
- src/components/chatbot/chatbot.tsx +174 -0
- src/components/chatbot/mcq-options.tsx +54 -0
- src/components/journey/journey-card.tsx +59 -0
- src/components/journey/summary-report-dialog.tsx +112 -0
- src/components/layout/app-header.tsx +58 -0
- src/lib/journeys.ts +29 -0
- src/types/index.ts +20 -0
- tailwind.config.ts +1 -1
.modified
ADDED
|
File without changes
|
docs/blueprint.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# **App Name**: JourneyAI
|
| 2 |
+
|
| 3 |
+
## Core Features:
|
| 4 |
+
|
| 5 |
+
- Landing Page: Landing page with demo information and a 'View Demo' button.
|
| 6 |
+
- Journey Selection: 'Select a journey' screen with three predefined journey options, each with an image, label, and 'launch' button. A tooltip explains.
|
| 7 |
+
- AI-Powered MCQ Generation: Generative AI powered chatbot interface on the right, using the image as context for question generation.
|
| 8 |
+
- Chatbot UI: Displays system messages (MCQ questions, options, feedback) and user responses in a conversational chat layout.
|
| 9 |
+
- Response Handling: Implements logic for feedback and option disabling upon wrong answers.
|
| 10 |
+
- Final Summary Report: Uses generative AI as a tool to create a final summary report interpreting the selected image. The LLM chooses what to include from its analysis.
|
| 11 |
+
- Consistent Navigation: A consistent back button and details button throughout the app for navigation and context.
|
| 12 |
+
|
| 13 |
+
## Style Guidelines:
|
| 14 |
+
|
| 15 |
+
- Primary color: Indigo (#4B0082) to evoke intellect, exploration, and clarity.
|
| 16 |
+
- Background color: Light grey (#E6E6FA), a desaturated version of indigo, for a calming and clean backdrop.
|
| 17 |
+
- Accent color: Violet (#8F00FF) to highlight interactive elements and guide the user through the interface.
|
| 18 |
+
- Body text and headlines: 'Inter', sans-serif. Modern, machined, neutral, for clean readability and an objective feel.
|
| 19 |
+
- Code font: 'Source Code Pro', monospace, for displaying any relevant technical details or instructions.
|
| 20 |
+
- Simple, geometric icons to complement the tech-focused aesthetic.
|
| 21 |
+
- Split-screen layout in the main interface, featuring image and chatbot side-by-side.
|
src/ai/dev.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from 'dotenv';
|
| 2 |
+
config();
|
| 3 |
+
|
| 4 |
+
import '@/ai/flows/summarize-image.ts';
|
| 5 |
+
import '@/ai/flows/generate-mcq.ts';
|
src/ai/flows/generate-mcq.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Generates multiple-choice questions (MCQs) related to a given image.
|
| 5 |
+
*
|
| 6 |
+
* - generateMCQ - A function that generates MCQs based on the provided image data URI.
|
| 7 |
+
* - GenerateMCQInput - The input type for the generateMCQ function, including the image data URI.
|
| 8 |
+
* - GenerateMCQOutput - The return type for the generateMCQ function, providing the generated MCQs.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import {ai} from '@/ai/genkit';
|
| 12 |
+
import {z} from 'genkit';
|
| 13 |
+
|
| 14 |
+
const GenerateMCQInputSchema = z.object({
|
| 15 |
+
imageDataUri: z
|
| 16 |
+
.string()
|
| 17 |
+
.describe(
|
| 18 |
+
"A image, as a data URI that must include a MIME type and use Base64 encoding. Expected format: 'data:<mimetype>;base64,<encoded_data>'."
|
| 19 |
+
),
|
| 20 |
+
});
|
| 21 |
+
export type GenerateMCQInput = z.infer<typeof GenerateMCQInputSchema>;
|
| 22 |
+
|
| 23 |
+
const GenerateMCQOutputSchema = z.object({
|
| 24 |
+
mcq: z.string().describe('A multiple-choice question related to the image.'),
|
| 25 |
+
options: z.array(z.string()).describe('Array of possible answers.'),
|
| 26 |
+
correctAnswer: z.string().describe('The correct answer to the question.'),
|
| 27 |
+
});
|
| 28 |
+
export type GenerateMCQOutput = z.infer<typeof GenerateMCQOutputSchema>;
|
| 29 |
+
|
| 30 |
+
export async function generateMCQ(input: GenerateMCQInput): Promise<GenerateMCQOutput> {
|
| 31 |
+
return generateMCQFlow(input);
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const prompt = ai.definePrompt({
|
| 35 |
+
name: 'generateMCQPrompt',
|
| 36 |
+
input: {schema: GenerateMCQInputSchema},
|
| 37 |
+
output: {schema: GenerateMCQOutputSchema},
|
| 38 |
+
prompt: `You are an expert educator creating multiple choice questions. Create one question related to the image provided, along with possible answers and the single correct answer.
|
| 39 |
+
|
| 40 |
+
Image: {{media url=imageDataUri}}
|
| 41 |
+
`,
|
| 42 |
+
});
|
| 43 |
+
|
| 44 |
+
const generateMCQFlow = ai.defineFlow(
|
| 45 |
+
{
|
| 46 |
+
name: 'generateMCQFlow',
|
| 47 |
+
inputSchema: GenerateMCQInputSchema,
|
| 48 |
+
outputSchema: GenerateMCQOutputSchema,
|
| 49 |
+
},
|
| 50 |
+
async input => {
|
| 51 |
+
const {output} = await prompt(input);
|
| 52 |
+
return output!;
|
| 53 |
+
}
|
| 54 |
+
);
|
src/ai/flows/summarize-image.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview Summarizes an image to provide insights and key takeaways.
|
| 5 |
+
*
|
| 6 |
+
* - summarizeImage - A function that summarizes the image.
|
| 7 |
+
* - SummarizeImageInput - The input type for the summarizeImage function.
|
| 8 |
+
* - SummarizeImageOutput - The return type for the summarizeImage function.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import {ai} from '@/ai/genkit';
|
| 12 |
+
import {z} from 'genkit';
|
| 13 |
+
|
| 14 |
+
const SummarizeImageInputSchema = z.object({
|
| 15 |
+
imageDataUri: z
|
| 16 |
+
.string()
|
| 17 |
+
.describe(
|
| 18 |
+
'The image to summarize, as a data URI that must include a MIME type and use Base64 encoding. Expected format: \'data:<mimetype>;base64,<encoded_data>\'.'
|
| 19 |
+
),
|
| 20 |
+
});
|
| 21 |
+
export type SummarizeImageInput = z.infer<typeof SummarizeImageInputSchema>;
|
| 22 |
+
|
| 23 |
+
const SummarizeImageOutputSchema = z.object({
|
| 24 |
+
summary: z.string().describe('The summary of the image.'),
|
| 25 |
+
});
|
| 26 |
+
export type SummarizeImageOutput = z.infer<typeof SummarizeImageOutputSchema>;
|
| 27 |
+
|
| 28 |
+
export async function summarizeImage(input: SummarizeImageInput): Promise<SummarizeImageOutput> {
|
| 29 |
+
return summarizeImageFlow(input);
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
const prompt = ai.definePrompt({
|
| 33 |
+
name: 'summarizeImagePrompt',
|
| 34 |
+
input: {schema: SummarizeImageInputSchema},
|
| 35 |
+
output: {schema: SummarizeImageOutputSchema},
|
| 36 |
+
prompt: `You are an AI expert in image analysis. Summarize the key aspects and insights of the following image in a concise report.
|
| 37 |
+
|
| 38 |
+
Image: {{media url=imageDataUri}}`,
|
| 39 |
+
});
|
| 40 |
+
|
| 41 |
+
const summarizeImageFlow = ai.defineFlow(
|
| 42 |
+
{
|
| 43 |
+
name: 'summarizeImageFlow',
|
| 44 |
+
inputSchema: SummarizeImageInputSchema,
|
| 45 |
+
outputSchema: SummarizeImageOutputSchema,
|
| 46 |
+
},
|
| 47 |
+
async input => {
|
| 48 |
+
const {output} = await prompt(input);
|
| 49 |
+
return output!;
|
| 50 |
+
}
|
| 51 |
+
);
|
src/app/globals.css
CHANGED
|
@@ -8,73 +8,73 @@ body {
|
|
| 8 |
|
| 9 |
@layer base {
|
| 10 |
:root {
|
| 11 |
-
--background:
|
| 12 |
-
--foreground:
|
| 13 |
-
--card:
|
| 14 |
-
--card-foreground:
|
| 15 |
-
--popover:
|
| 16 |
-
--popover-foreground:
|
| 17 |
-
--primary:
|
| 18 |
-
--primary-foreground: 0 0% 98%;
|
| 19 |
-
--secondary:
|
| 20 |
-
--secondary-foreground:
|
| 21 |
-
--muted:
|
| 22 |
-
--muted-foreground:
|
| 23 |
-
--accent:
|
| 24 |
-
--accent-foreground: 0 0%
|
| 25 |
--destructive: 0 84.2% 60.2%;
|
| 26 |
--destructive-foreground: 0 0% 98%;
|
| 27 |
-
--border:
|
| 28 |
-
--input:
|
| 29 |
-
--ring:
|
| 30 |
--chart-1: 12 76% 61%;
|
| 31 |
--chart-2: 173 58% 39%;
|
| 32 |
--chart-3: 197 37% 24%;
|
| 33 |
--chart-4: 43 74% 66%;
|
| 34 |
--chart-5: 27 87% 67%;
|
| 35 |
--radius: 0.5rem;
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
--sidebar-
|
|
|
|
|
|
|
| 39 |
--sidebar-primary-foreground: 0 0% 98%;
|
| 40 |
-
--sidebar-accent:
|
| 41 |
-
--sidebar-accent-foreground:
|
| 42 |
-
--sidebar-border:
|
| 43 |
-
--sidebar-ring:
|
| 44 |
}
|
|
|
|
| 45 |
.dark {
|
| 46 |
-
|
|
|
|
| 47 |
--foreground: 0 0% 98%;
|
| 48 |
-
--card:
|
| 49 |
--card-foreground: 0 0% 98%;
|
| 50 |
-
--popover:
|
| 51 |
--popover-foreground: 0 0% 98%;
|
| 52 |
-
--primary:
|
| 53 |
-
--primary-foreground:
|
| 54 |
-
--secondary:
|
| 55 |
--secondary-foreground: 0 0% 98%;
|
| 56 |
-
--muted:
|
| 57 |
--muted-foreground: 0 0% 63.9%;
|
| 58 |
-
--accent:
|
| 59 |
-
--accent-foreground:
|
| 60 |
--destructive: 0 62.8% 30.6%;
|
| 61 |
--destructive-foreground: 0 0% 98%;
|
| 62 |
-
--border:
|
| 63 |
-
--input:
|
| 64 |
-
--ring:
|
| 65 |
-
|
| 66 |
-
--
|
| 67 |
-
--
|
| 68 |
-
--
|
| 69 |
-
--
|
| 70 |
-
--sidebar-
|
| 71 |
-
--sidebar-foreground:
|
| 72 |
-
--sidebar-
|
| 73 |
-
--sidebar-
|
| 74 |
-
--sidebar-accent: 240 3.7% 15.9%;
|
| 75 |
-
--sidebar-accent-foreground: 240 4.8% 95.9%;
|
| 76 |
-
--sidebar-border: 240 3.7% 15.9%;
|
| 77 |
-
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 78 |
}
|
| 79 |
}
|
| 80 |
|
|
|
|
| 8 |
|
| 9 |
@layer base {
|
| 10 |
:root {
|
| 11 |
+
--background: 240 67% 97%; /* Light Lavender - E6E6FA */
|
| 12 |
+
--foreground: 240 10% 3.9%; /* Default dark text */
|
| 13 |
+
--card: 240 67% 97%; /* Same as background for a cohesive look */
|
| 14 |
+
--card-foreground: 240 10% 3.9%;
|
| 15 |
+
--popover: 240 67% 97%;
|
| 16 |
+
--popover-foreground: 240 10% 3.9%;
|
| 17 |
+
--primary: 272 100% 25%; /* Indigo - 4B0082 */
|
| 18 |
+
--primary-foreground: 0 0% 98%; /* White text on Indigo */
|
| 19 |
+
--secondary: 240 30% 90%; /* Lighter grey for secondary elements */
|
| 20 |
+
--secondary-foreground: 240 5% 25%;
|
| 21 |
+
--muted: 240 20% 85%; /* Muted grey */
|
| 22 |
+
--muted-foreground: 240 5% 45%;
|
| 23 |
+
--accent: 274 100% 50%; /* Violet - 8F00FF */
|
| 24 |
+
--accent-foreground: 0 0% 98%; /* White text on Violet */
|
| 25 |
--destructive: 0 84.2% 60.2%;
|
| 26 |
--destructive-foreground: 0 0% 98%;
|
| 27 |
+
--border: 240 20% 88%; /* Slightly darker border */
|
| 28 |
+
--input: 240 20% 92%; /* Input background */
|
| 29 |
+
--ring: 274 100% 60%; /* Ring color related to accent */
|
| 30 |
--chart-1: 12 76% 61%;
|
| 31 |
--chart-2: 173 58% 39%;
|
| 32 |
--chart-3: 197 37% 24%;
|
| 33 |
--chart-4: 43 74% 66%;
|
| 34 |
--chart-5: 27 87% 67%;
|
| 35 |
--radius: 0.5rem;
|
| 36 |
+
|
| 37 |
+
/* Sidebar specific colors, matched to the theme */
|
| 38 |
+
--sidebar-background: 240 60% 95%; /* Slightly darker lavender for sidebar */
|
| 39 |
+
--sidebar-foreground: 240 10% 20%;
|
| 40 |
+
--sidebar-primary: 272 100% 25%;
|
| 41 |
--sidebar-primary-foreground: 0 0% 98%;
|
| 42 |
+
--sidebar-accent: 274 100% 50%;
|
| 43 |
+
--sidebar-accent-foreground: 0 0% 98%;
|
| 44 |
+
--sidebar-border: 240 30% 85%;
|
| 45 |
+
--sidebar-ring: 274 100% 60%;
|
| 46 |
}
|
| 47 |
+
|
| 48 |
.dark {
|
| 49 |
+
/* Define dark theme if needed, for now keep it consistent or adjust as per dark mode strategy */
|
| 50 |
+
--background: 240 10% 3.9%;
|
| 51 |
--foreground: 0 0% 98%;
|
| 52 |
+
--card: 240 10% 3.9%;
|
| 53 |
--card-foreground: 0 0% 98%;
|
| 54 |
+
--popover: 240 10% 3.9%;
|
| 55 |
--popover-foreground: 0 0% 98%;
|
| 56 |
+
--primary: 272 100% 70%; /* Lighter Indigo for dark mode */
|
| 57 |
+
--primary-foreground: 272 100% 15%;
|
| 58 |
+
--secondary: 240 5% 15%;
|
| 59 |
--secondary-foreground: 0 0% 98%;
|
| 60 |
+
--muted: 240 5% 20%;
|
| 61 |
--muted-foreground: 0 0% 63.9%;
|
| 62 |
+
--accent: 274 100% 65%; /* Lighter Violet for dark mode */
|
| 63 |
+
--accent-foreground: 274 100% 10%;
|
| 64 |
--destructive: 0 62.8% 30.6%;
|
| 65 |
--destructive-foreground: 0 0% 98%;
|
| 66 |
+
--border: 240 5% 15%;
|
| 67 |
+
--input: 240 5% 15%;
|
| 68 |
+
--ring: 274 100% 70%;
|
| 69 |
+
|
| 70 |
+
--sidebar-background: 240 8% 7%;
|
| 71 |
+
--sidebar-foreground: 0 0% 95%;
|
| 72 |
+
--sidebar-primary: 272 100% 70%;
|
| 73 |
+
--sidebar-primary-foreground: 272 100% 15%;
|
| 74 |
+
--sidebar-accent: 274 100% 65%;
|
| 75 |
+
--sidebar-accent-foreground: 274 100% 10%;
|
| 76 |
+
--sidebar-border: 240 5% 15%;
|
| 77 |
+
--sidebar-ring: 274 100% 70%;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
}
|
| 80 |
|
src/app/journey/[journeyId]/page.tsx
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState, useCallback } from 'react';
|
| 4 |
+
import { useParams, useRouter } from 'next/navigation';
|
| 5 |
+
import Image from 'next/image';
|
| 6 |
+
import { getJourneyById, type Journey } from '@/lib/journeys';
|
| 7 |
+
import { Chatbot } from '@/components/chatbot/chatbot';
|
| 8 |
+
import { SummaryReportDialog } from '@/components/journey/summary-report-dialog';
|
| 9 |
+
import { AppHeader } from '@/components/layout/app-header';
|
| 10 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 11 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 12 |
+
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
| 13 |
+
import { AlertTriangle, Loader2 } from 'lucide-react';
|
| 14 |
+
import { useToast } from '@/hooks/use-toast';
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
async function imageToDataUri(imageUrl: string): Promise<string | null> {
|
| 18 |
+
try {
|
| 19 |
+
const response = await fetch(imageUrl);
|
| 20 |
+
if (!response.ok) {
|
| 21 |
+
throw new Error(`Failed to fetch image: ${response.statusText}`);
|
| 22 |
+
}
|
| 23 |
+
const blob = await response.blob();
|
| 24 |
+
return new Promise((resolve, reject) => {
|
| 25 |
+
const reader = new FileReader();
|
| 26 |
+
reader.onloadend = () => resolve(reader.result as string);
|
| 27 |
+
reader.onerror = reject;
|
| 28 |
+
reader.readAsDataURL(blob);
|
| 29 |
+
});
|
| 30 |
+
} catch (error) {
|
| 31 |
+
console.error("Error converting image to data URI:", error);
|
| 32 |
+
return null;
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
export default function JourneyPage() {
|
| 38 |
+
const router = useRouter();
|
| 39 |
+
const params = useParams();
|
| 40 |
+
const journeyId = typeof params.journeyId === 'string' ? params.journeyId : '';
|
| 41 |
+
const [journey, setJourney] = useState<Journey | null>(null);
|
| 42 |
+
const [imageDataUri, setImageDataUri] = useState<string | null>(null);
|
| 43 |
+
const [isSummaryDialogOpen, setIsSummaryDialogOpen] = useState(false);
|
| 44 |
+
const [isLoadingJourney, setIsLoadingJourney] = useState(true);
|
| 45 |
+
const [error, setError] = useState<string | null>(null);
|
| 46 |
+
const { toast } = useToast();
|
| 47 |
+
|
| 48 |
+
useEffect(() => {
|
| 49 |
+
if (journeyId) {
|
| 50 |
+
const currentJourney = getJourneyById(journeyId);
|
| 51 |
+
if (currentJourney) {
|
| 52 |
+
setJourney(currentJourney);
|
| 53 |
+
setError(null);
|
| 54 |
+
} else {
|
| 55 |
+
setError("Journey not found. Please select a valid journey.");
|
| 56 |
+
setJourney(null);
|
| 57 |
+
toast({
|
| 58 |
+
title: "Error",
|
| 59 |
+
description: "Journey not found.",
|
| 60 |
+
variant: "destructive",
|
| 61 |
+
});
|
| 62 |
+
}
|
| 63 |
+
} else {
|
| 64 |
+
setError("No journey ID provided.");
|
| 65 |
+
toast({
|
| 66 |
+
title: "Error",
|
| 67 |
+
description: "No journey ID in URL.",
|
| 68 |
+
variant: "destructive",
|
| 69 |
+
});
|
| 70 |
+
}
|
| 71 |
+
}, [journeyId, toast]);
|
| 72 |
+
|
| 73 |
+
useEffect(() => {
|
| 74 |
+
if (journey?.imageUrl) {
|
| 75 |
+
setIsLoadingJourney(true); // Start loading state for image conversion
|
| 76 |
+
imageToDataUri(journey.imageUrl)
|
| 77 |
+
.then(uri => {
|
| 78 |
+
setImageDataUri(uri);
|
| 79 |
+
if (!uri) {
|
| 80 |
+
setError("Failed to load journey image for AI interaction.");
|
| 81 |
+
toast({
|
| 82 |
+
title: "Image Load Error",
|
| 83 |
+
description: "Could not load image data for AI features. Some functionalities might be limited.",
|
| 84 |
+
variant: "destructive",
|
| 85 |
+
});
|
| 86 |
+
}
|
| 87 |
+
})
|
| 88 |
+
.finally(() => setIsLoadingJourney(false));
|
| 89 |
+
} else if (journey && !journey.imageUrl) {
|
| 90 |
+
// Journey exists but has no image URL
|
| 91 |
+
setError("Journey image is missing.");
|
| 92 |
+
toast({
|
| 93 |
+
title: "Missing Image",
|
| 94 |
+
description: "The selected journey does not have an image.",
|
| 95 |
+
variant: "destructive",
|
| 96 |
+
});
|
| 97 |
+
setIsLoadingJourney(false);
|
| 98 |
+
}
|
| 99 |
+
}, [journey, toast]);
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
const handleOpenSummaryDialog = useCallback(() => {
|
| 103 |
+
if (!imageDataUri) {
|
| 104 |
+
toast({
|
| 105 |
+
title: "Image Not Ready",
|
| 106 |
+
description: "The image data is still loading or failed to load. Please wait or try refreshing.",
|
| 107 |
+
variant: "destructive",
|
| 108 |
+
});
|
| 109 |
+
return;
|
| 110 |
+
}
|
| 111 |
+
setIsSummaryDialogOpen(true);
|
| 112 |
+
}, [imageDataUri, toast]);
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
if (error) {
|
| 116 |
+
return (
|
| 117 |
+
<div className="flex min-h-screen flex-col">
|
| 118 |
+
<AppHeader showBackButton backHref="/select-journey" />
|
| 119 |
+
<main className="container mx-auto flex flex-1 items-center justify-center p-4">
|
| 120 |
+
<Alert variant="destructive" className="max-w-md">
|
| 121 |
+
<AlertTriangle className="h-4 w-4" />
|
| 122 |
+
<AlertTitle>Error</AlertTitle>
|
| 123 |
+
<AlertDescription>{error}</AlertDescription>
|
| 124 |
+
</Alert>
|
| 125 |
+
</main>
|
| 126 |
+
</div>
|
| 127 |
+
);
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
if (!journey && !isLoadingJourney && !error) { // Initial state before journey is resolved or if ID is bad but not caught by setError yet
|
| 131 |
+
return (
|
| 132 |
+
<div className="flex min-h-screen flex-col">
|
| 133 |
+
<AppHeader showBackButton backHref="/select-journey" />
|
| 134 |
+
<main className="container mx-auto flex flex-1 items-center justify-center p-4">
|
| 135 |
+
<Loader2 className="h-12 w-12 animate-spin text-primary" />
|
| 136 |
+
</main>
|
| 137 |
+
</div>
|
| 138 |
+
);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
const journeyTitle = journey?.title || 'Loading Journey...';
|
| 142 |
+
|
| 143 |
+
return (
|
| 144 |
+
<div className="flex h-screen flex-col">
|
| 145 |
+
<AppHeader
|
| 146 |
+
showBackButton
|
| 147 |
+
backHref="/select-journey"
|
| 148 |
+
title={journeyTitle}
|
| 149 |
+
showDetailsButton={!!journey && !!imageDataUri} // Only show if journey and image are loaded
|
| 150 |
+
onDetailsClick={handleOpenSummaryDialog}
|
| 151 |
+
/>
|
| 152 |
+
<main className="flex-1 overflow-hidden p-2 md:p-4">
|
| 153 |
+
<div className="grid h-full grid-cols-1 gap-2 md:grid-cols-2 md:gap-4">
|
| 154 |
+
<Card className="flex flex-col items-center justify-center overflow-hidden shadow-lg">
|
| 155 |
+
{isLoadingJourney || !journey?.imageUrl ? (
|
| 156 |
+
<div className="flex h-full w-full flex-col items-center justify-center bg-muted">
|
| 157 |
+
<Loader2 className="h-16 w-16 animate-spin text-primary mb-4" />
|
| 158 |
+
<p className="text-muted-foreground">Loading image for {journey?.title || 'journey'}...</p>
|
| 159 |
+
</div>
|
| 160 |
+
) : (
|
| 161 |
+
<Image
|
| 162 |
+
src={journey.imageUrl}
|
| 163 |
+
alt={journey.title}
|
| 164 |
+
width={800}
|
| 165 |
+
height={600}
|
| 166 |
+
className="h-full w-full object-contain p-2"
|
| 167 |
+
data-ai-hint={journey.imageHint}
|
| 168 |
+
priority
|
| 169 |
+
/>
|
| 170 |
+
)}
|
| 171 |
+
{!isLoadingJourney && !journey?.imageUrl && journey && (
|
| 172 |
+
<div className="flex h-full w-full flex-col items-center justify-center bg-muted p-4 text-center">
|
| 173 |
+
<AlertTriangle className="h-16 w-16 text-destructive mb-4" />
|
| 174 |
+
<p className="text-destructive-foreground font-semibold">Image Not Available</p>
|
| 175 |
+
<p className="text-sm text-muted-foreground">The image for this journey could not be displayed.</p>
|
| 176 |
+
</div>
|
| 177 |
+
)}
|
| 178 |
+
</Card>
|
| 179 |
+
|
| 180 |
+
<div className="h-full overflow-y-auto">
|
| 181 |
+
{isLoadingJourney && !imageDataUri && (
|
| 182 |
+
<div className="flex h-full flex-col items-center justify-center rounded-lg border bg-card p-4 shadow-xl">
|
| 183 |
+
<Loader2 className="h-12 w-12 animate-spin text-primary mb-4" />
|
| 184 |
+
<p className="text-muted-foreground">Preparing AI interactions...</p>
|
| 185 |
+
</div>
|
| 186 |
+
)}
|
| 187 |
+
{!isLoadingJourney && imageDataUri && journey && (
|
| 188 |
+
<Chatbot imageDataUri={imageDataUri} journeyTitle={journey.title} />
|
| 189 |
+
)}
|
| 190 |
+
{!isLoadingJourney && !imageDataUri && journey && (
|
| 191 |
+
<div className="flex h-full flex-col items-center justify-center rounded-lg border bg-card p-4 shadow-xl text-center">
|
| 192 |
+
<AlertTriangle className="h-12 w-12 text-destructive mb-4" />
|
| 193 |
+
<p className="font-semibold text-destructive">AI Features Unavailable</p>
|
| 194 |
+
<p className="text-sm text-muted-foreground">Could not load image data required for AI chat and summary.</p>
|
| 195 |
+
</div>
|
| 196 |
+
)}
|
| 197 |
+
</div>
|
| 198 |
+
</div>
|
| 199 |
+
</main>
|
| 200 |
+
{journey && ( // SummaryReportDialog should only be available if a journey context exists
|
| 201 |
+
<SummaryReportDialog
|
| 202 |
+
isOpen={isSummaryDialogOpen}
|
| 203 |
+
onOpenChange={setIsSummaryDialogOpen}
|
| 204 |
+
imageDataUri={imageDataUri}
|
| 205 |
+
journeyTitle={journey.title}
|
| 206 |
+
/>
|
| 207 |
+
)}
|
| 208 |
+
</div>
|
| 209 |
+
);
|
| 210 |
+
}
|
src/app/layout.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
import type {Metadata} from 'next';
|
| 2 |
import './globals.css';
|
|
|
|
| 3 |
|
| 4 |
export const metadata: Metadata = {
|
| 5 |
-
title: '
|
| 6 |
-
description: '
|
| 7 |
};
|
| 8 |
|
| 9 |
export default function RootLayout({
|
|
@@ -14,11 +15,15 @@ export default function RootLayout({
|
|
| 14 |
return (
|
| 15 |
<html lang="en">
|
| 16 |
<head>
|
| 17 |
-
<link rel="preconnect" href="https://fonts.googleapis.com">
|
| 18 |
-
<link rel="preconnect" href="https://fonts.gstatic.com"
|
| 19 |
-
<link href="https://fonts.googleapis.com/css2?family=Inter&display=swap" rel="stylesheet"
|
|
|
|
| 20 |
</head>
|
| 21 |
-
<body className="font-body antialiased">
|
|
|
|
|
|
|
|
|
|
| 22 |
</html>
|
| 23 |
);
|
| 24 |
}
|
|
|
|
| 1 |
import type {Metadata} from 'next';
|
| 2 |
import './globals.css';
|
| 3 |
+
import { Toaster } from "@/components/ui/toaster";
|
| 4 |
|
| 5 |
export const metadata: Metadata = {
|
| 6 |
+
title: 'JourneyAI',
|
| 7 |
+
description: 'AI-Powered MCQ and Summary Generation',
|
| 8 |
};
|
| 9 |
|
| 10 |
export default function RootLayout({
|
|
|
|
| 15 |
return (
|
| 16 |
<html lang="en">
|
| 17 |
<head>
|
| 18 |
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 19 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
| 20 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet" />
|
| 21 |
+
<link href="https://fonts.googleapis.com/css2?family=Source+Code+Pro:wght@400;500&display=swap" rel="stylesheet" />
|
| 22 |
</head>
|
| 23 |
+
<body className="font-body antialiased">
|
| 24 |
+
{children}
|
| 25 |
+
<Toaster />
|
| 26 |
+
</body>
|
| 27 |
</html>
|
| 28 |
);
|
| 29 |
}
|
src/app/page.tsx
CHANGED
|
@@ -1,3 +1,103 @@
|
|
| 1 |
-
|
| 2 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
}
|
|
|
|
| 1 |
+
import Link from 'next/link';
|
| 2 |
+
import { Button } from '@/components/ui/button';
|
| 3 |
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
| 4 |
+
import { Rocket, Lightbulb, CheckCircle } from 'lucide-react';
|
| 5 |
+
import Image from 'next/image';
|
| 6 |
+
import { AppHeader } from '@/components/layout/app-header';
|
| 7 |
+
|
| 8 |
+
export default function LandingPage() {
|
| 9 |
+
return (
|
| 10 |
+
<div className="flex min-h-screen flex-col">
|
| 11 |
+
<AppHeader />
|
| 12 |
+
<main className="flex-1">
|
| 13 |
+
<section className="container mx-auto flex flex-col items-center justify-center py-12 text-center md:py-20 lg:py-28">
|
| 14 |
+
<h1 className="mb-6 font-headline text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl">
|
| 15 |
+
Discover with <span className="text-primary">JourneyAI</span>
|
| 16 |
+
</h1>
|
| 17 |
+
<p className="mb-8 max-w-2xl text-lg text-muted-foreground md:text-xl">
|
| 18 |
+
Embark on interactive learning adventures. JourneyAI uses cutting-edge generative AI to create engaging multiple-choice questions and insightful summaries from images.
|
| 19 |
+
</p>
|
| 20 |
+
<Button size="lg" asChild className="font-semibold">
|
| 21 |
+
<Link href="/select-journey">
|
| 22 |
+
View Demo
|
| 23 |
+
<Rocket className="ml-2 h-5 w-5" />
|
| 24 |
+
</Link>
|
| 25 |
+
</Button>
|
| 26 |
+
</section>
|
| 27 |
+
|
| 28 |
+
<section className="bg-secondary py-12 md:py-20">
|
| 29 |
+
<div className="container mx-auto">
|
| 30 |
+
<h2 className="mb-12 text-center font-headline text-3xl font-bold md:text-4xl">
|
| 31 |
+
How It Works
|
| 32 |
+
</h2>
|
| 33 |
+
<div className="grid grid-cols-1 gap-8 md:grid-cols-3">
|
| 34 |
+
<Card className="shadow-lg">
|
| 35 |
+
<CardHeader>
|
| 36 |
+
<div className="mb-4 flex justify-center">
|
| 37 |
+
<div className="rounded-full bg-primary/10 p-3">
|
| 38 |
+
<Image src="https://placehold.co/48x48.png" alt="Select Journey Icon" width={48} height={48} data-ai-hint="map compass" className="rounded-full" />
|
| 39 |
+
</div>
|
| 40 |
+
</div>
|
| 41 |
+
<CardTitle className="text-center font-headline">1. Select Your Journey</CardTitle>
|
| 42 |
+
</CardHeader>
|
| 43 |
+
<CardContent>
|
| 44 |
+
<CardDescription className="text-center">
|
| 45 |
+
Choose from a variety of captivating visual scenarios to begin your exploration.
|
| 46 |
+
</CardDescription>
|
| 47 |
+
</CardContent>
|
| 48 |
+
</Card>
|
| 49 |
+
<Card className="shadow-lg">
|
| 50 |
+
<CardHeader>
|
| 51 |
+
<div className="mb-4 flex justify-center">
|
| 52 |
+
<div className="rounded-full bg-primary/10 p-3">
|
| 53 |
+
<Lightbulb className="h-12 w-12 text-primary" />
|
| 54 |
+
</div>
|
| 55 |
+
</div>
|
| 56 |
+
<CardTitle className="text-center font-headline">2. Engage with AI</CardTitle>
|
| 57 |
+
</CardHeader>
|
| 58 |
+
<CardContent>
|
| 59 |
+
<CardDescription className="text-center">
|
| 60 |
+
Answer AI-generated multiple-choice questions designed to deepen your understanding of the image.
|
| 61 |
+
</CardDescription>
|
| 62 |
+
</CardContent>
|
| 63 |
+
</Card>
|
| 64 |
+
<Card className="shadow-lg">
|
| 65 |
+
<CardHeader>
|
| 66 |
+
<div className="mb-4 flex justify-center">
|
| 67 |
+
<div className="rounded-full bg-primary/10 p-3">
|
| 68 |
+
<CheckCircle className="h-12 w-12 text-primary" />
|
| 69 |
+
</div>
|
| 70 |
+
</div>
|
| 71 |
+
<CardTitle className="text-center font-headline">3. Get Insights</CardTitle>
|
| 72 |
+
</CardHeader>
|
| 73 |
+
<CardContent>
|
| 74 |
+
<CardDescription className="text-center">
|
| 75 |
+
Receive a comprehensive AI-generated summary report interpreting the key aspects of the image.
|
| 76 |
+
</CardDescription>
|
| 77 |
+
</CardContent>
|
| 78 |
+
</Card>
|
| 79 |
+
</div>
|
| 80 |
+
</div>
|
| 81 |
+
</section>
|
| 82 |
+
|
| 83 |
+
<section className="container mx-auto py-12 text-center md:py-20">
|
| 84 |
+
<h2 className="mb-6 font-headline text-2xl font-bold md:text-3xl">Ready to Start?</h2>
|
| 85 |
+
<p className="mb-8 max-w-xl mx-auto text-muted-foreground">
|
| 86 |
+
Click the button below to dive into an AI-driven learning experience like no other.
|
| 87 |
+
</p>
|
| 88 |
+
<Button size="lg" asChild className="font-semibold">
|
| 89 |
+
<Link href="/select-journey">
|
| 90 |
+
Explore Journeys
|
| 91 |
+
<Rocket className="ml-2 h-5 w-5" />
|
| 92 |
+
</Link>
|
| 93 |
+
</Button>
|
| 94 |
+
</section>
|
| 95 |
+
</main>
|
| 96 |
+
<footer className="border-t py-6 text-center">
|
| 97 |
+
<p className="text-sm text-muted-foreground">
|
| 98 |
+
© {new Date().getFullYear()} JourneyAI. Powered by Generative AI.
|
| 99 |
+
</p>
|
| 100 |
+
</footer>
|
| 101 |
+
</div>
|
| 102 |
+
);
|
| 103 |
}
|
src/app/select-journey/page.tsx
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { journeys } from '@/lib/journeys';
|
| 2 |
+
import { JourneyCard } from '@/components/journey/journey-card';
|
| 3 |
+
import { AppHeader } from '@/components/layout/app-header';
|
| 4 |
+
|
| 5 |
+
export default function SelectJourneyPage() {
|
| 6 |
+
return (
|
| 7 |
+
<div className="flex min-h-screen flex-col">
|
| 8 |
+
<AppHeader showBackButton backHref="/" />
|
| 9 |
+
<main className="flex-1">
|
| 10 |
+
<section className="container mx-auto py-12 md:py-16">
|
| 11 |
+
<div className="mb-10 text-center">
|
| 12 |
+
<h1 className="font-headline text-3xl font-bold tracking-tight md:text-4xl">
|
| 13 |
+
Select Your Journey
|
| 14 |
+
</h1>
|
| 15 |
+
<p className="mt-3 max-w-xl mx-auto text-lg text-muted-foreground">
|
| 16 |
+
Choose an image to begin your AI-guided exploration. Each journey offers unique questions and insights.
|
| 17 |
+
</p>
|
| 18 |
+
</div>
|
| 19 |
+
<div className="grid grid-cols-1 gap-8 sm:grid-cols-2 lg:grid-cols-3">
|
| 20 |
+
{journeys.map((journey) => (
|
| 21 |
+
<JourneyCard key={journey.id} journey={journey} />
|
| 22 |
+
))}
|
| 23 |
+
</div>
|
| 24 |
+
</section>
|
| 25 |
+
</main>
|
| 26 |
+
<footer className="border-t py-6 text-center">
|
| 27 |
+
<p className="text-sm text-muted-foreground">
|
| 28 |
+
© {new Date().getFullYear()} JourneyAI. Interactive learning powered by AI.
|
| 29 |
+
</p>
|
| 30 |
+
</footer>
|
| 31 |
+
</div>
|
| 32 |
+
);
|
| 33 |
+
}
|
src/components/chatbot/chat-message.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import type { ChatMessage as ChatMessageType } from '@/types';
|
| 4 |
+
import { cn } from '@/lib/utils';
|
| 5 |
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
| 6 |
+
import { Bot, User, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
|
| 7 |
+
import { Card, CardContent } from '@/components/ui/card';
|
| 8 |
+
import { Badge } from '@/components/ui/badge';
|
| 9 |
+
|
| 10 |
+
interface ChatMessageProps {
|
| 11 |
+
message: ChatMessageType;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function ChatMessage({ message }: ChatMessageProps) {
|
| 15 |
+
const isUser = message.sender === 'user';
|
| 16 |
+
const Icon = isUser ? User : Bot;
|
| 17 |
+
const avatarFallback = isUser ? 'U' : 'AI';
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<div
|
| 21 |
+
className={cn(
|
| 22 |
+
'mb-4 flex items-end space-x-3',
|
| 23 |
+
isUser ? 'justify-end' : 'justify-start'
|
| 24 |
+
)}
|
| 25 |
+
>
|
| 26 |
+
{!isUser && (
|
| 27 |
+
<Avatar className="h-8 w-8 self-start">
|
| 28 |
+
<AvatarImage src="/placeholder-bot.png" alt="AI Avatar" />
|
| 29 |
+
<AvatarFallback><Bot className="h-4 w-4" /></AvatarFallback>
|
| 30 |
+
</Avatar>
|
| 31 |
+
)}
|
| 32 |
+
<Card
|
| 33 |
+
className={cn(
|
| 34 |
+
'max-w-sm rounded-2xl p-0 shadow-md md:max-w-md',
|
| 35 |
+
isUser
|
| 36 |
+
? 'rounded-br-none bg-primary text-primary-foreground'
|
| 37 |
+
: 'rounded-bl-none bg-card text-card-foreground border'
|
| 38 |
+
)}
|
| 39 |
+
>
|
| 40 |
+
<CardContent className="p-3">
|
| 41 |
+
{message.type === 'mcq' && message.mcq && (
|
| 42 |
+
<div className="space-y-2">
|
| 43 |
+
<p className="font-medium">{message.mcq.mcq}</p>
|
| 44 |
+
{message.mcq.options.map((option, index) => (
|
| 45 |
+
<Badge key={index} variant="secondary" className="mr-2 mt-1 block w-fit py-1 px-2.5">
|
| 46 |
+
{String.fromCharCode(65 + index)}. {option}
|
| 47 |
+
</Badge>
|
| 48 |
+
))}
|
| 49 |
+
</div>
|
| 50 |
+
)}
|
| 51 |
+
{message.type === 'text' && message.text && <p>{message.text}</p>}
|
| 52 |
+
{message.type === 'feedback' && message.text && (
|
| 53 |
+
<div className="flex items-center">
|
| 54 |
+
{message.isCorrect ? (
|
| 55 |
+
<CheckCircle className="mr-2 h-5 w-5 text-green-500" />
|
| 56 |
+
) : (
|
| 57 |
+
<XCircle className="mr-2 h-5 w-5 text-destructive" />
|
| 58 |
+
)}
|
| 59 |
+
<p>{message.text}</p>
|
| 60 |
+
</div>
|
| 61 |
+
)}
|
| 62 |
+
{message.type === 'error' && message.text && (
|
| 63 |
+
<div className="flex items-center text-destructive">
|
| 64 |
+
<AlertTriangle className="mr-2 h-5 w-5" />
|
| 65 |
+
<p>{message.text}</p>
|
| 66 |
+
</div>
|
| 67 |
+
)}
|
| 68 |
+
{message.type === 'user' && message.text && (
|
| 69 |
+
<p className="italic">You chose: "{message.text}"</p>
|
| 70 |
+
)}
|
| 71 |
+
|
| 72 |
+
<p className={cn("mt-1.5 text-xs", isUser ? "text-primary-foreground/70" : "text-muted-foreground")}>
|
| 73 |
+
{message.timestamp.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })}
|
| 74 |
+
</p>
|
| 75 |
+
</CardContent>
|
| 76 |
+
</Card>
|
| 77 |
+
{isUser && (
|
| 78 |
+
<Avatar className="h-8 w-8 self-start">
|
| 79 |
+
<AvatarImage src="/placeholder-user.png" alt="User Avatar" />
|
| 80 |
+
<AvatarFallback><User className="h-4 w-4" /></AvatarFallback>
|
| 81 |
+
</Avatar>
|
| 82 |
+
)}
|
| 83 |
+
</div>
|
| 84 |
+
);
|
| 85 |
+
}
|
src/components/chatbot/chatbot.tsx
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import type { ChangeEvent } from 'react';
|
| 4 |
+
import { useState, useEffect, useRef } from 'react';
|
| 5 |
+
import Image from 'next/image';
|
| 6 |
+
import { Button } from '@/components/ui/button';
|
| 7 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 8 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 9 |
+
import { generateMCQ, type GenerateMCQOutput } from '@/ai/flows/generate-mcq';
|
| 10 |
+
import type { ChatMessage as ChatMessageType } from '@/types';
|
| 11 |
+
import { ChatMessage } from './chat-message';
|
| 12 |
+
import { MCQOptions } from './mcq-options';
|
| 13 |
+
import { useToast } from '@/hooks/use-toast';
|
| 14 |
+
import { Send, RotateCcw } from 'lucide-react';
|
| 15 |
+
|
| 16 |
+
interface ChatbotProps {
|
| 17 |
+
imageDataUri: string | null; // Null initially until image is loaded and converted
|
| 18 |
+
journeyTitle: string;
|
| 19 |
+
}
|
| 20 |
+
|
| 21 |
+
export function Chatbot({ imageDataUri, journeyTitle }: ChatbotProps) {
|
| 22 |
+
const [messages, setMessages] = useState<ChatMessageType[]>([]);
|
| 23 |
+
const [currentMCQ, setCurrentMCQ] = useState<GenerateMCQOutput | null>(null);
|
| 24 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 25 |
+
const [isAwaitingAnswer, setIsAwaitingAnswer] = useState(false);
|
| 26 |
+
const scrollAreaRef = useRef<HTMLDivElement>(null);
|
| 27 |
+
const { toast } = useToast();
|
| 28 |
+
|
| 29 |
+
const addMessage = (message: Omit<ChatMessageType, 'id' | 'timestamp'>) => {
|
| 30 |
+
setMessages(prev => [...prev, { ...message, id: Date.now().toString(), timestamp: new Date() }]);
|
| 31 |
+
};
|
| 32 |
+
|
| 33 |
+
const fetchNewMCQ = async () => {
|
| 34 |
+
if (!imageDataUri) {
|
| 35 |
+
addMessage({ sender: 'ai', type: 'error', text: "Image data is not available to generate questions." });
|
| 36 |
+
return;
|
| 37 |
+
}
|
| 38 |
+
setIsLoading(true);
|
| 39 |
+
setIsAwaitingAnswer(false);
|
| 40 |
+
setCurrentMCQ(null); // Clear previous MCQ options
|
| 41 |
+
|
| 42 |
+
try {
|
| 43 |
+
const mcqData = await generateMCQ({ imageDataUri });
|
| 44 |
+
setCurrentMCQ(mcqData);
|
| 45 |
+
addMessage({ sender: 'ai', type: 'mcq', mcq: mcqData });
|
| 46 |
+
setIsAwaitingAnswer(true);
|
| 47 |
+
} catch (error) {
|
| 48 |
+
console.error("Error generating MCQ:", error);
|
| 49 |
+
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
|
| 50 |
+
addMessage({ sender: 'ai', type: 'error', text: `Failed to generate question: ${errorMessage}` });
|
| 51 |
+
toast({
|
| 52 |
+
title: "Error",
|
| 53 |
+
description: `Could not generate a new question. ${errorMessage}`,
|
| 54 |
+
variant: "destructive",
|
| 55 |
+
});
|
| 56 |
+
} finally {
|
| 57 |
+
setIsLoading(false);
|
| 58 |
+
}
|
| 59 |
+
};
|
| 60 |
+
|
| 61 |
+
useEffect(() => {
|
| 62 |
+
addMessage({
|
| 63 |
+
sender: 'ai',
|
| 64 |
+
type: 'text',
|
| 65 |
+
text: `Welcome to the ${journeyTitle} journey! Let's start with your first question.`,
|
| 66 |
+
});
|
| 67 |
+
if (imageDataUri) {
|
| 68 |
+
fetchNewMCQ();
|
| 69 |
+
} else {
|
| 70 |
+
// Message will be shown if imageDataUri is still null after initial load
|
| 71 |
+
addMessage({ sender: 'ai', type: 'text', text: "Preparing your journey..." });
|
| 72 |
+
}
|
| 73 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 74 |
+
}, [imageDataUri, journeyTitle]); // Added imageDataUri dependency
|
| 75 |
+
|
| 76 |
+
useEffect(() => {
|
| 77 |
+
if (imageDataUri && messages.length === 2 && messages[1].text === "Preparing your journey...") { // Assuming 2 initial messages
|
| 78 |
+
fetchNewMCQ();
|
| 79 |
+
}
|
| 80 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 81 |
+
}, [imageDataUri, messages]);
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
useEffect(() => {
|
| 85 |
+
if (scrollAreaRef.current) {
|
| 86 |
+
scrollAreaRef.current.scrollTo({ top: scrollAreaRef.current.scrollHeight, behavior: 'smooth' });
|
| 87 |
+
}
|
| 88 |
+
}, [messages]);
|
| 89 |
+
|
| 90 |
+
const handleOptionSelect = (option: string, isCorrect: boolean) => {
|
| 91 |
+
setIsAwaitingAnswer(false); // User has answered
|
| 92 |
+
|
| 93 |
+
addMessage({
|
| 94 |
+
sender: 'user',
|
| 95 |
+
type: 'user', // Special type for user's MCQ choice
|
| 96 |
+
text: option,
|
| 97 |
+
});
|
| 98 |
+
|
| 99 |
+
if (isCorrect) {
|
| 100 |
+
addMessage({ sender: 'ai', type: 'feedback', text: "That's correct! Well done.", isCorrect: true });
|
| 101 |
+
} else {
|
| 102 |
+
addMessage({
|
| 103 |
+
sender: 'ai',
|
| 104 |
+
type: 'feedback',
|
| 105 |
+
text: `Not quite. The correct answer was: ${currentMCQ?.correctAnswer}`,
|
| 106 |
+
isCorrect: false,
|
| 107 |
+
});
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// Fetch next question after a short delay to allow user to read feedback
|
| 111 |
+
setTimeout(() => {
|
| 112 |
+
fetchNewMCQ();
|
| 113 |
+
}, 2000);
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const handleRestartJourney = () => {
|
| 117 |
+
setMessages([]);
|
| 118 |
+
setCurrentMCQ(null);
|
| 119 |
+
setIsLoading(false);
|
| 120 |
+
setIsAwaitingAnswer(false);
|
| 121 |
+
addMessage({
|
| 122 |
+
sender: 'ai',
|
| 123 |
+
type: 'text',
|
| 124 |
+
text: `Welcome to the ${journeyTitle} journey! Let's start with your first question.`,
|
| 125 |
+
});
|
| 126 |
+
if (imageDataUri) {
|
| 127 |
+
fetchNewMCQ();
|
| 128 |
+
} else {
|
| 129 |
+
addMessage({ sender: 'ai', type: 'text', text: "Preparing your journey..." });
|
| 130 |
+
}
|
| 131 |
+
};
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
return (
|
| 135 |
+
<div className="flex h-full flex-col rounded-lg border bg-card shadow-xl">
|
| 136 |
+
<div className="border-b p-4">
|
| 137 |
+
<h2 className="font-headline text-lg font-semibold text-center">AI Chat Helper</h2>
|
| 138 |
+
</div>
|
| 139 |
+
<ScrollArea className="flex-1 p-4" ref={scrollAreaRef}>
|
| 140 |
+
<div className="space-y-4">
|
| 141 |
+
{messages.map((msg) => (
|
| 142 |
+
<ChatMessage key={msg.id} message={msg} />
|
| 143 |
+
))}
|
| 144 |
+
{isLoading && !currentMCQ && (
|
| 145 |
+
<div className="flex items-start space-x-3">
|
| 146 |
+
<Skeleton className="h-8 w-8 rounded-full" />
|
| 147 |
+
<div className="space-y-2">
|
| 148 |
+
<Skeleton className="h-4 w-48 rounded" />
|
| 149 |
+
<Skeleton className="h-4 w-32 rounded" />
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
</ScrollArea>
|
| 155 |
+
<div className="border-t bg-background/80 p-4">
|
| 156 |
+
{isLoading && <p className="text-center text-sm text-muted-foreground">AI is thinking...</p>}
|
| 157 |
+
{!isLoading && currentMCQ && isAwaitingAnswer && (
|
| 158 |
+
<MCQOptions mcq={currentMCQ} onOptionSelect={handleOptionSelect} disabled={!isAwaitingAnswer} />
|
| 159 |
+
)}
|
| 160 |
+
{!isLoading && !isAwaitingAnswer && messages.length > 0 && (
|
| 161 |
+
<Button onClick={fetchNewMCQ} className="w-full mt-2" variant="ghost" disabled={isLoading}>
|
| 162 |
+
{currentMCQ ? 'Next Question' : 'Start Questions'} <Send className="ml-2 h-4 w-4" />
|
| 163 |
+
</Button>
|
| 164 |
+
)}
|
| 165 |
+
{!imageDataUri && !isLoading && (
|
| 166 |
+
<p className="text-center text-sm text-muted-foreground">Loading image, please wait...</p>
|
| 167 |
+
)}
|
| 168 |
+
<Button onClick={handleRestartJourney} className="w-full mt-2" variant="outline" disabled={isLoading}>
|
| 169 |
+
Restart Journey <RotateCcw className="ml-2 h-4 w-4" />
|
| 170 |
+
</Button>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
);
|
| 174 |
+
}
|
src/components/chatbot/mcq-options.tsx
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import type { GenerateMCQOutput } from '@/ai/flows/generate-mcq';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { cn } from '@/lib/utils';
|
| 6 |
+
import { useState } from 'react';
|
| 7 |
+
|
| 8 |
+
interface MCQOptionsProps {
|
| 9 |
+
mcq: GenerateMCQOutput;
|
| 10 |
+
onOptionSelect: (option: string, isCorrect: boolean) => void;
|
| 11 |
+
disabled: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function MCQOptions({ mcq, onOptionSelect, disabled }: MCQOptionsProps) {
|
| 15 |
+
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
| 16 |
+
|
| 17 |
+
const handleSelect = (option: string) => {
|
| 18 |
+
if (disabled || selectedOption) return; // Prevent selection if already disabled or an option is selected
|
| 19 |
+
|
| 20 |
+
setSelectedOption(option);
|
| 21 |
+
const isCorrect = option === mcq.correctAnswer;
|
| 22 |
+
onOptionSelect(option, isCorrect);
|
| 23 |
+
};
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<div className="mt-4 space-y-2">
|
| 27 |
+
{mcq.options.map((option, index) => {
|
| 28 |
+
const isSelected = selectedOption === option;
|
| 29 |
+
const isCorrectSelection = isSelected && option === mcq.correctAnswer;
|
| 30 |
+
const isIncorrectSelection = isSelected && option !== mcq.correctAnswer;
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<Button
|
| 34 |
+
key={index}
|
| 35 |
+
variant="outline"
|
| 36 |
+
className={cn(
|
| 37 |
+
'w-full justify-start text-left h-auto py-2.5 px-4 whitespace-normal',
|
| 38 |
+
isSelected && 'font-semibold',
|
| 39 |
+
isCorrectSelection && 'bg-green-100 border-green-500 text-green-700 hover:bg-green-200',
|
| 40 |
+
isIncorrectSelection && 'bg-red-100 border-red-500 text-red-700 hover:bg-red-200',
|
| 41 |
+
(disabled || selectedOption) && !isSelected && 'opacity-60 cursor-not-allowed'
|
| 42 |
+
)}
|
| 43 |
+
onClick={() => handleSelect(option)}
|
| 44 |
+
disabled={disabled || !!selectedOption}
|
| 45 |
+
aria-pressed={isSelected}
|
| 46 |
+
>
|
| 47 |
+
<span className="mr-2 font-medium">{String.fromCharCode(65 + index)}.</span>
|
| 48 |
+
{option}
|
| 49 |
+
</Button>
|
| 50 |
+
);
|
| 51 |
+
})}
|
| 52 |
+
</div>
|
| 53 |
+
);
|
| 54 |
+
}
|
src/components/journey/journey-card.tsx
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Image from 'next/image';
|
| 4 |
+
import Link from 'next/link';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
|
| 7 |
+
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
|
| 8 |
+
import { Rocket, Info } from 'lucide-react';
|
| 9 |
+
import type { Journey } from '@/types';
|
| 10 |
+
|
| 11 |
+
interface JourneyCardProps {
|
| 12 |
+
journey: Journey;
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
export function JourneyCard({ journey }: JourneyCardProps) {
|
| 16 |
+
return (
|
| 17 |
+
<Card className="flex h-full flex-col overflow-hidden shadow-lg transition-shadow hover:shadow-xl">
|
| 18 |
+
<CardHeader className="relative h-48 w-full p-0 md:h-56">
|
| 19 |
+
<Image
|
| 20 |
+
src={journey.imageUrl}
|
| 21 |
+
alt={journey.title}
|
| 22 |
+
layout="fill"
|
| 23 |
+
objectFit="cover"
|
| 24 |
+
data-ai-hint={journey.imageHint}
|
| 25 |
+
className="transition-transform duration-300 ease-in-out group-hover:scale-105"
|
| 26 |
+
/>
|
| 27 |
+
</CardHeader>
|
| 28 |
+
<CardContent className="flex-grow p-6">
|
| 29 |
+
<div className="flex items-center justify-between">
|
| 30 |
+
<CardTitle className="font-headline text-xl">{journey.title}</CardTitle>
|
| 31 |
+
<TooltipProvider>
|
| 32 |
+
<Tooltip delayDuration={100}>
|
| 33 |
+
<TooltipTrigger asChild>
|
| 34 |
+
<Button variant="ghost" size="icon" className="h-8 w-8 text-muted-foreground hover:text-accent">
|
| 35 |
+
<Info className="h-5 w-5" />
|
| 36 |
+
<span className="sr-only">More info about {journey.title}</span>
|
| 37 |
+
</Button>
|
| 38 |
+
</TooltipTrigger>
|
| 39 |
+
<TooltipContent side="top" className="max-w-xs bg-popover text-popover-foreground">
|
| 40 |
+
<p>{journey.description}</p>
|
| 41 |
+
</TooltipContent>
|
| 42 |
+
</Tooltip>
|
| 43 |
+
</TooltipProvider>
|
| 44 |
+
</div>
|
| 45 |
+
<CardDescription className="mt-2 text-sm">
|
| 46 |
+
{journey.description.substring(0, 100)}{journey.description.length > 100 ? '...' : ''}
|
| 47 |
+
</CardDescription>
|
| 48 |
+
</CardContent>
|
| 49 |
+
<CardFooter className="p-6 pt-0">
|
| 50 |
+
<Button asChild className="w-full font-semibold">
|
| 51 |
+
<Link href={`/journey/${journey.id}`}>
|
| 52 |
+
Launch Journey
|
| 53 |
+
<Rocket className="ml-2 h-4 w-4" />
|
| 54 |
+
</Link>
|
| 55 |
+
</Button>
|
| 56 |
+
</CardFooter>
|
| 57 |
+
</Card>
|
| 58 |
+
);
|
| 59 |
+
}
|
src/components/journey/summary-report-dialog.tsx
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useState, useEffect } from 'react';
|
| 4 |
+
import {
|
| 5 |
+
Dialog,
|
| 6 |
+
DialogContent,
|
| 7 |
+
DialogDescription,
|
| 8 |
+
DialogHeader,
|
| 9 |
+
DialogTitle,
|
| 10 |
+
DialogFooter,
|
| 11 |
+
} from '@/components/ui/dialog';
|
| 12 |
+
import { Button } from '@/components/ui/button';
|
| 13 |
+
import { ScrollArea } from '@/components/ui/scroll-area';
|
| 14 |
+
import { summarizeImage } from '@/ai/flows/summarize-image';
|
| 15 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 16 |
+
import { useToast } from '@/hooks/use-toast';
|
| 17 |
+
import { Loader2 } from 'lucide-react';
|
| 18 |
+
|
| 19 |
+
interface SummaryReportDialogProps {
|
| 20 |
+
isOpen: boolean;
|
| 21 |
+
onOpenChange: (open: boolean) => void;
|
| 22 |
+
imageDataUri: string | null;
|
| 23 |
+
journeyTitle: string;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
export function SummaryReportDialog({
|
| 27 |
+
isOpen,
|
| 28 |
+
onOpenChange,
|
| 29 |
+
imageDataUri,
|
| 30 |
+
journeyTitle,
|
| 31 |
+
}: SummaryReportDialogProps) {
|
| 32 |
+
const [summary, setSummary] = useState<string | null>(null);
|
| 33 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 34 |
+
const { toast } = useToast();
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
if (isOpen && imageDataUri && !summary) { // Fetch summary only if dialog is open, image is available, and summary isn't already fetched
|
| 38 |
+
const fetchSummary = async () => {
|
| 39 |
+
setIsLoading(true);
|
| 40 |
+
try {
|
| 41 |
+
const result = await summarizeImage({ imageDataUri });
|
| 42 |
+
setSummary(result.summary);
|
| 43 |
+
} catch (error) {
|
| 44 |
+
console.error('Error generating summary:', error);
|
| 45 |
+
const errorMessage = error instanceof Error ? error.message : "An unknown error occurred.";
|
| 46 |
+
setSummary(`Failed to generate summary: ${errorMessage}`);
|
| 47 |
+
toast({
|
| 48 |
+
title: "Error",
|
| 49 |
+
description: `Could not generate summary. ${errorMessage}`,
|
| 50 |
+
variant: "destructive",
|
| 51 |
+
});
|
| 52 |
+
} finally {
|
| 53 |
+
setIsLoading(false);
|
| 54 |
+
}
|
| 55 |
+
};
|
| 56 |
+
fetchSummary();
|
| 57 |
+
} else if (!isOpen) {
|
| 58 |
+
// Optionally reset summary when dialog closes to refetch next time
|
| 59 |
+
// setSummary(null);
|
| 60 |
+
}
|
| 61 |
+
}, [isOpen, imageDataUri, summary, toast]);
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
// Effect to reset summary when imageDataUri changes (e.g., new journey)
|
| 65 |
+
useEffect(() => {
|
| 66 |
+
setSummary(null); // Reset summary if image URI changes, to force refetch
|
| 67 |
+
}, [imageDataUri]);
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
return (
|
| 71 |
+
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
| 72 |
+
<DialogContent className="sm:max-w-lg md:max-w-xl lg:max-w-2xl">
|
| 73 |
+
<DialogHeader>
|
| 74 |
+
<DialogTitle className="font-headline text-2xl">Summary Report: {journeyTitle}</DialogTitle>
|
| 75 |
+
<DialogDescription>
|
| 76 |
+
AI-generated insights and key takeaways from the selected image.
|
| 77 |
+
</DialogDescription>
|
| 78 |
+
</DialogHeader>
|
| 79 |
+
<ScrollArea className="max-h-[60vh] pr-6">
|
| 80 |
+
{isLoading && (
|
| 81 |
+
<div className="space-y-3 py-4">
|
| 82 |
+
<Skeleton className="h-5 w-3/4 rounded-md" />
|
| 83 |
+
<Skeleton className="h-5 w-full rounded-md" />
|
| 84 |
+
<Skeleton className="h-5 w-5/6 rounded-md" />
|
| 85 |
+
<Skeleton className="h-5 w-full rounded-md" />
|
| 86 |
+
<Skeleton className="h-5 w-1/2 rounded-md" />
|
| 87 |
+
</div>
|
| 88 |
+
)}
|
| 89 |
+
{!isLoading && summary && (
|
| 90 |
+
<div className="prose prose-sm dark:prose-invert max-w-none py-4 whitespace-pre-wrap font-body">
|
| 91 |
+
{summary}
|
| 92 |
+
</div>
|
| 93 |
+
)}
|
| 94 |
+
{!isLoading && !summary && imageDataUri && (
|
| 95 |
+
<div className="flex flex-col items-center justify-center py-10 text-muted-foreground">
|
| 96 |
+
<Loader2 className="h-8 w-8 animate-spin mb-2" />
|
| 97 |
+
<p>Generating summary...</p>
|
| 98 |
+
</div>
|
| 99 |
+
)}
|
| 100 |
+
{!imageDataUri && (
|
| 101 |
+
<div className="py-10 text-center text-muted-foreground">
|
| 102 |
+
<p>Image data is not available to generate a summary.</p>
|
| 103 |
+
</div>
|
| 104 |
+
)}
|
| 105 |
+
</ScrollArea>
|
| 106 |
+
<DialogFooter>
|
| 107 |
+
<Button onClick={() => onOpenChange(false)}>Close</Button>
|
| 108 |
+
</DialogFooter>
|
| 109 |
+
</DialogContent>
|
| 110 |
+
</Dialog>
|
| 111 |
+
);
|
| 112 |
+
}
|
src/components/layout/app-header.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import Link from 'next/link';
|
| 4 |
+
import { ArrowLeft, FileText, Sparkles } from 'lucide-react';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { usePathname } from 'next/navigation';
|
| 7 |
+
|
| 8 |
+
interface AppHeaderProps {
|
| 9 |
+
showBackButton?: boolean;
|
| 10 |
+
backHref?: string;
|
| 11 |
+
showDetailsButton?: boolean;
|
| 12 |
+
onDetailsClick?: () => void;
|
| 13 |
+
title?: string;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function AppHeader({
|
| 17 |
+
showBackButton = false,
|
| 18 |
+
backHref = '/',
|
| 19 |
+
showDetailsButton = false,
|
| 20 |
+
onDetailsClick,
|
| 21 |
+
title,
|
| 22 |
+
}: AppHeaderProps) {
|
| 23 |
+
const pathname = usePathname();
|
| 24 |
+
|
| 25 |
+
return (
|
| 26 |
+
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
| 27 |
+
<div className="container flex h-16 items-center">
|
| 28 |
+
<div className="mr-4 flex items-center">
|
| 29 |
+
{showBackButton && (
|
| 30 |
+
<Button variant="ghost" size="icon" asChild className="mr-2">
|
| 31 |
+
<Link href={backHref} aria-label="Go back">
|
| 32 |
+
<ArrowLeft className="h-5 w-5" />
|
| 33 |
+
</Link>
|
| 34 |
+
</Button>
|
| 35 |
+
)}
|
| 36 |
+
<Link href="/" className="flex items-center space-x-2">
|
| 37 |
+
<Sparkles className="h-6 w-6 text-primary" />
|
| 38 |
+
<span className="font-headline text-xl font-bold">JourneyAI</span>
|
| 39 |
+
</Link>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
{title && <h1 className="flex-1 text-center font-headline text-lg font-semibold">{title}</h1>}
|
| 43 |
+
|
| 44 |
+
<div className="ml-auto flex items-center space-x-2">
|
| 45 |
+
{showDetailsButton && onDetailsClick && (
|
| 46 |
+
<Button variant="outline" onClick={onDetailsClick} aria-label="View Details">
|
| 47 |
+
<FileText className="mr-2 h-4 w-4" />
|
| 48 |
+
Details
|
| 49 |
+
</Button>
|
| 50 |
+
)}
|
| 51 |
+
</div>
|
| 52 |
+
{/* Placeholder to balance the flex layout if title is not present and details button is not shown */}
|
| 53 |
+
{(!title && !showDetailsButton) && <div className="flex-1"></div>}
|
| 54 |
+
|
| 55 |
+
</div>
|
| 56 |
+
</header>
|
| 57 |
+
);
|
| 58 |
+
}
|
src/lib/journeys.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { Journey } from '@/types';
|
| 2 |
+
|
| 3 |
+
export const journeys: Journey[] = [
|
| 4 |
+
{
|
| 5 |
+
id: 'ancient-civilization',
|
| 6 |
+
title: 'Ancient Civilization',
|
| 7 |
+
description: 'Explore the ruins of a long-lost civilization and uncover its secrets through AI-generated questions.',
|
| 8 |
+
imageUrl: 'https://placehold.co/600x400.png',
|
| 9 |
+
imageHint: 'archaeology ruins',
|
| 10 |
+
},
|
| 11 |
+
{
|
| 12 |
+
id: 'space-anomaly',
|
| 13 |
+
title: 'Space Anomaly',
|
| 14 |
+
description: 'Investigate a mysterious anomaly detected in a distant galaxy. What will the AI ask you?',
|
| 15 |
+
imageUrl: 'https://placehold.co/600x400.png',
|
| 16 |
+
imageHint: 'nebula galaxy',
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
id: 'microscopic-world',
|
| 20 |
+
title: 'Microscopic Marvels',
|
| 21 |
+
description: 'Dive into the unseen world of microorganisms and their complex structures, guided by AI.',
|
| 22 |
+
imageUrl: 'https://placehold.co/600x400.png',
|
| 23 |
+
imageHint: 'microscope cells',
|
| 24 |
+
},
|
| 25 |
+
];
|
| 26 |
+
|
| 27 |
+
export const getJourneyById = (id: string): Journey | undefined => {
|
| 28 |
+
return journeys.find(journey => journey.id === id);
|
| 29 |
+
};
|
src/types/index.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { GenerateMCQOutput } from '@/ai/flows/generate-mcq';
|
| 2 |
+
|
| 3 |
+
export interface Journey {
|
| 4 |
+
id: string;
|
| 5 |
+
title: string;
|
| 6 |
+
description: string;
|
| 7 |
+
imageUrl: string;
|
| 8 |
+
imageHint: string; // For data-ai-hint
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export interface ChatMessage {
|
| 12 |
+
id: string;
|
| 13 |
+
sender: 'user' | 'ai';
|
| 14 |
+
type: 'text' | 'mcq' | 'feedback' | 'error';
|
| 15 |
+
text?: string;
|
| 16 |
+
mcq?: GenerateMCQOutput;
|
| 17 |
+
options?: string[]; // For user's answer display
|
| 18 |
+
timestamp: Date;
|
| 19 |
+
isCorrect?: boolean; // For feedback messages
|
| 20 |
+
}
|
tailwind.config.ts
CHANGED
|
@@ -12,7 +12,7 @@ export default {
|
|
| 12 |
fontFamily: {
|
| 13 |
body: ['Inter', 'sans-serif'],
|
| 14 |
headline: ['Inter', 'sans-serif'],
|
| 15 |
-
code: ['monospace'],
|
| 16 |
},
|
| 17 |
colors: {
|
| 18 |
background: 'hsl(var(--background))',
|
|
|
|
| 12 |
fontFamily: {
|
| 13 |
body: ['Inter', 'sans-serif'],
|
| 14 |
headline: ['Inter', 'sans-serif'],
|
| 15 |
+
code: ['Source Code Pro', 'monospace'],
|
| 16 |
},
|
| 17 |
colors: {
|
| 18 |
background: 'hsl(var(--background))',
|