feat:添加AI功能

This commit is contained in:
2025-11-13 14:43:48 +08:00
parent bce8bdf9ef
commit 5cf6de697b
11 changed files with 408 additions and 14 deletions

View File

@@ -11,3 +11,12 @@ NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
GEMINI_API_KEY=
OCR_PROJECT_ID=
FILE_STORAGE_BUCKET=
# DeepSeek AI (OpenAI compatible)
DEEPSEEK_API_KEY=
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
# Feature flags
AI_ENABLE_CLASSIFICATION=false
AI_AUTO_LEARN_RULES=false

View File

@@ -15,6 +15,12 @@ NOTIFICATION_WEBHOOK_SECRET=bf921ce9ac433ba2fdfccbccb50e7d805ac89f166962e4b8437a
NOTIFICATION_ALLOWED_PACKAGES=com.eg.android.AlipayGphone,com.tencent.mm
# Optional integrations (leave blank if unused)
GEMINI_API_KEY=
OCR_PROJECT_ID=
FILE_STORAGE_BUCKET=
# DeepSeek AI (OpenAI compatible)
DEEPSEEK_API_KEY=sk-aa152188952b4708a087693af9082721
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
# Feature flags
AI_ENABLE_CLASSIFICATION=false
AI_AUTO_LEARN_RULES=false

View File

@@ -19,6 +19,7 @@
"express-rate-limit": "^7.1.5",
"helmet": "^7.1.0",
"jsonwebtoken": "^9.0.2",
"openai": "^4.58.1",
"mongoose": "^7.6.0",
"multer": "^1.4.5-lts.1",
"pino": "^9.5.0",

View File

