ai integration

This commit is contained in:
2025-08-15 00:44:26 -04:00
parent 238d3cfbfe
commit 3ee7be9110
3 changed files with 199 additions and 67 deletions

View 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 },
);
}
}

View 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 },
);
}
}

View File

@@ -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>