feat: support multiple event proposals in single AI response
- Change proposedChange to proposedChanges array in ChatMessage type - Add unique id and individual respondedAction to each ProposedEventChange - Implement arrow navigation UI for multiple proposals with "Event X von Y" counter - Add updateProposalResponse() method for per-proposal confirm/reject tracking - GPTAdapter now collects multiple tool call results into proposals array - Add RRULE documentation to system prompt (separate events for different times) - Fix RRULE parsing to strip RRULE: prefix if present - Add log summarization for large args (conversationHistory, existingEvents) - Keep proposedChanges logged in full for debugging AI issues
This commit is contained in:
@@ -20,6 +20,7 @@ import {
|
||||
} from "../../stores";
|
||||
import { ProposedEventChange } from "@caldav/shared";
|
||||
import { ProposedEventCard } from "../../components/ProposedEventCard";
|
||||
import { Ionicons } from "@expo/vector-icons";
|
||||
|
||||
// TODO: better shadows for everything
|
||||
// (maybe with extra library because of differences between android and ios)
|
||||
@@ -30,10 +31,9 @@ type BubbleSide = "left" | "right";
|
||||
type ChatMessageProps = {
|
||||
side: BubbleSide;
|
||||
content: string;
|
||||
proposedChange?: ProposedEventChange;
|
||||
respondedAction?: "confirm" | "reject";
|
||||
onConfirm?: () => void;
|
||||
onReject?: () => void;
|
||||
proposedChanges?: ProposedEventChange[];
|
||||
onConfirm?: (proposalId: string, proposal: ProposedEventChange) => void;
|
||||
onReject?: (proposalId: string) => void;
|
||||
};
|
||||
|
||||
type ChatInputProps = {
|
||||
@@ -88,10 +88,17 @@ const Chat = () => {
|
||||
action: "confirm" | "reject",
|
||||
messageId: string,
|
||||
conversationId: string,
|
||||
proposalId: string,
|
||||
proposedChange?: ProposedEventChange,
|
||||
) => {
|
||||
// Mark message as responded (optimistic update)
|
||||
updateMessage(messageId, { respondedAction: action });
|
||||
// Mark proposal as responded (optimistic update)
|
||||
const message = messages.find((m) => m.id === messageId);
|
||||
if (message?.proposedChanges) {
|
||||
const updatedProposals = message.proposedChanges.map((p) =>
|
||||
p.id === proposalId ? { ...p, respondedAction: action as "confirm" | "reject" } : p,
|
||||
);
|
||||
updateMessage(messageId, { proposedChanges: updatedProposals });
|
||||
}
|
||||
|
||||
try {
|
||||
const response =
|
||||
@@ -99,12 +106,13 @@ const Chat = () => {
|
||||
? await ChatService.confirmEvent(
|
||||
conversationId,
|
||||
messageId,
|
||||
proposalId,
|
||||
proposedChange.action,
|
||||
proposedChange.event,
|
||||
proposedChange.eventId,
|
||||
proposedChange.updates,
|
||||
)
|
||||
: await ChatService.rejectEvent(conversationId, messageId);
|
||||
: await ChatService.rejectEvent(conversationId, messageId, proposalId);
|
||||
|
||||
const botMessage: MessageData = {
|
||||
id: response.message.id,
|
||||
@@ -117,7 +125,12 @@ const Chat = () => {
|
||||
} catch (error) {
|
||||
console.error(`Failed to ${action} event:`, error);
|
||||
// Revert on error
|
||||
updateMessage(messageId, { respondedAction: undefined });
|
||||
if (message?.proposedChanges) {
|
||||
const revertedProposals = message.proposedChanges.map((p) =>
|
||||
p.id === proposalId ? { ...p, respondedAction: undefined } : p,
|
||||
);
|
||||
updateMessage(messageId, { proposedChanges: revertedProposals });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -149,7 +162,7 @@ const Chat = () => {
|
||||
id: response.message.id,
|
||||
side: "left",
|
||||
content: response.message.content,
|
||||
proposedChange: response.message.proposedChange,
|
||||
proposedChanges: response.message.proposedChanges,
|
||||
conversationId: response.conversationId,
|
||||
};
|
||||
addMessage(botMessage);
|
||||
@@ -173,18 +186,18 @@ const Chat = () => {
|
||||
<ChatMessage
|
||||
side={item.side}
|
||||
content={item.content}
|
||||
proposedChange={item.proposedChange}
|
||||
respondedAction={item.respondedAction}
|
||||
onConfirm={() =>
|
||||
proposedChanges={item.proposedChanges}
|
||||
onConfirm={(proposalId, proposal) =>
|
||||
handleEventResponse(
|
||||
"confirm",
|
||||
item.id,
|
||||
item.conversationId!,
|
||||
item.proposedChange,
|
||||
proposalId,
|
||||
proposal,
|
||||
)
|
||||
}
|
||||
onReject={() =>
|
||||
handleEventResponse("reject", item.id, item.conversationId!)
|
||||
onReject={(proposalId) =>
|
||||
handleEventResponse("reject", item.id, item.conversationId!, proposalId)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -271,11 +284,12 @@ const ChatInput = ({ onSend }: ChatInputProps) => {
|
||||
const ChatMessage = ({
|
||||
side,
|
||||
content,
|
||||
proposedChange,
|
||||
respondedAction,
|
||||
proposedChanges,
|
||||
onConfirm,
|
||||
onReject,
|
||||
}: ChatMessageProps) => {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const borderColor =
|
||||
side === "left" ? currentTheme.chatBot : currentTheme.primeFg;
|
||||
const selfSide =
|
||||
@@ -283,24 +297,87 @@ const ChatMessage = ({
|
||||
? "self-start ml-2 rounded-bl-sm"
|
||||
: "self-end mr-2 rounded-br-sm";
|
||||
|
||||
const hasProposals = proposedChanges && proposedChanges.length > 0;
|
||||
const hasMultiple = proposedChanges && proposedChanges.length > 1;
|
||||
const currentProposal = proposedChanges?.[currentIndex];
|
||||
|
||||
const goToPrev = () => setCurrentIndex((i) => Math.max(0, i - 1));
|
||||
const goToNext = () =>
|
||||
setCurrentIndex((i) =>
|
||||
Math.min((proposedChanges?.length || 1) - 1, i + 1),
|
||||
);
|
||||
|
||||
const canGoPrev = currentIndex > 0;
|
||||
const canGoNext = currentIndex < (proposedChanges?.length || 1) - 1;
|
||||
|
||||
return (
|
||||
<View
|
||||
className={`bg-white border-2 border-solid rounded-xl my-2 ${selfSide}`}
|
||||
style={{
|
||||
borderColor: borderColor,
|
||||
maxWidth: "80%",
|
||||
minWidth: hasProposals ? "75%" : undefined,
|
||||
elevation: 8,
|
||||
}}
|
||||
>
|
||||
<Text className="p-2">{content}</Text>
|
||||
|
||||
{proposedChange && onConfirm && onReject && (
|
||||
<ProposedEventCard
|
||||
proposedChange={proposedChange}
|
||||
respondedAction={respondedAction}
|
||||
onConfirm={onConfirm}
|
||||
onReject={onReject}
|
||||
/>
|
||||
{hasProposals && currentProposal && onConfirm && onReject && (
|
||||
<View>
|
||||
{/* Event card with optional navigation arrows */}
|
||||
<View className="flex-row items-center">
|
||||
{/* Left arrow */}
|
||||
{hasMultiple && (
|
||||
<Pressable
|
||||
onPress={goToPrev}
|
||||
disabled={!canGoPrev}
|
||||
className="p-1"
|
||||
style={{ opacity: canGoPrev ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-back"
|
||||
size={24}
|
||||
color={currentTheme.primeFg}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
|
||||
{/* Event Card */}
|
||||
<View className="flex-1">
|
||||
<ProposedEventCard
|
||||
proposedChange={currentProposal}
|
||||
onConfirm={() => onConfirm(currentProposal.id, currentProposal)}
|
||||
onReject={() => onReject(currentProposal.id)}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* Right arrow */}
|
||||
{hasMultiple && (
|
||||
<Pressable
|
||||
onPress={goToNext}
|
||||
disabled={!canGoNext}
|
||||
className="p-1"
|
||||
style={{ opacity: canGoNext ? 1 : 0.3 }}
|
||||
>
|
||||
<Ionicons
|
||||
name="chevron-forward"
|
||||
size={24}
|
||||
color={currentTheme.primeFg}
|
||||
/>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Event counter */}
|
||||
{hasMultiple && (
|
||||
<Text
|
||||
className="text-center text-sm pb-2"
|
||||
style={{ color: currentTheme.textSecondary || "#666" }}
|
||||
>
|
||||
Event {currentIndex + 1} von {proposedChanges.length}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user