From 7e24c4ed83a2a89d2d92817a69c92a9da77c85c9 Mon Sep 17 00:00:00 2001 From: guotai Date: Sun, 10 May 2026 10:18:04 +1000 Subject: [PATCH 1/3] fix: add error dto and oiptimize the error handling flow --- .../Modules/ChatTaskGenerator/AiHubFilter.cs | 13 +++---- .../Dtos/AiGenerateMessage.cs | 12 +------ .../Dtos/AiGenerationError.cs | 12 +++++++ .../Services/AiChatService.cs | 34 +++++++++---------- blotztask-mobile/package-lock.json | 30 ++++++++-------- 5 files changed, 50 insertions(+), 51 deletions(-) create mode 100644 blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerationError.cs diff --git a/blotztask-api/Modules/ChatTaskGenerator/AiHubFilter.cs b/blotztask-api/Modules/ChatTaskGenerator/AiHubFilter.cs index f27b7a999..60894cb57 100644 --- a/blotztask-api/Modules/ChatTaskGenerator/AiHubFilter.cs +++ b/blotztask-api/Modules/ChatTaskGenerator/AiHubFilter.cs @@ -5,8 +5,8 @@ namespace BlotzTask.Modules.ChatTaskGenerator; -// All methods on this hub use ReceiveGenerationResult as the error envelope. If a new method -// with a different client contract is added, handle it before reaching this filter. +// All hub method errors are sent to the caller via ReceiveGenerationError. If a new hub method +// needs a different error contract, handle it before reaching this filter. public class AiHubFilter(ILogger logger) : IHubFilter { public async ValueTask InvokeMethodAsync( @@ -21,9 +21,8 @@ public class AiHubFilter(ILogger logger) : IHubFilter { logger.LogWarning(ex, "Hub method {Method} failed. ErrorCode: {Code}", invocationContext.HubMethodName, ex.Code); - await invocationContext.Hub.Clients.Caller.SendAsync("ReceiveGenerationResult", new AiGenerateMessage + await invocationContext.Hub.Clients.Caller.SendAsync("ReceiveGenerationError", new AiGenerationError { - IsSuccess = false, ErrorCode = ex.Code.ToString(), ErrorMessage = ex.Message }); @@ -32,9 +31,8 @@ public class AiHubFilter(ILogger logger) : IHubFilter catch (AiQuotaExceededException ex) { logger.LogWarning(ex, "Hub method {Method} failed: quota exceeded", invocationContext.HubMethodName); - await invocationContext.Hub.Clients.Caller.SendAsync("ReceiveGenerationResult", new AiGenerateMessage + await invocationContext.Hub.Clients.Caller.SendAsync("ReceiveGenerationError", new AiGenerationError { - IsSuccess = false, ErrorCode = AiErrorCode.QuotaExceeded.ToString(), ErrorMessage = ex.Message }); @@ -44,9 +42,8 @@ public class AiHubFilter(ILogger logger) : IHubFilter { logger.LogError(ex, "Hub method {Method} failed unexpectedly. ConnectionId: {ConnectionId}", invocationContext.HubMethodName, invocationContext.Context.ConnectionId); - await invocationContext.Hub.Clients.Caller.SendAsync("ReceiveGenerationResult", new AiGenerateMessage + await invocationContext.Hub.Clients.Caller.SendAsync("ReceiveGenerationError", new AiGenerationError { - IsSuccess = false, ErrorCode = AiErrorCode.Unknown.ToString(), ErrorMessage = "An unexpected error occurred. Please try again." }); diff --git a/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerateMessage.cs b/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerateMessage.cs index 2ec22eff4..4ae5599a0 100644 --- a/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerateMessage.cs +++ b/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerateMessage.cs @@ -10,9 +10,6 @@ namespace BlotzTask.Modules.ChatTaskGenerator.Dtos; /// public class AiGenerateMessage { - [JsonPropertyName("isSuccess")] - [Description("Indicates whether extraction was successful. True if at least one task or one note was extracted, false otherwise.")] - public bool IsSuccess { get; set; } [JsonPropertyName("userInput")] public string UserInput { get; set; } = ""; @@ -24,14 +21,7 @@ public class AiGenerateMessage [JsonPropertyName("extractedNotes")] [Description("Array of notes extracted from user input (items with no date/time). Empty when all have time.")] public List ExtractedNotes { get; set; } = new(); - - [JsonPropertyName("errorCode")] - [Description("Machine-readable error code identifying the failure type. Empty string when isSuccess is true.")] - public string ErrorCode { get; set; } = ""; - - [JsonPropertyName("errorMessage")] - [Description("Error message explaining why task extraction failed. Empty string when isSuccess is true.")] - public string ErrorMessage { get; set; } = ""; + [JsonPropertyName("inputTokens")] [Description("Number of prompt tokens used.")] diff --git a/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerationError.cs b/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerationError.cs new file mode 100644 index 000000000..f25cde59e --- /dev/null +++ b/blotztask-api/Modules/ChatTaskGenerator/Dtos/AiGenerationError.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace BlotzTask.Modules.ChatTaskGenerator.Dtos; + +public class AiGenerationError +{ + [JsonPropertyName("errorCode")] + public string ErrorCode { get; set; } = ""; + + [JsonPropertyName("errorMessage")] + public string ErrorMessage { get; set; } = ""; +} diff --git a/blotztask-api/Modules/ChatTaskGenerator/Services/AiChatService.cs b/blotztask-api/Modules/ChatTaskGenerator/Services/AiChatService.cs index 8b2ef12a3..18e910f17 100644 --- a/blotztask-api/Modules/ChatTaskGenerator/Services/AiChatService.cs +++ b/blotztask-api/Modules/ChatTaskGenerator/Services/AiChatService.cs @@ -72,6 +72,8 @@ public async Task GenerateAiResponse( { context.Tools.ResetCallCount(); + int inputTokens = 0, outputTokens = 0, totalTokens = 0; + try { await checkAiQuotaService.CheckQuotaAsync(userId, ct); @@ -82,9 +84,9 @@ public async Task GenerateAiResponse( var response = await context.Agent.RunAsync(userMessage, context.Session, cancellationToken: ct); runSw.Stop(); - int inputTokens = (int)(response.Usage?.InputTokenCount ?? 0); - int outputTokens = (int)(response.Usage?.OutputTokenCount ?? 0); - int totalTokens = (int)(response.Usage?.TotalTokenCount ?? 0); + inputTokens = (int)(response.Usage?.InputTokenCount ?? 0); + outputTokens = (int)(response.Usage?.OutputTokenCount ?? 0); + totalTokens = (int)(response.Usage?.TotalTokenCount ?? 0); await recordAiUsageService.RecordAiUsageAsync(new RecordAiUsageRequest { UserId = userId, @@ -97,20 +99,6 @@ await recordAiUsageService.RecordAiUsageAsync(new RecordAiUsageRequest "TaskGeneration: RunAsync completed in {RunMs}ms | InputTokens={InputTokens} | OutputTokens={OutputTokens} | TotalTokens={TotalTokens} | ToolCalls={ToolCallCount} | Tasks={TaskCount} | Notes={NoteCount}", runSw.ElapsedMilliseconds, inputTokens, outputTokens, totalTokens, context.Tools.ToolCallCount, context.Tools.Tasks.Count, context.Tools.Notes.Count); - - var isSuccess = context.Tools.ToolCallCount > 0; - - return new AiGenerateMessage - { - IsSuccess = isSuccess, - ErrorCode = isSuccess ? "" : AiErrorCode.NoTasksExtracted.ToString(), - ExtractedTasks = context.Tools.Tasks, - ExtractedNotes = context.Tools.Notes, - ErrorMessage = isSuccess ? "" : "Could not extract any tasks or notes from your input.", - InputTokens = inputTokens, - OutputTokens = outputTokens, - TotalTokens = totalTokens - }; } catch (OperationCanceledException oce) { @@ -137,5 +125,17 @@ await recordAiUsageService.RecordAiUsageAsync(new RecordAiUsageRequest throw new AiTaskGenerationException(AiErrorCode.Unknown, "An unhandled exception occurred during AI task generation.", ex); } + + if (context.Tools.ToolCallCount <= 0) + throw new AiTaskGenerationException(AiErrorCode.NoTasksExtracted, "No tasks or notes were extracted."); + + return new AiGenerateMessage + { + ExtractedTasks = context.Tools.Tasks, + ExtractedNotes = context.Tools.Notes, + InputTokens = inputTokens, + OutputTokens = outputTokens, + TotalTokens = totalTokens + }; } } \ No newline at end of file diff --git a/blotztask-mobile/package-lock.json b/blotztask-mobile/package-lock.json index dfd65894e..34db22fc9 100644 --- a/blotztask-mobile/package-lock.json +++ b/blotztask-mobile/package-lock.json @@ -3036,23 +3036,23 @@ } }, "node_modules/@react-native/babel-plugin-codegen": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.2.tgz", - "integrity": "sha512-5Dqn08kRTUIxPLYju9hExI0cR1ESX+P5tEv5yv0q0UZcisRTw0VB8iUWDIph2LdY1i5Dc8PIvuaWMRNCw3vnKg==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-plugin-codegen/-/babel-plugin-codegen-0.85.3.tgz", + "integrity": "sha512-Wc94zGfeFG8Njf9SHMPfYZP04kjigkOps6F1TYTvd7ZVXuGxqseCDgxc50LWcOhOCLypI9n3oVVqz81C3p44ZA==", "license": "MIT", "optional": true, "dependencies": { "@babel/traverse": "^7.29.0", - "@react-native/codegen": "0.85.2" + "@react-native/codegen": "0.85.3" }, "engines": { "node": "^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0" } }, "node_modules/@react-native/babel-plugin-codegen/node_modules/@react-native/codegen": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.2.tgz", - "integrity": "sha512-XCginmxh0//++EXVOEJHBVZxHla294FzLCFF6jXwAUjvXVhqyIKyxhABfz+r4OOmaiuWk4Rtd4arqdAzeHeprg==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/codegen/-/codegen-0.85.3.tgz", + "integrity": "sha512-/JkS1lGLyzBWP1FbgDwaqEf7qShIC6pUC1M0a/YMAd/v4iqR24MRkQWe7jkYvcBQ2LpEhs5NGE9InhxSv21zCA==", "license": "MIT", "optional": true, "dependencies": { @@ -3089,9 +3089,9 @@ } }, "node_modules/@react-native/babel-preset": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.2.tgz", - "integrity": "sha512-7d2yW23eKkVt0FbbnZLxqO7KybGLtQXOuvvcO1NUOYGtjzVh6ihNKn0TIHrhSNpMyHwYLDoiiuj95wLtcg3IwQ==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.85.3.tgz", + "integrity": "sha512-fD7fxEhkJB/aF57tWoXjaAWpklfrExYZS3k6aXPP3BQ77DZY7gvf/b7dbirwjID6NVnP1JDRJyTuPBGr0K/vlw==", "license": "MIT", "optional": true, "dependencies": { @@ -3124,7 +3124,7 @@ "@babel/plugin-transform-runtime": "^7.24.7", "@babel/plugin-transform-typescript": "^7.25.2", "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@react-native/babel-plugin-codegen": "0.85.2", + "@react-native/babel-plugin-codegen": "0.85.3", "babel-plugin-syntax-hermes-parser": "0.33.3", "babel-plugin-transform-flow-enums": "^0.0.2", "react-refresh": "^0.14.0" @@ -3323,14 +3323,14 @@ } }, "node_modules/@react-native/metro-babel-transformer": { - "version": "0.85.2", - "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.2.tgz", - "integrity": "sha512-lU9XOGahpHvQff30H5lnvh9RYbVwC1zpSHpl84E+7BD2zj0FvW+pD7MBh7CWbmbWmegjtAb+U/2bokXcDVA+jA==", + "version": "0.85.3", + "resolved": "https://registry.npmjs.org/@react-native/metro-babel-transformer/-/metro-babel-transformer-0.85.3.tgz", + "integrity": "sha512-omuKq+r7jM4XvCMIlNMPP7Up3SyB8o5EAdZtF7YXniKyq7UOMBqhYHFqgsdOXr0lT+3ADf7VCJG3sb82jlBrrQ==", "license": "MIT", "optional": true, "dependencies": { "@babel/core": "^7.25.2", - "@react-native/babel-preset": "0.85.2", + "@react-native/babel-preset": "0.85.3", "hermes-parser": "0.33.3", "nullthrows": "^1.1.1" }, From 181eaa62d74feec3557cf9d80e91f2c14a94ea24 Mon Sep 17 00:00:00 2001 From: guotai Date: Sun, 10 May 2026 11:09:41 +1000 Subject: [PATCH 2/3] feat: enhance AI task generation error handling with dedicated error DTO --- .../hooks/useAiTaskGenerator.ts | 28 +++++++++++++------ .../models/ai-result-message-dto.ts | 8 ++++-- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts b/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts index b3b8594db..4cdf649dd 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts +++ b/blotztask-mobile/src/feature/ai-task-generate/hooks/useAiTaskGenerator.ts @@ -2,7 +2,12 @@ import { useEffect, useRef, useState } from "react"; import * as signalR from "@microsoft/signalr"; import { File as ExpoFile } from "expo-file-system"; import { signalRService } from "@/feature/ai-task-generate/services/ai-task-generator-signalr-service"; -import { AiNoteDTO, AiResultMessageDTO, ExtractedTaskDTO } from "../models/ai-result-message-dto"; +import { + AiGenerationErrorDTO, + AiNoteDTO, + AiResultMessageDTO, + ExtractedTaskDTO, +} from "../models/ai-result-message-dto"; import { useTranslation } from "react-i18next"; import Toast from "react-native-toast-message"; @@ -68,20 +73,23 @@ export function useAiTaskGenerator({ setIsAiGenerating(false); requestStartedAtRef.current = null; - if (!result.isSuccess) { - setStreamedTasks([]); - setStreamedNotes([]); - const i18nKey = ERROR_CODE_TO_I18N_KEY[result.errorCode ?? ""] ?? "errors.default"; - Toast.show({ type: "error", text1: t(i18nKey) }); - return; - } - // Sync to authoritative final list — streaming only covers CreateTask, not RemoveTask/UpdateTask setStreamedTasks(result.extractedTasks ?? []); setStreamedNotes(result.extractedNotes ?? []); setUserInput(result.userInput); }; + const generationErrorHandler = (error: AiGenerationErrorDTO) => { + setTranscript(undefined); + setIsAiGenerating(false); + requestStartedAtRef.current = null; + setStreamedTasks([]); + setStreamedNotes([]); + + const i18nKey = ERROR_CODE_TO_I18N_KEY[error.errorCode] ?? "errors.default"; + Toast.show({ type: "error", text1: t(i18nKey) }); + }; + useEffect(() => { let conn: signalR.HubConnection | null = null; @@ -91,6 +99,7 @@ export function useAiTaskGenerator({ conn = newConn; setConnection(conn); conn.on("ReceiveGenerationResult", generationCompleteHandler); + conn.on("ReceiveGenerationError", generationErrorHandler); conn.on("ReceiveTranscript", (text: string) => setTranscript(text)); conn.on("ReceiveTaskExtracted", (task: ExtractedTaskDTO) => { if (requestStartedAtRef.current == null) return; @@ -107,6 +116,7 @@ export function useAiTaskGenerator({ return () => { if (conn) { conn.off("ReceiveGenerationResult", generationCompleteHandler); + conn.off("ReceiveGenerationError", generationErrorHandler); conn.off("ReceiveTranscript"); conn.off("ReceiveTaskExtracted"); conn.off("ReceiveNoteExtracted"); diff --git a/blotztask-mobile/src/feature/ai-task-generate/models/ai-result-message-dto.ts b/blotztask-mobile/src/feature/ai-task-generate/models/ai-result-message-dto.ts index 980e9b4bb..bb1c6ca58 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/models/ai-result-message-dto.ts +++ b/blotztask-mobile/src/feature/ai-task-generate/models/ai-result-message-dto.ts @@ -1,10 +1,12 @@ export interface AiResultMessageDTO { - isSuccess: boolean; userInput?: string; extractedTasks?: ExtractedTaskDTO[]; extractedNotes?: AiNoteDTO[]; - errorCode?: string; - errorMessage?: string; +} + +export interface AiGenerationErrorDTO { + errorCode: string; + errorMessage: string; } export interface AiNoteDTO { From 628261a2ba2c57b7a8c48454085fe879e7fc814f Mon Sep 17 00:00:00 2001 From: guotai Date: Sun, 10 May 2026 11:21:03 +1000 Subject: [PATCH 3/3] feat: integrate custom toast configuration in AI task sheet screen --- .../screens/ai-task-sheet-screen.tsx | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx b/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx index 7eaf40375..eab910e7b 100644 --- a/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx +++ b/blotztask-mobile/src/feature/ai-task-generate/screens/ai-task-sheet-screen.tsx @@ -22,6 +22,7 @@ import useTaskMutations from "@/shared/hooks/useTaskMutations"; import { useNotesMutation } from "@/feature/notes/hooks/useNotesMutation"; import Toast from "react-native-toast-message"; import { analytics } from "@/shared/services/analytics"; +import { toastConfig } from "@/shared/components/toast-config"; export default function AiTaskSheetScreen() { // --- Hooks --- @@ -31,7 +32,14 @@ export default function AiTaskSheetScreen() { const [textInput, setTextInput] = useState(""); const { isVisible: isKeyboardVisible } = useKeyboardState(); const { isHoldHintVisible, showHoldHint, hideHoldHint } = useHoldHint(1500); - const { userInput, transcript, streamedTasks, streamedNotes, submitAudioForTranscription, sendTextMessage } = useAiTaskGenerator({ + const { + userInput, + transcript, + streamedTasks, + streamedNotes, + submitAudioForTranscription, + sendTextMessage, + } = useAiTaskGenerator({ setIsAiGenerating, }); const { labels } = useAllLabels(); @@ -168,7 +176,6 @@ export default function AiTaskSheetScreen() { )} {/* Input bar sticks to the keyboard only */} - + ); }