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

View File

@@ -27,7 +27,7 @@ const Header = (props: HeaderProps) => {
{props.children}
<Pressable
onPress={handleLogout}
className="absolute right-1 top-0 p-2"
className="absolute left-1 bottom-0 p-2"
hitSlop={8}
>
<Ionicons name="log-out-outline" size={24} color={currentTheme.primeFg} />

View File

@@ -5,7 +5,6 @@ import { EventCardBase } from "./EventCardBase";
type ProposedEventCardProps = {
proposedChange: ProposedEventChange;
respondedAction?: "confirm" | "reject";
onConfirm: () => void;
onReject: () => void;
};
@@ -59,12 +58,12 @@ const ConfirmRejectButtons = ({
export const ProposedEventCard = ({
proposedChange,
respondedAction,
onConfirm,
onReject,
}: ProposedEventCardProps) => {
const event = proposedChange.event;
const isDisabled = !!respondedAction;
// respondedAction is now part of the proposedChange
const isDisabled = !!proposedChange.respondedAction;
if (!event) {
return null;
@@ -82,7 +81,7 @@ export const ProposedEventCard = ({
>
<ConfirmRejectButtons
isDisabled={isDisabled}
respondedAction={respondedAction}
respondedAction={proposedChange.respondedAction}
onConfirm={onConfirm}
onReject={onReject}
/>

View File

@@ -11,12 +11,17 @@ import {
import { ApiClient } from "./ApiClient";
interface ConfirmEventRequest {
proposalId: string;
action: EventAction;
event?: CreateEventDTO;
eventId?: string;
updates?: UpdateEventDTO;
}
interface RejectEventRequest {
proposalId: string;
}
export const ChatService = {
sendMessage: async (data: SendMessageDTO): Promise<ChatResponse> => {
return ApiClient.post<ChatResponse>("/chat/message", data);
@@ -25,12 +30,19 @@ export const ChatService = {
confirmEvent: async (
conversationId: string,
messageId: string,
proposalId: string,
action: EventAction,
event?: CreateEventDTO,
eventId?: string,
updates?: UpdateEventDTO,
): Promise<ChatResponse> => {
const body: ConfirmEventRequest = { action, event, eventId, updates };
const body: ConfirmEventRequest = {
proposalId,
action,
event,
eventId,
updates,
};
return ApiClient.post<ChatResponse>(
`/chat/confirm/${conversationId}/${messageId}`,
body,
@@ -40,9 +52,12 @@ export const ChatService = {
rejectEvent: async (
conversationId: string,
messageId: string,
proposalId: string,
): Promise<ChatResponse> => {
const body: RejectEventRequest = { proposalId };
return ApiClient.post<ChatResponse>(
`/chat/reject/${conversationId}/${messageId}`,
body,
);
},

View File

@@ -7,8 +7,7 @@ export type MessageData = {
id: string;
side: BubbleSide;
content: string;
proposedChange?: ProposedEventChange;
respondedAction?: "confirm" | "reject";
proposedChanges?: ProposedEventChange[];
conversationId?: string;
};
@@ -46,8 +45,7 @@ export function chatMessageToMessageData(msg: ChatMessage): MessageData {
id: msg.id,
side: msg.sender === "assistant" ? "left" : "right",
content: msg.content,
proposedChange: msg.proposedChange,
respondedAction: msg.respondedAction,
proposedChanges: msg.proposedChanges,
conversationId: msg.conversationId,
};
}