为智能查数补充提问历史的后端持久化(含创建时间、可清空),并完善前端历史展示、会话缓存与若干体验问题。 变更内容: - 新增 t_ai_sql_history 表(MySQL/SQLite 双脚本)与 AiSqlHistory 实体、Mapper、Service、VO,记录提问内容与创建时间 - AiSqlQueryServiceImpl 在查询成功后写入提问历史,新增 GET/DELETE /api/ai/sql/history 接口,支持按创建时间倒序查询与清空 - 前端历史改为后端持久化:卡片式列表、展示创建时间、搜索过滤、一键复制提问、点击回填重查与清空 - 新增会话缓存(sessionStorage):切走再回或刷新后恢复上次输入、结果与视图模式,结果超过 500 行时不缓存结果,登出时清除 体验修复: - 修复柱状图与饼图切换时旧 series/坐标轴残留(VChart 采用 notMerge) - 修复进入 /smart-query 时短暂误显示「AI 未启用」(新增 aiEnabledLoaded 加载态) - 引入 JetBrains Mono 字体并修复生成的 SQL 字体未生效(内部 code 元素继承父级字体) 依赖: - 新增前端依赖 @fontsource/jetbrains-mono 说明: - 历史表由 SchemaInitializer 启动时幂等创建,无需手工执行 SQL
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
// AI 智能建议相关 API 接口:配置读写、连通性测试、建议生成与应用
|
||
import request from './request'
|
||
|
||
/** AI 配置数据结构(API Key 已脱敏) */
|
||
export interface AiConfig {
|
||
enabled: boolean
|
||
apiKey: string | null
|
||
hasApiKey: boolean
|
||
baseUrl: string
|
||
model: string
|
||
visionModel: string
|
||
}
|
||
|
||
/** AI 建议新建的标签 */
|
||
export interface AiNewTag {
|
||
name: string
|
||
color: string
|
||
}
|
||
|
||
/** 单条 AI 建议(按类型、备注、交易渠道聚合后的一组记录) */
|
||
export interface AiSuggestion {
|
||
transactionId: number
|
||
transactionIds: number[]
|
||
count: number
|
||
categoryId: number | null
|
||
categoryName: string | null
|
||
newCategoryName: string | null
|
||
newCategoryIcon: string | null
|
||
tagIds: number[]
|
||
tagNames: string[]
|
||
channel: string | null
|
||
channelName: string | null
|
||
newTags: AiNewTag[]
|
||
reason: string
|
||
}
|
||
|
||
/** 获取当前用户的 AI 配置 */
|
||
export function getAiConfig() {
|
||
return request.get('/ai/config')
|
||
}
|
||
|
||
/** 保存 AI 配置,apiKey 留空表示不修改原值 */
|
||
export function saveAiConfig(data: { enabled: boolean; apiKey?: string; baseUrl?: string; model?: string; visionModel?: string }) {
|
||
return request.put('/ai/config', data)
|
||
}
|
||
|
||
/** 测试 DeepSeek 连通性(耗时较长,单独放宽超时) */
|
||
export function testAiConfig() {
|
||
return request.post('/ai/config/test', {}, { timeout: 60000 })
|
||
}
|
||
|
||
/** 生成分类与标签建议(scope:BOTH/CATEGORY/TAG;重复记录聚合后按批调用,超时按批数放宽) */
|
||
export function suggestByAi(ids: number[], scope = 'BOTH') {
|
||
const batches = Math.max(1, Math.ceil(ids.length / 100))
|
||
return request.post('/ai/suggest', { ids, scope }, { timeout: Math.min(Math.max(120000, batches * 90000), 600000) })
|
||
}
|
||
|
||
/** AI 生成的分类建议 */
|
||
export interface AiCategoryResult {
|
||
name: string
|
||
type: string
|
||
icon: string
|
||
reason: string
|
||
}
|
||
|
||
/** 根据自然语言描述生成分类(调用大模型,超时放宽) */
|
||
export function parseCategoryByAi(text: string) {
|
||
return request.post('/ai/category', { text }, { timeout: 60000 })
|
||
}
|
||
|
||
/** AI 生成的标签建议 */
|
||
export interface AiTagResult {
|
||
name: string
|
||
color: string
|
||
reason: string
|
||
}
|
||
|
||
/** 根据自然语言描述生成标签(调用大模型,超时放宽) */
|
||
export function parseTagByAi(text: string) {
|
||
return request.post('/ai/tag', { text }, { timeout: 60000 })
|
||
}
|
||
|
||
/** AI 生成的交易渠道建议 */
|
||
export interface AiChannelResult {
|
||
name: string
|
||
reason: string
|
||
}
|
||
|
||
/** 根据自然语言描述生成交易渠道(调用大模型,超时放宽) */
|
||
export function parseChannelByAi(text: string) {
|
||
return request.post('/ai/channel', { text }, { timeout: 60000 })
|
||
}
|
||
|
||
/** AI 生成的账单关系名称与备注建议 */
|
||
export interface AiRelationResult {
|
||
name: string
|
||
remark: string
|
||
reason: string
|
||
}
|
||
|
||
/** 根据关系类型与成员账单特征生成关系名称与备注(调用大模型,超时放宽) */
|
||
export function parseRelationByAi(data: {
|
||
type: string
|
||
members: { type: string; channel?: string; categoryName?: string; remark?: string }[]
|
||
}) {
|
||
return request.post('/ai/relation', data, { timeout: 60000 })
|
||
}
|
||
|
||
/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */
|
||
export function suggestOneByAi(data: { type: string; amount?: number | null; channel?: string; remark?: string }) {
|
||
return request.post('/ai/suggest-one', data, { timeout: 60000 })
|
||
}
|
||
|
||
/** AI 推荐分类 Emoji 图标(调用大模型,超时放宽) */
|
||
export function suggestEmoji(name: string, type?: string) {
|
||
return request.post('/ai/emoji', { name, type }, { timeout: 60000 })
|
||
}
|
||
|
||
/** AI 推荐标签颜色(调用大模型,超时放宽) */
|
||
export function suggestColor(name: string) {
|
||
return request.post('/ai/color', { name }, { timeout: 60000 })
|
||
}
|
||
|
||
/** AI 生成的规则建议 */
|
||
export interface AiRuleSuggestion {
|
||
name: string
|
||
type: string | null
|
||
keyword: string | null
|
||
minAmount: number | null
|
||
maxAmount: number | null
|
||
categoryId: number | null
|
||
categoryName: string | null
|
||
newCategoryName: string | null
|
||
newCategoryIcon: string | null
|
||
tagIds: number[]
|
||
tagNames: string[]
|
||
newTags: AiNewTag[]
|
||
reason: string
|
||
/** 是否与已有规则重复:重复时默认不勾选且禁止导入 */
|
||
duplicate: boolean
|
||
}
|
||
|
||
/** AI 生成规则的结果 */
|
||
export interface AiRuleSuggestResult {
|
||
summary: string
|
||
rules: AiRuleSuggestion[]
|
||
}
|
||
|
||
/** AI 统计查询解析结果 */
|
||
export interface AiStatisticsResult {
|
||
summary: string
|
||
types: string[]
|
||
categoryIds: number[]
|
||
categoryNames: string[]
|
||
/** 是否仅筛选未分类记录 */
|
||
uncategorized: boolean
|
||
tagIds: number[]
|
||
tagNames: string[]
|
||
/** 是否仅筛选无标签记录;与 tagIds 互斥 */
|
||
untagged: boolean
|
||
channels: string[]
|
||
startTime: string | null
|
||
endTime: string | null
|
||
minAmount: number | null
|
||
maxAmount: number | null
|
||
remark: string | null
|
||
statsScope: 'INCLUDED' | 'ALL' | 'EXCLUDED' | null
|
||
dimension: 'day' | 'week' | 'month' | 'quarter' | 'year' | null
|
||
unmatched: string[]
|
||
}
|
||
|
||
/** 将自然语言统计查询解析为统计面板参数(调用大模型,超时放宽) */
|
||
export function parseStatisticsByAi(query: string) {
|
||
return request.post('/ai/statistics', { query }, { timeout: 120000 })
|
||
}
|
||
|
||
/** 根据选中的已打标签记录生成规则建议(调用大模型,超时放宽) */
|
||
export function suggestRulesByAi(transactionIds: number[]) {
|
||
return request.post('/ai/rule/suggest', { transactionIds }, { timeout: 120000 })
|
||
}
|
||
|
||
/** 批量创建 AI 生成的规则,必要时新建分类与标签 */
|
||
export function applyAiRules(rules: {
|
||
name: string
|
||
enabled?: boolean
|
||
type?: string | null
|
||
keyword?: string | null
|
||
minAmount?: number | null
|
||
maxAmount?: number | null
|
||
categoryId?: number | null
|
||
newCategoryName?: string | null
|
||
newCategoryIcon?: string | null
|
||
tagIds?: number[]
|
||
newTags?: AiNewTag[]
|
||
}[]) {
|
||
return request.post('/ai/rule/apply', { rules })
|
||
}
|
||
|
||
/** 一键应用建议:覆盖分类并追加标签,标记为新建的分类/标签会先创建再关联 */
|
||
export function applyAiSuggestions(suggestions: {
|
||
transactionId: number
|
||
categoryId: number | null
|
||
newCategoryName?: string | null
|
||
newCategoryIcon?: string | null
|
||
tagIds: number[]
|
||
newTags?: AiNewTag[]
|
||
}[]) {
|
||
return request.post('/ai/apply', { suggestions })
|
||
}
|
||
|
||
/** 自然语言查询解析结果 */
|
||
export interface AiQueryResult {
|
||
summary: string
|
||
types: string[]
|
||
categoryIds: number[]
|
||
categoryNames: string[]
|
||
uncategorized: boolean
|
||
channels: string[]
|
||
startTime: string | null
|
||
endTime: string | null
|
||
includeInStats: boolean | null
|
||
minAmount: number | null
|
||
maxAmount: number | null
|
||
remark: string | null
|
||
tagIds: number[]
|
||
tagNames: string[]
|
||
/** 是否筛选无标签记录;与 tagIds 互斥 */
|
||
untagged: boolean
|
||
unmatched: string[]
|
||
}
|
||
|
||
/** 将自然语言查询解析为筛选条件 */
|
||
export function queryByAi(query: string) {
|
||
return request.post('/ai/query', { query }, { timeout: 120000 })
|
||
}
|
||
|
||
/** 智能查数结果的列元数据 */
|
||
export interface AiSqlColumn {
|
||
key: string
|
||
title: string
|
||
type: 'NUMBER' | 'STRING' | 'DATE' | 'DATETIME' | 'BOOLEAN'
|
||
}
|
||
|
||
/** 智能查数结果 */
|
||
export interface AiSqlQueryResult {
|
||
/** 生成的 SQL(经安全校验后的最终语句) */
|
||
sql: string
|
||
/** 查询意图说明 */
|
||
explanation: string
|
||
/** 建议的展示类型 */
|
||
chartType: 'SCALAR' | 'TABLE' | 'BAR' | 'PIE'
|
||
/** 结果列元数据 */
|
||
columns: AiSqlColumn[]
|
||
/** 结果行数据 */
|
||
rows: Record<string, unknown>[]
|
||
/** 返回行数 */
|
||
rowCount: number
|
||
/** 是否因行数上限被截断 */
|
||
truncated: boolean
|
||
}
|
||
|
||
/** 将自然语言转换为只读 SQL 并执行查询(调用大模型,超时放宽) */
|
||
export function queryBySql(query: string) {
|
||
return request.post('/ai/sql', { query }, { timeout: 120000 })
|
||
}
|
||
|
||
/** 智能查数历史记录 */
|
||
export interface AiSqlHistoryItem {
|
||
id: number
|
||
query: string
|
||
createdAt: string
|
||
}
|
||
|
||
/** 获取智能查数历史(按创建时间倒序) */
|
||
export function getSqlHistory(limit = 100) {
|
||
return request.get('/ai/sql/history', { params: { limit } })
|
||
}
|
||
|
||
/** 清空智能查数历史 */
|
||
export function clearSqlHistory() {
|
||
return request.delete('/ai/sql/history')
|
||
}
|
||
|
||
/** 自然语言记账解析结果 */
|
||
export interface AiRecordResult {
|
||
summary: string
|
||
type: string
|
||
amount: number | null
|
||
categoryId: number | null
|
||
categoryName: string | null
|
||
channel: string | null
|
||
transactionTime: string | null
|
||
remark: string | null
|
||
includeInStats: boolean | null
|
||
tagIds: number[]
|
||
tagNames: string[]
|
||
unmatched: string[]
|
||
/** 图片记账时由模型提取的原始字段 JSON 文本,文本记账为 null */
|
||
rawData: string | null
|
||
/** 来源图片序号(图片记账,从 1 开始),文本记账为 null */
|
||
imageIndex: number | null
|
||
/** 来源图片ID(图片记账),文本记账为 null */
|
||
imageId: number | null
|
||
/** 是否疑似重复:与已有记录或本批其它记录的类型、金额、时间一致 */
|
||
duplicate: boolean | null
|
||
}
|
||
|
||
/** 将自然语言记账内容解析为交易记录 */
|
||
export function parseRecordByAi(text: string) {
|
||
return request.post('/ai/record', { text }, { timeout: 120000 })
|
||
}
|
||
|
||
/** 图片记账解析结果 */
|
||
export interface AiRecordImageResult {
|
||
/** 保存的图片ID列表,按上传顺序排列 */
|
||
imageIds: number[]
|
||
summary: string
|
||
records: AiRecordResult[]
|
||
unmatched: string[]
|
||
}
|
||
|
||
/** 上传一至多张账单/支付截图,由 AI 一次性识别并生成多条交易记录(调用视觉模型,超时放宽) */
|
||
export function parseRecordByImage(files: File[], hint?: string) {
|
||
const formData = new FormData()
|
||
for (const file of files) {
|
||
formData.append('files', file)
|
||
}
|
||
if (hint) formData.append('hint', hint)
|
||
return request.post('/ai/record/image', formData, { timeout: 180000 })
|
||
}
|
||
|
||
/** 获取 AI 记账图片(blob,经鉴权头请求,避免 token 出现在 URL;thumbnail=true 返回压缩缩略图,默认原图) */
|
||
export function getAiImageBlob(id: number, thumbnail = false) {
|
||
return request.get(`/ai/image/${id}${thumbnail ? '?thumbnail=true' : ''}`, { responseType: 'blob', timeout: 30000 })
|
||
}
|
||
|
||
/** AI 使用累计汇总 */
|
||
export interface AiUsageSummary {
|
||
totalCalls: number
|
||
successCalls: number
|
||
failedCalls: number
|
||
promptTokens: number
|
||
completionTokens: number
|
||
totalTokens: number
|
||
cachedTokens: number
|
||
}
|
||
|
||
/** 单条 AI 使用明细 */
|
||
export interface AiUsageItem {
|
||
id: number
|
||
type: string
|
||
model: string
|
||
transactionCount: number
|
||
promptTokens: number
|
||
completionTokens: number
|
||
totalTokens: number
|
||
cachedTokens: number
|
||
success: boolean
|
||
errorMessage: string | null
|
||
durationMs: number | null
|
||
createdAt: string
|
||
}
|
||
|
||
/** AI 使用情况(累计汇总 + 最近明细) */
|
||
export interface AiUsageResult {
|
||
summary: AiUsageSummary
|
||
recent: AiUsageItem[]
|
||
}
|
||
|
||
/** 获取 AI 使用情况 */
|
||
export function getAiUsage(limit = 20) {
|
||
return request.get('/ai/usage', { params: { limit } })
|
||
}
|