feat: 添加 AI 分类功能,支持交易自动分类与标签生成
This commit is contained in:
@@ -4,6 +4,7 @@ apply plugin: 'kotlin-android'
|
|||||||
android {
|
android {
|
||||||
namespace "com.echo.app"
|
namespace "com.echo.app"
|
||||||
compileSdkVersion rootProject.ext.compileSdkVersion
|
compileSdkVersion rootProject.ext.compileSdkVersion
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "com.echo.app"
|
applicationId "com.echo.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
@@ -11,21 +12,22 @@ android {
|
|||||||
versionCode 301
|
versionCode 301
|
||||||
versionName "0.3.1"
|
versionName "0.3.1"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// 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
|
// 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:!*~'
|
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 {
|
compileOptions {
|
||||||
sourceCompatibility JavaVersion.VERSION_21
|
sourceCompatibility JavaVersion.VERSION_17
|
||||||
targetCompatibility JavaVersion.VERSION_21
|
targetCompatibility JavaVersion.VERSION_17
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlinOptions {
|
kotlinOptions {
|
||||||
jvmTarget = '21'
|
jvmTarget = '17'
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
@@ -37,7 +39,7 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
repositories {
|
||||||
flatDir{
|
flatDir {
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -54,4 +56,3 @@ dependencies {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply from: 'capacitor.build.gradle'
|
apply from: 'capacitor.build.gradle'
|
||||||
|
|
||||||
|
|||||||
21
src/config/transactionTags.js
Normal file
21
src/config/transactionTags.js
Normal file
@@ -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' },
|
||||||
|
]
|
||||||
58
src/lib/aiPrompt.js
Normal file
58
src/lib/aiPrompt.js
Normal file
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Capacitor } from '@capacitor/core'
|
import { Capacitor } from '@capacitor/core'
|
||||||
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
|
import { CapacitorSQLite, SQLiteConnection } from '@capacitor-community/sqlite'
|
||||||
import { defineCustomElements as defineJeep } from 'jeep-sqlite/loader'
|
import { defineCustomElements as defineJeep } from 'jeep-sqlite/loader'
|
||||||
|
|
||||||
@@ -12,9 +12,35 @@ const TRANSACTION_TABLE_SQL = `
|
|||||||
category TEXT DEFAULT 'Uncategorized',
|
category TEXT DEFAULT 'Uncategorized',
|
||||||
date TEXT NOT NULL,
|
date TEXT NOT NULL,
|
||||||
note TEXT,
|
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 sqliteConnection
|
||||||
let db
|
let db
|
||||||
@@ -37,7 +63,7 @@ const ensureStoreReady = async (jeepEl, retries = 5) => {
|
|||||||
}
|
}
|
||||||
await waitFor(200 * (attempt + 1))
|
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 () => {
|
const ensureJeepElement = async () => {
|
||||||
@@ -66,6 +92,17 @@ const ensureJeepElement = async () => {
|
|||||||
await CapacitorSQLite.initWebStore()
|
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 () => {
|
const prepareConnection = async () => {
|
||||||
if (!sqliteConnection) {
|
if (!sqliteConnection) {
|
||||||
sqliteConnection = new SQLiteConnection(CapacitorSQLite)
|
sqliteConnection = new SQLiteConnection(CapacitorSQLite)
|
||||||
@@ -89,6 +126,8 @@ const prepareConnection = async () => {
|
|||||||
|
|
||||||
await db.open()
|
await db.open()
|
||||||
await db.execute(TRANSACTION_TABLE_SQL)
|
await db.execute(TRANSACTION_TABLE_SQL)
|
||||||
|
await db.execute(MERCHANT_PROFILE_TABLE_SQL)
|
||||||
|
await ensureTableColumns('transactions', TRANSACTION_REQUIRED_COLUMNS)
|
||||||
initialized = true
|
initialized = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -112,7 +151,7 @@ export const saveDbToStore = async () => {
|
|||||||
try {
|
try {
|
||||||
await sqliteConnection.saveToStore(DB_NAME)
|
await sqliteConnection.saveToStore(DB_NAME)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[sqlite] saveToStore 执行失败,数据将保留在内存中', error)
|
console.warn('[sqlite] saveToStore failed, data remains in memory until next sync', error)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
5
src/services/ai/aiProvider.js
Normal file
5
src/services/ai/aiProvider.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export class AiProvider {
|
||||||
|
async enrichTransaction() {
|
||||||
|
throw new Error('AI provider is not implemented')
|
||||||
|
}
|
||||||
|
}
|
||||||
63
src/services/ai/aiService.js
Normal file
63
src/services/ai/aiService.js
Normal file
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/services/ai/deepseekProvider.js
Normal file
55
src/services/ai/deepseekProvider.js
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,12 +7,46 @@ const statusMap = {
|
|||||||
error: 2,
|
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) => {
|
const normalizeStatus = (value) => {
|
||||||
if (value === 1) return 'synced'
|
if (value === 1) return 'synced'
|
||||||
if (value === 2) return 'error'
|
if (value === 2) return 'error'
|
||||||
return 'pending'
|
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) => ({
|
const mapRow = (row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
amount: Number(row.amount),
|
amount: Number(row.amount),
|
||||||
@@ -21,13 +55,92 @@ const mapRow = (row) => ({
|
|||||||
date: row.date,
|
date: row.date,
|
||||||
note: row.note || '',
|
note: row.note || '',
|
||||||
syncStatus: normalizeStatus(row.sync_status),
|
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 () => {
|
export const fetchTransactions = async () => {
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
const result = await db.query(
|
const result = await db.query(
|
||||||
`
|
`
|
||||||
SELECT id, amount, merchant, category, date, note, sync_status
|
SELECT ${TRANSACTION_SELECT_FIELDS}
|
||||||
FROM transactions
|
FROM transactions
|
||||||
ORDER BY date DESC
|
ORDER BY date DESC
|
||||||
`,
|
`,
|
||||||
@@ -35,6 +148,45 @@ export const fetchTransactions = async () => {
|
|||||||
return (result?.values || []).map(mapRow)
|
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) => {
|
export const insertTransaction = async (payload) => {
|
||||||
const db = await getDb()
|
const db = await getDb()
|
||||||
const id = payload.id || uuidv4()
|
const id = payload.id || uuidv4()
|
||||||
@@ -69,6 +221,13 @@ export const insertTransaction = async (payload) => {
|
|||||||
id,
|
id,
|
||||||
...sanitized,
|
...sanitized,
|
||||||
syncStatus: payload.syncStatus || 'pending',
|
syncStatus: payload.syncStatus || 'pending',
|
||||||
|
aiCategory: '',
|
||||||
|
aiTags: [],
|
||||||
|
aiConfidence: 0,
|
||||||
|
aiReason: '',
|
||||||
|
aiStatus: 'idle',
|
||||||
|
aiModel: '',
|
||||||
|
aiNormalizedMerchant: '',
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,18 +251,72 @@ export const updateTransaction = async (payload) => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
await saveDbToStore()
|
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
|
UPDATE transactions
|
||||||
FROM transactions
|
SET category = ?, ai_category = ?, ai_tags = ?, ai_confidence = ?, ai_reason = ?, ai_status = ?, ai_model = ?, ai_normalized_merchant = ?
|
||||||
WHERE id = ?
|
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]
|
if (!options.skipMerchantProfile && (enrichment.normalizedMerchant || enrichment.category || enrichment.tags?.length)) {
|
||||||
return updated ? mapRow(updated) : null
|
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) => {
|
export const deleteTransaction = async (id) => {
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { DEFAULT_CATEGORY_BUDGETS } from '../config/transactionCategories.js'
|
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(
|
export const useSettingsStore = defineStore(
|
||||||
'settings',
|
'settings',
|
||||||
() => {
|
() => {
|
||||||
const notificationCaptureEnabled = ref(true)
|
const notificationCaptureEnabled = ref(true)
|
||||||
const aiAutoCategoryEnabled = ref(false)
|
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 monthlyBudget = ref(12000)
|
||||||
const budgetResetCycle = ref('monthly')
|
const budgetResetCycle = ref('monthly')
|
||||||
const budgetMonthlyResetDay = ref(1)
|
const budgetMonthlyResetDay = ref(1)
|
||||||
@@ -24,6 +32,27 @@ export const useSettingsStore = defineStore(
|
|||||||
aiAutoCategoryEnabled.value = !!value
|
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 setMonthlyBudget = (value) => {
|
||||||
const numeric = Number(value)
|
const numeric = Number(value)
|
||||||
monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
|
monthlyBudget.value = Number.isFinite(numeric) && numeric >= 0 ? numeric : 0
|
||||||
@@ -72,6 +101,10 @@ export const useSettingsStore = defineStore(
|
|||||||
return {
|
return {
|
||||||
notificationCaptureEnabled,
|
notificationCaptureEnabled,
|
||||||
aiAutoCategoryEnabled,
|
aiAutoCategoryEnabled,
|
||||||
|
aiProvider,
|
||||||
|
aiApiKey,
|
||||||
|
aiModel,
|
||||||
|
aiAutoApplyThreshold,
|
||||||
monthlyBudget,
|
monthlyBudget,
|
||||||
budgetResetCycle,
|
budgetResetCycle,
|
||||||
budgetMonthlyResetDay,
|
budgetMonthlyResetDay,
|
||||||
@@ -82,6 +115,10 @@ export const useSettingsStore = defineStore(
|
|||||||
profileAvatar,
|
profileAvatar,
|
||||||
setNotificationCaptureEnabled,
|
setNotificationCaptureEnabled,
|
||||||
setAiAutoCategoryEnabled,
|
setAiAutoCategoryEnabled,
|
||||||
|
setAiProvider,
|
||||||
|
setAiApiKey,
|
||||||
|
setAiModel,
|
||||||
|
setAiAutoApplyThreshold,
|
||||||
setMonthlyBudget,
|
setMonthlyBudget,
|
||||||
setBudgetResetCycle,
|
setBudgetResetCycle,
|
||||||
setBudgetMonthlyResetDay,
|
setBudgetMonthlyResetDay,
|
||||||
@@ -97,6 +134,10 @@ export const useSettingsStore = defineStore(
|
|||||||
paths: [
|
paths: [
|
||||||
'notificationCaptureEnabled',
|
'notificationCaptureEnabled',
|
||||||
'aiAutoCategoryEnabled',
|
'aiAutoCategoryEnabled',
|
||||||
|
'aiProvider',
|
||||||
|
'aiApiKey',
|
||||||
|
'aiModel',
|
||||||
|
'aiAutoApplyThreshold',
|
||||||
'monthlyBudget',
|
'monthlyBudget',
|
||||||
'budgetResetCycle',
|
'budgetResetCycle',
|
||||||
'budgetMonthlyResetDay',
|
'budgetMonthlyResetDay',
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
import { maybeEnrichTransactionWithAi } from '../services/ai/aiService.js'
|
||||||
import {
|
import {
|
||||||
deleteTransaction,
|
deleteTransaction,
|
||||||
fetchTransactions,
|
fetchTransactions,
|
||||||
@@ -26,6 +27,9 @@ const formatDayLabel = (dayKey) => {
|
|||||||
}).format(localDate)
|
}).format(localDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const replaceTransaction = (list, nextTransaction) =>
|
||||||
|
list.map((tx) => (tx.id === nextTransaction.id ? nextTransaction : tx))
|
||||||
|
|
||||||
export const useTransactionStore = defineStore(
|
export const useTransactionStore = defineStore(
|
||||||
'transactions',
|
'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 addTransaction = async (payload) => {
|
||||||
const normalized = {
|
const normalized = {
|
||||||
...payload,
|
...payload,
|
||||||
@@ -120,6 +130,7 @@ export const useTransactionStore = defineStore(
|
|||||||
}
|
}
|
||||||
const created = await insertTransaction(normalized)
|
const created = await insertTransaction(normalized)
|
||||||
transactions.value = [created, ...transactions.value]
|
transactions.value = [created, ...transactions.value]
|
||||||
|
void enrichTransactionInBackground(created)
|
||||||
return created
|
return created
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,7 +141,8 @@ export const useTransactionStore = defineStore(
|
|||||||
}
|
}
|
||||||
const updated = await updateTransaction(normalized)
|
const updated = await updateTransaction(normalized)
|
||||||
if (updated) {
|
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
|
return updated
|
||||||
}
|
}
|
||||||
|
|||||||
7
src/types/transaction.d.ts
vendored
7
src/types/transaction.d.ts
vendored
@@ -6,4 +6,11 @@ export interface Transaction {
|
|||||||
date: string;
|
date: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
syncStatus: 'pending' | 'synced' | 'error';
|
syncStatus: 'pending' | 'synced' | 'error';
|
||||||
|
aiCategory?: string;
|
||||||
|
aiTags?: string[];
|
||||||
|
aiConfidence?: number;
|
||||||
|
aiReason?: string;
|
||||||
|
aiStatus?: 'idle' | 'applied' | 'suggested' | 'failed';
|
||||||
|
aiModel?: string;
|
||||||
|
aiNormalizedMerchant?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import NotificationBridge, { isNativeNotificationBridgeAvailable } from '../lib/
|
|||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import EchoInput from '../components/EchoInput.vue'
|
import EchoInput from '../components/EchoInput.vue'
|
||||||
import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js'
|
import { BUDGET_CATEGORY_OPTIONS } from '../config/transactionCategories.js'
|
||||||
|
import { AI_MODEL_OPTIONS, AI_PROVIDER_OPTIONS } from '../config/transactionTags.js'
|
||||||
|
|
||||||
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
// 从 Vite 注入的版本号(来源于 package.json),用于在设置页展示
|
||||||
// eslint-disable-next-line no-undef
|
// eslint-disable-next-line no-undef
|
||||||
@@ -23,6 +24,24 @@ const aiAutoCategoryEnabled = computed({
|
|||||||
set: (value) => settingsStore.setAiAutoCategoryEnabled(value),
|
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 nativeBridgeReady = isNativeNotificationBridgeAvailable()
|
||||||
const notificationPermissionGranted = ref(true)
|
const notificationPermissionGranted = ref(true)
|
||||||
const checkingPermission = ref(false)
|
const checkingPermission = ref(false)
|
||||||
@@ -373,7 +392,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- AI 自动分类开关(预留) -->
|
<!-- AI 自动分类与标签 -->
|
||||||
<div class="flex flex-col gap-1 p-4">
|
<div class="flex flex-col gap-1 p-4">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -383,9 +402,9 @@ onMounted(() => {
|
|||||||
<i class="ph-fill ph-magic-wand" />
|
<i class="ph-fill ph-magic-wand" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-bold text-stone-700 text-sm">AI 自动分类与标签(预留)</p>
|
<p class="font-bold text-stone-700 text-sm">AI 自动分类与标签</p>
|
||||||
<p class="text-[11px] text-stone-400 mt-0.5">
|
<p class="text-[11px] text-stone-400 mt-0.5">
|
||||||
开启后,未来会自动补全分类、标签,并生成可复用的商户画像。
|
使用本地 DeepSeek API Key 为新交易补全分类、标签,并记录商户画像。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -397,9 +416,42 @@ onMounted(() => {
|
|||||||
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
|
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="aiAutoCategoryEnabled" class="px-4 pb-1 text-[11px] text-purple-600">
|
|
||||||
当前版本仅记录偏好,后续接入 AI 能力后会自动生效。
|
<div v-if="aiAutoCategoryEnabled" class="px-4 pt-3 pb-1 space-y-3">
|
||||||
</p>
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span class="px-3 py-1 rounded-full bg-stone-100 text-[11px] font-bold text-stone-600">
|
||||||
|
Provider · {{ currentAiProviderLabel }}
|
||||||
|
</span>
|
||||||
|
<span class="px-3 py-1 rounded-full bg-stone-100 text-[11px] font-bold text-stone-600">
|
||||||
|
Model · {{ currentAiModelLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EchoInput
|
||||||
|
v-model="aiApiKey"
|
||||||
|
label="DeepSeek API Key"
|
||||||
|
type="password"
|
||||||
|
placeholder="sk-..."
|
||||||
|
hint="仅保存在当前设备,本阶段用于本地直连 DeepSeek。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EchoInput
|
||||||
|
v-model="aiAutoApplyThreshold"
|
||||||
|
label="自动应用阈值(0-1)"
|
||||||
|
type="number"
|
||||||
|
inputmode="decimal"
|
||||||
|
step="0.05"
|
||||||
|
placeholder="例如 0.9"
|
||||||
|
hint="高于该阈值的 AI 分类会自动写入交易分类,低于阈值仅作为建议保留。"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p
|
||||||
|
v-if="!settingsStore.aiApiKey"
|
||||||
|
class="text-[11px] text-amber-600 bg-amber-50 border border-amber-200 rounded-2xl px-3 py-2"
|
||||||
|
>
|
||||||
|
当前已开启 AI,但还没有填写 DeepSeek API Key,保存交易时不会发起 AI 分类请求。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
Reference in New Issue
Block a user