support canceling AI image recognition

This commit is contained in:
MaysWind
2025-09-27 23:53:40 +08:00
parent e9e6644e7f
commit 4d0d3959a9
19 changed files with 162 additions and 13 deletions

View File

@@ -29,7 +29,7 @@
import { ref, useTemplateRef } from 'vue';
import { useI18n } from '@/locales/helpers.ts';
import { useI18nUIComponents, showLoading, hideLoading } from '@/lib/ui/mobile.ts';
import { useI18nUIComponents, closeAllDialog } from '@/lib/ui/mobile.ts';
import { useTransactionsStore } from '@/stores/transaction.ts';
@@ -38,6 +38,7 @@ import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts';
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import { compressJpgImage } from '@/lib/ui/common.ts';
import logger from '@/lib/logger.ts';
@@ -51,7 +52,7 @@ const emit = defineEmits<{
}>();
const { tt } = useI18n();
const { showToast } = useI18nUIComponents();
const { showCancelableLoading, showToast } = useI18nUIComponents();
const transactionsStore = useTransactionsStore();
@@ -59,6 +60,7 @@ const imageInput = useTemplateRef<HTMLInputElement>('imageInput');
const loading = ref<boolean>(false);
const recognizing = ref<boolean>(false);
const cancelRecognizingUuid = ref<string | undefined>(undefined);
const imageFile = ref<File | null>(null);
const imageSrc = ref<string | undefined>(undefined);
@@ -105,19 +107,27 @@ function confirm(): void {
return;
}
cancelRecognizingUuid.value = generateRandomUUID();
recognizing.value = true;
showLoading(() => recognizing.value);
showCancelableLoading('Recognizing...', 'Cancel Recognition', cancelRecognize);
transactionsStore.recognizeReceiptImage({
imageFile: imageFile.value
imageFile: imageFile.value,
cancelableUuid: cancelRecognizingUuid.value
}).then(response => {
recognizing.value = false;
hideLoading();
cancelRecognizingUuid.value = undefined;
closeAllDialog();
emit('update:show', false);
emit('recognition:change', response);
}).catch(error => {
if (error.canceled) {
return;
}
recognizing.value = false;
hideLoading();
cancelRecognizingUuid.value = undefined;
closeAllDialog();
if (!error.processed) {
showToast(error.message || error);
@@ -125,6 +135,19 @@ function confirm(): void {
});
}
function cancelRecognize(): void {
if (!cancelRecognizingUuid.value) {
return;
}
transactionsStore.cancelRecognizeReceiptImage(cancelRecognizingUuid.value);
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
closeAllDialog();
showToast('User Canceled');
}
function cancel(): void {
close();
}
@@ -133,6 +156,7 @@ function close(): void {
emit('update:show', false);
loading.value = false;
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
imageFile.value = null;
imageSrc.value = undefined;
}
@@ -140,6 +164,7 @@ function close(): void {
function onSheetOpen(): void {
loading.value = false;
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
imageFile.value = null;
imageSrc.value = undefined;
}

View File

@@ -159,6 +159,7 @@ import {
import { getTimezoneOffsetMinutes } from './datetime.ts';
import { generateRandomUUID } from './misc.ts';
import { getBasePath } from './web.ts';
import logger from './logger.ts';
interface ApiRequestConfig extends AxiosRequestConfig {
readonly headers: AxiosRequestHeaders;
@@ -166,12 +167,14 @@ interface ApiRequestConfig extends AxiosRequestConfig {
readonly ignoreBlocked?: boolean;
readonly ignoreError?: boolean;
readonly timeout?: number;
readonly cancelableUuid?: string;
}
export type ApiResponsePromise<T> = Promise<AxiosResponse<ApiResponse<T>>>;
let needBlockRequest = false;
const blockedRequests: ((token: string | undefined) => void)[] = [];
const cancelableRequests: Record<string, boolean> = {};
axios.defaults.baseURL = getBasePath() + BASE_API_URL_PATH;
axios.defaults.timeout = DEFAULT_API_TIMEOUT;
@@ -202,8 +205,20 @@ axios.interceptors.request.use((config: ApiRequestConfig) => {
});
axios.interceptors.response.use(response => {
if ('cancelableUuid' in response.config && response.config.cancelableUuid && cancelableRequests[response.config.cancelableUuid as string]) {
logger.debug('Response canceled by user request, url: ' + response.config.url + ', cancelableUuid: ' + response.config.cancelableUuid);
delete cancelableRequests[response.config.cancelableUuid as string];
return Promise.reject({ canceled: true });
}
return response;
}, error => {
if ('cancelableUuid' in error.response.config && error.response.config.cancelableUuid && cancelableRequests[error.response.config.cancelableUuid]) {
logger.debug('Response canceled by user request, url: ' + error.response.config.url + ', cancelableUuid: ' + error.response.config.cancelableUuid);
delete cancelableRequests[error.response.config.cancelableUuid];
return Promise.reject({ canceled: true });
}
if (error.response && !error.response.config.ignoreError && error.response.data && error.response.data.errorCode) {
const errorCode = error.response.data.errorCode;
@@ -650,12 +665,13 @@ export default {
deleteTransactionTemplate: (req: TransactionTemplateDeleteRequest): ApiResponsePromise<boolean> => {
return axios.post<ApiResponse<boolean>>('v1/transaction/templates/delete.json', req);
},
recognizeReceiptImage: ({ imageFile }: { imageFile: File }): ApiResponsePromise<RecognizedReceiptImageResponse> => {
recognizeReceiptImage: ({ imageFile, cancelableUuid }: { imageFile: File, cancelableUuid?: string }): ApiResponsePromise<RecognizedReceiptImageResponse> => {
return axios.postForm<ApiResponse<RecognizedReceiptImageResponse>>('v1/llm/transactions/recognize_receipt_image.json', {
image: imageFile
}, {
timeout: DEFAULT_LLM_API_TIMEOUT
});
timeout: DEFAULT_LLM_API_TIMEOUT,
cancelableUuid: cancelableUuid
} as ApiRequestConfig);
},
getLatestExchangeRates: (param: { ignoreError?: boolean }): ApiResponsePromise<LatestExchangeRateResponse> => {
return axios.get<ApiResponse<LatestExchangeRateResponse>>('v1/exchange_rates/latest.json', {
@@ -672,6 +688,9 @@ export default {
getServerVersion: (): ApiResponsePromise<VersionInfo> => {
return axios.get<ApiResponse<VersionInfo>>('v1/systems/version.json');
},
cancelRequest: (cancelableUuid: string) => {
cancelableRequests[cancelableUuid] = true;
},
generateQrCodeUrl: (qrCodeName: string): string => {
return `${getBasePath()}${BASE_QRCODE_PATH}/${qrCodeName}.png`;
},

View File

@@ -42,6 +42,12 @@ export function hideLoading(): void {
});
}
export function closeAllDialog(): void {
f7ready((f7) => {
return f7.dialog.close();
});
}
export function createInlinePicker(containerEl: string, inputEl: string, cols: Picker.ColumnParameters[], value: string[], events?: { change: (picker: Picker.Picker, value: unknown, displayValue: unknown) => void }): Picker.Picker {
return f7.picker.create({
containerEl: containerEl,
@@ -282,6 +288,27 @@ export function useI18nUIComponents() {
});
}
function showCancelableLoading(message: string, cancelButtonText: string, cancelCallback?: (dialog: Dialog.Dialog, e: Event) => void): void {
const cancelButton: Dialog.DialogButton = {
text: tt(cancelButtonText),
onClick: (dialog, event) => {
if (cancelCallback) {
cancelCallback(dialog, event);
}
}
};
f7ready((f7) => {
f7.dialog.create({
title: tt(message),
content: `<div class="preloader"><span class="preloader-inner">${[0, 1, 2, 3, 4, 5, 6, 7].map(() => '<span class="preloader-inner-line"></span>').join('')}</span></div>`,
cssClass: 'dialog-preloader',
animate: isEnableAnimate(),
buttons: [cancelButton]
}).open();
});
}
function showToast(message: string, timeout?: number): void {
f7ready((f7) => {
f7.toast.create({
@@ -296,6 +323,7 @@ export function useI18nUIComponents() {
showAlert: showAlert,
showConfirm: showConfirm,
showPrompt: showPrompt,
showCancelableLoading: showCancelableLoading,
showToast: showToast,
routeBackOnError
}

View File

@@ -1398,6 +1398,8 @@
"Clear": "Löschen",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Keine",
"Unspecified": "Nicht angegeben",
"Not set": "Nicht festgelegt",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Anzeigereihenfolge speichern",
"Change Language": "Sprache ändern",
"Date is too early": "Datum ist zu früh",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Willkommen bei ezBookkeeping",
"Please log in with your ezBookkeeping account": "Bitte melden Sie sich mit Ihrem ezBookkeeping-Konto an",
"Unlock Application": "Anwendung entsperren",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Clear",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "None",
"Unspecified": "Unspecified",
"Not set": "Not set",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Save Display Order",
"Change Language": "Change Language",
"Date is too early": "Date is too early",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Welcome to ezBookkeeping",
"Please log in with your ezBookkeeping account": "Please log in with your ezBookkeeping account",
"Unlock Application": "Unlock Application",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Claro",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Ninguno",
"Unspecified": "No especificado",
"Not set": "No establecido",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Guardar orden de visualización",
"Change Language": "Cambiar idioma",
"Date is too early": "La fecha es demasiado temprana",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Bienvenido a ezBookkeeping",
"Please log in with your ezBookkeeping account": "Inicie sesión con su cuenta de ezBookkeeping",
"Unlock Application": "Desbloquear aplicación",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Effacer",
"Generate": "Générer",
"Recognize": "Reconnaître",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Aucun",
"Unspecified": "Non spécifié",
"Not set": "Non défini",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Enregistrer l'ordre d'affichage",
"Change Language": "Changer de langue",
"Date is too early": "La date est trop ancienne",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Bienvenue dans ezBookkeeping",
"Please log in with your ezBookkeeping account": "Veuillez vous connecter avec votre compte ezBookkeeping",
"Unlock Application": "Déverrouiller l'application",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Pulisci",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Nessuno",
"Unspecified": "Non specificato",
"Not set": "Non impostato",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Salva ordine di visualizzazione",
"Change Language": "Cambia lingua",
"Date is too early": "Data troppo anticipata",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Benvenuto in ezBookkeeping",
"Please log in with your ezBookkeeping account": "Accedi con il tuo account ezBookkeeping",
"Unlock Application": "Sblocca applicazione",

View File

@@ -1398,6 +1398,8 @@
"Clear": "消去",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "なし",
"Unspecified": "不特定",
"Not set": "セットしていない",
@@ -1522,6 +1524,7 @@
"Save Display Order": "表示順の保存",
"Change Language": "言語の変更",
"Date is too early": "日付が早すぎます",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "ezBookkeepingへようこそ",
"Please log in with your ezBookkeeping account": "ezBookkeepingアカウントにログインしてください",
"Unlock Application": "アプリのロックを解除",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Wissen",
"Generate": "Genereren",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Geen",
"Unspecified": "Niet gespecificeerd",
"Not set": "Niet ingesteld",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Weergavevolgorde opslaan",
"Change Language": "Taal wijzigen",
"Date is too early": "Datum is te vroeg",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Welkom bij ezBookkeeping",
"Please log in with your ezBookkeeping account": "Log in met je ezBookkeeping-account",
"Unlock Application": "Applicatie ontgrendelen",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Limpar",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Nenhum",
"Unspecified": "Não especificado",
"Not set": "Não definido",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Salvar Ordem de Exibição",
"Change Language": "Alterar Idioma",
"Date is too early": "Data é muito cedo",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Bem-vindo ao ezBookkeeping",
"Please log in with your ezBookkeeping account": "Por favor, faça login com sua conta ezBookkeeping",
"Unlock Application": "Desbloquear Aplicativo",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Очистить",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Нет",
"Unspecified": "Не указано",
"Not set": "Не установлено",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Сохранить порядок отображения",
"Change Language": "Изменить язык",
"Date is too early": "Дата слишком ранняя",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Добро пожаловать в ezBookkeeping",
"Please log in with your ezBookkeeping account": "Пожалуйста, войдите в свою учетную запись ezBookkeeping",
"Unlock Application": "Разблокировать приложение",

View File

@@ -1398,6 +1398,8 @@
"Clear": "ล้าง",
"Generate": "สร้าง",
"Recognize": "จดจำ",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "ไม่มี",
"Unspecified": "ไม่ระบุ",
"Not set": "ยังไม่ได้ตั้งค่า",
@@ -1522,6 +1524,7 @@
"Save Display Order": "บันทึกลำดับการแสดง",
"Change Language": "เปลี่ยนภาษา",
"Date is too early": "วันที่เร็วเกินไป",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "ยินดีต้อนรับสู่ ezBookkeeping",
"Please log in with your ezBookkeeping account": "กรุณาเข้าสู่ระบบด้วยบัญชี ezBookkeeping ของคุณ",
"Unlock Application": "ปลดล็อกแอปพลิเคชัน",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Очистити",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Немає",
"Unspecified": "Не вказано",
"Not set": "Не встановлено",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Зберегти порядок відображення",
"Change Language": "Змінити мову",
"Date is too early": "Дата занадто рання",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Ласкаво просимо до ezBookkeeping",
"Please log in with your ezBookkeeping account": "Будь ласка, увійдіть до свого облікового запису ezBookkeeping",
"Unlock Application": "Розблокувати застосунок",

View File

@@ -1398,6 +1398,8 @@
"Clear": "Xóa",
"Generate": "Generate",
"Recognize": "Recognize",
"Recognizing...": "Recognizing...",
"Cancel Recognition": "Cancel Recognition",
"None": "Không có",
"Unspecified": "Không xác định",
"Not set": "Not set",
@@ -1522,6 +1524,7 @@
"Save Display Order": "Lưu thứ tự hiển thị",
"Change Language": "Thay đổi ngôn ngữ",
"Date is too early": "Ngày quá sớm",
"User Canceled": "User Canceled",
"Welcome to ezBookkeeping": "Chào mừng đến với ezBookkeeping",
"Please log in with your ezBookkeeping account": "Vui lòng đăng nhập bằng tài khoản ezBookkeeping của bạn",
"Unlock Application": "Mở khóa ứng dụng",

View File

@@ -1398,6 +1398,8 @@
"Clear": "清除",
"Generate": "生成",
"Recognize": "识别",
"Recognizing...": "正在识别...",
"Cancel Recognition": "取消识别",
"None": "无",
"Unspecified": "未指定",
"Not set": "未设置",
@@ -1522,6 +1524,7 @@
"Save Display Order": "保存显示顺序",
"Change Language": "修改语言",
"Date is too early": "日期过早",
"User Canceled": "用户已取消",
"Welcome to ezBookkeeping": "欢迎使用 ezBookkeeping",
"Please log in with your ezBookkeeping account": "请使用您的 ezBookkeeping 账号登录",
"Unlock Application": "解锁应用",

View File

@@ -1398,6 +1398,8 @@
"Clear": "清除",
"Generate": "產生",
"Recognize": "識別",
"Recognizing...": "正在識別...",
"Cancel Recognition": "取消識別",
"None": "無",
"Unspecified": "未指定",
"Not set": "未設置",
@@ -1522,6 +1524,7 @@
"Save Display Order": "儲存顯示順序",
"Change Language": "變更語言",
"Date is too early": "日期過早",
"User Canceled": "使用者已取消",
"Welcome to ezBookkeeping": "歡迎使用 ezBookkeeping",
"Please log in with your ezBookkeeping account": "請使用您的 ezBookkeeping 帳號登入",
"Unlock Application": "解鎖應用程式",

View File

@@ -1160,9 +1160,9 @@ export const useTransactionsStore = defineStore('transactions', () => {
});
}
function recognizeReceiptImage({ imageFile }: { imageFile: File }): Promise<RecognizedReceiptImageResponse> {
function recognizeReceiptImage({ imageFile, cancelableUuid }: { imageFile: File, cancelableUuid?: string }): Promise<RecognizedReceiptImageResponse> {
return new Promise((resolve, reject) => {
services.recognizeReceiptImage({ imageFile }).then(response => {
services.recognizeReceiptImage({ imageFile, cancelableUuid }).then(response => {
const data = response.data;
if (!data || !data.success || !data.result) {
@@ -1172,6 +1172,10 @@ export const useTransactionsStore = defineStore('transactions', () => {
resolve(data.result);
}).catch(error => {
if (error.canceled) {
reject(error);
}
logger.error('failed to recognize image', error);
if (error.response && error.response.data && error.response.data.errorMessage) {
@@ -1185,6 +1189,10 @@ export const useTransactionsStore = defineStore('transactions', () => {
});
}
function cancelRecognizeReceiptImage(cancelableUuid: string): void {
services.cancelRequest(cancelableUuid);
}
function parseImportDsvFile({ fileType, fileEncoding, importFile }: { fileType: string, fileEncoding?: string, importFile: File }): Promise<string[][]> {
return new Promise((resolve, reject) => {
services.parseImportDsvFile({ fileType, fileEncoding, importFile }).then(response => {
@@ -1399,6 +1407,7 @@ export const useTransactionsStore = defineStore('transactions', () => {
saveTransaction,
deleteTransaction,
recognizeReceiptImage,
cancelRecognizeReceiptImage,
parseImportDsvFile,
parseImportTransaction,
importTransactions,

View File

@@ -33,8 +33,10 @@
{{ tt('Recognize') }}
<v-progress-circular indeterminate size="22" class="ms-2" v-if="recognizing"></v-progress-circular>
</v-btn>
<v-btn color="secondary" variant="tonal" :disabled="loading"
@click="cancelRecognize" v-if="recognizing && cancelRecognizingUuid">{{ tt('Cancel Recognition') }}</v-btn>
<v-btn color="secondary" variant="tonal" :disabled="loading || recognizing"
@click="cancel">{{ tt('Cancel') }}</v-btn>
@click="cancel" v-if="!recognizing || !cancelRecognizingUuid">{{ tt('Cancel') }}</v-btn>
</div>
</v-card-text>
</v-card>
@@ -58,6 +60,7 @@ import { SUPPORTED_IMAGE_EXTENSIONS } from '@/consts/file.ts';
import type { RecognizedReceiptImageResponse } from '@/models/large_language_model.ts';
import { generateRandomUUID } from '@/lib/misc.ts';
import { compressJpgImage } from '@/lib/ui/common.ts';
import logger from '@/lib/logger.ts';
@@ -76,6 +79,7 @@ let rejectFunc: ((reason?: unknown) => void) | null = null;
const showState = ref<boolean>(false);
const loading = ref<boolean>(false);
const recognizing = ref<boolean>(false);
const cancelRecognizingUuid = ref<string | undefined>(undefined);
const imageFile = ref<File | null>(null);
const imageSrc = ref<string | undefined>(undefined);
const isDragOver = ref<boolean>(false);
@@ -96,6 +100,7 @@ function open(): Promise<RecognizedReceiptImageResponse> {
showState.value = true;
loading.value = false;
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
imageFile.value = null;
imageSrc.value = undefined;
@@ -136,16 +141,24 @@ function recognize(): void {
return;
}
cancelRecognizingUuid.value = generateRandomUUID();
recognizing.value = true;
transactionsStore.recognizeReceiptImage({
imageFile: imageFile.value
imageFile: imageFile.value,
cancelableUuid: cancelRecognizingUuid.value
}).then(response => {
resolveFunc?.(response);
showState.value = false;
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
}).catch(error => {
if (error.canceled) {
return;
}
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
if (!error.processed) {
snackbar.value?.showError(error);
@@ -153,11 +166,24 @@ function recognize(): void {
});
}
function cancelRecognize(): void {
if (!cancelRecognizingUuid.value) {
return;
}
transactionsStore.cancelRecognizeReceiptImage(cancelRecognizingUuid.value);
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
snackbar.value?.showMessage('User Canceled');
}
function cancel(): void {
rejectFunc?.();
showState.value = false;
loading.value = false;
recognizing.value = false;
cancelRecognizingUuid.value = undefined;
imageFile.value = null;
imageSrc.value = undefined;
}