feat: 添加 AI 分类功能,支持交易自动分类与标签生成
This commit is contained in:
@@ -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'
|
||||
|
||||
|
||||
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 { 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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
7
src/types/transaction.d.ts
vendored
7
src/types/transaction.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- AI 自动分类开关(预留) -->
|
||||
<!-- AI 自动分类与标签 -->
|
||||
<div class="flex flex-col gap-1 p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
@@ -383,9 +402,9 @@ onMounted(() => {
|
||||
<i class="ph-fill ph-magic-wand" />
|
||||
</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">
|
||||
开启后,未来会自动补全分类、标签,并生成可复用的商户画像。
|
||||
使用本地 DeepSeek API Key 为新交易补全分类、标签,并记录商户画像。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -397,9 +416,42 @@ onMounted(() => {
|
||||
<div class="w-4 h-4 bg-white rounded-full shadow-sm" />
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="aiAutoCategoryEnabled" class="px-4 pb-1 text-[11px] text-purple-600">
|
||||
当前版本仅记录偏好,后续接入 AI 能力后会自动生效。
|
||||
</p>
|
||||
|
||||
<div v-if="aiAutoCategoryEnabled" class="px-4 pt-3 pb-1 space-y-3">
|
||||
<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>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user