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
This commit is contained in:
SmileQWQ
2026-05-04 04:05:40 +08:00
parent cf768434f1
commit 2e5603bb8b
12 changed files with 275 additions and 27 deletions

View File

@@ -98,8 +98,8 @@ function validateSettingsPayload(settings: Awaited<ReturnType<typeof getAppSetti
.filter(([, value]) => !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('、')}`)
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<div>
<page-header
title="系统设置"
subtitle="管理基础参数、预算、汇率、通知与 AI 识别"
subtitle="管理基础参数、预算、汇率、通知与 AI 能力"
:icon="settingsOutline"
icon-background="linear-gradient(135deg, #64748b 0%, #334155 100%)"
/>
@@ -441,12 +441,15 @@
</n-grid-item>
<n-grid-item>
<n-card title="AI 识别设置" class="settings-card">
<n-card title="AI 能力设置" class="settings-card">
<n-form :model="settingsForm.aiConfig" label-placement="top">
<n-form-item>
<n-switch v-model:value="settingsForm.aiConfig.enabled" />
<span class="switch-label">启用 AI 识别</span>
<span class="switch-label">启用 AI 能力</span>
</n-form-item>
<n-alert type="info" :show-icon="false" style="margin-bottom: 12px">
AI 能力总开关控制识别与连接测试AI 总结可单独开启或关闭
</n-alert>
<n-grid :cols="formCols" :x-gap="12" :y-gap="12">
<n-grid-item>
@@ -468,12 +471,20 @@
<n-input v-model:value="settingsForm.aiConfig.model" />
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item>
<n-switch v-model:value="settingsForm.aiConfig.capabilities.vision" />
<span class="switch-label">模型视觉输入</span>
</n-form-item>
</n-grid-item>
<n-grid-item>
<n-form-item label="能力开关">
<div class="switch-group switch-group--ai-capabilities-inline">
<div class="switch-group__item">
<n-switch v-model:value="settingsForm.aiConfig.capabilities.vision" />
<span class="switch-label switch-label--compact">模型视觉输入</span>
</div>
<div class="switch-group__item">
<n-switch v-model:value="settingsForm.aiConfig.dashboardSummaryEnabled" :disabled="!settingsForm.aiConfig.enabled" />
<span class="switch-label switch-label--compact">AI 总结</span>
</div>
</div>
</n-form-item>
</n-grid-item>
</n-grid>
<n-form-item label="API Base URL">
@@ -494,12 +505,20 @@
<n-form-item label="请求超时(毫秒)">
<n-input-number v-model:value="settingsForm.aiConfig.timeoutMs" :min="5000" :max="120000" style="width: 100%" />
</n-form-item>
<n-form-item label="自定义提示词">
<n-form-item label="自定义识别提示词">
<n-input
v-model:value="aiPromptInput"
type="textarea"
:autosize="{ minRows: 6, maxRows: 12 }"
placeholder="未修改或为空时,会继续使用系统预设提示词"
placeholder="留空时,订阅识别使用系统识别提示词;仪表盘 AI 总结始终使用系统总结提示词"
/>
</n-form-item>
<n-form-item label="自定义总结提示词">
<n-input
v-model:value="dashboardSummaryPromptInput"
type="textarea"
:autosize="{ minRows: 6, maxRows: 12 }"
placeholder="留空时AI 总结使用系统预设总结提示词"
/>
</n-form-item>
</n-collapse-item>
@@ -538,7 +557,7 @@
<n-space vertical style="width: 100%">
<n-card size="small" embedded title="备份">
<n-space vertical style="width: 100%">
<div class="card-muted">支持通过 ZIP 进行导出备份与恢复备份包含订阅标签支付记录排序系统设置与本地 Logo</div>
<div class="card-muted">支持通过 ZIP 进行备份与恢复包含订阅标签支付记录排序系统设置与本地 Logo</div>
<n-space wrap>
<n-button type="primary" @click="exportBackup">导出备份</n-button>
<n-button type="success" ghost @click="showSubtrackerBackupModal = true">恢复备份</n-button>
@@ -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<NotificationWebhookSettings>({
const snapshot = ref<ExchangeRateSnapshot | null>(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;

View File

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

View File

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

View File

@@ -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 识别')
})
})

View File

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

View File

@@ -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 天续订密集')
})
})

View File

@@ -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<typeof StorageCapabilitiesSchema>
export type LogoSearchInput = z.infer<typeof LogoSearchSchema>
export type LogoUploadInput = z.infer<typeof LogoUploadSchema>
export type AiRecognizeSubscriptionInput = z.infer<typeof AiRecognizeSubscriptionSchema>
export type AiDashboardSummaryStatus = z.infer<typeof AiDashboardSummaryStatusSchema>
export type WallosImportInspectInput = z.infer<typeof WallosImportInspectSchema>
export type WallosImportCommitInput = z.infer<typeof WallosImportCommitSchema>
export type SubtrackerBackupScope = z.infer<typeof SubtrackerBackupScopeSchema>
@@ -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