diff --git a/android/app/build.gradle b/android/app/build.gradle index 3873cc4..d34cd6f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -4,6 +4,7 @@ apply plugin: 'kotlin-android' android { namespace "com.echo.app" compileSdkVersion rootProject.ext.compileSdkVersion + defaultConfig { applicationId "com.echo.app" minSdkVersion rootProject.ext.minSdkVersion @@ -11,21 +12,22 @@ android { versionCode 301 versionName "0.3.1" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + aaptOptions { - // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. - // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 + // Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps. + // Default: https://android.googlesource.com/platform/frameworks/base/+/282e181b58cf72b6ca770dc7ca5f91f135444502/tools/aapt/AaptAssets.cpp#61 ignoreAssetsPattern '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~' } } - // 显式统一 Java / Kotlin 的编译目标版本,保持与 Capacitor 生成配置一致(Java 21) + // Keep Java and Kotlin on the same JVM target to avoid Gradle compatibility errors. compileOptions { - sourceCompatibility JavaVersion.VERSION_21 - targetCompatibility JavaVersion.VERSION_21 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = '21' + jvmTarget = '17' } buildTypes { @@ -37,7 +39,7 @@ android { } repositories { - flatDir{ + flatDir { dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs' } } @@ -54,4 +56,3 @@ dependencies { } apply from: 'capacitor.build.gradle' - diff --git a/src/config/transactionTags.js b/src/config/transactionTags.js new file mode 100644 index 0000000..1b73dd4 --- /dev/null +++ b/src/config/transactionTags.js @@ -0,0 +1,21 @@ +export const AI_TRANSACTION_TAGS = [ + '餐饮', + '通勤', + '买菜', + '娱乐', + '订阅', + '家庭', + '工作', + '报销', + '夜间消费', + '固定支出', + '冲动消费', +] + +export const AI_PROVIDER_OPTIONS = [ + { label: 'DeepSeek', value: 'deepseek' }, +] + +export const AI_MODEL_OPTIONS = [ + { label: 'deepseek-chat', value: 'deepseek-chat' }, +] diff --git a/src/lib/aiPrompt.js b/src/lib/aiPrompt.js new file mode 100644 index 0000000..f8a543a --- /dev/null +++ b/src/lib/aiPrompt.js @@ -0,0 +1,58 @@ +import { TRANSACTION_CATEGORIES } from '../config/transactionCategories.js' +import { AI_TRANSACTION_TAGS } from '../config/transactionTags.js' + +const categoryValues = TRANSACTION_CATEGORIES.map((item) => item.value) + +export const buildTransactionEnrichmentMessages = ({ transaction, similarTransactions = [] }) => { + const payload = { + transaction: { + id: transaction.id, + merchant: transaction.merchant || 'Unknown', + amount: transaction.amount, + direction: transaction.amount >= 0 ? 'income' : 'expense', + note: transaction.note || '', + currentCategory: transaction.category || 'Uncategorized', + date: transaction.date, + }, + categories: categoryValues, + tags: AI_TRANSACTION_TAGS, + similarTransactions: similarTransactions.slice(0, 6).map((item) => ({ + merchant: item.aiNormalizedMerchant || item.merchant, + category: item.category, + tags: item.aiTags || [], + note: item.note || '', + })), + } + + return [ + { + role: 'system', + content: + '你是记账应用的交易分类引擎。你必须只输出 JSON 对象,不要输出 markdown。category 必须从 categories 中选择一个。tags 必须从 tags 中选择零到三个。confidence 必须是 0 到 1 的数字。若信息不足,请降低 confidence。', + }, + { + role: 'user', + content: JSON.stringify(payload), + }, + ] +} + +export const normalizeEnrichmentResult = (rawResult) => { + const result = rawResult && typeof rawResult === 'object' ? rawResult : {} + const category = categoryValues.includes(result.category) ? result.category : 'Uncategorized' + const tags = Array.isArray(result.tags) + ? result.tags.filter((tag) => AI_TRANSACTION_TAGS.includes(tag)).slice(0, 3) + : [] + const confidenceValue = Number(result.confidence) + const confidence = Number.isFinite(confidenceValue) + ? Math.min(Math.max(confidenceValue, 0), 1) + : 0 + + return { + normalizedMerchant: String(result.normalizedMerchant || '').trim() || '', + category, + tags, + confidence, + reason: String(result.reason || '').trim().slice(0, 200), + } +} diff --git a/src/lib/sqlite.js b/src/lib/sqlite.js index e9b49d8..72b0dfd 100644 --- a/src/lib/sqlite.js +++ b/src/lib/sqlite.js @@ -1,4 +1,4 @@ -import { Capacitor } from '@capacitor/core' +import { Capacitor } from '@capacitor/core' import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite' import { defineCustomElements as defineJeep } from 'jeep-sqlite/loader' @@ -12,9 +12,35 @@ const TRANSACTION_TABLE_SQL = ` category TEXT DEFAULT 'Uncategorized', date TEXT NOT NULL, note TEXT, - sync_status INTEGER DEFAULT 0 + sync_status INTEGER DEFAULT 0, + ai_category TEXT, + ai_tags TEXT, + ai_confidence REAL, + ai_reason TEXT, + ai_status TEXT DEFAULT 'idle', + ai_model TEXT, + ai_normalized_merchant TEXT ); ` +const MERCHANT_PROFILE_TABLE_SQL = ` + CREATE TABLE IF NOT EXISTS merchant_profiles ( + merchant_key TEXT PRIMARY KEY NOT NULL, + normalized_merchant TEXT NOT NULL, + category TEXT, + tags TEXT, + hit_count INTEGER DEFAULT 0, + updated_at TEXT NOT NULL + ); +` +const TRANSACTION_REQUIRED_COLUMNS = { + ai_category: 'TEXT', + ai_tags: 'TEXT', + ai_confidence: 'REAL', + ai_reason: 'TEXT', + ai_status: "TEXT DEFAULT 'idle'", + ai_model: 'TEXT', + ai_normalized_merchant: 'TEXT', +} let sqliteConnection let db @@ -37,7 +63,7 @@ const ensureStoreReady = async (jeepEl, retries = 5) => { } await waitFor(200 * (attempt + 1)) } - throw new Error('jeep-sqlite IndexedDB store 未初始化成功') + throw new Error('jeep-sqlite IndexedDB store failed to initialize') } const ensureJeepElement = async () => { @@ -66,6 +92,17 @@ const ensureJeepElement = async () => { await CapacitorSQLite.initWebStore() } +const ensureTableColumns = async (tableName, requiredColumns) => { + const result = await db.query(`PRAGMA table_info(${tableName});`) + const existingColumns = new Set((result?.values || []).map((row) => row.name)) + + for (const [columnName, definition] of Object.entries(requiredColumns)) { + if (!existingColumns.has(columnName)) { + await db.execute(`ALTER TABLE ${tableName} ADD COLUMN ${columnName} ${definition};`) + } + } +} + const prepareConnection = async () => { if (!sqliteConnection) { sqliteConnection = new SQLiteConnection(CapacitorSQLite) @@ -89,6 +126,8 @@ const prepareConnection = async () => { await db.open() await db.execute(TRANSACTION_TABLE_SQL) + await db.execute(MERCHANT_PROFILE_TABLE_SQL) + await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS) initialized = true } @@ -112,7 +151,7 @@ export const saveDbToStore = async () => { try { await sqliteConnection.saveToStore(DB_NAME) } catch (error) { - console.warn('[sqlite] saveToStore 执行失败,数据将保留在内存中', error) + console.warn('[sqlite] saveToStore failed, data remains in memory until next sync', error) } } diff --git a/src/services/ai/aiProvider.js b/src/services/ai/aiProvider.js new file mode 100644 index 0000000..aecd01b --- /dev/null +++ b/src/services/ai/aiProvider.js @@ -0,0 +1,5 @@ +export class AiProvider { + async enrichTransaction() { + throw new Error('AI provider is not implemented') + } +} diff --git a/src/services/ai/aiService.js b/src/services/ai/aiService.js new file mode 100644 index 0000000..8057db0 --- /dev/null +++ b/src/services/ai/aiService.js @@ -0,0 +1,63 @@ +import { buildTransactionEnrichmentMessages } from '../../lib/aiPrompt.js' +import { useSettingsStore } from '../../stores/settings.js' +import { + applyAiEnrichment, + fetchRecentTransactionsForAi, + markTransactionAiFailed, +} from '../transactionService.js' +import { DeepSeekProvider } from './deepseekProvider.js' + +const clampThreshold = (value) => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) return 0.9 + return Math.min(Math.max(numeric, 0), 1) +} + +const createProvider = (settingsStore) => { + if (settingsStore.aiProvider !== 'deepseek') { + throw new Error(`Unsupported AI provider: ${settingsStore.aiProvider}`) + } + + return new DeepSeekProvider({ + apiKey: settingsStore.aiApiKey, + model: settingsStore.aiModel, + }) +} + +export const isAiReady = (settingsStore = useSettingsStore()) => + !!settingsStore.aiAutoCategoryEnabled && !!settingsStore.aiApiKey + +export const maybeEnrichTransactionWithAi = async (transaction) => { + if (!transaction?.id) return transaction + + const settingsStore = useSettingsStore() + if (!isAiReady(settingsStore)) { + return transaction + } + + try { + const provider = createProvider(settingsStore) + const similarTransactions = await fetchRecentTransactionsForAi(6, transaction.id) + const messages = buildTransactionEnrichmentMessages({ + transaction, + similarTransactions, + }) + const result = await provider.enrichTransaction({ messages }) + const threshold = clampThreshold(settingsStore.aiAutoApplyThreshold) + + const updated = await applyAiEnrichment(transaction.id, result.normalized, { + applyCategory: result.normalized.confidence >= threshold, + model: result.model, + }) + + return updated || transaction + } catch (error) { + console.warn('[ai] transaction enrichment failed', error) + const failed = await markTransactionAiFailed( + transaction.id, + error?.message || 'AI enrichment failed', + settingsStore.aiModel, + ) + return failed || transaction + } +} diff --git a/src/services/ai/deepseekProvider.js b/src/services/ai/deepseekProvider.js new file mode 100644 index 0000000..1e74e5a --- /dev/null +++ b/src/services/ai/deepseekProvider.js @@ -0,0 +1,55 @@ +import { normalizeEnrichmentResult } from '../../lib/aiPrompt.js' +import { AiProvider } from './aiProvider.js' + +export class DeepSeekProvider extends AiProvider { + constructor({ apiKey, baseUrl = 'https://api.deepseek.com', model = 'deepseek-chat' }) { + super() + this.apiKey = apiKey + this.baseUrl = baseUrl.replace(/\/$/, '') + this.model = model + } + + async enrichTransaction({ messages }) { + if (!this.apiKey) { + throw new Error('DeepSeek API key is required') + } + + const response = await fetch(`${this.baseUrl}/chat/completions`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + model: this.model, + temperature: 0.2, + response_format: { type: 'json_object' }, + messages, + }), + }) + + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`DeepSeek request failed: ${response.status} ${detail}`.trim()) + } + + const data = await response.json() + const content = data?.choices?.[0]?.message?.content + if (!content) { + throw new Error('DeepSeek returned an empty response') + } + + let parsed + try { + parsed = JSON.parse(content) + } catch { + throw new Error('DeepSeek returned invalid JSON') + } + + return { + raw: parsed, + normalized: normalizeEnrichmentResult(parsed), + model: data?.model || this.model, + } + } +} diff --git a/src/services/transactionService.js b/src/services/transactionService.js index ca9f51f..bc08758 100644 --- a/src/services/transactionService.js +++ b/src/services/transactionService.js @@ -7,12 +7,46 @@ const statusMap = { error: 2, } +const TRANSACTION_SELECT_FIELDS = ` + id, + amount, + merchant, + category, + date, + note, + sync_status, + ai_category, + ai_tags, + ai_confidence, + ai_reason, + ai_status, + ai_model, + ai_normalized_merchant +` + const normalizeStatus = (value) => { if (value === 1) return 'synced' if (value === 2) return 'error' return 'pending' } +const normalizeAiStatus = (value) => { + if (['applied', 'suggested', 'failed'].includes(value)) { + return value + } + return 'idle' +} + +const parseAiTags = (value) => { + if (!value) return [] + try { + const parsed = JSON.parse(value) + return Array.isArray(parsed) ? parsed.filter((item) => typeof item === 'string') : [] + } catch { + return [] + } +} + const mapRow = (row) => ({ id: row.id, amount: Number(row.amount), @@ -21,13 +55,92 @@ const mapRow = (row) => ({ date: row.date, note: row.note || '', syncStatus: normalizeStatus(row.sync_status), + aiCategory: row.ai_category || '', + aiTags: parseAiTags(row.ai_tags), + aiConfidence: typeof row.ai_confidence === 'number' ? row.ai_confidence : Number(row.ai_confidence || 0), + aiReason: row.ai_reason || '', + aiStatus: normalizeAiStatus(row.ai_status), + aiModel: row.ai_model || '', + aiNormalizedMerchant: row.ai_normalized_merchant || '', }) +const getMerchantKey = (merchant) => + String(merchant || '') + .trim() + .toLowerCase() + .replace(/\s+/g, '') + .slice(0, 120) + +const upsertMerchantProfile = async ({ merchant, normalizedMerchant, category, tags }) => { + const sourceName = normalizedMerchant || merchant + const merchantKey = getMerchantKey(sourceName) + if (!merchantKey) return + + const db = await getDb() + const result = await db.query( + ` + SELECT merchant_key, hit_count + FROM merchant_profiles + WHERE merchant_key = ? + `, + [merchantKey], + ) + + const hitCount = Number(result?.values?.[0]?.hit_count || 0) + const payload = { + normalizedMerchant: sourceName, + category: category || 'Uncategorized', + tags: JSON.stringify(Array.isArray(tags) ? tags : []), + updatedAt: new Date().toISOString(), + } + + if (result?.values?.length) { + await db.run( + ` + UPDATE merchant_profiles + SET normalized_merchant = ?, category = ?, tags = ?, hit_count = ?, updated_at = ? + WHERE merchant_key = ? + `, + [ + payload.normalizedMerchant, + payload.category, + payload.tags, + hitCount + 1, + payload.updatedAt, + merchantKey, + ], + ) + return + } + + await db.run( + ` + INSERT INTO merchant_profiles ( + merchant_key, + normalized_merchant, + category, + tags, + hit_count, + updated_at + ) + VALUES (?, ?, ?, ?, ?, ?) + `, + [ + merchantKey, + payload.normalizedMerchant, + payload.category, + payload.tags, + 1, + payload.updatedAt, + ], + ) +} + export const fetchTransactions = async () => { const db = await getDb() const result = await db.query( ` - SELECT id, amount, merchant, category, date, note, sync_status + SELECT ${TRANSACTION_SELECT_FIELDS} FROM transactions ORDER BY date DESC `, @@ -35,6 +148,45 @@ export const fetchTransactions = async () => { return (result?.values || []).map(mapRow) } +export const getTransactionById = async (id) => { + const db = await getDb() + const result = await db.query( + ` + SELECT ${TRANSACTION_SELECT_FIELDS} + FROM transactions + WHERE id = ? + `, + [id], + ) + + const row = result?.values?.[0] + return row ? mapRow(row) : null +} + +export const fetchRecentTransactionsForAi = async (limit = 6, excludeId = '') => { + const db = await getDb() + const safeLimit = Math.max(1, Math.min(Number(limit) || 6, 20)) + const params = [] + const whereClause = excludeId ? 'WHERE id != ?' : '' + if (excludeId) { + params.push(excludeId) + } + params.push(safeLimit) + + const result = await db.query( + ` + SELECT ${TRANSACTION_SELECT_FIELDS} + FROM transactions + ${whereClause} + ORDER BY date DESC + LIMIT ? + `, + params, + ) + + return (result?.values || []).map(mapRow) +} + export const insertTransaction = async (payload) => { const db = await getDb() const id = payload.id || uuidv4() @@ -69,6 +221,13 @@ export const insertTransaction = async (payload) => { id, ...sanitized, syncStatus: payload.syncStatus || 'pending', + aiCategory: '', + aiTags: [], + aiConfidence: 0, + aiReason: '', + aiStatus: 'idle', + aiModel: '', + aiNormalizedMerchant: '', } } @@ -92,18 +251,72 @@ export const updateTransaction = async (payload) => { ) await saveDbToStore() + return getTransactionById(payload.id) +} - const result = await db.query( +export const applyAiEnrichment = async (transactionId, enrichment, options = {}) => { + const existing = await getTransactionById(transactionId) + if (!existing) return null + + const applyCategory = !!options.applyCategory + const nextCategory = applyCategory ? enrichment.category || existing.category : existing.category + const nextStatus = options.status || (applyCategory ? 'applied' : 'suggested') + const db = await getDb() + + await db.run( ` - SELECT id, amount, merchant, category, date, note, sync_status - FROM transactions + UPDATE transactions + SET category = ?, ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ? WHERE id = ? `, - [payload.id], + [ + nextCategory, + enrichment.category || null, + JSON.stringify(Array.isArray(enrichment.tags) ? enrichment.tags : []), + Number(enrichment.confidence || 0), + enrichment.reason || null, + nextStatus, + options.model || null, + enrichment.normalizedMerchant || null, + transactionId, + ], ) - const updated = result?.values?.[0] - return updated ? mapRow(updated) : null + if (!options.skipMerchantProfile && (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)) { + await upsertMerchantProfile({ + merchant: existing.merchant, + normalizedMerchant: enrichment.normalizedMerchant, + category: enrichment.category, + tags: enrichment.tags, + }) + } + + await saveDbToStore() + return getTransactionById(transactionId) +} + +export const markTransactionAiFailed = async (transactionId, reason, model = '') => { + const db = await getDb() + await db.run( + ` + UPDATE transactions + SET ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ? + WHERE id = ? + `, + [ + null, + JSON.stringify([]), + 0, + reason || 'AI enrichment failed', + 'failed', + model || null, + null, + transactionId, + ], + ) + + await saveDbToStore() + return getTransactionById(transactionId) } export const deleteTransaction = async (id) => { diff --git a/src/stores/settings.js b/src/stores/settings.js index 304baed..d3bd5bb 100644 --- a/src/stores/settings.js +++ b/src/stores/settings.js @@ -1,12 +1,20 @@ -import { defineStore } from 'pinia' +import { defineStore } from 'pinia' import { ref } from 'vue' import { DEFAULT_CATEGORY_BUDGETS } from '../config/transactionCategories.js' +import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js' + +const AI_PROVIDER_VALUES = AI_PROVIDER_OPTIONS.map((item) => item.value) +const AI_MODEL_VALUES = AI_MODEL_OPTIONS.map((item) => item.value) export const useSettingsStore = defineStore( 'settings', () => { const notificationCaptureEnabled = ref(true) const aiAutoCategoryEnabled = ref(false) + const aiProvider = ref('deepseek') + const aiApiKey = ref('') + const aiModel = ref('deepseek-chat') + const aiAutoApplyThreshold = ref(0.9) const monthlyBudget = ref(12000) const budgetResetCycle = ref('monthly') const budgetMonthlyResetDay = ref(1) @@ -24,6 +32,27 @@ export const useSettingsStore = defineStore( aiAutoCategoryEnabled.value = !!value } + const setAiProvider = (value) => { + aiProvider.value = AI_PROVIDER_VALUES.includes(value) ? value : 'deepseek' + } + + const setAiApiKey = (value) => { + aiApiKey.value = String(value || '').trim() + } + + const setAiModel = (value) => { + aiModel.value = AI_MODEL_VALUES.includes(value) ? value : 'deepseek-chat' + } + + const setAiAutoApplyThreshold = (value) => { + const numeric = Number(value) + if (!Number.isFinite(numeric)) { + aiAutoApplyThreshold.value = 0.9 + return + } + aiAutoApplyThreshold.value = Math.min(Math.max(numeric, 0), 1) + } + const setMonthlyBudget = (value) => { const numeric = Number(value) monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0 @@ -72,6 +101,10 @@ export const useSettingsStore = defineStore( return { notificationCaptureEnabled, aiAutoCategoryEnabled, + aiProvider, + aiApiKey, + aiModel, + aiAutoApplyThreshold, monthlyBudget, budgetResetCycle, budgetMonthlyResetDay, @@ -82,6 +115,10 @@ export const useSettingsStore = defineStore( profileAvatar, setNotificationCaptureEnabled, setAiAutoCategoryEnabled, + setAiProvider, + setAiApiKey, + setAiModel, + setAiAutoApplyThreshold, setMonthlyBudget, setBudgetResetCycle, setBudgetMonthlyResetDay, @@ -97,6 +134,10 @@ export const useSettingsStore = defineStore( paths: [ 'notificationCaptureEnabled', 'aiAutoCategoryEnabled', + 'aiProvider', + 'aiApiKey', + 'aiModel', + 'aiAutoApplyThreshold', 'monthlyBudget', 'budgetResetCycle', 'budgetMonthlyResetDay', diff --git a/src/stores/transactions.js b/src/stores/transactions.js index e1e43ff..1688beb 100644 --- a/src/stores/transactions.js +++ b/src/stores/transactions.js @@ -1,5 +1,6 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { maybeEnrichTransactionWithAi } from '../services/ai/aiService.js' import { deleteTransaction, fetchTransactions, @@ -26,6 +27,9 @@ const formatDayLabel = (dayKey) => { }).format(localDate) } +const replaceTransaction = (list, nextTransaction) => + list.map((tx) => (tx.id === nextTransaction.id ? nextTransaction : tx)) + export const useTransactionStore = defineStore( 'transactions', () => { @@ -113,6 +117,12 @@ export const useTransactionStore = defineStore( } } + const enrichTransactionInBackground = async (transaction) => { + const enriched = await maybeEnrichTransactionWithAi(transaction) + if (!enriched || enriched.id !== transaction.id) return + transactions.value = replaceTransaction(transactions.value, enriched) + } + const addTransaction = async (payload) => { const normalized = { ...payload, @@ -120,6 +130,7 @@ export const useTransactionStore = defineStore( } const created = await insertTransaction(normalized) transactions.value = [created, ...transactions.value] + void enrichTransactionInBackground(created) return created } @@ -130,7 +141,8 @@ export const useTransactionStore = defineStore( } const updated = await updateTransaction(normalized) if (updated) { - transactions.value = transactions.value.map((tx) => (tx.id === updated.id ? updated : tx)) + transactions.value = replaceTransaction(transactions.value, updated) + void enrichTransactionInBackground(updated) } return updated } diff --git a/src/types/transaction.d.ts b/src/types/transaction.d.ts index 5c0007a..231baf9 100644 --- a/src/types/transaction.d.ts +++ b/src/types/transaction.d.ts @@ -6,4 +6,11 @@ export interface Transaction { date: string; note?: string; syncStatus: 'pending' | 'synced' | 'error'; + aiCategory?: string; + aiTags?: string[]; + aiConfidence?: number; + aiReason?: string; + aiStatus?: 'idle' | 'applied' | 'suggested' | 'failed'; + aiModel?: string; + aiNormalizedMerchant?: string; } diff --git a/src/views/SettingsView.vue b/src/views/SettingsView.vue index 075d989..a0b278f 100644 --- a/src/views/SettingsView.vue +++ b/src/views/SettingsView.vue @@ -4,6 +4,7 @@ import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/ import { useSettingsStore } from '../stores/settings' import EchoInput from '../components/EchoInput.vue' import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js' +import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js' // 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示 // eslint-disable-next-line no-undef @@ -23,6 +24,24 @@ const aiAutoCategoryEnabled = computed({ set: (value) => settingsStore.setAiAutoCategoryEnabled(value), }) +const aiApiKey = computed({ + get: () => settingsStore.aiApiKey, + set: (value) => settingsStore.setAiApiKey(value), +}) + +const aiAutoApplyThreshold = computed({ + get: () => String(settingsStore.aiAutoApplyThreshold ?? 0.9), + set: (value) => settingsStore.setAiAutoApplyThreshold(value), +}) + +const currentAiProviderLabel = computed( + () => AI_PROVIDER_OPTIONS.find((item) => item.value === settingsStore.aiProvider)?.label || 'DeepSeek', +) + +const currentAiModelLabel = computed( + () => AI_MODEL_OPTIONS.find((item) => item.value === settingsStore.aiModel)?.label || 'deepseek-chat', +) + const nativeBridgeReady = isNativeNotificationBridgeAvailable() const notificationPermissionGranted = ref(true) const checkingPermission = ref(false) @@ -373,7 +392,7 @@ onMounted(() => { - +
AI 自动分类与标签(预留)
+AI 自动分类与标签
- 开启后,未来会自动补全分类、标签,并生成可复用的商户画像。 + 使用本地 DeepSeek API Key 为新交易补全分类、标签,并记录商户画像。
- 当前版本仅记录偏好,后续接入 AI 能力后会自动生效。 -
+ ++ 当前已开启 AI,但还没有填写 DeepSeek API Key,保存交易时不会发起 AI 分类请求。 +
+