Skip to content

Commit 6ff5e58

Browse files
committed
Adding AI editing of the cards
1 parent ecab418 commit 6ff5e58

File tree

7 files changed

+788
-9
lines changed

7 files changed

+788
-9
lines changed

app/actions/aiEdits.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
'use server';
2+
3+
import { z } from 'zod';
4+
import { ObjectId } from 'mongodb'; // For cardId validation if needed
5+
import { getOpenAIClient } from '@/lib/openai';
6+
import { verifyAuthToken } from '@/lib/auth';
7+
import { fetchDeckByIdAction } from './decks'; // To get deck name
8+
import { fetchDeckCardsAction } from './cards'; // To get current cards
9+
import type {
10+
AICreateCardSuggestion,
11+
AIUpdateCardSuggestion,
12+
AIDeleteCardSuggestion,
13+
AICardEditSuggestion,
14+
Card // For serializing current cards
15+
} from '@/types';
16+
17+
// --- Zod Schemas for AI Edit Suggestions ---
18+
19+
const AICreateCardSchema = z.object({
20+
type: z.literal('create'),
21+
front_text: z.string().trim().min(1, "Front text cannot be empty for new card"),
22+
back_text: z.string().trim().min(1, "Back text cannot be empty for new card"),
23+
extra_context: z.string().optional(),
24+
});
25+
26+
const AIUpdateCardSchema = z.object({
27+
type: z.literal('update'),
28+
cardId: z.string().refine(val => ObjectId.isValid(val), { message: "Invalid cardId format for update" }),
29+
front_text: z.string().trim().min(1).optional(),
30+
back_text: z.string().trim().min(1).optional(),
31+
extra_context: z.string().optional(),
32+
});
33+
34+
const AIDeleteCardSchema = z.object({
35+
type: z.literal('delete'),
36+
cardId: z.string().refine(val => ObjectId.isValid(val), { message: "Invalid cardId format for delete" }),
37+
});
38+
39+
const AIEditSuggestionSchema = z.discriminatedUnion("type", [
40+
AICreateCardSchema,
41+
AIUpdateCardSchema,
42+
AIDeleteCardSchema,
43+
]);
44+
45+
const AIEditResponseSchema = z.object({
46+
edits: z.array(AIEditSuggestionSchema),
47+
});
48+
49+
50+
// --- Action: Get AI Edit Suggestions ---
51+
52+
interface GetAIEditSuggestionsResult {
53+
success: boolean;
54+
suggestions?: AICardEditSuggestion[];
55+
message?: string;
56+
}
57+
58+
export async function getAIEditSuggestionsAction(
59+
deckId: string,
60+
userPrompt: string,
61+
token: string | undefined
62+
): Promise<GetAIEditSuggestionsResult> {
63+
const authResult = verifyAuthToken(token);
64+
if (!authResult) {
65+
return { success: false, message: 'Unauthorized.' };
66+
}
67+
68+
if (!deckId || !ObjectId.isValid(deckId)) {
69+
return { success: false, message: 'Invalid Deck ID.' };
70+
}
71+
if (!userPrompt || !userPrompt.trim()) {
72+
return { success: false, message: 'Prompt cannot be empty.' };
73+
}
74+
75+
try {
76+
const openai = getOpenAIClient();
77+
78+
// 1. Fetch current deck and cards
79+
const deckResult = await fetchDeckByIdAction(deckId); // Does not require token as per its definition
80+
if (!deckResult.success || !deckResult.deck) {
81+
return { success: false, message: deckResult.message || 'Failed to fetch deck details.' };
82+
}
83+
84+
const cardsResult = await fetchDeckCardsAction(deckId);
85+
if (!cardsResult.success || !cardsResult.cards) {
86+
return { success: false, message: cardsResult.message || 'Failed to fetch deck cards.' };
87+
}
88+
89+
// 2. Serialize deck state for the LLM
90+
const currentCardsSerialized = cardsResult.cards.map(card => ({
91+
id: card.id,
92+
front_text: card.front_text,
93+
back_text: card.back_text,
94+
// Do not include extra_context here unless we also want AI to edit it based on current value
95+
}));
96+
97+
const systemPrompt = `You are an AI assistant helping to manage flashcard decks. Based on the user's request and the current state of the deck, provide a list of edits. The deck name is "${deckResult.deck.name}".
98+
99+
Current cards in the deck (only id, front_text, back_text are shown):
100+
${JSON.stringify(currentCardsSerialized, null, 2)}
101+
102+
User's request: "${userPrompt}"
103+
104+
Instructions for your response:
105+
- Respond with a JSON object containing a single key "edits", which is an array of edit objects.
106+
- Each edit object must have a "type" field: "create", "update", or "delete".
107+
- For "create": include "front_text" (string), "back_text" (string), and optionally "extra_context" (string).
108+
- For "update": include "cardId" (string - ID of the card to change) and AT LEAST ONE of "front_text" (string), "back_text" (string), or "extra_context" (string).
109+
- For "delete": include "cardId" (string - ID of the card to remove).
110+
- If no edits are needed, return an empty "edits" array: {"edits": []}.
111+
- Ensure card IDs for updates/deletes are from the provided current card list.
112+
- If the user asks to add cards, provide 'create' operations.
113+
- If the user asks to change existing cards, provide 'update' operations with the relevant cardId.
114+
- If the user asks to remove cards, provide 'delete' operations with the relevant cardId.
115+
- Be precise. Do not add conversational fluff. Only provide the JSON object.`;
116+
117+
const completion = await openai.chat.completions.create({
118+
model: 'gpt-4o', // Or your preferred model
119+
messages: [{ role: 'system', content: systemPrompt }],
120+
response_format: { type: 'json_object' },
121+
temperature: 0.3, // Lower temperature for more predictable edits
122+
});
123+
124+
const content = completion.choices[0]?.message?.content;
125+
if (!content) {
126+
throw new Error('OpenAI did not return content for edit suggestions.');
127+
}
128+
129+
let parsedContent;
130+
try {
131+
parsedContent = JSON.parse(content);
132+
} catch (parseError) {
133+
console.error('Failed to parse OpenAI JSON response for edits:', parseError, 'Content:', content);
134+
throw new Error('Failed to parse edit suggestions from AI response.');
135+
}
136+
137+
const validationResult = AIEditResponseSchema.safeParse(parsedContent);
138+
139+
if (!validationResult.success) {
140+
console.error('OpenAI edit response validation (initial) failed:', validationResult.error.flatten());
141+
const errorMessages = validationResult.error.errors.map(err => `${err.path.join('.')} - ${err.message}`).join('; ');
142+
throw new Error(`Generated edit data is not in the expected format: ${errorMessages}`);
143+
}
144+
145+
// Manually validate the refinement for update operations
146+
const validatedSuggestions: AICardEditSuggestion[] = [];
147+
for (const edit of validationResult.data.edits) {
148+
if (edit.type === 'update') {
149+
if (!edit.front_text && !edit.back_text && !edit.extra_context) {
150+
throw new Error(`Update operation for cardId ${edit.cardId} must include at least one field to change (front_text, back_text, or extra_context).`);
151+
}
152+
}
153+
validatedSuggestions.push(edit as AICardEditSuggestion); // Cast after validation
154+
}
155+
156+
return { success: true, suggestions: validatedSuggestions };
157+
158+
} catch (error) {
159+
console.error('[Get AI Edit Suggestions Action Error]', error);
160+
const message = error instanceof Error ? error.message : 'Failed to get AI edit suggestions.';
161+
return { success: false, message };
162+
}
163+
}
164+
165+
// Placeholder for applyAIEditsAction - to be implemented next
166+
interface ApplyAIEditsResult {
167+
success: boolean;
168+
appliedCount: number;
169+
failedCount: number;
170+
// Optionally, details about failures
171+
failureDetails?: { edit: AICardEditSuggestion, message: string }[];
172+
message?: string;
173+
}
174+
175+
export async function applyAIEditsAction(
176+
deckId: string,
177+
edits: AICardEditSuggestion[],
178+
token: string | undefined
179+
): Promise<ApplyAIEditsResult> {
180+
const authResult = verifyAuthToken(token);
181+
if (!authResult) {
182+
return { success: false, appliedCount: 0, failedCount: edits.length, message: 'Unauthorized.' };
183+
}
184+
185+
if (!deckId || !ObjectId.isValid(deckId)) {
186+
return { success: false, appliedCount: 0, failedCount: edits.length, message: 'Invalid Deck ID.' };
187+
}
188+
189+
let appliedCount = 0;
190+
const failureDetails: { edit: AICardEditSuggestion, message: string }[] = [];
191+
192+
// Import card actions here to avoid circular dependency issues at module level if they also import from here
193+
const { createCardAction, updateCardAction, deleteCardAction } = await import('./cards');
194+
195+
for (const edit of edits) {
196+
try {
197+
let result: { success: boolean; message?: string; card?: any };
198+
switch (edit.type) {
199+
case 'create':
200+
result = await createCardAction({
201+
deckId,
202+
frontText: edit.front_text,
203+
backText: edit.back_text,
204+
// extra_context is not directly supported by createCardAction, handle if needed
205+
// or update createCardAction to accept it.
206+
// For now, it will be ignored for 'create' via this path.
207+
token,
208+
});
209+
break;
210+
case 'update':
211+
// Ensure at least one field is being updated (already validated by getAIEditSuggestionsAction)
212+
result = await updateCardAction({
213+
cardId: edit.cardId,
214+
deckId, // updateCardAction needs deckId for revalidation context
215+
frontText: edit.front_text,
216+
backText: edit.back_text,
217+
// extra_context is not directly supported by updateCardAction.
218+
// If we want to update it, updateCardAction must be modified.
219+
token,
220+
});
221+
break;
222+
case 'delete':
223+
result = await deleteCardAction({
224+
cardId: edit.cardId,
225+
deckId, // deleteCardAction needs deckId
226+
token,
227+
});
228+
break;
229+
default:
230+
// Should not happen due to Zod validation
231+
throw new Error('Invalid edit type encountered.');
232+
}
233+
234+
if (result.success) {
235+
appliedCount++;
236+
} else {
237+
failureDetails.push({ edit, message: result.message || 'Unknown error during edit application.' });
238+
}
239+
} catch (error) {
240+
const message = error instanceof Error ? error.message : 'Unknown exception during edit application.';
241+
failureDetails.push({ edit, message });
242+
}
243+
}
244+
245+
// Revalidate the deck edit page path after all edits
246+
if (appliedCount > 0 || failureDetails.length > 0) { // Revalidate if any change or attempted change
247+
const { revalidatePath } = await import('next/cache');
248+
revalidatePath(`/deck/${deckId}/edit`);
249+
revalidatePath(`/deck/${deckId}/overview`); // Also overview if card counts change
250+
}
251+
252+
if (failureDetails.length > 0) {
253+
return {
254+
success: false, // Overall success is false if any edit failed
255+
appliedCount,
256+
failedCount: failureDetails.length,
257+
failureDetails,
258+
message: `${failureDetails.length} edit(s) failed to apply.`
259+
};
260+
}
261+
262+
return {
263+
success: true,
264+
appliedCount,
265+
failedCount: 0,
266+
message: `${appliedCount} edit(s) applied successfully.`
267+
};
268+
}

