Files
ledgerly/frontend/src/api/ai.ts
T
rosercode feb56c8a8d feat: 智能查数支持提问历史持久化与体验优化
为智能查数补充提问历史的后端持久化(含创建时间、可清空),并完善前端历史展示、会话缓存与若干体验问题。

变更内容:
- 新增 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
2026-09-24 12:30:00 +08:00

374 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 } })
}