feat: 添加 AI 分类功能,支持交易自动分类与标签生成

This commit is contained in:
2026-03-12 10:59:44 +08:00
parent 8cbe01dd9c
commit 6a00875246
12 changed files with 594 additions and 27 deletions

View File

@@ -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'

View 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
View 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),
}
}

View File

@@ -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)
}
}

View File

@@ -0,0 +1,5 @@
export class AiProvider {
async enrichTransaction() {
throw new Error('AI provider is not implemented')
}
}

View 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
}
}

View 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,
}
}
}

View File

@@ -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) => {

View File

@@ -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',

View File

@@ -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
}

View File

@@ -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;
}

View File

@@ -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>