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:
2026-01-10 23:30:32 +01:00
parent 8efe6c304e
commit e6b9dd9d34
18 changed files with 533 additions and 158 deletions

View File

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