From 2e5603bb8b4aabfd7cc0d87caa604e1376472f0c Mon Sep 17 00:00:00 2001 From: SmileQWQ Date: Mon, 4 May 2026 04:05:40 +0800 Subject: [PATCH] feat: add dashboard summary settings capability - extend shared AI config with dashboard summary fields and DTOs - update settings validation and AI config helpers for summary-specific toggles - align settings page copy and form state with AI capability wording - cover schema, settings route, and form cloning changes with tests --- apps/api/src/routes/settings.ts | 4 +- apps/api/src/services/ai.service.ts | 14 ++- .../tests/integration/settings-routes.test.ts | 44 +++++++++- apps/api/tests/unit/ai.service.test.ts | 2 + .../api/tests/unit/statistics.service.test.ts | 2 + apps/web/src/pages/SettingsPage.vue | 87 +++++++++++++++---- apps/web/src/types/api.ts | 19 ++++ apps/web/src/utils/settings-form.ts | 2 + .../components/settings-import-export.test.ts | 18 +++- .../tests/unit/utils/settings-form.test.ts | 2 + packages/shared/src/index.test.ts | 21 +++++ packages/shared/src/index.ts | 87 +++++++++++++++++++ 12 files changed, 275 insertions(+), 27 deletions(-) diff --git a/apps/api/src/routes/settings.ts b/apps/api/src/routes/settings.ts index 01fe7bb..6227956 100644 --- a/apps/api/src/routes/settings.ts +++ b/apps/api/src/routes/settings.ts @@ -98,8 +98,8 @@ function validateSettingsPayload(settings: Awaited !String(value ?? '').trim()) .map(([label]) => label) - if (settings.aiConfig.enabled && missingAiFields.length) { - throw new Error(`启用 AI 识别时必须填写:${missingAiFields.join('、')}`) + if ((settings.aiConfig.enabled || settings.aiConfig.dashboardSummaryEnabled) && missingAiFields.length) { + throw new Error(`启用 AI 能力时必须填写:${missingAiFields.join('、')}`) } } diff --git a/apps/api/src/services/ai.service.ts b/apps/api/src/services/ai.service.ts index 7c50bc1..1983351 100644 --- a/apps/api/src/services/ai.service.ts +++ b/apps/api/src/services/ai.service.ts @@ -21,16 +21,24 @@ const visionTestImageBase64 = 'iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAKUlEQVR4nO3OIQEAAAACIP+f1hkWWEB6FgEBAQEBAQEBAQEBAQEBgXdgl/rw4unIZ5cAAAAASUVORK5CYII=' const jsonOnlySuffix = '必须只返回合法 JSON 对象,不要返回 Markdown、代码块或额外解释。' -function ensureAiConfig(aiConfig: AiSettings, options?: { requireEnabled?: boolean }) { +export function ensureAiConfig(aiConfig: AiSettings, options?: { requireEnabled?: boolean; featureLabel?: string }) { if (options?.requireEnabled !== false && !aiConfig.enabled) { - throw new Error('AI 识别未启用') + throw new Error(`${options?.featureLabel ?? 'AI 功能'}未启用`) } if (!aiConfig.baseUrl || !aiConfig.apiKey || !aiConfig.model) { - throw new Error('AI 配置不完整') + throw new Error(`${options?.featureLabel ?? 'AI'}配置不完整`) } } +export function ensureAiSummaryConfig(aiConfig: AiSettings) { + if (!aiConfig.dashboardSummaryEnabled) { + throw new Error('AI 总结未启用') + } + + ensureAiConfig(aiConfig, { requireEnabled: false, featureLabel: 'AI 总结' }) +} + export function looksLikeImageFormatUnsupported(errorText: string) { const normalized = errorText.toLowerCase() return ( diff --git a/apps/api/tests/integration/settings-routes.test.ts b/apps/api/tests/integration/settings-routes.test.ts index 9605472..007418d 100644 --- a/apps/api/tests/integration/settings-routes.test.ts +++ b/apps/api/tests/integration/settings-routes.test.ts @@ -70,6 +70,7 @@ vi.mock('../../src/services/settings.service', () => ({ }, aiConfig: { enabled: false, + dashboardSummaryEnabled: false, providerPreset: 'custom', providerName: 'DeepSeek', baseUrl: 'https://api.deepseek.com', @@ -77,6 +78,7 @@ vi.mock('../../src/services/settings.service', () => ({ model: 'deepseek-chat', timeoutMs: 30000, promptTemplate: '', + dashboardSummaryPromptTemplate: '', capabilities: { vision: false, structuredOutput: true @@ -89,6 +91,12 @@ vi.mock('../../src/services/settings.service', () => ({ }) })) +vi.mock('../../src/services/worker-lite-repository.service', () => ({ + setSettingLite: vi.fn(async (key: string, value: unknown) => { + store.set(key, value) + }) +})) + vi.mock('../../src/services/subtracker-backup.service', () => ({ createSubtrackerBackupArchive: createSubtrackerBackupArchiveMock })) @@ -108,13 +116,14 @@ describe('settings routes validation', () => { await app.close() }) - it('rejects incomplete AI config when enabling AI recognition', async () => { + it('rejects incomplete AI config when enabling AI capability', async () => { const res = await app.inject({ method: 'PATCH', url: '/settings', payload: { aiConfig: { enabled: true, + dashboardSummaryEnabled: true, providerPreset: 'custom', providerName: '', baseUrl: 'https://api.deepseek.com', @@ -122,6 +131,7 @@ describe('settings routes validation', () => { model: '', timeoutMs: 30000, promptTemplate: '', + dashboardSummaryPromptTemplate: '', capabilities: { vision: false, structuredOutput: true @@ -131,7 +141,37 @@ describe('settings routes validation', () => { }) expect(res.statusCode).toBe(422) - expect(res.json().error.message).toContain('启用 AI 识别时必须填写') + expect(res.json().error.message).toContain('启用 AI 能力时必须填写') + }) + + it('accepts dashboard summary switch without forcing AI recognition to be enabled', async () => { + const res = await app.inject({ + method: 'PATCH', + url: '/settings', + payload: { + aiConfig: { + enabled: false, + dashboardSummaryEnabled: true, + providerPreset: 'custom', + providerName: 'DeepSeek', + baseUrl: 'https://api.deepseek.com', + apiKey: 'token', + model: 'deepseek-chat', + timeoutMs: 30000, + promptTemplate: '', + dashboardSummaryPromptTemplate: '你是一个统计摘要助手。', + capabilities: { + vision: false, + structuredOutput: true + } + } + } + }) + + expect(res.statusCode).toBe(200) + expect(res.json().data.aiConfig.dashboardSummaryEnabled).toBe(true) + expect(res.json().data.aiConfig.enabled).toBe(false) + expect(res.json().data.aiConfig.dashboardSummaryPromptTemplate).toBe('你是一个统计摘要助手。') }) it('rejects smtp email notifications in worker runtime', async () => { diff --git a/apps/api/tests/unit/ai.service.test.ts b/apps/api/tests/unit/ai.service.test.ts index 77f22f8..6cb24cc 100644 --- a/apps/api/tests/unit/ai.service.test.ts +++ b/apps/api/tests/unit/ai.service.test.ts @@ -7,6 +7,7 @@ const mockedSettings: { aiConfig: { ...DEFAULT_AI_CONFIG, enabled: true, + dashboardSummaryEnabled: false, apiKey: 'test-key', capabilities: { ...DEFAULT_AI_CONFIG.capabilities @@ -34,6 +35,7 @@ describe('ai service', () => { mockedSettings.aiConfig = { ...DEFAULT_AI_CONFIG, enabled: true, + dashboardSummaryEnabled: false, apiKey: 'test-key', capabilities: { ...DEFAULT_AI_CONFIG.capabilities diff --git a/apps/api/tests/unit/statistics.service.test.ts b/apps/api/tests/unit/statistics.service.test.ts index c01cc92..dde3fe9 100644 --- a/apps/api/tests/unit/statistics.service.test.ts +++ b/apps/api/tests/unit/statistics.service.test.ts @@ -77,6 +77,7 @@ vi.mock('../../src/services/settings.service', () => ({ }, aiConfig: { enabled: false, + dashboardSummaryEnabled: false, providerPreset: 'custom', providerName: 'DeepSeek', baseUrl: 'https://api.deepseek.com', @@ -84,6 +85,7 @@ vi.mock('../../src/services/settings.service', () => ({ model: 'deepseek-chat', timeoutMs: 30000, promptTemplate: '', + dashboardSummaryPromptTemplate: '', capabilities: { vision: false, structuredOutput: true diff --git a/apps/web/src/pages/SettingsPage.vue b/apps/web/src/pages/SettingsPage.vue index 2a6ed33..6e075a0 100644 --- a/apps/web/src/pages/SettingsPage.vue +++ b/apps/web/src/pages/SettingsPage.vue @@ -2,7 +2,7 @@
@@ -441,12 +441,15 @@ - + - 启用 AI 识别 + 启用 AI 能力 + + AI 能力总开关控制识别与连接测试;AI 总结可单独开启或关闭。 + @@ -468,12 +471,20 @@ - - - - 模型视觉输入 - - + + +
+
+ + 模型视觉输入 +
+
+ + AI 总结 +
+
+
+
@@ -494,12 +505,20 @@ - + + + + @@ -538,7 +557,7 @@ -
支持通过 ZIP 进行导出备份与恢复备份,包含订阅、标签、支付记录、排序、系统设置与本地 Logo
+
支持通过 ZIP 进行备份与恢复,包含订阅、标签、支付记录、排序、系统设置与本地 Logo
导出备份 恢复备份 @@ -583,6 +602,7 @@ import { useQueryClient } from '@tanstack/vue-query' import { DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_AI_CONFIG, + DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT, DEFAULT_AI_SUBSCRIPTION_PROMPT, DEFAULT_NOTIFICATION_WEBHOOK_PAYLOAD_TEMPLATE, DEFAULT_OVERDUE_REMINDER_RULES, @@ -764,6 +784,7 @@ const webhookForm = reactive({ const snapshot = ref(null) const aiPromptInput = ref(DEFAULT_AI_SUBSCRIPTION_PROMPT) +const dashboardSummaryPromptInput = ref(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT) const savingBasicSettings = ref(false) const savingEmailSettings = ref(false) const savingPushplusSettings = ref(false) @@ -930,7 +951,7 @@ function validateWebhookSettings(action: 'save' | 'test') { } function validateAiSettings(action: 'save' | 'connection-test' | 'vision-test') { - if (action === 'save' && !settingsForm.aiConfig.enabled) { + if (action === 'save' && !settingsForm.aiConfig.enabled && !settingsForm.aiConfig.dashboardSummaryEnabled) { return true } @@ -942,7 +963,7 @@ function validateAiSettings(action: 'save' | 'connection-test' | 'vision-test') ]) if (!missing.length) return true - message.error(`AI 识别缺少必填项:${missing.join('、')}`) + message.error(`AI 能力缺少必填项:${missing.join('、')}`) return false } @@ -953,6 +974,8 @@ watch( Object.assign(settingsForm, cloneSettingsForForm(settings)) normalizeWorkerEmailProvider() aiPromptInput.value = settings.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT + dashboardSummaryPromptInput.value = + settings.aiConfig.dashboardSummaryPromptTemplate.trim() || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT credentialsForm.oldUsername = authStore.username credentialsForm.newUsername = authStore.username targetCurrency.value = settings.baseCurrency @@ -980,6 +1003,9 @@ watch( function applySavedSettings(result: Settings) { Object.assign(settingsForm, cloneSettingsForForm(result)) normalizeWorkerEmailProvider() + aiPromptInput.value = result.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT + dashboardSummaryPromptInput.value = + result.aiConfig.dashboardSummaryPromptTemplate.trim() || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT queryClient.setQueryData(SETTINGS_QUERY_KEY, result) } @@ -1101,8 +1127,11 @@ async function saveAiSettings() { if (savingAiSettings.value) return if (!validateAiSettings('save')) return const promptTemplate = normalizeAiPrompt(aiPromptInput.value) + const dashboardSummaryPromptTemplate = normalizeDashboardSummaryPrompt(dashboardSummaryPromptInput.value) settingsForm.aiConfig.promptTemplate = promptTemplate + settingsForm.aiConfig.dashboardSummaryPromptTemplate = dashboardSummaryPromptTemplate aiPromptInput.value = promptTemplate || DEFAULT_AI_SUBSCRIPTION_PROMPT + dashboardSummaryPromptInput.value = dashboardSummaryPromptTemplate || DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT savingAiSettings.value = true try { const result = await api.updateSettings({ @@ -1111,12 +1140,12 @@ async function saveAiSettings() { capabilities: { ...settingsForm.aiConfig.capabilities }, - promptTemplate + promptTemplate, + dashboardSummaryPromptTemplate } }) applySavedSettings(result) - aiPromptInput.value = result.aiConfig.promptTemplate.trim() || DEFAULT_AI_SUBSCRIPTION_PROMPT - message.success(settingsForm.aiConfig.enabled ? 'AI 识别配置已保存' : 'AI 识别已关闭') + message.success(settingsForm.aiConfig.enabled || settingsForm.aiConfig.dashboardSummaryEnabled ? 'AI 能力配置已保存' : 'AI 能力已关闭') } finally { savingAiSettings.value = false } @@ -1126,9 +1155,11 @@ async function testAiConnectionSettings() { if (!validateAiSettings('connection-test')) return try { const promptTemplate = normalizeAiPrompt(aiPromptInput.value) + const dashboardSummaryPromptTemplate = normalizeDashboardSummaryPrompt(dashboardSummaryPromptInput.value) const result = await api.testAiConfigurationWithPayload({ ...settingsForm.aiConfig, promptTemplate, + dashboardSummaryPromptTemplate, capabilities: { ...settingsForm.aiConfig.capabilities } @@ -1143,9 +1174,11 @@ async function testAiVisionSettings() { if (!validateAiSettings('vision-test')) return try { const promptTemplate = normalizeAiPrompt(aiPromptInput.value) + const dashboardSummaryPromptTemplate = normalizeDashboardSummaryPrompt(dashboardSummaryPromptInput.value) const result = await api.testAiVisionConfigurationWithPayload({ ...settingsForm.aiConfig, promptTemplate, + dashboardSummaryPromptTemplate, capabilities: { ...settingsForm.aiConfig.capabilities } @@ -1163,6 +1196,13 @@ function normalizeAiPrompt(value: string) { return value } +function normalizeDashboardSummaryPrompt(value: string) { + const normalized = value.trim() + if (!normalized) return '' + if (normalized === DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT.trim()) return '' + return value +} + function handleAiPresetChange(value: AiProviderPreset) { settingsForm.aiConfig.providerPreset = value if (value === 'custom') { @@ -1262,9 +1302,9 @@ async function exportBackup() { try { const result = await api.exportBackup() downloadBlob(result.blob, result.filename) - message.success('备份导出已开始') + message.success('ZIP 导出已开始') } catch (error) { - message.error(error instanceof Error ? error.message : '备份导出失败') + message.error(error instanceof Error ? error.message : 'ZIP 导出失败') } } @@ -1440,6 +1480,11 @@ function formatTime(value: string) { align-items: center; } +.switch-group--ai-capabilities-inline { + width: 100%; + justify-content: flex-start; +} + .switch-group--single { min-height: 34px; } @@ -1451,6 +1496,10 @@ function formatTime(value: string) { min-height: 34px; } +.switch-label--compact { + margin-left: 0; +} + .label-with-tip { display: inline-flex; align-items: center; diff --git a/apps/web/src/types/api.ts b/apps/web/src/types/api.ts index a4dde1d..fecf35d 100644 --- a/apps/web/src/types/api.ts +++ b/apps/web/src/types/api.ts @@ -205,6 +205,7 @@ export interface AiCapabilities { export interface AiConfig { enabled: boolean + dashboardSummaryEnabled: boolean providerPreset: AiProviderPreset providerName: string baseUrl: string @@ -212,6 +213,7 @@ export interface AiConfig { model: string timeoutMs: number promptTemplate: string + dashboardSummaryPromptTemplate: string capabilities: AiCapabilities } @@ -222,6 +224,23 @@ export interface AiTestResponse { response: string } +export type AiDashboardSummaryStatus = 'idle' | 'unconfigured' | 'generating' | 'success' | 'failed' + +export interface AiDashboardSummary { + scope: 'dashboard-overview' + status: AiDashboardSummaryStatus + content: string | null + previewContent: string | null + errorMessage: string | null + generatedAt: string | null + updatedAt: string | null + sourceDataHash: string | null + fromCache: boolean + isStale: boolean + canGenerate: boolean + needsGeneration: boolean +} + export interface StorageCapabilities { runtime: 'worker-lite' r2Enabled: boolean diff --git a/apps/web/src/utils/settings-form.ts b/apps/web/src/utils/settings-form.ts index d284569..b1ab678 100644 --- a/apps/web/src/utils/settings-form.ts +++ b/apps/web/src/utils/settings-form.ts @@ -13,6 +13,8 @@ export function cloneSettingsForForm(settings: Settings): Settings { gotifyConfig: { ...settings.gotifyConfig }, aiConfig: { ...settings.aiConfig, + dashboardSummaryEnabled: settings.aiConfig.dashboardSummaryEnabled, + dashboardSummaryPromptTemplate: settings.aiConfig.dashboardSummaryPromptTemplate, capabilities: { ...settings.aiConfig.capabilities } diff --git a/apps/web/tests/unit/components/settings-import-export.test.ts b/apps/web/tests/unit/components/settings-import-export.test.ts index 04fdccb..742a6b2 100644 --- a/apps/web/tests/unit/components/settings-import-export.test.ts +++ b/apps/web/tests/unit/components/settings-import-export.test.ts @@ -13,7 +13,7 @@ describe('settings import export copy', () => { expect(source).toContain('embedded title="迁移"') expect(source).toContain('从第三方同类项目导入数据') expect(source).toContain('导入 Wallos') - expect(source).toContain('支持通过 ZIP 进行导出备份与恢复备份') + expect(source).toContain('支持通过 ZIP 进行备份与恢复') expect(source).toContain('当前运行环境为 Cloudflare Worker + D1') expect(source).not.toContain('导出 CSV') expect(source).not.toContain('导出 JSON') @@ -24,11 +24,27 @@ describe('settings import export copy', () => { expect(backupModal).not.toContain('title="导入 ZIP"') expect(backupModal).toContain('预览备份') expect(backupModal).toContain('确认恢复') + expect(backupModal).toContain('恢复模式') + expect(backupModal).toContain('恢复预览') expect(backupModal).toContain('备份 ZIP 无法解析') expect(backupModal).toContain('未导入任何新数据,重复项已自动跳过') expect(backupModal).toContain('按备份中的唯一标识(CUID)幂等跳过') expect(backupModal).toContain('现有同唯一标识(CUID)订阅') expect(backupModal).toContain('现有同唯一标识(CUID)支付记录') + expect(backupModal).not.toContain('生成预览') expect(backupModal).not.toContain('确认导入') }) + + it('expands ai settings wording from recognition-only to shared ai capability', () => { + const source = readFileSync('src/pages/SettingsPage.vue', 'utf8') + + expect(source).toContain('title="AI 能力设置"') + expect(source).toContain('启用 AI 能力') + expect(source).toContain('AI 总结') + expect(source).toContain('AI 能力总开关控制识别与连接测试;AI 总结可单独开启或关闭') + expect(source).toContain('自定义识别提示词') + expect(source).toContain('自定义总结提示词') + expect(source).not.toContain('title="AI 识别设置"') + expect(source).not.toContain('启用 AI 识别') + }) }) diff --git a/apps/web/tests/unit/utils/settings-form.test.ts b/apps/web/tests/unit/utils/settings-form.test.ts index a417b4e..aa375f5 100644 --- a/apps/web/tests/unit/utils/settings-form.test.ts +++ b/apps/web/tests/unit/utils/settings-form.test.ts @@ -58,6 +58,7 @@ describe('cloneSettingsForForm', () => { }, aiConfig: { enabled: true, + dashboardSummaryEnabled: true, providerPreset: 'custom', providerName: 'OpenAI', baseUrl: 'https://example.com', @@ -65,6 +66,7 @@ describe('cloneSettingsForForm', () => { model: 'gpt', timeoutMs: 30000, promptTemplate: 'prompt', + dashboardSummaryPromptTemplate: 'summary prompt', capabilities: { vision: true, structuredOutput: true diff --git a/packages/shared/src/index.test.ts b/packages/shared/src/index.test.ts index 69e9464..af8d5ca 100644 --- a/packages/shared/src/index.test.ts +++ b/packages/shared/src/index.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from 'vitest' import { + AiDashboardSummaryStatusSchema, CreateSubscriptionSchema, + DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT, + DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT, DEFAULT_ADVANCE_REMINDER_RULES, DEFAULT_OVERDUE_REMINDER_RULES, + formatAiSummaryPreviewText, SettingsSchema, SubtrackerBackupCommitSchema, SubtrackerBackupInspectSchema @@ -34,6 +38,8 @@ describe('shared schema', () => { expect(parsed.overdueReminderDays).toEqual([1, 2, 3]) expect(parsed.defaultOverdueReminderRules).toBe(DEFAULT_OVERDUE_REMINDER_RULES) expect(parsed.timezone).toBe('Asia/Shanghai') + expect(parsed.aiConfig.dashboardSummaryEnabled).toBe(false) + expect(parsed.aiConfig.dashboardSummaryPromptTemplate).toBe('') expect(parsed.telegramNotificationsEnabled).toBe(false) expect(parsed.telegramConfig).toEqual({ botToken: '', @@ -76,4 +82,19 @@ describe('shared schema', () => { expect(parsed.mode).toBe('append') expect(parsed.restoreSettings).toBe(true) }) + + it('should expose dashboard summary prompt and status schema', () => { + expect(DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT).toContain('订阅运营摘要助手') + expect(DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT).toContain('摘要压缩助手') + expect(AiDashboardSummaryStatusSchema.parse('success')).toBe('success') + expect(() => AiDashboardSummaryStatusSchema.parse('unknown')).toThrow() + }) + + it('formats ai summary preview text into readable plain text', () => { + expect( + formatAiSummaryPreviewText( + '## 总览\n- 当前 13 个活跃订阅\n- 月支出 1254.61 元\n\n## 近期风险\n1. 未来 30 天续订密集' + ) + ).toBe('总览 当前 13 个活跃订阅 月支出 1254.61 元 近期风险 未来 30 天续订密集') + }) }) diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 4a12549..2b8e330 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -23,6 +23,65 @@ export const DEFAULT_AI_SUBSCRIPTION_PROMPT = `你是订阅账单信息提取助 4. 周期单位必须在 day/week/month/quarter/year 中。 5. 只返回 JSON,不要返回 Markdown。` +export const DEFAULT_AI_DASHBOARD_SUMMARY_PROMPT = `你是订阅运营摘要助手。请基于用户当前的订阅统计数据,输出一份简洁、准确、可执行的 Markdown 总结。 + +目标: +1. 帮助用户快速理解当前订阅规模、支出结构、预算压力和近期续订风险。 +2. 总结数据中的明显模式、异常点和需要关注的事项。 +3. 给出中性、可执行、但不依赖具体服务功能知识的建议。 + +硬性要求: +- 只能基于输入数据分析,不要虚构事实。 +- 不要假设你了解某个订阅服务的功能细节。 +- 不要输出“取消某服务更省钱”“某两个服务功能重叠”之类的建议。 +- 不要臆测用户偏好、使用频率或用途。 +- 不要输出 JSON,不要输出代码块,只输出 Markdown 正文。 + +输出建议结构: +## 总览 +## 支出结构 +## 近期风险 +## 值得注意的模式 +## 中性建议 + +写作要求: +- 使用简体中文。 +- 结论明确,少空话。 +- 每个小节控制在 2~5 条要点内。 +- 如果某部分没有明显异常,直接说明“暂无显著异常”或“整体平稳”。` + +export const DEFAULT_AI_DASHBOARD_SUMMARY_PREVIEW_PROMPT = `你是订阅统计摘要压缩助手。请根据已经生成好的完整 AI 总结,提炼出一个默认折叠展示用的超简短摘要。 + +硬性要求: +- 只输出简体中文纯文本,不要输出 Markdown,不要输出代码块。 +- 输出 2 到 3 行,每行一句,自然换行。 +- 不要输出标题,不要输出项目符号,不要编号。 +- 只保留最重要的结论:订阅规模、预算压力、近期风险。 +- 不要发散,不要补充原文没有的信息。 +- 如果原文信息有限,就直接给出 1 到 2 句自然语言摘要。` + +function normalizePreviewSource(text: string) { + return String(text ?? '') + .replace(/\r\n/g, '\n') + .split('\n') + .map((line) => + line + .replace(/^#{1,6}\s*/g, '') + .replace(/^\s*[-*+]\s*/g, '') + .replace(/^\s*\d+[.)]\s*/g, '') + .replace(/[`>*_]/g, '') + .trim() + ) + .filter(Boolean) + .join(' ') + .replace(/\s+/g, ' ') + .trim() +} + +export function formatAiSummaryPreviewText(text: string) { + return normalizePreviewSource(text) +} + export const SubscriptionStatusSchema = z.enum(['active', 'paused', 'cancelled', 'expired']) export const BillingIntervalUnitSchema = z.enum(['day', 'week', 'month', 'quarter', 'year']) export const WebhookRequestMethodSchema = z.enum(['POST', 'PUT', 'PATCH', 'DELETE']) @@ -163,6 +222,7 @@ export const DEFAULT_AI_CAPABILITIES = { export const DEFAULT_AI_CONFIG = { enabled: false, + dashboardSummaryEnabled: false, providerPreset: 'custom', providerName: 'DeepSeek', baseUrl: 'https://api.deepseek.com', @@ -170,6 +230,7 @@ export const DEFAULT_AI_CONFIG = { model: 'deepseek-chat', timeoutMs: 30000, promptTemplate: '', + dashboardSummaryPromptTemplate: '', capabilities: { ...DEFAULT_AI_CAPABILITIES } @@ -191,6 +252,7 @@ export const AiCapabilitiesSchema = z.object({ export const AiConfigSchema = z.object({ enabled: z.boolean().default(DEFAULT_AI_CONFIG.enabled), + dashboardSummaryEnabled: z.boolean().default(DEFAULT_AI_CONFIG.dashboardSummaryEnabled), providerPreset: AiProviderPresetSchema.default(DEFAULT_AI_CONFIG.providerPreset), providerName: z.string().max(100).default(DEFAULT_AI_CONFIG.providerName), baseUrl: z.string().url().default(DEFAULT_AI_CONFIG.baseUrl), @@ -198,6 +260,7 @@ export const AiConfigSchema = z.object({ model: z.string().max(100).default(DEFAULT_AI_CONFIG.model), timeoutMs: z.number().int().min(5000).max(120000).default(DEFAULT_AI_CONFIG.timeoutMs), promptTemplate: z.string().max(5000).default(DEFAULT_AI_CONFIG.promptTemplate), + dashboardSummaryPromptTemplate: z.string().max(5000).default(DEFAULT_AI_CONFIG.dashboardSummaryPromptTemplate), capabilities: AiCapabilitiesSchema.default({ ...DEFAULT_AI_CAPABILITIES }) @@ -273,6 +336,14 @@ export const AiRecognizeSubscriptionSchema = z.object({ mimeType: z.string().max(100).optional() }) +export const AiDashboardSummaryStatusSchema = z.enum([ + 'idle', + 'unconfigured', + 'generating', + 'success', + 'failed' +]) + const WallosImportSummarySchema = z.object({ fileType: z.enum(['json', 'db', 'zip']), subscriptionsTotal: z.number().int().min(0), @@ -393,6 +464,7 @@ export type StorageCapabilitiesInput = z.infer export type LogoSearchInput = z.infer export type LogoUploadInput = z.infer export type AiRecognizeSubscriptionInput = z.infer +export type AiDashboardSummaryStatus = z.infer export type WallosImportInspectInput = z.infer export type WallosImportCommitInput = z.infer export type SubtrackerBackupScope = z.infer @@ -446,6 +518,21 @@ export interface AiRecognitionResultDto { rawText?: string } +export interface AiDashboardSummaryDto { + scope: 'dashboard-overview' + status: AiDashboardSummaryStatus + content: string | null + previewContent: string | null + errorMessage: string | null + generatedAt: string | null + updatedAt: string | null + sourceDataHash: string | null + fromCache: boolean + isStale: boolean + canGenerate: boolean + needsGeneration: boolean +} + export interface DashboardOverview { activeSubscriptions: number upcoming7Days: number