@@ -28,6 +28,11 @@ const envSchema = z
})
.transform((values) => ({
...values,
DEEPSEEK_API_KEY: (values as any).DEEPSEEK_API_KEY as string | undefined,
DEEPSEEK_BASE_URL: ((values as any).DEEPSEEK_BASE_URL as string | undefined) ?? 'https://api.deepseek.com',
DEEPSEEK_MODEL: ((values as any).DEEPSEEK_MODEL as string | undefined) ?? 'deepseek-chat',
AI_ENABLE_CLASSIFICATION: ((values as any).AI_ENABLE_CLASSIFICATION as string | undefined) === 'true',
AI_AUTO_LEARN_RULES: ((values as any).AI_AUTO_LEARN_RULES as string | undefined) === 'true',
isProduction: values.NODE_ENV === 'production',
notificationPackageWhitelist: values.NOTIFICATION_ALLOWED_PACKAGES
? values.NOTIFICATION_ALLOWED_PACKAGES.split(',').map((pkg) => pkg.trim()).filter(Boolean)

View File

@@ -145,4 +145,3 @@ export async function applyNotificationRules(input: {
return { ignore: false, overrides: {} };
}

View File

@@ -11,6 +11,9 @@ import {
ingestNotification
} from './transactions.service.js';
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
import { aiClassifyByText, aiSuggestRuleForNotification } from '../../services/ai.service.js';
import { createRule } from '../notifications/notification-rules.service.js';
import { TransactionModel } from '../../models/transaction.model.js';
export const listTransactionsHandler = async (req: Request, res: Response) => {
if (!req.user) {
@@ -104,3 +107,91 @@ export const createTransactionFromNotificationHandler = async (req: Request, res
data: transaction
});
};
export const aiClassifyTransactionHandler = async (req: Request, res: Response) => {
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
const id = req.params.id;
const txn = await getTransactionById(id, req.user.id);
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
const bodyText = (txn.notes as string | undefined) ?? (txn.metadata as any)?.rawBody ?? '';
const ai = await aiClassifyByText(txn.title, bodyText);
if (!ai || (!ai.category && !ai.type && !ai.amount)) {
return res.status(200).json({ data: txn, message: 'No AI classification available' });
}
const updated = await updateTransaction(id, {
category: ai.category ?? txn.category,
type: (ai.type as any) ?? txn.type,
amount: ai.amount ?? txn.amount
} as UpdateTransactionInput, req.user.id);
return res.json({ data: updated });
};
export const createBlacklistRuleFromTransactionHandler = async (req: Request, res: Response) => {
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
const id = req.params.id;
const txn = await getTransactionById(id, req.user.id);
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
const pkg = (txn.metadata as any)?.packageName as string | undefined;
const title = txn.title;
if (!pkg || !title) return res.status(400).json({ message: 'Missing notification metadata' });
const rule = await createRule({
name: `黑名单-${pkg}-${title.substring(0, 10)}`,
enabled: true,
priority: 95,
packageName: pkg,
keywords: [title],
action: 'ignore'
} as any);
return res.status(201).json({ data: rule });
};
export const createRuleFromTransactionHandler = async (req: Request, res: Response) => {
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
const id = req.params.id;
const txn = await getTransactionById(id, req.user.id);
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
const pkg = (txn.metadata as any)?.packageName as string | undefined;
const title = txn.title;
if (!pkg || !title) return res.status(400).json({ message: 'Missing notification metadata' });
const rule = await createRule({
name: `规则-${pkg}-${title.substring(0, 10)}`,
enabled: true,
priority: 80,
packageName: pkg,
keywords: [title],
action: 'record',
category: (txn as any).category,
type: (txn as any).type
} as any);
return res.status(201).json({ data: rule });
};
export const aiSuggestRuleFromTransactionHandler = async (req: Request, res: Response) => {
if (!req.user) return res.status(401).json({ message: 'Authentication required' });
const id = req.params.id;
const txn = await getTransactionById(id, req.user.id);
if (!txn) return res.status(404).json({ message: 'Transaction not found' });
if (txn.source !== 'notification') return res.status(400).json({ message: 'Only notification transactions supported' });
const pkg = (txn.metadata as any)?.packageName as string | undefined;
const body = (txn.metadata as any)?.rawBody as string | undefined;
const title = txn.title;
if (!pkg) return res.status(400).json({ message: 'Missing notification metadata' });
const suggestion = await aiSuggestRuleForNotification({ packageName: pkg, title: title ?? '', body: body ?? '' });
if (!suggestion) return res.status(200).json({ message: 'No AI suggestion' });
const rule = await createRule({
name: suggestion.name ?? `AI规则-${pkg}-${Date.now()}`,
enabled: suggestion.enabled ?? false,
priority: suggestion.priority ?? 60,
packageName: pkg,
keywords: suggestion.keywords ?? [],
pattern: suggestion.pattern ?? undefined,
action: suggestion.action ?? 'record',
category: suggestion.category ?? undefined,
type: suggestion.type ?? undefined,
amountPattern: suggestion.amountPattern ?? undefined
} as any);
return res.status(201).json({ data: rule });
};

View File

@@ -13,7 +13,11 @@ import {
getTransactionHandler,
updateTransactionHandler,
deleteTransactionHandler,
createTransactionFromNotificationHandler
createTransactionFromNotificationHandler,
aiClassifyTransactionHandler,
createBlacklistRuleFromTransactionHandler,
createRuleFromTransactionHandler,
aiSuggestRuleFromTransactionHandler
} from './transactions.controller.js';
const router = Router();
@@ -27,5 +31,9 @@ router.post('/', validateRequest({ body: createTransactionSchema }), createTrans
router.get('/:id', validateRequest({ params: transactionIdSchema }), getTransactionHandler);
router.patch('/:id', validateRequest({ params: transactionIdSchema, body: updateTransactionSchema }), updateTransactionHandler);
router.delete('/:id', validateRequest({ params: transactionIdSchema }), deleteTransactionHandler);
router.post('/:id/ai-classify', validateRequest({ params: transactionIdSchema }), aiClassifyTransactionHandler);
router.post('/:id/rules/blacklist', validateRequest({ params: transactionIdSchema }), createBlacklistRuleFromTransactionHandler);
router.post('/:id/rules/from-notification', validateRequest({ params: transactionIdSchema }), createRuleFromTransactionHandler);
router.post('/:id/rules/ai-suggest', validateRequest({ params: transactionIdSchema }), aiSuggestRuleFromTransactionHandler);
export default router;

View File

@@ -7,7 +7,9 @@ import { getChannelByPackage } from '../notifications/notification-channels.serv
import { getDefaultUserId } from '../../services/user-context.js';
import type { CreateTransactionInput, UpdateTransactionInput } from './transactions.schema.js';
import { parseNotificationPayload } from './notification.parser.js';
import { applyNotificationRules } from '../notifications/notification-rules.service.js';
import { applyNotificationRules, createRule } from '../notifications/notification-rules.service.js';
import { aiClassifyByText, aiSuggestRuleForNotification } from '../../services/ai.service.js';
import { env } from '../../config/env.js';
export interface TransactionDto {
id: string;
@@ -187,6 +189,14 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
const parsed = parseNotificationPayload(payload.title ?? '', payload.body ?? '', mergedTemplate);
// Optionally enhance with AI classification
if (env.AI_ENABLE_CLASSIFICATION && !parsed.category) {
const ai = await aiClassifyByText(payload.title ?? '', payload.body ?? '');
if (ai?.category) parsed.category = ai.category;
if (ai?.type) parsed.type = ai.type;
if (ai?.amount && (!parsed.amount || parsed.amount === 0)) parsed.amount = ai.amount;
}
const hashSource = `${payload.packageName}|${payload.title}|${payload.body}|${now.getTime()}`;
const notificationHash = createHash('sha256').update(hashSource).digest('hex');
@@ -219,5 +229,36 @@ export async function ingestNotification(payload: NotificationPayload): Promise<
};
const userId = await getDefaultUserId();
return createTransaction(transactionPayload, userId);
const created = await createTransaction(transactionPayload, userId);
// Auto-learn rule (as draft) in background
if (env.AI_AUTO_LEARN_RULES) {
void (async () => {
try {
const suggestion = await aiSuggestRuleForNotification({
packageName: payload.packageName,
title: payload.title ?? '',
body: payload.body ?? ''
});
if (suggestion && (suggestion.keywords?.length || suggestion.pattern)) {
await createRule({
name: suggestion.name ?? `AI规则-${payload.packageName}-${Date.now()}`,
enabled: suggestion.enabled ?? false,
priority: suggestion.priority ?? 60,
packageName: payload.packageName,
keywords: suggestion.keywords ?? [],
pattern: suggestion.pattern ?? undefined,
action: suggestion.action ?? 'record',
category: suggestion.category ?? undefined,
type: suggestion.type ?? undefined,
amountPattern: suggestion.amountPattern ?? undefined
} as any);
}
} catch (e) {
// swallow errors
}
})();
}
return created;
}

View File

@@ -0,0 +1,102 @@
import OpenAI from 'openai';
import { env } from '../config/env.js';
import { logger } from '../config/logger.js';
const ai = env.DEEPSEEK_API_KEY
? new OpenAI({ apiKey: env.DEEPSEEK_API_KEY, baseURL: env.DEEPSEEK_BASE_URL })
: null;
export interface AiClassification {
category?: string;
type?: 'income' | 'expense';
amount?: number;
confidence?: number; // 0-1
}
export interface AiRuleSuggestion {
enabled?: boolean;
action?: 'record' | 'ignore';
name?: string;
priority?: number;
packageName?: string;
keywords?: string[];
pattern?: string;
category?: string;
type?: 'income' | 'expense';
amountPattern?: string;
confidence?: number; // 0-1
}
function safeJson<T = unknown>(text: string): T | null {
try {
return JSON.parse(text) as T;
} catch {
return null;
}
}
export async function aiClassifyByText(title: string, body?: string): Promise<AiClassification | null> {
if (!ai) return null;
const prompt = `你是一个财务助手。请从通知文本中判断账单类型与分类,并尽量提取金额。
只返回JSON不要任何多余解释。例如{"category":"餐饮","type":"expense","amount":25.5,"confidence":0.8}
常见分类:餐饮、交通、购物、生活缴费、医疗、教育、娱乐、转账、退款、理财、公益、收入、其他支出。
如果无法判断保持相应字段为空或省略confidence 介于 0 到 1。
标题: ${title}\n正文: ${body ?? ''}`;
try {
const res = await ai.chat.completions.create({
model: env.DEEPSEEK_MODEL,
messages: [
{ role: 'system', content: 'You are a helpful assistant that outputs only JSON.' },
{ role: 'user', content: prompt }
]
});
const content = res.choices?.[0]?.message?.content ?? '';
const parsed = safeJson<AiClassification>(content);
return parsed ?? null;
} catch (error) {
logger.warn({ error }, 'AI classify failed');
return null;
}
}
export async function aiSuggestRuleForNotification(input: {
packageName: string;
title: string;
body: string;
}): Promise<AiRuleSuggestion | null> {
if (!ai) return null;
const prompt = `你是一个通知解析助手。根据通知标题与正文生成一个用于匹配的规则建议返回纯JSON
{
"enabled": false,
"action": "record" | "ignore",
"name": "简短规则名",
"priority": 60,
"keywords": ["多个关键词可选"],
"pattern": "可选的正则表达式(不要加分隔符)",
"category": "可选 分类",
"type": "income" | "expense",
"amountPattern": "可选,金额捕获组",
"confidence": 0.0-1.0
}
说明keywords 与 pattern 二选一或都给pattern 不要包含 /;若该通知应忽略,请 action 设为 "ignore"。
标题: ${input.title}\n正文: ${input.body}\n包名: ${input.packageName}`;
try {
const res = await ai.chat.completions.create({
model: env.DEEPSEEK_MODEL,
messages: [
{ role: 'system', content: 'You are a helpful assistant that outputs only JSON.' },
{ role: 'user', content: prompt }
]
});
const content = res.choices?.[0]?.message?.content ?? '';
const parsed = safeJson<AiRuleSuggestion>(content);
return parsed ?? null;
} catch (error) {
logger.warn({ error }, 'AI rule suggestion failed');
return null;
}
}

View File

@@ -5,6 +5,7 @@ import LucideIcon from '../../../components/common/LucideIcon.vue';
import { useTransactionQuery } from '../../../composables/useTransaction';
import { useUpdateTransactionMutation } from '../../../composables/useTransactions';
import { PRESET_CATEGORIES } from '../../../lib/categories';
import { apiClient } from '../../../lib/api/client';
const route = useRoute();
const router = useRouter();
@@ -27,6 +28,53 @@ const saveCategory = async () => {
await updateMutation.mutateAsync({ id: txn.value.id, payload: { category: editing.category || '未分类' } });
await query.refetch();
};
const feedback = ref<string | null>(null);
const show = (msg: string) => {
feedback.value = msg;
setTimeout(() => (feedback.value = null), 2000);
};
const useAiClassify = async () => {
if (!txn.value?.id) return;
try {
await apiClient.post(`/transactions/${txn.value.id}/ai-classify`);
await query.refetch();
show('已使用 AI 分类');
} catch (e) {
show('AI 分类失败');
}
};
const createBlacklistRule = async () => {
if (!txn.value?.id) return;
try {
await apiClient.post(`/transactions/${txn.value.id}/rules/blacklist`);
show('已加入黑名单规则');
} catch (e) {
show('创建黑名单失败');
}
};
const createRuleFromNotification = async () => {
if (!txn.value?.id) return;
try {
await apiClient.post(`/transactions/${txn.value.id}/rules/from-notification`);
show('已根据该通知创建规则');
} catch (e) {
show('创建规则失败');
}
};
const aiSuggestRule = async () => {
if (!txn.value?.id) return;
try {
await apiClient.post(`/transactions/${txn.value.id}/rules/ai-suggest`);
show('已创建 AI 规则草案');
} catch (e) {
show('AI 规则分析失败');
}
};
</script>
<template>
@@ -78,7 +126,7 @@ const saveCategory = async () => {
</div>
</div>
<div v-if="isNotification" class="bg-white rounded-2xl p-6 border border-gray-100">
<div v-if="isNotification" class="bg-white rounded-2xl p-6 border border-gray-100 space-y-4">
<h3 class="text-base font-semibold text-gray-900 mb-4">手工分类</h3>
<div class="space-y-3">
<div class="flex items-center space-x-3">
@@ -97,6 +145,16 @@ const saveCategory = async () => {
</button>
</div>
</div>
<div class="h-px bg-gray-100"></div>
<h3 class="text-base font-semibold text-gray-900">快捷操作</h3>
<div class="grid grid-cols-2 gap-3">
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="useAiClassify">使用 AI 分类</button>
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="createBlacklistRule">避免此类通知</button>
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="createRuleFromNotification">使用该通知生成规则</button>
<button class="px-3 py-2 border border-gray-200 rounded-xl text-sm" @click="aiSuggestRule">AI 分析规则</button>
</div>
<p v-if="feedback" class="text-xs text-emerald-600 text-right">{{ feedback }}</p>
</div>
<div v-if="txn.metadata && Object.keys(txn.metadata).length" class="bg-white rounded-2xl p-6 border border-gray-100">

86
pnpm-lock.yaml generated
View File

@@ -40,6 +40,9 @@ importers:
multer:
specifier: ^1.4.5-lts.1
version: 1.4.5-lts.2
openai:
specifier: ^4.58.1
version: 4.104.0(zod@3.25.76)
pino:
specifier: ^9.5.0
version: 9.14.0
@@ -1308,6 +1311,19 @@ packages:
'@types/express': 4.17.25
dev: true
/@types/node-fetch@2.6.13:
resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
dependencies:
'@types/node': 24.9.2
form-data: 4.0.4
dev: false
/@types/node@18.19.130:
resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
dependencies:
undici-types: 5.26.5
dev: false
/@types/node@20.19.24:
resolution: {integrity: sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA==}
dependencies:
@@ -1657,7 +1673,6 @@ packages:
engines: {node: '>=6.5'}
dependencies:
event-target-shim: 5.0.1
dev: true
/accepts@1.3.8:
resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
@@ -1691,6 +1706,13 @@ packages:
resolution: {integrity: sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==}
dev: true
/agentkeepalive@4.6.0:
resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
engines: {node: '>= 8.0.0'}
dependencies:
humanize-ms: 1.2.1
dev: false
/aggregate-error@3.1.0:
resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==}
engines: {node: '>=8'}
@@ -2932,7 +2954,6 @@ packages:
/event-target-shim@5.0.1:
resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
engines: {node: '>=6'}
dev: true
/events-universal@1.0.1:
resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==}
@@ -3145,6 +3166,10 @@ packages:
cross-spawn: 7.0.6
signal-exit: 4.1.0
/form-data-encoder@1.7.2:
resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
dev: false
/form-data@4.0.4:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
@@ -3156,6 +3181,14 @@ packages:
mime-types: 2.1.35
dev: false
/formdata-node@4.4.1:
resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
engines: {node: '>= 12.20'}
dependencies:
node-domexception: 1.0.0
web-streams-polyfill: 4.0.0-beta.3
dev: false
/formidable@3.5.4:
resolution: {integrity: sha512-YikH+7CUTOtP44ZTnUhR7Ic2UASBPOqmaRkRKxRbywPTe5VxF7RRCck4af9wutiZ/QKM5nME9Bie2fFaPz5Gug==}
engines: {node: '>=14.0.0'}
@@ -3489,6 +3522,12 @@ packages:
statuses: 2.0.1
toidentifier: 1.0.1
/humanize-ms@1.2.1:
resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
dependencies:
ms: 2.1.3
dev: false
/iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
engines: {node: '>=0.10.0'}
@@ -4279,6 +4318,12 @@ packages:
resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==}
dev: true
/node-domexception@1.0.0:
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
engines: {node: '>=10.5.0'}
deprecated: Use your platform's native DOMException instead
dev: false
/node-fetch@2.7.0:
resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
engines: {node: 4.x || >=6.0.0}
@@ -4289,7 +4334,6 @@ packages:
optional: true
dependencies:
whatwg-url: 5.0.0
dev: true
/node-html-parser@5.4.2:
resolution: {integrity: sha512-RaBPP3+51hPne/OolXxcz89iYvQvKOydaqoePpOgXcrOKZhjVIzmpKZz+Hd/RBO2/zN2q6CNJhQzucVz+u3Jyw==}
@@ -4374,6 +4418,30 @@ packages:
is-docker: 2.2.1
is-wsl: 2.2.0
/openai@4.104.0(zod@3.25.76):
resolution: {integrity: sha512-p99EFNsA/yX6UhVO93f5kJsDRLAg+CTA2RBqdHK4RtK8u5IJw32Hyb2dTGKbnnFmnuoBv5r7Z2CURI9sGZpSuA==}
hasBin: true
peerDependencies:
ws: ^8.18.0
zod: ^3.23.8
peerDependenciesMeta:
ws:
optional: true
zod:
optional: true
dependencies:
'@types/node': 18.19.130
'@types/node-fetch': 2.6.13
abort-controller: 3.0.0
agentkeepalive: 4.6.0
form-data-encoder: 1.7.2
formdata-node: 4.4.1
node-fetch: 2.7.0
zod: 3.25.76
transitivePeerDependencies:
- encoding
dev: false
/optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -5646,7 +5714,6 @@ packages:
/tr46@0.0.3:
resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
dev: true
/tr46@3.0.0:
resolution: {integrity: sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==}
@@ -5788,6 +5855,10 @@ packages:
dev: true
optional: true
/undici-types@5.26.5:
resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
dev: false
/undici-types@6.21.0:
resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
@@ -5962,9 +6033,13 @@ packages:
'@vue/shared': 3.5.22
typescript: 5.9.3
/web-streams-polyfill@4.0.0-beta.3:
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
engines: {node: '>= 14'}
dev: false
/webidl-conversions@3.0.1:
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
dev: true
/webidl-conversions@7.0.0:
resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==}
@@ -5984,7 +6059,6 @@ packages:
dependencies:
tr46: 0.0.3
webidl-conversions: 3.0.1
dev: true
/which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}