Spaces:
Sleeping
Sleeping
Initial prototype
Browse files- .idx/icon.png +0 -0
- .modified +0 -0
- docs/blueprint.md +19 -0
- src/ai/dev.ts +5 -1
- src/ai/flows/ai-context-aware-suggestions.ts +66 -0
- src/ai/flows/ai-suggest-message.ts +52 -0
- src/app/globals.css +32 -36
- src/app/layout.tsx +17 -7
- src/app/page.tsx +3 -1
- src/components/chat/chat.tsx +67 -0
- src/components/chat/message-form.tsx +65 -0
- src/components/chat/message-list.tsx +41 -0
- src/components/chat/message.tsx +42 -0
- src/components/chat/suggestions.tsx +89 -0
- src/components/markdown.tsx +30 -0
- src/components/theme-provider.tsx +74 -0
- src/components/theme-toggle.tsx +40 -0
- src/lib/types.ts +5 -0
.idx/icon.png
ADDED
|
|
.modified
ADDED
|
File without changes
|
docs/blueprint.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# **App Name**: ProtoChat
|
| 2 |
+
|
| 3 |
+
## Core Features:
|
| 4 |
+
|
| 5 |
+
- Real-time Chat Interface: Provide a clean and intuitive interface for users to engage in real-time conversations.
|
| 6 |
+
- AI-Powered Message Generation: Utilize generative AI to create message suggestions, offering users creative prompts. The LLM will act as a tool to suggest appropriate phrases for the current message thread.
|
| 7 |
+
- Markdown Support: Enable users to format their messages using Markdown for richer text styling.
|
| 8 |
+
- Theming Options: Allow users to customize the appearance of the chat interface with light and dark themes.
|
| 9 |
+
- Context Aware Suggestions: Use the past chat messages as context to produce more precise AI suggestions.
|
| 10 |
+
|
| 11 |
+
## Style Guidelines:
|
| 12 |
+
|
| 13 |
+
- Primary color: HSL 215, 70%, 50% (Hex: #3385FF) to evoke trust and intelligence.
|
| 14 |
+
- Background color: HSL 215, 10%, 95% (Hex: #F2F4FF) for a clean and light aesthetic.
|
| 15 |
+
- Accent color: HSL 185, 60%, 40% (Hex: #33A6A6) for interactive elements and highlights.
|
| 16 |
+
- Body and headline font: 'Inter' sans-serif for a clean and modern user experience.
|
| 17 |
+
- Use simple, outline-style icons to maintain a minimalist design.
|
| 18 |
+
- Emphasize a clean and straightforward layout to focus on conversation flow.
|
| 19 |
+
- Implement subtle animations for loading indicators and user interactions to provide a polished feel.
|
src/ai/dev.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
| 1 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { config } from 'dotenv';
|
| 2 |
+
config();
|
| 3 |
+
|
| 4 |
+
import '@/ai/flows/ai-context-aware-suggestions.ts';
|
| 5 |
+
import '@/ai/flows/ai-suggest-message.ts';
|
src/ai/flows/ai-context-aware-suggestions.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
/**
|
| 3 |
+
* @fileOverview AI-powered, context-aware message suggestions.
|
| 4 |
+
*
|
| 5 |
+
* - `getContextAwareSuggestions` - Generates message suggestions based on the conversation history.
|
| 6 |
+
* - `ContextAwareSuggestionsInput` - The input type for the `getContextAwareSuggestions` function.
|
| 7 |
+
* - `ContextAwareSuggestionsOutput` - The return type for the `getContextAwareSuggestions` function.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
import {ai} from '@/ai/genkit';
|
| 11 |
+
import {z} from 'genkit';
|
| 12 |
+
|
| 13 |
+
const ContextAwareSuggestionsInputSchema = z.object({
|
| 14 |
+
chatHistory: z
|
| 15 |
+
.array(z.string())
|
| 16 |
+
.describe('The history of messages in the chat.'),
|
| 17 |
+
currentMessage: z.string().describe('The current message being composed.'),
|
| 18 |
+
});
|
| 19 |
+
|
| 20 |
+
export type ContextAwareSuggestionsInput = z.infer<
|
| 21 |
+
typeof ContextAwareSuggestionsInputSchema
|
| 22 |
+
>;
|
| 23 |
+
|
| 24 |
+
const ContextAwareSuggestionsOutputSchema = z.object({
|
| 25 |
+
suggestions: z
|
| 26 |
+
.array(z.string())
|
| 27 |
+
.describe('A list of suggested messages based on the context.'),
|
| 28 |
+
});
|
| 29 |
+
|
| 30 |
+
export type ContextAwareSuggestionsOutput = z.infer<
|
| 31 |
+
typeof ContextAwareSuggestionsOutputSchema
|
| 32 |
+
>;
|
| 33 |
+
|
| 34 |
+
export async function getContextAwareSuggestions(
|
| 35 |
+
input: ContextAwareSuggestionsInput
|
| 36 |
+
): Promise<ContextAwareSuggestionsOutput> {
|
| 37 |
+
return contextAwareSuggestionsFlow(input);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
const prompt = ai.definePrompt({
|
| 41 |
+
name: 'contextAwareSuggestionsPrompt',
|
| 42 |
+
input: {schema: ContextAwareSuggestionsInputSchema},
|
| 43 |
+
output: {schema: ContextAwareSuggestionsOutputSchema},
|
| 44 |
+
prompt: `You are an AI assistant that provides helpful message suggestions in a chat application. Consider the chat history to produce contextually relevant suggestions.
|
| 45 |
+
|
| 46 |
+
Chat History:
|
| 47 |
+
{{#each chatHistory}}
|
| 48 |
+
- {{{this}}}
|
| 49 |
+
{{/each}}
|
| 50 |
+
|
| 51 |
+
Current Message: {{{currentMessage}}}
|
| 52 |
+
|
| 53 |
+
Suggestions:`,
|
| 54 |
+
});
|
| 55 |
+
|
| 56 |
+
const contextAwareSuggestionsFlow = ai.defineFlow(
|
| 57 |
+
{
|
| 58 |
+
name: 'contextAwareSuggestionsFlow',
|
| 59 |
+
inputSchema: ContextAwareSuggestionsInputSchema,
|
| 60 |
+
outputSchema: ContextAwareSuggestionsOutputSchema,
|
| 61 |
+
},
|
| 62 |
+
async input => {
|
| 63 |
+
const {output} = await prompt(input);
|
| 64 |
+
return output!;
|
| 65 |
+
}
|
| 66 |
+
);
|
src/ai/flows/ai-suggest-message.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use server';
|
| 2 |
+
|
| 3 |
+
/**
|
| 4 |
+
* @fileOverview An AI agent to suggest relevant phrases based on the current chat thread.
|
| 5 |
+
*
|
| 6 |
+
* - suggestMessage - A function that suggests messages based on the chat thread.
|
| 7 |
+
* - SuggestMessageInput - The input type for the suggestMessage function.
|
| 8 |
+
* - SuggestMessageOutput - The return type for the suggestMessage function.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import {ai} from '@/ai/genkit';
|
| 12 |
+
import {z} from 'genkit';
|
| 13 |
+
|
| 14 |
+
const SuggestMessageInputSchema = z.object({
|
| 15 |
+
chatThread: z.string().describe('The current chat thread.'),
|
| 16 |
+
});
|
| 17 |
+
export type SuggestMessageInput = z.infer<typeof SuggestMessageInputSchema>;
|
| 18 |
+
|
| 19 |
+
const SuggestMessageOutputSchema = z.object({
|
| 20 |
+
suggestions: z.array(z.string()).describe('An array of suggested phrases.'),
|
| 21 |
+
});
|
| 22 |
+
export type SuggestMessageOutput = z.infer<typeof SuggestMessageOutputSchema>;
|
| 23 |
+
|
| 24 |
+
export async function suggestMessage(input: SuggestMessageInput): Promise<SuggestMessageOutput> {
|
| 25 |
+
return suggestMessageFlow(input);
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const prompt = ai.definePrompt({
|
| 29 |
+
name: 'suggestMessagePrompt',
|
| 30 |
+
input: {schema: SuggestMessageInputSchema},
|
| 31 |
+
output: {schema: SuggestMessageOutputSchema},
|
| 32 |
+
prompt: `You are a helpful AI assistant that suggests relevant phrases based on the current chat thread.
|
| 33 |
+
|
| 34 |
+
Here is the current chat thread:
|
| 35 |
+
{{chatThread}}
|
| 36 |
+
|
| 37 |
+
Suggest 3 short phrases that the user might want to say next to keep the conversation flowing.
|
| 38 |
+
Return the suggestions as a JSON array of strings.
|
| 39 |
+
`,
|
| 40 |
+
});
|
| 41 |
+
|
| 42 |
+
const suggestMessageFlow = ai.defineFlow(
|
| 43 |
+
{
|
| 44 |
+
name: 'suggestMessageFlow',
|
| 45 |
+
inputSchema: SuggestMessageInputSchema,
|
| 46 |
+
outputSchema: SuggestMessageOutputSchema,
|
| 47 |
+
},
|
| 48 |
+
async input => {
|
| 49 |
+
const {output} = await prompt(input);
|
| 50 |
+
return output!;
|
| 51 |
+
}
|
| 52 |
+
);
|
src/app/globals.css
CHANGED
|
@@ -2,31 +2,27 @@
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
| 5 |
-
body {
|
| 6 |
-
font-family: Arial, Helvetica, sans-serif;
|
| 7 |
-
}
|
| 8 |
-
|
| 9 |
@layer base {
|
| 10 |
:root {
|
| 11 |
-
--background:
|
| 12 |
-
--foreground:
|
| 13 |
--card: 0 0% 100%;
|
| 14 |
-
--card-foreground:
|
| 15 |
--popover: 0 0% 100%;
|
| 16 |
-
--popover-foreground:
|
| 17 |
-
--primary:
|
| 18 |
-
--primary-foreground: 0 0%
|
| 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%;
|
|
@@ -43,25 +39,25 @@ body {
|
|
| 43 |
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 44 |
}
|
| 45 |
.dark {
|
| 46 |
-
--background:
|
| 47 |
-
--foreground:
|
| 48 |
-
--card:
|
| 49 |
-
--card-foreground:
|
| 50 |
-
--popover:
|
| 51 |
-
--popover-foreground:
|
| 52 |
-
--primary:
|
| 53 |
-
--primary-foreground: 0 0%
|
| 54 |
-
--secondary:
|
| 55 |
-
--secondary-foreground:
|
| 56 |
-
--muted:
|
| 57 |
-
--muted-foreground:
|
| 58 |
-
--accent:
|
| 59 |
-
--accent-foreground: 0 0%
|
| 60 |
--destructive: 0 62.8% 30.6%;
|
| 61 |
--destructive-foreground: 0 0% 98%;
|
| 62 |
-
--border:
|
| 63 |
-
--input:
|
| 64 |
-
--ring:
|
| 65 |
--chart-1: 220 70% 50%;
|
| 66 |
--chart-2: 160 60% 45%;
|
| 67 |
--chart-3: 30 80% 55%;
|
|
|
|
| 2 |
@tailwind components;
|
| 3 |
@tailwind utilities;
|
| 4 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
@layer base {
|
| 6 |
:root {
|
| 7 |
+
--background: 215 10% 95%;
|
| 8 |
+
--foreground: 222.2 84% 4.9%;
|
| 9 |
--card: 0 0% 100%;
|
| 10 |
+
--card-foreground: 222.2 84% 4.9%;
|
| 11 |
--popover: 0 0% 100%;
|
| 12 |
+
--popover-foreground: 222.2 84% 4.9%;
|
| 13 |
+
--primary: 215 70% 50%;
|
| 14 |
+
--primary-foreground: 0 0% 100%;
|
| 15 |
+
--secondary: 210 40% 96.1%;
|
| 16 |
+
--secondary-foreground: 222.2 47.4% 11.2%;
|
| 17 |
+
--muted: 210 40% 96.1%;
|
| 18 |
+
--muted-foreground: 215.4 16.3% 46.9%;
|
| 19 |
+
--accent: 185 60% 40%;
|
| 20 |
+
--accent-foreground: 0 0% 100%;
|
| 21 |
--destructive: 0 84.2% 60.2%;
|
| 22 |
--destructive-foreground: 0 0% 98%;
|
| 23 |
+
--border: 214.3 31.8% 91.4%;
|
| 24 |
+
--input: 214.3 31.8% 91.4%;
|
| 25 |
+
--ring: 215 70% 50%;
|
| 26 |
--chart-1: 12 76% 61%;
|
| 27 |
--chart-2: 173 58% 39%;
|
| 28 |
--chart-3: 197 37% 24%;
|
|
|
|
| 39 |
--sidebar-ring: 217.2 91.2% 59.8%;
|
| 40 |
}
|
| 41 |
.dark {
|
| 42 |
+
--background: 222.2 84% 4.9%;
|
| 43 |
+
--foreground: 210 40% 98%;
|
| 44 |
+
--card: 222.2 84% 4.9%;
|
| 45 |
+
--card-foreground: 210 40% 98%;
|
| 46 |
+
--popover: 222.2 84% 4.9%;
|
| 47 |
+
--popover-foreground: 210 40% 98%;
|
| 48 |
+
--primary: 215 70% 50%;
|
| 49 |
+
--primary-foreground: 0 0% 100%;
|
| 50 |
+
--secondary: 217.2 32.6% 17.5%;
|
| 51 |
+
--secondary-foreground: 210 40% 98%;
|
| 52 |
+
--muted: 217.2 32.6% 17.5%;
|
| 53 |
+
--muted-foreground: 215 20.2% 65.1%;
|
| 54 |
+
--accent: 185 60% 40%;
|
| 55 |
+
--accent-foreground: 0 0% 100%;
|
| 56 |
--destructive: 0 62.8% 30.6%;
|
| 57 |
--destructive-foreground: 0 0% 98%;
|
| 58 |
+
--border: 217.2 32.6% 17.5%;
|
| 59 |
+
--input: 217.2 32.6% 17.5%;
|
| 60 |
+
--ring: 215 70% 50%;
|
| 61 |
--chart-1: 220 70% 50%;
|
| 62 |
--chart-2: 160 60% 45%;
|
| 63 |
--chart-3: 30 80% 55%;
|
src/app/layout.tsx
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 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({
|
|
@@ -12,13 +14,21 @@ export default function RootLayout({
|
|
| 12 |
children: React.ReactNode;
|
| 13 |
}>) {
|
| 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
|
|
|
|
|
|
|
|
|
|
| 20 |
</head>
|
| 21 |
-
<body className="font-body antialiased">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
</html>
|
| 23 |
);
|
| 24 |
}
|
|
|
|
| 1 |
+
import type { Metadata } from 'next';
|
| 2 |
+
import { ThemeProvider } from '@/components/theme-provider';
|
| 3 |
+
import { Toaster } from '@/components/ui/toaster';
|
| 4 |
import './globals.css';
|
| 5 |
|
| 6 |
export const metadata: Metadata = {
|
| 7 |
+
title: 'ProtoChat',
|
| 8 |
+
description: 'An AI-powered chat application',
|
| 9 |
};
|
| 10 |
|
| 11 |
export default function RootLayout({
|
|
|
|
| 14 |
children: React.ReactNode;
|
| 15 |
}>) {
|
| 16 |
return (
|
| 17 |
+
<html lang="en" suppressHydrationWarning>
|
| 18 |
<head>
|
| 19 |
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
| 20 |
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossOrigin="anonymous" />
|
| 21 |
+
<link
|
| 22 |
+
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
| 23 |
+
rel="stylesheet"
|
| 24 |
+
/>
|
| 25 |
</head>
|
| 26 |
+
<body className="font-body antialiased">
|
| 27 |
+
<ThemeProvider>
|
| 28 |
+
{children}
|
| 29 |
+
<Toaster />
|
| 30 |
+
</ThemeProvider>
|
| 31 |
+
</body>
|
| 32 |
</html>
|
| 33 |
);
|
| 34 |
}
|
src/app/page.tsx
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
|
|
|
|
|
| 1 |
export default function Home() {
|
| 2 |
-
return <
|
| 3 |
}
|
|
|
|
| 1 |
+
import { Chat } from '@/components/chat/chat';
|
| 2 |
+
|
| 3 |
export default function Home() {
|
| 4 |
+
return <Chat />;
|
| 5 |
}
|
src/components/chat/chat.tsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState } from 'react';
|
| 4 |
+
import type { Message } from '@/lib/types';
|
| 5 |
+
import { MessageList } from './message-list';
|
| 6 |
+
import { MessageForm } from './message-form';
|
| 7 |
+
import { ThemeToggle } from '@/components/theme-toggle';
|
| 8 |
+
import { MessageSquare } from 'lucide-react';
|
| 9 |
+
import { useToast } from '@/hooks/use-toast';
|
| 10 |
+
|
| 11 |
+
export function Chat() {
|
| 12 |
+
const [messages, setMessages] = useState<Message[]>([
|
| 13 |
+
{ id: '1', role: 'assistant', content: "Hello! I'm Proto, your AI assistant. How can I help you today?" }
|
| 14 |
+
]);
|
| 15 |
+
const [isLoading, setIsLoading] = useState(false);
|
| 16 |
+
const { toast } = useToast();
|
| 17 |
+
|
| 18 |
+
const handleNewMessage = async (message: string) => {
|
| 19 |
+
const userMessage: Message = { id: crypto.randomUUID(), role: 'user', content: message };
|
| 20 |
+
const newMessages = [...messages, userMessage];
|
| 21 |
+
setMessages(newMessages);
|
| 22 |
+
setIsLoading(true);
|
| 23 |
+
|
| 24 |
+
try {
|
| 25 |
+
// Simulate an AI response.
|
| 26 |
+
await new Promise(resolve => setTimeout(resolve, 1500));
|
| 27 |
+
|
| 28 |
+
const assistantMessage: Message = {
|
| 29 |
+
id: crypto.randomUUID(),
|
| 30 |
+
role: 'assistant',
|
| 31 |
+
content: `This is a simulated response to your message: "${message}". I support basic **Markdown** formatting, like *italics* and \`inline code\`.`,
|
| 32 |
+
};
|
| 33 |
+
setMessages((prev) => [...prev, assistantMessage]);
|
| 34 |
+
} catch (error) {
|
| 35 |
+
console.error('Error getting AI response:', error);
|
| 36 |
+
toast({
|
| 37 |
+
title: 'Error',
|
| 38 |
+
description: 'Failed to get AI response. Please try again.',
|
| 39 |
+
variant: 'destructive',
|
| 40 |
+
});
|
| 41 |
+
// Optionally remove the user's message if the AI fails
|
| 42 |
+
setMessages(messages);
|
| 43 |
+
} finally {
|
| 44 |
+
setIsLoading(false);
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="flex h-screen w-full flex-col bg-background">
|
| 50 |
+
<header className="flex h-16 shrink-0 items-center justify-between border-b px-4 md:px-6">
|
| 51 |
+
<div className="flex items-center gap-3">
|
| 52 |
+
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary/10 text-primary">
|
| 53 |
+
<MessageSquare className="h-5 w-5" />
|
| 54 |
+
</div>
|
| 55 |
+
<h1 className="text-xl font-semibold tracking-tight">ProtoChat</h1>
|
| 56 |
+
</div>
|
| 57 |
+
<ThemeToggle />
|
| 58 |
+
</header>
|
| 59 |
+
<div className="flex-1 overflow-hidden">
|
| 60 |
+
<MessageList messages={messages} isLoading={isLoading} />
|
| 61 |
+
</div>
|
| 62 |
+
<div className="border-t bg-card p-4 md:p-6">
|
| 63 |
+
<MessageForm onNewMessage={handleNewMessage} isLoading={isLoading} messages={messages} />
|
| 64 |
+
</div>
|
| 65 |
+
</div>
|
| 66 |
+
);
|
| 67 |
+
}
|
src/components/chat/message-form.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useState, useRef, type FormEvent } from 'react';
|
| 4 |
+
import { Textarea } from '@/components/ui/textarea';
|
| 5 |
+
import { Button } from '@/components/ui/button';
|
| 6 |
+
import { ArrowUp, Loader2 } from 'lucide-react';
|
| 7 |
+
import { Suggestions } from './suggestions';
|
| 8 |
+
import type { Message } from '@/lib/types';
|
| 9 |
+
|
| 10 |
+
interface MessageFormProps {
|
| 11 |
+
onNewMessage: (message: string) => void;
|
| 12 |
+
isLoading: boolean;
|
| 13 |
+
messages: Message[];
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
export function MessageForm({ onNewMessage, isLoading, messages }: MessageFormProps) {
|
| 17 |
+
const [input, setInput] = useState('');
|
| 18 |
+
const inputRef = useRef<HTMLTextAreaElement>(null);
|
| 19 |
+
|
| 20 |
+
const handleSubmit = (e: FormEvent) => {
|
| 21 |
+
e.preventDefault();
|
| 22 |
+
if (input.trim()) {
|
| 23 |
+
onNewMessage(input.trim());
|
| 24 |
+
setInput('');
|
| 25 |
+
inputRef.current?.focus();
|
| 26 |
+
}
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const handleSuggestionClick = (suggestion: string) => {
|
| 30 |
+
onNewMessage(suggestion);
|
| 31 |
+
setInput('');
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return (
|
| 35 |
+
<div className="mx-auto w-full max-w-3xl space-y-4">
|
| 36 |
+
<Suggestions onSuggestionClick={handleSuggestionClick} messages={messages} isLoading={isLoading} />
|
| 37 |
+
<form onSubmit={handleSubmit} className="relative">
|
| 38 |
+
<Textarea
|
| 39 |
+
ref={inputRef}
|
| 40 |
+
value={input}
|
| 41 |
+
onChange={(e) => setInput(e.target.value)}
|
| 42 |
+
onKeyDown={(e) => {
|
| 43 |
+
if (e.key === 'Enter' && !e.shiftKey) {
|
| 44 |
+
e.preventDefault();
|
| 45 |
+
handleSubmit(e);
|
| 46 |
+
}
|
| 47 |
+
}}
|
| 48 |
+
placeholder="Send a message..."
|
| 49 |
+
className="min-h-[48px] resize-none pr-16"
|
| 50 |
+
disabled={isLoading}
|
| 51 |
+
autoFocus
|
| 52 |
+
/>
|
| 53 |
+
<Button
|
| 54 |
+
type="submit"
|
| 55 |
+
size="icon"
|
| 56 |
+
className="absolute bottom-3 right-3 h-8 w-8"
|
| 57 |
+
disabled={isLoading || !input.trim()}
|
| 58 |
+
aria-label="Send message"
|
| 59 |
+
>
|
| 60 |
+
{isLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <ArrowUp className="h-4 w-4" />}
|
| 61 |
+
</Button>
|
| 62 |
+
</form>
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
src/components/chat/message-list.tsx
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import type { Message as MessageType } from '@/lib/types';
|
| 4 |
+
import { Message } from './message';
|
| 5 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 6 |
+
import { useEffect, useRef } from 'react';
|
| 7 |
+
import { Avatar, AvatarFallback } from '../ui/avatar';
|
| 8 |
+
|
| 9 |
+
interface MessageListProps {
|
| 10 |
+
messages: MessageType[];
|
| 11 |
+
isLoading: boolean;
|
| 12 |
+
}
|
| 13 |
+
|
| 14 |
+
export function MessageList({ messages, isLoading }: MessageListProps) {
|
| 15 |
+
const scrollRef = useRef<HTMLDivElement>(null);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (scrollRef.current) {
|
| 19 |
+
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
|
| 20 |
+
}
|
| 21 |
+
}, [messages, isLoading]);
|
| 22 |
+
|
| 23 |
+
return (
|
| 24 |
+
<div ref={scrollRef} className="h-full overflow-y-auto px-4 pt-6 md:px-6">
|
| 25 |
+
<div className="mx-auto max-w-3xl space-y-6">
|
| 26 |
+
{messages.map((message) => <Message key={message.id} message={message} />)}
|
| 27 |
+
{isLoading && (
|
| 28 |
+
<div className="flex items-start gap-4">
|
| 29 |
+
<Avatar className="h-8 w-8 border">
|
| 30 |
+
<AvatarFallback className="bg-primary/10 text-primary">AI</AvatarFallback>
|
| 31 |
+
</Avatar>
|
| 32 |
+
<div className="grid gap-2 pt-2">
|
| 33 |
+
<Skeleton className="h-4 w-48" />
|
| 34 |
+
<Skeleton className="h-4 w-32" />
|
| 35 |
+
</div>
|
| 36 |
+
</div>
|
| 37 |
+
)}
|
| 38 |
+
</div>
|
| 39 |
+
</div>
|
| 40 |
+
);
|
| 41 |
+
}
|
src/components/chat/message.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { cn } from '@/lib/utils';
|
| 2 |
+
import type { Message as MessageType } from '@/lib/types';
|
| 3 |
+
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
| 4 |
+
import { Markdown } from '@/components/markdown';
|
| 5 |
+
import { User, Bot } from 'lucide-react';
|
| 6 |
+
|
| 7 |
+
interface MessageProps {
|
| 8 |
+
message: MessageType;
|
| 9 |
+
}
|
| 10 |
+
|
| 11 |
+
export function Message({ message }: MessageProps) {
|
| 12 |
+
const isUser = message.role === 'user';
|
| 13 |
+
|
| 14 |
+
return (
|
| 15 |
+
<div className={cn('flex items-start gap-4', isUser && 'justify-end')}>
|
| 16 |
+
{!isUser && (
|
| 17 |
+
<Avatar className="h-8 w-8 border">
|
| 18 |
+
<AvatarFallback className="bg-primary/10 text-primary">
|
| 19 |
+
<Bot className="h-5 w-5" />
|
| 20 |
+
</AvatarFallback>
|
| 21 |
+
</Avatar>
|
| 22 |
+
)}
|
| 23 |
+
<div
|
| 24 |
+
className={cn(
|
| 25 |
+
'max-w-[75%] rounded-2xl p-3 px-4',
|
| 26 |
+
isUser
|
| 27 |
+
? 'rounded-br-none bg-primary text-primary-foreground'
|
| 28 |
+
: 'rounded-bl-none bg-muted'
|
| 29 |
+
)}
|
| 30 |
+
>
|
| 31 |
+
<Markdown content={message.content} />
|
| 32 |
+
</div>
|
| 33 |
+
{isUser && (
|
| 34 |
+
<Avatar className="h-8 w-8 border">
|
| 35 |
+
<AvatarFallback className="bg-secondary">
|
| 36 |
+
<User className="h-5 w-5" />
|
| 37 |
+
</AvatarFallback>
|
| 38 |
+
</Avatar>
|
| 39 |
+
)}
|
| 40 |
+
</div>
|
| 41 |
+
);
|
| 42 |
+
}
|
src/components/chat/suggestions.tsx
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
'use client';
|
| 2 |
+
|
| 3 |
+
import { useEffect, useState } from 'react';
|
| 4 |
+
import { Button } from '@/components/ui/button';
|
| 5 |
+
import { suggestMessage } from '@/ai/flows/ai-suggest-message';
|
| 6 |
+
import { Skeleton } from '@/components/ui/skeleton';
|
| 7 |
+
import { Sparkles } from 'lucide-react';
|
| 8 |
+
import type { Message } from '@/lib/types';
|
| 9 |
+
import { useToast } from '@/hooks/use-toast';
|
| 10 |
+
|
| 11 |
+
interface SuggestionsProps {
|
| 12 |
+
onSuggestionClick: (suggestion: string) => void;
|
| 13 |
+
messages: Message[];
|
| 14 |
+
isLoading: boolean;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
export function Suggestions({ onSuggestionClick, messages, isLoading }: SuggestionsProps) {
|
| 18 |
+
const [suggestions, setSuggestions] = useState<string[]>([]);
|
| 19 |
+
const [loading, setLoading] = useState(false);
|
| 20 |
+
const { toast } = useToast();
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
// A simple way to prevent fetching on every message while AI is responding
|
| 24 |
+
const timer = setTimeout(() => {
|
| 25 |
+
if (isLoading) return;
|
| 26 |
+
|
| 27 |
+
async function fetchSuggestions() {
|
| 28 |
+
setLoading(true);
|
| 29 |
+
try {
|
| 30 |
+
const chatThread = messages.map(m => `${m.role}: ${m.content}`).join('\n');
|
| 31 |
+
const response = await suggestMessage({ chatThread: chatThread || 'Start a new conversation.' });
|
| 32 |
+
|
| 33 |
+
if (response.suggestions && response.suggestions.length > 0) {
|
| 34 |
+
setSuggestions(response.suggestions);
|
| 35 |
+
} else {
|
| 36 |
+
setSuggestions([]);
|
| 37 |
+
}
|
| 38 |
+
} catch (error) {
|
| 39 |
+
console.error('Failed to fetch suggestions:', error);
|
| 40 |
+
// Only show toast on first load failure maybe
|
| 41 |
+
if (messages.length <= 1) {
|
| 42 |
+
toast({ title: 'Suggestion Error', description: 'Could not load AI suggestions.', variant: 'destructive' });
|
| 43 |
+
}
|
| 44 |
+
setSuggestions([]);
|
| 45 |
+
} finally {
|
| 46 |
+
setLoading(false);
|
| 47 |
+
}
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
fetchSuggestions();
|
| 51 |
+
}, 500); // Debounce fetching
|
| 52 |
+
|
| 53 |
+
return () => clearTimeout(timer);
|
| 54 |
+
}, [messages, isLoading, toast]);
|
| 55 |
+
|
| 56 |
+
if (loading) {
|
| 57 |
+
return (
|
| 58 |
+
<div className="flex h-8 animate-pulse items-center gap-2">
|
| 59 |
+
<Sparkles className="h-5 w-5 text-muted-foreground" />
|
| 60 |
+
<Skeleton className="h-8 w-32 rounded-full" />
|
| 61 |
+
<Skeleton className="h-8 w-40 rounded-full" />
|
| 62 |
+
<Skeleton className="h-8 w-28 rounded-full" />
|
| 63 |
+
</div>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
if (suggestions.length === 0 || isLoading) {
|
| 68 |
+
return <div className="h-8" />; // Maintain layout consistency
|
| 69 |
+
}
|
| 70 |
+
|
| 71 |
+
return (
|
| 72 |
+
<div className="flex h-8 items-center gap-2">
|
| 73 |
+
<Sparkles className="h-5 w-5 flex-shrink-0 text-muted-foreground" />
|
| 74 |
+
<div className="flex gap-2 overflow-x-auto pb-2">
|
| 75 |
+
{suggestions.map((suggestion, index) => (
|
| 76 |
+
<Button
|
| 77 |
+
key={index}
|
| 78 |
+
variant="outline"
|
| 79 |
+
size="sm"
|
| 80 |
+
className="rounded-full whitespace-nowrap"
|
| 81 |
+
onClick={() => onSuggestionClick(suggestion)}
|
| 82 |
+
>
|
| 83 |
+
{suggestion}
|
| 84 |
+
</Button>
|
| 85 |
+
))}
|
| 86 |
+
</div>
|
| 87 |
+
</div>
|
| 88 |
+
);
|
| 89 |
+
}
|
src/components/markdown.tsx
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import { cn } from '@/lib/utils';
|
| 3 |
+
|
| 4 |
+
export const Markdown = ({ content, className }: { content: string, className?: string }) => {
|
| 5 |
+
const parts = content.split(/(\*\*.*?\*\*|\*.*?\*|`.*?`|```[\s\S]*?```)/g);
|
| 6 |
+
|
| 7 |
+
return (
|
| 8 |
+
<div className={cn("prose dark:prose-invert", className)}>
|
| 9 |
+
{parts.map((part, index) => {
|
| 10 |
+
if (part.startsWith('**') && part.endsWith('**')) {
|
| 11 |
+
return <strong key={index}>{part.slice(2, -2)}</strong>;
|
| 12 |
+
}
|
| 13 |
+
if (part.startsWith('*') && part.endsWith('*')) {
|
| 14 |
+
return <em key={index}>{part.slice(1, -1)}</em>;
|
| 15 |
+
}
|
| 16 |
+
if (part.startsWith('```') && part.endsWith('```')) {
|
| 17 |
+
return (
|
| 18 |
+
<pre className="whitespace-pre-wrap bg-muted p-2 rounded-md my-2" key={index}>
|
| 19 |
+
<code>{part.slice(3, -3)}</code>
|
| 20 |
+
</pre>
|
| 21 |
+
);
|
| 22 |
+
}
|
| 23 |
+
if (part.startsWith('`') && part.endsWith('`')) {
|
| 24 |
+
return <code className="bg-muted text-foreground rounded px-1 py-0.5" key={index}>{part.slice(1, -1)}</code>;
|
| 25 |
+
}
|
| 26 |
+
return <span key={index}>{part}</span>;
|
| 27 |
+
})}
|
| 28 |
+
</div>
|
| 29 |
+
);
|
| 30 |
+
};
|
src/components/theme-provider.tsx
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { createContext, useContext, useEffect, useState, ReactNode } from 'react';
|
| 4 |
+
|
| 5 |
+
type Theme = "dark" | "light" | "system";
|
| 6 |
+
|
| 7 |
+
type ThemeProviderProps = {
|
| 8 |
+
children: ReactNode;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
type ThemeProviderState = {
|
| 12 |
+
theme: Theme;
|
| 13 |
+
setTheme: (theme: Theme) => void;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
const initialState: ThemeProviderState = {
|
| 17 |
+
theme: "system",
|
| 18 |
+
setTheme: () => null,
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
|
| 22 |
+
|
| 23 |
+
export function ThemeProvider({
|
| 24 |
+
children,
|
| 25 |
+
...props
|
| 26 |
+
}: ThemeProviderProps) {
|
| 27 |
+
const [theme, setTheme] = useState<Theme>("system");
|
| 28 |
+
|
| 29 |
+
useEffect(() => {
|
| 30 |
+
const storedTheme = localStorage.getItem("protochat-theme") as Theme | null;
|
| 31 |
+
if (storedTheme) {
|
| 32 |
+
setTheme(storedTheme);
|
| 33 |
+
}
|
| 34 |
+
}, []);
|
| 35 |
+
|
| 36 |
+
useEffect(() => {
|
| 37 |
+
const root = window.document.documentElement;
|
| 38 |
+
|
| 39 |
+
root.classList.remove("light", "dark");
|
| 40 |
+
|
| 41 |
+
let currentTheme = theme;
|
| 42 |
+
if (currentTheme === "system") {
|
| 43 |
+
currentTheme = window.matchMedia("(prefers-color-scheme: dark)")
|
| 44 |
+
.matches
|
| 45 |
+
? "dark"
|
| 46 |
+
: "light";
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
root.classList.add(currentTheme);
|
| 50 |
+
}, [theme]);
|
| 51 |
+
|
| 52 |
+
const value = {
|
| 53 |
+
theme,
|
| 54 |
+
setTheme: (newTheme: Theme) => {
|
| 55 |
+
localStorage.setItem("protochat-theme", newTheme);
|
| 56 |
+
setTheme(newTheme);
|
| 57 |
+
},
|
| 58 |
+
};
|
| 59 |
+
|
| 60 |
+
return (
|
| 61 |
+
<ThemeProviderContext.Provider {...props} value={value}>
|
| 62 |
+
{children}
|
| 63 |
+
</ThemeProviderContext.Provider>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export const useTheme = () => {
|
| 68 |
+
const context = useContext(ThemeProviderContext);
|
| 69 |
+
|
| 70 |
+
if (context === undefined)
|
| 71 |
+
throw new Error("useTheme must be used within a ThemeProvider");
|
| 72 |
+
|
| 73 |
+
return context;
|
| 74 |
+
};
|
src/components/theme-toggle.tsx
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client"
|
| 2 |
+
|
| 3 |
+
import * as React from "react"
|
| 4 |
+
import { Moon, Sun } from "lucide-react"
|
| 5 |
+
|
| 6 |
+
import { useTheme } from "@/components/theme-provider"
|
| 7 |
+
import { Button } from "@/components/ui/button"
|
| 8 |
+
import {
|
| 9 |
+
DropdownMenu,
|
| 10 |
+
DropdownMenuContent,
|
| 11 |
+
DropdownMenuItem,
|
| 12 |
+
DropdownMenuTrigger,
|
| 13 |
+
} from "@/components/ui/dropdown-menu"
|
| 14 |
+
|
| 15 |
+
export function ThemeToggle() {
|
| 16 |
+
const { setTheme } = useTheme()
|
| 17 |
+
|
| 18 |
+
return (
|
| 19 |
+
<DropdownMenu>
|
| 20 |
+
<DropdownMenuTrigger asChild>
|
| 21 |
+
<Button variant="ghost" size="icon">
|
| 22 |
+
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
|
| 23 |
+
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
|
| 24 |
+
<span className="sr-only">Toggle theme</span>
|
| 25 |
+
</Button>
|
| 26 |
+
</DropdownMenuTrigger>
|
| 27 |
+
<DropdownMenuContent align="end">
|
| 28 |
+
<DropdownMenuItem onClick={() => setTheme("light")}>
|
| 29 |
+
Light
|
| 30 |
+
</DropdownMenuItem>
|
| 31 |
+
<DropdownMenuItem onClick={() => setTheme("dark")}>
|
| 32 |
+
Dark
|
| 33 |
+
</DropdownMenuItem>
|
| 34 |
+
<DropdownMenuItem onClick={() => setTheme("system")}>
|
| 35 |
+
System
|
| 36 |
+
</DropdownMenuItem>
|
| 37 |
+
</DropdownMenuContent>
|
| 38 |
+
</DropdownMenu>
|
| 39 |
+
)
|
| 40 |
+
}
|
src/lib/types.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export interface Message {
|
| 2 |
+
id: string;
|
| 3 |
+
role: 'user' | 'assistant';
|
| 4 |
+
content: string;
|
| 5 |
+
}
|