app/components/AIEditPromptModal.tsx

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
'use client';
2+
3+
import React, { useState } from 'react';
4+
import Button from './Button';
5+
import Spinner from './Spinner';
6+
7+
interface AIEditPromptModalProps {
8+
isOpen: boolean;
9+
onClose: () => void;
10+
onSubmitPrompt: (prompt: string) => void;
11+
isLoading: boolean;
12+
error: string | null;
13+
}
14+
15+
export default function AIEditPromptModal({
16+
isOpen,
17+
onClose,
18+
onSubmitPrompt,
19+
isLoading,
20+
error,
21+
}: AIEditPromptModalProps) {
22+
const [prompt, setPrompt] = useState('');
23+
24+
const handleSubmit = (e: React.FormEvent) => {
25+
e.preventDefault();
26+
if (!prompt.trim() || isLoading) return;
27+
onSubmitPrompt(prompt.trim());
28+
// Optionally close modal on submit, or let parent decide
29+
// setPrompt(''); // Clear prompt after submit
30+
};
31+
32+
const handleClose = () => {
33+
setPrompt(''); // Clear prompt on close
34+
onClose();
35+
}
36+
37+
if (!isOpen) return null;
38+
39+
return (
40+
<div className="fixed inset-0 bg-black bg-opacity-60 flex justify-center items-center p-4 z-50">
41+
<div className="bg-white p-6 rounded-lg shadow-xl w-full max-w-lg space-y-4">
42+
<div className="flex justify-between items-center">
43+
<h2 className="text-xl font-semibold text-gray-900">AI Deck Edit Assistant</h2>
44+
<Button variant="default" size="sm" onClick={handleClose} disabled={isLoading} className="!p-1">
45+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor" className="w-5 h-5">
46+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
47+
</svg>
48+
</Button>
49+
</div>
50+
<form onSubmit={handleSubmit} className="space-y-4">
51+
<div>
52+
<label htmlFor="ai-edit-prompt" className="block text-sm font-medium text-gray-700 mb-1">
53+
Describe the changes you want to make:
54+
</label>
55+
<textarea
56+
id="ai-edit-prompt"
57+
rows={5}
58+
value={prompt}
59+
onChange={(e) => setPrompt(e.target.value)}
60+
placeholder="e.g., 'Add 5 more cards about cellular respiration.', 'Make all answers more concise.', 'Remove any cards related to glycolysis.'"
61+
required
62+
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-primary focus:border-primary"
63+
disabled={isLoading}
64+
/>
65+
</div>
66+
67+
{error && (
68+
<div className="text-red-600 text-sm p-3 bg-red-50 border border-red-200 rounded">
69+
Error: {error}
70+
</div>
71+
)}
72+
73+
<div className="flex justify-end space-x-2 pt-2">
74+
<Button type="button" variant="default" onClick={handleClose} disabled={isLoading}>
75+
Cancel
76+
</Button>
77+
<Button type="submit" variant="primary" disabled={isLoading || !prompt.trim()}>
78+
{isLoading ? <><Spinner size="sm" /> Getting Suggestions...</> : 'Get Suggestions'}
79+
</Button>
80+
</div>
81+
</form>
82+
</div>
83+
</div>
84+
);
85+
}

0 commit comments

Comments
 (0)