ai integration
This commit is contained in:
51
src/app/api/ai-event/route.ts
Normal file
51
src/app/api/ai-event/route.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const { prompt } = await request.json();
|
||||||
|
|
||||||
|
const systemPrompt = `
|
||||||
|
You are an assistant that converts natural language event descriptions into JSON objects
|
||||||
|
matching this TypeScript type EXACTLY:
|
||||||
|
{
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
location?: string,
|
||||||
|
url?: string,
|
||||||
|
start: string, // ISO datetime like 2024-06-14T13:00:00Z
|
||||||
|
end?: string,
|
||||||
|
allDay?: boolean
|
||||||
|
}
|
||||||
|
Today is ${new Date().toISOString().split("T")[0]}.
|
||||||
|
If no time is given, assume allDay event.
|
||||||
|
If no end time is given and not allDay, make it 1 hour after start.
|
||||||
|
Output ONLY valid JSON, nothing else.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "openai/gpt-3.5-turbo", // Or 'mistral/mistral-tiny' for cheaper
|
||||||
|
messages: [
|
||||||
|
{ role: "system", content: systemPrompt },
|
||||||
|
{ role: "user", content: prompt },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = data.choices[0].message.content;
|
||||||
|
const parsed = JSON.parse(content);
|
||||||
|
return NextResponse.json(parsed);
|
||||||
|
} catch (e) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to parse AI output", raw: data },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
src/app/api/ai-summary/route.ts
Normal file
46
src/app/api/ai-summary/route.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { CalendarEvent } from "@/lib/types";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const { events } = await request.json();
|
||||||
|
|
||||||
|
if (!events || !Array.isArray(events)) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invalid events array" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch("https://openrouter.ai/api/v1/chat/completions", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${process.env.OPENROUTER_API_KEY}`, // Server-side only
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: "@preset/i-cal-editor-summarize", // FREE model
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "system",
|
||||||
|
content: `You summarize a list of events in natural language. Include date, time, and title. Be concise.`,
|
||||||
|
},
|
||||||
|
{ role: "user", content: JSON.stringify(events) },
|
||||||
|
],
|
||||||
|
temperature: 0.4,
|
||||||
|
// max_tokens: 300,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
const summary =
|
||||||
|
data?.choices?.[0]?.message?.content || "No summary generated.";
|
||||||
|
return NextResponse.json({ summary });
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to summarize events" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
147
src/app/page.tsx
147
src/app/page.tsx
@@ -25,6 +25,10 @@ export default function HomePage() {
|
|||||||
const [end, setEnd] = useState('')
|
const [end, setEnd] = useState('')
|
||||||
const [allDay, setAllDay] = useState(false)
|
const [allDay, setAllDay] = useState(false)
|
||||||
|
|
||||||
|
// AI
|
||||||
|
const [aiPrompt, setAiPrompt] = useState('')
|
||||||
|
const [aiLoading, setAiLoading] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
(async () => {
|
(async () => {
|
||||||
const stored = await getAllEvents()
|
const stored = await getAllEvents()
|
||||||
@@ -128,6 +132,64 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- AI CREATE ---
|
||||||
|
const handleAiCreate = async () => {
|
||||||
|
if (!aiPrompt.trim()) return
|
||||||
|
setAiLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-event', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: aiPrompt })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.title) {
|
||||||
|
setTitle(data.title || '')
|
||||||
|
setDescription(data.description || '')
|
||||||
|
setLocation(data.location || '')
|
||||||
|
setUrl(data.url || '')
|
||||||
|
setStart(data.start || '')
|
||||||
|
setEnd(data.end || '')
|
||||||
|
setAllDay(data.allDay || false)
|
||||||
|
setEditingId(null)
|
||||||
|
setDialogOpen(true)
|
||||||
|
} else {
|
||||||
|
alert('AI could not parse event.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert('AI request error')
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleAiSummarize = async () => {
|
||||||
|
if (events.length === 0) {
|
||||||
|
alert("No events to summarize")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setAiLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai-summary', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ events })
|
||||||
|
})
|
||||||
|
const data = await res.json()
|
||||||
|
if (data.summary) {
|
||||||
|
alert(data.summary)
|
||||||
|
} else {
|
||||||
|
alert('No summary generated.')
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
alert('Error summarizing events')
|
||||||
|
} finally {
|
||||||
|
setAiLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
@@ -136,6 +198,21 @@ export default function HomePage() {
|
|||||||
className={`p-4 min-h-[80vh] rounded border-2 border-dashed transition ${isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
className={`p-4 min-h-[80vh] rounded border-2 border-dashed transition ${isDragOver ? 'border-blue-500 bg-blue-50' : 'border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex gap-2 mb-4">
|
||||||
|
<input
|
||||||
|
className="flex-1 border p-2 rounded"
|
||||||
|
placeholder='Describe an event for AI to create'
|
||||||
|
value={aiPrompt}
|
||||||
|
onChange={e => setAiPrompt(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Button onClick={handleAiCreate} disabled={aiLoading}>
|
||||||
|
{aiLoading ? 'Thinking...' : 'AI Create'}
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={handleAiSummarize} disabled={aiLoading}>
|
||||||
|
AI Summarize
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 flex-wrap mb-4">
|
<div className="flex gap-2 flex-wrap mb-4">
|
||||||
<Button onClick={() => setDialogOpen(true)}>Add Event</Button>
|
<Button onClick={() => setDialogOpen(true)}>Add Event</Button>
|
||||||
{events.length > 0 && (
|
{events.length > 0 && (
|
||||||
@@ -163,16 +240,11 @@ export default function HomePage() {
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{events.length === 0 && (
|
{events.length === 0 && <p className="text-gray-500 italic">No events yet.</p>}
|
||||||
<p className="text-gray-500 italic">No events yet. Add or drop an .ics file here.</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ul className="mt-4 space-y-2">
|
<ul className="space-y-2">
|
||||||
{events.map(ev => (
|
{events.map(ev => (
|
||||||
<li
|
<li key={ev.id} className="border p-2 rounded bg-white shadow-sm flex justify-between">
|
||||||
key={ev.id}
|
|
||||||
className="border p-2 rounded bg-white shadow-sm flex justify-between items-center"
|
|
||||||
>
|
|
||||||
<div>
|
<div>
|
||||||
<strong>{ev.title}</strong> — {ev.allDay
|
<strong>{ev.title}</strong> — {ev.allDay
|
||||||
? ev.start.split('T')[0]
|
? ev.start.split('T')[0]
|
||||||
@@ -180,9 +252,7 @@ export default function HomePage() {
|
|||||||
{ev.location && <div className="text-sm text-gray-500">{ev.location}</div>}
|
{ev.location && <div className="text-sm text-gray-500">{ev.location}</div>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button size="sm" onClick={() => {
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
setTitle(ev.title)
|
setTitle(ev.title)
|
||||||
setDescription(ev.description || '')
|
setDescription(ev.description || '')
|
||||||
setLocation(ev.location || '')
|
setLocation(ev.location || '')
|
||||||
@@ -192,68 +262,33 @@ export default function HomePage() {
|
|||||||
setAllDay(ev.allDay || false)
|
setAllDay(ev.allDay || false)
|
||||||
setEditingId(ev.id)
|
setEditingId(ev.id)
|
||||||
setDialogOpen(true)
|
setDialogOpen(true)
|
||||||
}}
|
}}>Edit</Button>
|
||||||
>
|
<Button variant="secondary" size="sm" onClick={() => handleDelete(ev.id)}>Delete</Button>
|
||||||
Edit
|
|
||||||
</Button>
|
|
||||||
<Button variant="secondary" size="sm" onClick={() => handleDelete(ev.id)}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<Dialog
|
<Dialog open={dialogOpen} onOpenChange={val => { if (!val) resetForm(); setDialogOpen(val) }}>
|
||||||
open={dialogOpen}
|
<DialogContent>
|
||||||
onOpenChange={val => {
|
|
||||||
if (!val) resetForm()
|
|
||||||
setDialogOpen(val)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="space-y-2">
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
|
<DialogTitle>{editingId ? 'Edit Event' : 'Add Event'}</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
|
<Input placeholder="Title" value={title} onChange={e => setTitle(e.target.value)} />
|
||||||
<textarea
|
<textarea className="border p-2 rounded w-full" placeholder="Description" value={description} onChange={e => setDescription(e.target.value)}></textarea>
|
||||||
className="border p-2 rounded w-full"
|
|
||||||
placeholder="Description"
|
|
||||||
value={description}
|
|
||||||
onChange={e => setDescription(e.target.value)}
|
|
||||||
></textarea>
|
|
||||||
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
|
<Input placeholder="Location" value={location} onChange={e => setLocation(e.target.value)} />
|
||||||
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
|
<Input placeholder="URL" value={url} onChange={e => setUrl(e.target.value)} />
|
||||||
<label className="flex items-center gap-2 mt-2">
|
<label className="flex items-center gap-2 mt-2">
|
||||||
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
|
<input type="checkbox" checked={allDay} onChange={e => setAllDay(e.target.checked)} />
|
||||||
All day event
|
All day event
|
||||||
</label>
|
</label>
|
||||||
|
{!allDay ? <>
|
||||||
{!allDay && (
|
|
||||||
<>
|
|
||||||
<label>Start date & time</label>
|
|
||||||
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
|
<Input type="datetime-local" value={start} onChange={e => setStart(e.target.value)} />
|
||||||
<label>End date & time</label>
|
|
||||||
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
|
<Input type="datetime-local" value={end} onChange={e => setEnd(e.target.value)} />
|
||||||
</>
|
</> : <>
|
||||||
)}
|
<Input type="date" value={start ? start.split('T')[0] : ''} onChange={e => setStart(e.target.value)} />
|
||||||
{allDay && (
|
<Input type="date" value={end ? end.split('T')[0] : ''} onChange={e => setEnd(e.target.value)} />
|
||||||
<>
|
</>}
|
||||||
<label>Start date</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={start ? start.split('T')[0] : ''}
|
|
||||||
onChange={e => setStart(e.target.value)}
|
|
||||||
/>
|
|
||||||
<label>End date</label>
|
|
||||||
<Input
|
|
||||||
type="date"
|
|
||||||
value={end ? end.split('T')[0] : ''}
|
|
||||||
onChange={e => setEnd(e.target.value)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
<Button onClick={handleSave}>{editingId ? 'Update' : 'Save'}</Button>
|
<Button onClick={handleSave}>{editingId ? 'Update' : 'Save'}</Button>
|
||||||
</DialogFooter>
|
</DialogFooter>
|
||||||
|
|||||||
Reference in New Issue
Block a user