diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..ce3f0032e672669cdaab5dc608cf270e0c47b633 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +node_modules +npm-debug.log +.env +.git +.gitignore +Dockerfile +render.yaml +tts_fastapi diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..7fc07bae76f17af66d3a19e46c4899823421852b --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Backend API URL +VITE_API_URL=http://localhost:5000 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..4108b33e7b3aae71ade8d7e209a72f99f17c3485 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..78752ac36906f744b54e0330fef2568b7e980ffe --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +# Build Stage +FROM node:20-slim AS build +WORKDIR /app +COPY package*.json ./ +RUN npm install +COPY . . +RUN npm run build + +# Production Stage +FROM nginx:stable-alpine +COPY --from=build /app/dist /usr/share/nginx/html +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/README.md b/README.md index e137726cc1497f047a785bb0bf00fb5f1feb9058..9cb4cc5b6a4b0515935b28397ddaa16391adc00f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,16 @@ ---- -title: ML LT -emoji: 🌖 -colorFrom: pink -colorTo: purple -sdk: docker -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# React + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project. diff --git a/config/app.config.js b/config/app.config.js new file mode 100644 index 0000000000000000000000000000000000000000..06a53e07baeb8a2dbd18fe9452982eade30fa798 --- /dev/null +++ b/config/app.config.js @@ -0,0 +1,5 @@ +export default { + PORT: process.env.PORT || 5000, + NODE_ENV: process.env.NODE_ENV || 'development', + CORS_ORIGIN: process.env.CORS_ORIGIN || '*' +}; diff --git a/config/brave.config.js b/config/brave.config.js new file mode 100644 index 0000000000000000000000000000000000000000..c7a600143958112eb74e5c8cd4b45f4af6ecafa8 --- /dev/null +++ b/config/brave.config.js @@ -0,0 +1,4 @@ +export default { + API_KEY: process.env.BRAVE_API_KEY, + BASE_URL: 'https://api.search.brave.com/res/v1/web/search' +}; diff --git a/config/gemini.config.js b/config/gemini.config.js new file mode 100644 index 0000000000000000000000000000000000000000..07d3e0c949b3f84b91cf102b11eba30f9b9f219b --- /dev/null +++ b/config/gemini.config.js @@ -0,0 +1,4 @@ +export default { + API_KEY: process.env.GEMINI_API_KEY, + MODEL: 'gemini-2.5-flash' +}; diff --git a/config/puppeteer.config.js b/config/puppeteer.config.js new file mode 100644 index 0000000000000000000000000000000000000000..003e094e125fe776abb8c78e1a95a96263d61b66 --- /dev/null +++ b/config/puppeteer.config.js @@ -0,0 +1,6 @@ +export default { + HEADLESS: 'new', + ARGS: ['--no-sandbox', '--disable-setuid-sandbox'], + TIMEOUT: 60000, + WAIT_UNTIL: 'domcontentloaded' +}; diff --git a/config/supabase.config.js b/config/supabase.config.js new file mode 100644 index 0000000000000000000000000000000000000000..dbb69af2e068cd7c50729dc59f2d7c866f484bda --- /dev/null +++ b/config/supabase.config.js @@ -0,0 +1,5 @@ +export default { + URL: process.env.SUPABASE_URL || '', + KEY: process.env.SUPABASE_KEY || '', + TTS_BUCKET: process.env.SUPABASE_TTS_BUCKET || 'tts_audio' +}; diff --git a/config/tts.config.js b/config/tts.config.js new file mode 100644 index 0000000000000000000000000000000000000000..0e7be232f79a6d1882567da48f9285996899793b --- /dev/null +++ b/config/tts.config.js @@ -0,0 +1,4 @@ +export default { + SERVICE_URL: process.env.TTS_SERVICE_URL || 'http://127.0.0.1:8001', + TIMEOUT_MS: Number(process.env.TTS_SERVICE_TIMEOUT_MS || 600000) +}; diff --git a/controllers/gemini.controller.js b/controllers/gemini.controller.js new file mode 100644 index 0000000000000000000000000000000000000000..f8f0a58c34a35ad5ba891deb4c6e76f4b7f73a5d --- /dev/null +++ b/controllers/gemini.controller.js @@ -0,0 +1,42 @@ +import geminiService from '../services/gemini.service.js'; + +class GeminiController { + async generate(req, res) { + try { + const { prompt } = req.body; + + if (!prompt) { + return res.status(400).json({ error: 'Prompt is required' }); + } + + const data = await geminiService.generateContent(prompt); + res.json(data); + } catch (error) { + console.error('Gemini Error:', error.message); + res.status(500).json({ + error: 'Failed to generate content', + details: error.message + }); + } + } + + async summarize(req, res) { + try { + const { content, title } = req.body; + + if (!content) { + return res.status(400).json({ error: 'Content is required' }); + } + + const data = await geminiService.summarizeNews(content, title); + res.json(data); + } catch (error) { + console.error('Gemini Summarize Error:', error.message); + res.status(500).json({ + error: 'Failed to summarize content', + details: error.message + }); + } + } +} +export default new GeminiController(); \ No newline at end of file diff --git a/controllers/scrape.controller.js b/controllers/scrape.controller.js new file mode 100644 index 0000000000000000000000000000000000000000..79c2b0151800ba3422398ab3e5e84ae1e9a7a38e --- /dev/null +++ b/controllers/scrape.controller.js @@ -0,0 +1,24 @@ +import scrapeService from '../services/scrape.service.js'; + +class ScrapeController { + async scrape(req, res) { + try { + const { url } = req.body; + + if (!url) { + return res.status(400).json({ error: 'URL is required' }); + } + + const data = await scrapeService.scrapeUrl(url); + res.json(data); + } catch (error) { + console.error('Scrape Error:', error.message); + res.status(500).json({ + error: 'Failed to scrape URL', + details: error.message + }); + } + } +} + +export default new ScrapeController(); diff --git a/controllers/search.controller.js b/controllers/search.controller.js new file mode 100644 index 0000000000000000000000000000000000000000..9dd6c1a4b293ae7d8335be792385635b4fbb380f --- /dev/null +++ b/controllers/search.controller.js @@ -0,0 +1,261 @@ +import braveService from '../services/brave.service.js'; +import scrapeService from '../services/scrape.service.js'; +import geminiService from '../services/gemini.service.js'; +import pLimit from 'p-limit'; + +// Max 5 URLs, max 3 concurrent scrapes (axios is fast; Puppeteer fallback is slow) +const MAX_SCRAPE_URLS = 5; +const scrapeLimit = pLimit(3); + +// Wrap a promise with a hard timeout +const withTimeout = (promise, ms, label) => + Promise.race([ + promise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Timeout after ${ms}ms: ${label}`)), ms) + ), + ]); + +class SearchController { + async search(req, res) { + try { + const { query, language, freshness } = req.body; + + if (!query) { + return res.status(400).json({ error: 'Query is required' }); + } + + const options = {}; + if (language) options.language = language; + if (freshness) options.freshness = freshness; + + const data = await braveService.search(query, options); + res.json(data); + } catch (error) { + console.error('Search Error:', error.message); + res.status(500).json({ + error: 'Failed to perform search', + details: error.response?.data || error.message + }); + } + } + + async searchAndSummarize(req, res) { + try { + const { query, language, freshness } = req.body; + + if (!query) { + return res.status(400).json({ error: 'Query is required' }); + } + + const options = {}; + if (language) options.language = language; + if (freshness) options.freshness = freshness; + + // 1. Search bằng Brave + console.log('[BRAVE API] Starting search request...'); + let searchData; + try { + searchData = await braveService.search(query, options); + console.log('[BRAVE API] Search successful, found results'); + } catch (err) { + console.error('[BRAVE API] ERROR:', err.message); + console.error('[BRAVE API] Status:', err.response?.status); + console.error('[BRAVE API] Details:', err.response?.data); + + if (err.response?.status === 429) { + return res.status(429).json({ + error: 'Rate limit exceeded', + api: 'Brave Search API', + details: 'Vuot qua gioi han API Brave Search. Vui long thu lai sau it phut.' + }); + } + throw err; + } + + // 2. Lấy URLs từ kết quả (ưu tiên news, fallback về web) + let urls = []; + if (searchData.news?.results && searchData.news.results.length > 0) { + urls = searchData.news.results.slice(0, MAX_SCRAPE_URLS).map(r => r.url); + } else if (searchData.web?.results && searchData.web.results.length > 0) { + urls = searchData.web.results + .filter(r => r.type === 'search_result') + .slice(0, MAX_SCRAPE_URLS) + .map(r => r.url); + } + + if (urls.length === 0) { + return res.status(404).json({ error: 'Khong tim thay ket qua nao' }); + } + + // 3. Scrape tất cả URLs bằng Puppeteer + console.log(`[PUPPETEER] Scraping ${urls.length} articles...`); + const scrapePromises = urls.map(async (url, index) => { + try { + const scraped = await scrapeService.scrapeUrl(url); + console.log(`[PUPPETEER] Successfully scraped: ${url}`); + return { + title: scraped.title || `Bai ${index + 1}`, + source: new URL(url).hostname, + content: scraped.text || '', + url: url + }; + } catch (err) { + console.error(`[PUPPETEER] Failed to scrape ${url}:`, err.message); + return null; + } + }); + + const articles = (await Promise.all(scrapePromises)).filter(a => a !== null); + console.log(`[PUPPETEER] Successfully scraped ${articles.length}/${urls.length} articles`); + + if (articles.length === 0) { + return res.status(500).json({ error: 'Khong the scrape duoc bai bao nao' }); + } + + // 4. Gửi tất cả vào Gemini để tóm tắt + console.log(`[GEMINI API] Starting summarization of ${articles.length} articles...`); + let summary; + try { + summary = await geminiService.summarizeMultipleNews(articles, query); + console.log('[GEMINI API] Summarization successful'); + } catch (err) { + console.error('[GEMINI API] ERROR:', err.message); + console.error('[GEMINI API] Status:', err.response?.status); + console.error('[GEMINI API] Details:', err.response?.data); + + if (err.message.includes('PROHIBITED_CONTENT')) { + return res.status(400).json({ + error: 'Prohibited content', + api: 'Google Gemini API', + details: 'Nội dung không phù hợp. Gemini AI đã chặn nội dung này vì vi phạm tiêu chuẩn an toàn.' + }); + } + + if (err.response?.status === 429 || err.message.includes('429')) { + return res.status(429).json({ + error: 'Rate limit exceeded', + api: 'Google Gemini API', + details: 'Vuot qua gioi han API Gemini. Vui long thu lai sau it phut.' + }); + } + throw err; + } + + console.log('[SUCCESS] Request completed successfully'); + res.json({ + summary: summary.summary, + totalArticles: summary.totalArticles, + articles: articles.map(a => ({ + title: a.title, + source: a.source, + url: a.url + })) + }); + } catch (error) { + console.error('[ERROR] Search and Summarize Error:', error.message); + console.error('[ERROR] Stack:', error.stack); + + res.status(500).json({ + error: 'Failed to search and summarize', + details: error.response?.data?.error || error.message + }); + } + } + + async scrapeAndSummarize(req, res) { + const tTotal = Date.now(); + try { + const { urls: rawUrls, query } = req.body; + + if (!rawUrls || !Array.isArray(rawUrls) || rawUrls.length === 0) { + return res.status(400).json({ error: 'URLs array is required' }); + } + + // Cap to MAX_SCRAPE_URLS to avoid long waits + const urls = rawUrls.slice(0, MAX_SCRAPE_URLS); + console.log(`\n${'='.repeat(60)}`); + console.log(`[REQUEST] scrapeAndSummarize — ${urls.length} URLs, query="${query?.substring(0,40)}"`); + urls.forEach((u, i) => console.log(` [${i+1}] ${u.substring(0, 80)}`)); + + // 1. Scrape URLs (axios fast path, Puppeteer fallback) with 30s total timeout + const tScrapeStart = Date.now(); + console.log(`[SCRAPE] ⏳ Starting scrape of ${urls.length} URLs (concurrency: 3)...`); + const scrapeWork = Promise.all( + urls.map((url, index) => + scrapeLimit(async () => { + const t = Date.now(); + try { + const scraped = await scrapeService.scrapeUrl(url); + console.log(`[SCRAPE] ✅ [${index+1}/${urls.length}] ${Date.now()-t}ms — ${url.substring(0, 60)}`); + return { + title: scraped.title || `Bài ${index + 1}`, + source: new URL(url).hostname, + content: scraped.text || '', + url, + }; + } catch (err) { + console.error(`[SCRAPE] ❌ [${index+1}/${urls.length}] ${Date.now()-t}ms — ${url.substring(0, 60)}: ${err.message}`); + return null; + } + }) + ) + ); + + const rawArticles = await withTimeout(scrapeWork, 30000, 'scrapeAndSummarize'); + const articles = rawArticles.filter((a) => a !== null); + console.log(`[SCRAPE] 🏁 Done: ${articles.length}/${urls.length} OK in ${Date.now()-tScrapeStart}ms (total elapsed: ${Date.now()-tTotal}ms)`); + + if (articles.length === 0) { + return res.status(500).json({ error: 'Khong the scrape duoc bai bao nao' }); + } + + // 2. Send to Gemini + const tGeminiStart = Date.now(); + console.log(`[GEMINI] ⏳ Summarizing ${articles.length} articles...`); + let summary; + try { + summary = await geminiService.summarizeMultipleNews(articles, query || ''); + console.log(`[GEMINI] ✅ Done in ${Date.now()-tGeminiStart}ms (total elapsed: ${Date.now()-tTotal}ms)`); + } catch (err) { + console.error('[GEMINI API] ERROR:', err.message); + console.error('[GEMINI API] Status:', err.response?.status); + console.error('[GEMINI API] Details:', err.response?.data); + + if (err.message.includes('PROHIBITED_CONTENT')) { + return res.status(400).json({ + error: 'Prohibited content', + api: 'Google Gemini API', + details: 'Nội dung không phù hợp. Gemini AI đã chặn nội dung này vì vi phạm tiêu chuẩn an toàn.' + }); + } + + if (err.response?.status === 429 || err.message.includes('429')) { + return res.status(429).json({ + error: 'Rate limit exceeded', + api: 'Google Gemini API', + details: 'Vuot qua gioi han API Gemini. Vui long thu lai sau it phut.' + }); + } + throw err; + } + + console.log(`[SUCCESS] 🏁 scrapeAndSummarize done in ${Date.now()-tTotal}ms total`); + console.log('='.repeat(60)); + res.json({ + summary: summary.summary, + totalArticles: summary.totalArticles + }); + } catch (error) { + console.error('[ERROR] Scrape and Summarize Error:', error.message); + console.error('[ERROR] Stack:', error.stack); + + res.status(500).json({ + error: 'Failed to scrape and summarize', + details: error.message + }); + } + } +} + +export default new SearchController(); diff --git a/controllers/tts.controller.js b/controllers/tts.controller.js new file mode 100644 index 0000000000000000000000000000000000000000..cd8f637fe741309a92052d6e47d4c7b7edd54dfc --- /dev/null +++ b/controllers/tts.controller.js @@ -0,0 +1,142 @@ +import ttsService from '../services/tts.service.js'; +import ttsJobService from '../services/ttsJob.service.js'; +import audioTranscodeService from '../services/audioTranscode.service.js'; + +class TtsController { + handleServiceError(error, res, defaultMessage) { + console.error('[TTS Bridge] Error:', error.message); + + if (error.response) { + return res.status(error.response.status).json({ + error: defaultMessage, + details: error.response.data + }); + } + + return res.status(500).json({ + error: defaultMessage, + details: error.message + }); + } + + async health(req, res) { + try { + const data = await ttsService.health(); + return res.json(data); + } catch (error) { + return this.handleServiceError(error, res, 'Failed to reach TTS service'); + } + } + + async synthesize(req, res) { + try { + const { text, language, speaker_audio } = req.body; + + if (!text) { + return res.status(400).json({ error: 'Text is required' }); + } + + const data = await ttsService.synthesize({ + text, + language, + speaker_audio + }); + + if (!data?.audio_base64) { + return res.status(502).json({ error: 'TTS service returned empty audio payload' }); + } + + const wavBuffer = Buffer.from(data.audio_base64, 'base64'); + const mp3Buffer = await audioTranscodeService.convertWavToMp3(wavBuffer); + + const responsePayload = { + ...data, + audio_base64: mp3Buffer.toString('base64'), + format: 'mp3', + mime_type: 'audio/mpeg', + size_bytes: mp3Buffer.length + }; + + return res.json(responsePayload); + } catch (error) { + return this.handleServiceError(error, res, 'Failed to synthesize speech'); + } + } + + async createTask(req, res) { + try { + const { text, language, speaker_audio } = req.body; + + if (!text) { + return res.status(400).json({ error: 'Text is required' }); + } + + const data = await ttsService.createTask({ + text, + language, + speaker_audio + }); + + return res.status(202).json(data); + } catch (error) { + return this.handleServiceError(error, res, 'Failed to dispatch TTS task'); + } + } + + async getTask(req, res) { + try { + const { taskId } = req.params; + + if (!taskId) { + return res.status(400).json({ error: 'taskId is required' }); + } + + const data = await ttsService.getTask(taskId); + return res.json(data); + } catch (error) { + return this.handleServiceError(error, res, 'Failed to fetch TTS task status'); + } + } + + async createStorageJob(req, res) { + try { + const { text, language, speaker_audio } = req.body; + + if (!text) { + return res.status(400).json({ error: 'Text is required' }); + } + + const data = ttsJobService.createJob({ + text, + language, + speaker_audio + }); + + return res.status(202).json(data); + } catch (error) { + return this.handleServiceError(error, res, 'Failed to create TTS storage job'); + } + } + + async getStorageJob(req, res) { + try { + const { key } = req.params; + + if (!key) { + return res.status(400).json({ error: 'key is required' }); + } + + const data = ttsJobService.getJob(key); + + if (!data) { + return res.status(404).json({ error: 'TTS job not found' }); + } + + return res.json(data); + } catch (error) { + return this.handleServiceError(error, res, 'Failed to fetch TTS storage job'); + } + } +} + +export default new TtsController(); diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000000000000000000000000000000000000..848071916e43c8128c61fbb20017656ed5f9bc06 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,29 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{js,jsx}'], + extends: [ + js.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + rules: { + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + }, + }, +]) diff --git a/index.html b/index.html new file mode 100644 index 0000000000000000000000000000000000000000..203644fdc61777fcde38041ffb1eccc7c4fdcb7c --- /dev/null +++ b/index.html @@ -0,0 +1,37 @@ + + + + + + + NewsAI - Digital Curator + + + + + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..66cf389549c183e5914914ece4755503b05128f0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3264 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "axios": "^1.13.5", + "quill": "^2.0.3", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.3.1" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", + "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", + "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz", + "integrity": "sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.27.3.tgz", + "integrity": "sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz", + "integrity": "sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.27.3.tgz", + "integrity": "sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz", + "integrity": "sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz", + "integrity": "sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz", + "integrity": "sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz", + "integrity": "sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz", + "integrity": "sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz", + "integrity": "sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz", + "integrity": "sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz", + "integrity": "sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz", + "integrity": "sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz", + "integrity": "sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz", + "integrity": "sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz", + "integrity": "sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz", + "integrity": "sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz", + "integrity": "sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz", + "integrity": "sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz", + "integrity": "sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz", + "integrity": "sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz", + "integrity": "sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz", + "integrity": "sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz", + "integrity": "sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz", + "integrity": "sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz", + "integrity": "sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz", + "integrity": "sha512-Kr+LPIUVKz2qkx1HAMH8q1q6azbqBAsXJUxBl/ODDuVPX45Z9DfwB8tPjTi6nNZ8BuM3nbJxC5zCAg5elnBUTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.4", + "debug": "^4.3.2", + "espree": "^10.0.1", + "globals": "^14.0.0", + "ignore": "^5.2.0", + "import-fresh": "^3.2.1", + "js-yaml": "^4.1.1", + "minimatch": "^3.1.2", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/eslintrc/node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.2.tgz", + "integrity": "sha512-q1mjIoW1VX4IvSocvM/vbTiveKC4k9eLrajNEuSsmjymSDEbpGddtpfOoN7YGAqBK3NG+uqo8ia4PDTt8buCYA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", + "integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.1", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-rc.3", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", + "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz", + "integrity": "sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz", + "integrity": "sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz", + "integrity": "sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz", + "integrity": "sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz", + "integrity": "sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz", + "integrity": "sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz", + "integrity": "sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz", + "integrity": "sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz", + "integrity": "sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz", + "integrity": "sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz", + "integrity": "sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz", + "integrity": "sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz", + "integrity": "sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz", + "integrity": "sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz", + "integrity": "sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz", + "integrity": "sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz", + "integrity": "sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz", + "integrity": "sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz", + "integrity": "sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz", + "integrity": "sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz", + "integrity": "sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz", + "integrity": "sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz", + "integrity": "sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz", + "integrity": "sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz", + "integrity": "sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.13", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.13.tgz", + "integrity": "sha512-KkiJeU6VbYbUOp5ITMIc7kBfqlYkKA5KhEHVrGMmUUMt7NeaZg65ojdPk+FtNrBAOXNVM5QM72jnADjM+XVRAQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "5.1.4", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", + "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.29.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-rc.3", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.18.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/acorn": { + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.9.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", + "integrity": "sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.1", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", + "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.9.0", + "caniuse-lite": "^1.0.30001759", + "electron-to-chromium": "^1.5.263", + "node-releases": "^2.0.27", + "update-browserslist-db": "^1.2.0" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001769", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001769.tgz", + "integrity": "sha512-BCfFL1sHijQlBGWBMuJyhZUhzo7wer5sVj9hqekB/7xn0Ypy+pER/edCYQm4exbXj4WiySGp40P8UuTh6w1srg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.286", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.286.tgz", + "integrity": "sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.3", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.3.tgz", + "integrity": "sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.3", + "@esbuild/android-arm": "0.27.3", + "@esbuild/android-arm64": "0.27.3", + "@esbuild/android-x64": "0.27.3", + "@esbuild/darwin-arm64": "0.27.3", + "@esbuild/darwin-x64": "0.27.3", + "@esbuild/freebsd-arm64": "0.27.3", + "@esbuild/freebsd-x64": "0.27.3", + "@esbuild/linux-arm": "0.27.3", + "@esbuild/linux-arm64": "0.27.3", + "@esbuild/linux-ia32": "0.27.3", + "@esbuild/linux-loong64": "0.27.3", + "@esbuild/linux-mips64el": "0.27.3", + "@esbuild/linux-ppc64": "0.27.3", + "@esbuild/linux-riscv64": "0.27.3", + "@esbuild/linux-s390x": "0.27.3", + "@esbuild/linux-x64": "0.27.3", + "@esbuild/netbsd-arm64": "0.27.3", + "@esbuild/netbsd-x64": "0.27.3", + "@esbuild/openbsd-arm64": "0.27.3", + "@esbuild/openbsd-x64": "0.27.3", + "@esbuild/openharmony-arm64": "0.27.3", + "@esbuild/sunos-x64": "0.27.3", + "@esbuild/win32-arm64": "0.27.3", + "@esbuild/win32-ia32": "0.27.3", + "@esbuild/win32-x64": "0.27.3" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.2", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz", + "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.1", + "@eslint/js": "9.39.2", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.12.4", + "chalk": "^4.0.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "esquery": "^1.5.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "lodash.merge": "^4.6.2", + "minimatch": "^3.1.2", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.0.1.tgz", + "integrity": "sha512-O0d0m04evaNzEPoSW+59Mezf8Qt0InfgGIBJnpC0h3NH/WjUAR7BIKUfysC6todmtiZ/A0oUVS8Gce0WhBrHsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.4.26", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.26.tgz", + "integrity": "sha512-1RETEylht2O6FM/MvgnyvT+8K21wLqDNg4qD51Zj3guhjt433XbnnkVttHMyaVyAFD03QSV4LPS5iE3VQmO7XQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": ">=8.40" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/eventemitter3": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz", + "integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==", + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", + "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.5.0.tgz", + "integrity": "sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash-es": { + "version": "4.17.23", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz", + "integrity": "sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg==", + "license": "MIT" + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.27", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", + "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parchment": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz", + "integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==", + "license": "BSD-3-Clause" + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "license": "MIT" + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/quill": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz", + "integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==", + "license": "BSD-3-Clause", + "dependencies": { + "eventemitter3": "^5.0.1", + "lodash-es": "^4.17.21", + "parchment": "^3.0.0", + "quill-delta": "^5.1.0" + }, + "engines": { + "npm": ">=8.2.3" + } + }, + "node_modules/quill-delta": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz", + "integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==", + "license": "MIT", + "dependencies": { + "fast-diff": "^1.3.0", + "lodash.clonedeep": "^4.5.0", + "lodash.isequal": "^4.5.0" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/react": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.4" + } + }, + "node_modules/react-refresh": { + "version": "0.18.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", + "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/rollup": { + "version": "4.57.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", + "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.57.1", + "@rollup/rollup-android-arm64": "4.57.1", + "@rollup/rollup-darwin-arm64": "4.57.1", + "@rollup/rollup-darwin-x64": "4.57.1", + "@rollup/rollup-freebsd-arm64": "4.57.1", + "@rollup/rollup-freebsd-x64": "4.57.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.57.1", + "@rollup/rollup-linux-arm-musleabihf": "4.57.1", + "@rollup/rollup-linux-arm64-gnu": "4.57.1", + "@rollup/rollup-linux-arm64-musl": "4.57.1", + "@rollup/rollup-linux-loong64-gnu": "4.57.1", + "@rollup/rollup-linux-loong64-musl": "4.57.1", + "@rollup/rollup-linux-ppc64-gnu": "4.57.1", + "@rollup/rollup-linux-ppc64-musl": "4.57.1", + "@rollup/rollup-linux-riscv64-gnu": "4.57.1", + "@rollup/rollup-linux-riscv64-musl": "4.57.1", + "@rollup/rollup-linux-s390x-gnu": "4.57.1", + "@rollup/rollup-linux-x64-gnu": "4.57.1", + "@rollup/rollup-linux-x64-musl": "4.57.1", + "@rollup/rollup-openbsd-x64": "4.57.1", + "@rollup/rollup-openharmony-arm64": "4.57.1", + "@rollup/rollup-win32-arm64-msvc": "4.57.1", + "@rollup/rollup-win32-ia32-msvc": "4.57.1", + "@rollup/rollup-win32-x64-gnu": "4.57.1", + "@rollup/rollup-win32-x64-msvc": "4.57.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/vite": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz", + "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.27.0", + "fdir": "^6.5.0", + "picomatch": "^4.0.3", + "postcss": "^8.5.6", + "rollup": "^4.43.0", + "tinyglobby": "^0.2.15" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "lightningcss": "^1.21.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", + "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000000000000000000000000000000000000..4476669105ffc901c42ed3b7248edf7fb52a1ae6 --- /dev/null +++ b/package.json @@ -0,0 +1,29 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "axios": "^1.13.5", + "quill": "^2.0.3", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@eslint/js": "^9.39.1", + "@types/react": "^19.2.7", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^5.1.1", + "eslint": "^9.39.1", + "eslint-plugin-react-hooks": "^7.0.1", + "eslint-plugin-react-refresh": "^0.4.24", + "globals": "^16.5.0", + "vite": "^7.3.1" + } +} diff --git a/public/vite.svg b/public/vite.svg new file mode 100644 index 0000000000000000000000000000000000000000..e7b8dfb1b2a60bd50538bec9f876511b9cac21e3 --- /dev/null +++ b/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/routes/gemini.routes.js b/routes/gemini.routes.js new file mode 100644 index 0000000000000000000000000000000000000000..d3aaf9bd637cbe3a9f236483f3c0986672832f96 --- /dev/null +++ b/routes/gemini.routes.js @@ -0,0 +1,9 @@ +import express from 'express'; +import geminiController from '../controllers/gemini.controller.js'; + +const router = express.Router(); + +router.post('/gemini', geminiController.generate.bind(geminiController)); +router.post('/gemini/summarize', geminiController.summarize.bind(geminiController)); + +export default router; diff --git a/routes/index.js b/routes/index.js new file mode 100644 index 0000000000000000000000000000000000000000..7d8043abd779c5ee3a5715e4341e169cf856e0da --- /dev/null +++ b/routes/index.js @@ -0,0 +1,20 @@ +import express from 'express'; +import searchRoutes from './search.routes.js'; +import scrapeRoutes from './scrape.routes.js'; +import geminiRoutes from './gemini.routes.js'; +import ttsRoutes from './tts.routes.js'; + +const router = express.Router(); + +// Health check +router.get('/health', (req, res) => { + res.json({ status: 'ok', message: 'Server is running' }); +}); + +// Mount routes +router.use('/', searchRoutes); +router.use('/', scrapeRoutes); +router.use('/', geminiRoutes); +router.use('/', ttsRoutes); + +export default router; diff --git a/routes/scrape.routes.js b/routes/scrape.routes.js new file mode 100644 index 0000000000000000000000000000000000000000..86d4bdfb6a66e362d9dc15f49df2ea579bba539f --- /dev/null +++ b/routes/scrape.routes.js @@ -0,0 +1,8 @@ +import express from 'express'; +import scrapeController from '../controllers/scrape.controller.js'; + +const router = express.Router(); + +router.post('/scrape', scrapeController.scrape.bind(scrapeController)); + +export default router; diff --git a/routes/search.routes.js b/routes/search.routes.js new file mode 100644 index 0000000000000000000000000000000000000000..5c32db2c13cf0c01c43c644523075ccda9338daa --- /dev/null +++ b/routes/search.routes.js @@ -0,0 +1,10 @@ +import express from 'express'; +import searchController from '../controllers/search.controller.js'; + +const router = express.Router(); + +router.post('/search', searchController.search.bind(searchController)); +router.post('/search-summarize', searchController.searchAndSummarize.bind(searchController)); +router.post('/scrape-summarize', searchController.scrapeAndSummarize.bind(searchController)); + +export default router; diff --git a/routes/tts.routes.js b/routes/tts.routes.js new file mode 100644 index 0000000000000000000000000000000000000000..6abf207dd7dfef64220d1bba8bb290129075e862 --- /dev/null +++ b/routes/tts.routes.js @@ -0,0 +1,13 @@ +import express from 'express'; +import ttsController from '../controllers/tts.controller.js'; + +const router = express.Router(); + +router.get('/tts/health', ttsController.health.bind(ttsController)); +router.post('/tts/synthesize', ttsController.synthesize.bind(ttsController)); +router.post('/tts/tasks', ttsController.createTask.bind(ttsController)); +router.get('/tts/tasks/:taskId', ttsController.getTask.bind(ttsController)); +router.post('/tts/jobs', ttsController.createStorageJob.bind(ttsController)); +router.get('/tts/jobs/:key', ttsController.getStorageJob.bind(ttsController)); + +export default router; diff --git a/server.js b/server.js new file mode 100644 index 0000000000000000000000000000000000000000..deea9b50ea2263fe48cc07393ab5775ca28e0666 --- /dev/null +++ b/server.js @@ -0,0 +1,22 @@ +import 'dotenv/config'; +import express from 'express'; +import cors from 'cors'; +import appConfig from './config/app.config.js'; +import routes from './routes/index.js'; + +const app = express(); + +// Middleware +app.use(cors({ + origin: appConfig.CORS_ORIGIN +})); +app.use(express.json()); + +// Mount API routes +app.use('/api', routes); + +// Start server +app.listen(appConfig.PORT, () => { + console.log(`Server is running on port ${appConfig.PORT}`); + console.log(`Environment: ${appConfig.NODE_ENV}`); +}); diff --git a/services/audioTranscode.service.js b/services/audioTranscode.service.js new file mode 100644 index 0000000000000000000000000000000000000000..d8c92b27295840932f58d51411ce6862360e5cea --- /dev/null +++ b/services/audioTranscode.service.js @@ -0,0 +1,71 @@ +import { spawn } from 'child_process'; +import ffmpegStaticPath from 'ffmpeg-static'; + +const DEFAULT_MP3_BITRATE = process.env.TTS_MP3_BITRATE || '64k'; + +class AudioTranscodeService { + constructor() { + this.ffmpegCommand = ffmpegStaticPath || process.env.FFMPEG_PATH || 'ffmpeg'; + } + + async convertWavToMp3(wavBuffer) { + if (!wavBuffer || wavBuffer.length === 0) { + throw new Error('WAV buffer is empty'); + } + + return new Promise((resolve, reject) => { + const ffmpeg = spawn(this.ffmpegCommand, [ + '-hide_banner', + '-loglevel', + 'error', + '-f', + 'wav', + '-i', + 'pipe:0', + '-vn', + '-codec:a', + 'libmp3lame', + '-b:a', + DEFAULT_MP3_BITRATE, + '-f', + 'mp3', + 'pipe:1' + ]); + + const outputChunks = []; + const errorChunks = []; + + ffmpeg.stdout.on('data', (chunk) => outputChunks.push(chunk)); + ffmpeg.stderr.on('data', (chunk) => errorChunks.push(chunk)); + + ffmpeg.on('error', (error) => { + reject( + new Error( + `Cannot run ffmpeg command "${this.ffmpegCommand}": ${error.message}` + ) + ); + }); + + ffmpeg.on('close', (code) => { + if (code !== 0) { + const stderr = Buffer.concat(errorChunks).toString('utf8').trim(); + reject(new Error(stderr || `ffmpeg exited with code ${code}`)); + return; + } + + const mp3Buffer = Buffer.concat(outputChunks); + if (!mp3Buffer.length) { + reject(new Error('ffmpeg produced an empty MP3 output')); + return; + } + + resolve(mp3Buffer); + }); + + ffmpeg.stdin.write(wavBuffer); + ffmpeg.stdin.end(); + }); + } +} + +export default new AudioTranscodeService(); diff --git a/services/brave.service.js b/services/brave.service.js new file mode 100644 index 0000000000000000000000000000000000000000..69008bd7dd5db7511c48850a44eb6ace0abba3f9 --- /dev/null +++ b/services/brave.service.js @@ -0,0 +1,50 @@ +import axios from 'axios'; +import braveConfig from '../config/brave.config.js'; + +class BraveService { + async search(query, options = {}) { + if (!braveConfig.API_KEY) { + throw new Error('Brave API key not configured'); + } + + const params = { + q: query, + search_lang: 'vi', + count: 10 + }; + + // Add freshness filter if provided + if (options.freshness) { + params.freshness = options.freshness; + } + + console.log('[BRAVE API] Request params:', JSON.stringify(params, null, 2)); + + const response = await axios.get(braveConfig.BASE_URL, { + params, + headers: { + 'Accept': 'application/json', + 'X-Subscription-Token': braveConfig.API_KEY + } + }).catch(err => { + if (err.response?.status === 429) { + throw new Error('Brave API rate limit exceeded. Please try again later.'); + } + throw err; + }); + + console.log('[BRAVE API] Response keys:', Object.keys(response.data)); + console.log('[BRAVE API] Has news results:', !!response.data.news?.results); + console.log('[BRAVE API] Has web results:', !!response.data.web?.results); + if (response.data.news?.results) { + console.log('[BRAVE API] News results count:', response.data.news.results.length); + } + if (response.data.web?.results) { + console.log('[BRAVE API] Web results count:', response.data.web.results.length); + } + + return response.data; + } +} + +export default new BraveService(); diff --git a/services/gemini.service.js b/services/gemini.service.js new file mode 100644 index 0000000000000000000000000000000000000000..5fd6ec4e04edcf3b55a0dd2a1729f73efdb4f49e --- /dev/null +++ b/services/gemini.service.js @@ -0,0 +1,135 @@ +import { GoogleGenerativeAI } from '@google/generative-ai'; +import geminiConfig from '../config/gemini.config.js'; + +class GeminiService { + constructor() { + if (!geminiConfig.API_KEY) { + console.warn('Gemini API key not configured'); + } else { + this.genAI = new GoogleGenerativeAI(geminiConfig.API_KEY); + } + } + + async generateContent(prompt) { + if (!geminiConfig.API_KEY) { + throw new Error('Gemini API key not configured'); + } + + const model = this.genAI.getGenerativeModel({ model: geminiConfig.MODEL }); + const result = await model.generateContent(prompt); + const response = await result.response; + const text = response.text(); + + return { text }; + } + + async summarizeNews(content, title = '') { + if (!geminiConfig.API_KEY) { + throw new Error('Gemini API key not configured'); + } + + const customInstruction = ` + Nhiệm vụ: Dưới đây là bài tin tức. Hãy biên tập lại TOÀN BỘ các tin này thành một bản tin tổng hợp ngắn gọn, không được thiếu bất kì bài nào. + +Yêu cầu biên tập: +0. Chỉ lấy nội dung dài nhất của một bài viết để tóm tắt, tránh bị lạc đề do quảng cáo trong bài. +1. Văn phong: Chính luận, trang trọng, gãy gọn, dứt khoát (đặc trưng của bản tin Thời sự). +2. Cấu trúc: + - Nhóm các tin liên quan lại với nhau (nếu có). + - Mỗi tin được tóm lược thành 2-3 câu, rõ ràng, dễ hiểu. +3. Độ dài: Mỗi tin khoảng 50-80 từ. +4. Tuyệt đối khách quan, không lồng ghép cảm xúc cá nhân. +5. Loại bỏ các bài trùng nội dung nếu có. +6. Chỉ trả về nội dung bài nói, bắt đầu bằng cách nói bản tổng hợp này có gì, các đoạn sau +đọc phải có câu chuyển (ví dụ: "Tiếp theo là tin về...", "Chuyển sang tin tiếp theo...", "Tin cuối cùng...") và topic sentence. +7. Không chú thích gì cả, phản hồi trông như lời nói của biên tập viên +8. Không dùng formal markdown, chỉ phản hồi thuần văn bản. + + Dữ liệu đầu vào: + - Tiêu đề gốc: ${title} + - Nội dung gốc: + ${content} + +Hãy bắt đầu bản tin: +`; + + const model = this.genAI.getGenerativeModel({ model: geminiConfig.MODEL }); + const result = await model.generateContent(customInstruction); + const response = await result.response; + const text = response.text(); + + return { summary: text }; + } + + async summarizeMultipleNews(articles, query = '') { + if (!geminiConfig.API_KEY) { + throw new Error('Gemini API key not configured'); + } + + // Format tất cả các bài báo thành 1 prompt + let formattedContent = `Đóng vai: Biên tập viên Ban Thời sự - Đài Truyền hình Việt Nam (VTV). +Từ khóa tìm kiếm: "${query}" +Nhiệm vụ: Dưới đây là ${articles.length} bài tin tức. Hãy biên tập lại TOÀN BỘ các tin này thành một bản tin tổng hợp ngắn gọn. + +Yêu cầu biên tập: +0. **QUAN TRỌNG**: Kiểm tra độ liên quan: + - Nếu nội dung các bài báo KHÔNG liên quan đến từ khóa tìm kiếm "${query}", hãy DỪNG LẠI và chỉ trả về: "Không tìm được bài viết liên quan đến từ khóa này." nếu query là "tin tức việt nam + ngày" thì sumary bình thường. + - Chỉ tiếp tục tóm tắt các bài báo CÓ LIÊN QUAN đến từ khóa. + - Chỉ lấy nội dung dài nhất của một bài viết để tóm tắt, tránh bị lạc đề do quảng cáo trong bài. +1. Văn phong: Chính luận, trang trọng, gãy gọn, dứt khoát (đặc trưng của bản tin Thời sự). +2. Cấu trúc: + - Nhóm các tin liên quan lại với nhau (nếu có). + - Mỗi tin được tóm lược thành 2-3 câu, rõ ràng, dễ hiểu. +3. Độ dài: Mỗi tin khoảng 50-80 từ. +4. Tuyệt đối khách quan, không lồng ghép cảm xúc cá nhân. +5. Loại bỏ các bài trùng nội dung nếu có. +6. Chỉ trả về nội dung bài nói, bắt đầu bằng cách nói bản tổng hợp này có gì, các đoạn sau +đọc phải có câu chuyển (ví dụ: "Tiếp theo là tin về...", "Chuyển sang tin tiếp theo...", "Tin cuối cùng...") và topic sentence. +7. Không chú thích gì cả, phản hồi trông như lời nói của biên tập viên +8. Không dùng formal markdown, chỉ phản hồi thuần văn bản. +--- +DỮ LIỆU ĐẦU VÀO: + +`; + + articles.forEach((article, index) => { + formattedContent += `\n[BÀI ${index + 1}]\n`; + formattedContent += `Tiêu đề: ${article.title}\n`; + formattedContent += `Nguồn: ${article.source}\n`; + formattedContent += `Nội dung:\n${article.content}\n`; + formattedContent += `\n---\n`; + }); + + formattedContent += `\n\nHãy bắt đầu bản tin tổng hợp:`; + + const model = this.genAI.getGenerativeModel({ model: geminiConfig.MODEL }); + const result = await model.generateContent(formattedContent); + const response = await result.response; + + // Check if response was blocked due to safety filters + if (!response.candidates || response.candidates.length === 0) { + throw new Error('PROHIBITED_CONTENT: Nội dung bị chặn bởi bộ lọc an toàn của Gemini AI'); + } + + const candidate = response.candidates[0]; + if (candidate.finishReason === 'SAFETY') { + const safetyRatings = candidate.safetyRatings || []; + console.log('[GEMINI API] Content blocked by safety filters:', safetyRatings); + throw new Error('PROHIBITED_CONTENT: Nội dung vi phạm tiêu chuẩn an toàn'); + } + + let text; + try { + text = response.text(); + } catch (textError) { + if (textError.message.includes('PROHIBITED_CONTENT') || textError.message.includes('blocked')) { + throw new Error('PROHIBITED_CONTENT: Nội dung không phù hợp được phát hiện'); + } + throw textError; + } + + return { summary: text, totalArticles: articles.length }; + } +} + +export default new GeminiService(); diff --git a/services/scrape.service.js b/services/scrape.service.js new file mode 100644 index 0000000000000000000000000000000000000000..6c888219a19dcd71fdc8630ecac514f7946049d1 --- /dev/null +++ b/services/scrape.service.js @@ -0,0 +1,256 @@ +import axios from 'axios'; +import * as cheerio from 'cheerio'; +import puppeteer from 'puppeteer'; +import puppeteerConfig from '../config/puppeteer.config.js'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; + +// ─── Shared browser-like headers ────────────────────────────────────────────── +const BROWSER_HEADERS = { + 'User-Agent': + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', + 'Accept': + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8', + 'Accept-Language': 'vi-VN,vi;q=0.9,en-US;q=0.8,en;q=0.7', + 'Accept-Encoding': 'gzip, deflate, br', + 'Cache-Control': 'no-cache', + 'Pragma': 'no-cache', + 'Sec-Fetch-Dest': 'document', + 'Sec-Fetch-Mode': 'navigate', + 'Sec-Fetch-Site': 'none', + 'Upgrade-Insecure-Requests': '1', +}; + +// ─── Content selectors (ordered by priority) ────────────────────────────────── +const ARTICLE_SELECTORS = [ + 'article', + '[class*="article-body"]', + '[class*="article-content"]', + '[class*="post-content"]', + '[class*="entry-content"]', + '[itemprop="articleBody"]', + '.content-detail', // VnExpress + '.fck_detail', // VnExpress + '#main-detail-body', // Tuổi Trẻ + '.detail-content', // Thanh Niên + '.singular-content', // Dân Trí + 'main', + '#content', + '.content', +]; + +// ─── Fast scrape via axios + cheerio ────────────────────────────────────────── +async function scrapeWithAxios(url) { + const t0 = Date.now(); + const shortUrl = url.substring(0, 60); + console.log(`[AXIOS] ⏳ Fetching: ${shortUrl}`); + + const response = await axios.get(url, { + headers: { ...BROWSER_HEADERS, Referer: new URL(url).origin }, + timeout: 8000, // 8s hard limit + maxRedirects: 5, + responseType: 'arraybuffer', // handle encoding correctly + }); + console.log(`[AXIOS] ✅ Got HTTP ${response.status} in ${Date.now() - t0}ms — ${shortUrl}`); + + // Detect charset from Content-Type header + const contentType = response.headers['content-type'] || ''; + const charsetMatch = contentType.match(/charset=([^\s;]+)/i); + const charset = charsetMatch ? charsetMatch[1].toLowerCase() : 'utf-8'; + + let html; + try { + html = new TextDecoder(charset).decode(response.data); + } catch { + html = new TextDecoder('utf-8').decode(response.data); + } + + const t1 = Date.now(); + const $ = cheerio.load(html); + + // Remove noise elements + $('script, style, noscript, nav, header, footer, aside, [class*="ads"], [class*="banner"], [id*="ads"], [class*="related"], [class*="comment"]').remove(); + + const title = $('title').text().trim() || $('h1').first().text().trim() || ''; + + // Try selectors in order + let text = ''; + let foundSelector = 'none'; + for (const sel of ARTICLE_SELECTORS) { + const el = $(sel).first(); + const content = el.text().replace(/\s+/g, ' ').trim(); + if (content.length > 300) { + text = content; + foundSelector = sel; + break; + } + } + + // Fallback: body text + if (!text) { + text = $('body').text().replace(/\s+/g, ' ').trim(); + foundSelector = 'body'; + } + + console.log(`[AXIOS] 📄 selector="${foundSelector}" chars=${text.length} parse=${Date.now()-t1}ms total=${Date.now()-t0}ms — ${shortUrl}`); + + return { + title, + url, + text: text.substring(0, 3000), + selector: foundSelector, + textLength: text.length, + }; +} + +// ─── Slow scrape via Puppeteer (fallback) ───────────────────────────────────── +async function scrapeWithPuppeteer(url) { + let browser = null; + let tempDir = null; + const t0 = Date.now(); + const shortUrl = url.substring(0, 60); + + try { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'puppeteer-')); + console.log(`[PUPPETEER] 🚀 Launching browser for: ${shortUrl}`); + + browser = await puppeteer.launch({ + headless: puppeteerConfig.HEADLESS, + userDataDir: tempDir, + args: [ + ...puppeteerConfig.ARGS, + '--disable-blink-features=AutomationControlled', + '--disable-dev-shm-usage', + '--no-first-run', + '--no-default-browser-check', + ], + }); + + const page = await browser.newPage(); + await page.setUserAgent(BROWSER_HEADERS['User-Agent']); + await page.setViewport({ width: 1920, height: 1080 }); + await page.setExtraHTTPHeaders({ + 'Accept-Language': BROWSER_HEADERS['Accept-Language'], + Accept: BROWSER_HEADERS['Accept'], + }); + + // Block heavy assets + await page.setRequestInterception(true); + page.on('request', (req) => { + const t = req.resourceType(); + if (['image', 'stylesheet', 'font', 'media'].includes(t)) { + req.abort(); + } else { + req.continue(); + } + }); + + await page.evaluateOnNewDocument(() => { + Object.defineProperty(navigator, 'webdriver', { get: () => false }); + window.chrome = { runtime: {} }; + }); + + const tNav = Date.now(); + console.log(`[PUPPETEER] ⏳ Navigating (browser launch took ${tNav - t0}ms)...`); + await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 25000 }); + console.log(`[PUPPETEER] ✅ DOM loaded in ${Date.now() - tNav}ms — ${shortUrl}`); + await new Promise((r) => setTimeout(r, 800)); + + const data = await page.evaluate((selectors) => { + const removeEls = document.querySelectorAll( + 'script,style,noscript,nav,header,footer,aside' + ); + removeEls.forEach((el) => el.remove()); + + const title = + document.title || + document.querySelector('h1')?.innerText || + ''; + let text = ''; + let foundSelector = 'none'; + + for (const sel of selectors) { + const el = document.querySelector(sel); + if (el && (el.innerText || '').length > 300) { + text = el.innerText; + foundSelector = sel; + break; + } + } + + if (!text) { + text = document.body?.innerText || ''; + foundSelector = 'body'; + } + + return { + title: title.trim(), + url: window.location.href, + text: text.replace(/\s+/g, ' ').trim().substring(0, 3000), + selector: foundSelector, + textLength: text.length, + }; + }, ARTICLE_SELECTORS); + + console.log(`[PUPPETEER] 📄 selector="${data.selector}" chars=${data.textLength} total=${Date.now()-t0}ms — ${shortUrl}`); + return data; + } finally { + if (browser) { + await browser.close(); + await new Promise((r) => setTimeout(r, 500)); + } + if (tempDir) { + try { + fs.rmSync(tempDir, { recursive: true, force: true }); + } catch { + // ignore cleanup errors + } + } + } +} + +const IS_PRODUCTION = process.env.NODE_ENV === 'production'; + +// ─── Public API ─────────────────────────────────────────────────────────────── +class ScrapeService { + /** + * Production: axios+cheerio only (Puppeteer uses 150-300MB RAM per instance + * which OOM-kills the process on Render's 512MB free tier). + * Development: axios first, Puppeteer fallback if content is thin/blocked. + */ + async scrapeUrl(url) { + try { + const data = await scrapeWithAxios(url); + + if (data.textLength >= 300) { + return data; + } + + // axios returned thin content + if (IS_PRODUCTION) { + console.warn(`[SCRAPE] axios thin content (${data.textLength} chars) — returning as-is (Puppeteer disabled in production)`); + return data; // return what we have; don't risk OOM + } + + console.warn(`[SCRAPE] axios thin content (${data.textLength} chars) — falling back to Puppeteer`); + } catch (err) { + const status = err.response?.status; + const msg = `${status || err.message}`; + + if (IS_PRODUCTION) { + console.warn(`[SCRAPE] axios failed (${msg}) — skipping Puppeteer in production, returning empty`); + // Return empty stub so the slot is skipped downstream + throw err; + } + + console.warn(`[SCRAPE] axios failed (${msg}) — falling back to Puppeteer`); + } + + // Development fallback only + return scrapeWithPuppeteer(url); + } +} + +export default new ScrapeService(); + diff --git a/services/tts.service.js b/services/tts.service.js new file mode 100644 index 0000000000000000000000000000000000000000..8029c6786add3c31b55ebf22d0ae6a3e221ab343 --- /dev/null +++ b/services/tts.service.js @@ -0,0 +1,33 @@ +import axios from 'axios'; +import ttsConfig from '../config/tts.config.js'; + +class TtsService { + constructor() { + this.client = axios.create({ + baseURL: ttsConfig.SERVICE_URL, + timeout: ttsConfig.TIMEOUT_MS + }); + } + + async health() { + const response = await this.client.get('/health'); + return response.data; + } + + async synthesize(payload) { + const response = await this.client.post('/v1/tts', payload); + return response.data; + } + + async createTask(payload) { + const response = await this.client.post('/v1/tasks/tts', payload); + return response.data; + } + + async getTask(taskId) { + const response = await this.client.get(`/v1/tasks/${encodeURIComponent(taskId)}`); + return response.data; + } +} + +export default new TtsService(); diff --git a/services/ttsJob.service.js b/services/ttsJob.service.js new file mode 100644 index 0000000000000000000000000000000000000000..ccc5703fd890d5b3783202373d5380c50d545600 --- /dev/null +++ b/services/ttsJob.service.js @@ -0,0 +1,143 @@ +import { randomUUID } from 'crypto'; +import { createClient } from '@supabase/supabase-js'; +import supabaseConfig from '../config/supabase.config.js'; +import audioTranscodeService from './audioTranscode.service.js'; +import ttsService from './tts.service.js'; + +const MAX_JOBS = 300; + +class TtsJobService { + constructor() { + this.jobs = new Map(); + this.supabase = null; + + if (supabaseConfig.URL && supabaseConfig.KEY) { + this.supabase = createClient(supabaseConfig.URL, supabaseConfig.KEY); + } + } + + createJob(payload) { + const key = randomUUID(); + const now = new Date().toISOString(); + + const job = { + key, + status: 'queued', + createdAt: now, + updatedAt: now, + error: null, + audioUrl: null + }; + + this.jobs.set(key, job); + this.trimJobs(); + + this.processJob(key, payload).catch((error) => { + this.updateJob(key, { + status: 'failed', + error: error.message || 'Unknown error' + }); + }); + + return { + key, + status: 'queued', + createdAt: now + }; + } + + getJob(key) { + return this.jobs.get(key) || null; + } + + updateJob(key, patch) { + const existing = this.jobs.get(key); + if (!existing) return; + + this.jobs.set(key, { + ...existing, + ...patch, + updatedAt: new Date().toISOString() + }); + } + + trimJobs() { + if (this.jobs.size <= MAX_JOBS) return; + + const removableCount = this.jobs.size - MAX_JOBS; + const keys = [...this.jobs.keys()].slice(0, removableCount); + keys.forEach((key) => this.jobs.delete(key)); + } + + async uploadAudio({ key, fileBuffer, format }) { + if (!this.supabase) { + throw new Error('Supabase is not configured. Please set SUPABASE_URL and SUPABASE_KEY'); + } + + const objectPath = `${key}.${format}`; + const contentType = format === 'mp3' ? 'audio/mpeg' : 'audio/wav'; + + const { error: uploadError } = await this.supabase.storage + .from(supabaseConfig.TTS_BUCKET) + .upload(objectPath, fileBuffer, { + contentType, + upsert: true, + cacheControl: '31536000' + }); + + if (uploadError) { + throw new Error(`Supabase upload failed: ${uploadError.message}`); + } + + const publicResult = this.supabase.storage + .from(supabaseConfig.TTS_BUCKET) + .getPublicUrl(objectPath); + + let audioUrl = publicResult?.data?.publicUrl || null; + + if (!audioUrl) { + const signedResult = await this.supabase.storage + .from(supabaseConfig.TTS_BUCKET) + .createSignedUrl(objectPath, 60 * 60 * 24 * 7); + + if (signedResult.error) { + throw new Error(`Supabase signed URL failed: ${signedResult.error.message}`); + } + + audioUrl = signedResult.data.signedUrl; + } + + return { audioUrl }; + } + + async processJob(key, payload) { + this.updateJob(key, { + status: 'processing', + error: null + }); + + const synthData = await ttsService.synthesize(payload); + + if (!synthData?.audio_base64) { + throw new Error('FastAPI did not return audio data'); + } + + const wavBuffer = Buffer.from(synthData.audio_base64, 'base64'); + const uploadBuffer = await audioTranscodeService.convertWavToMp3(wavBuffer); + const format = 'mp3'; + + const uploadResult = await this.uploadAudio({ + key, + fileBuffer: uploadBuffer, + format + }); + + this.updateJob(key, { + status: 'completed', + error: null, + audioUrl: uploadResult.audioUrl + }); + } +} + +export default new TtsJobService(); diff --git a/src/App.css b/src/App.css new file mode 100644 index 0000000000000000000000000000000000000000..3b85672ed79be35678da10cecf7840d170527e0c --- /dev/null +++ b/src/App.css @@ -0,0 +1,66 @@ +.material-symbols-outlined { + font-variation-settings: 'FILL' 0, 'wght' 450, 'GRAD' 0, 'opsz' 24; +} + +@keyframes summary-spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +@keyframes summary-shimmer { + 0% { + background-position: -220px 0; + } + 100% { + background-position: calc(220px + 100%) 0; + } +} + +.summary-panel { + backdrop-filter: blur(2px); +} + +.summary-spin { + animation: summary-spin 1.2s linear infinite; +} + +.summary-shimmer { + background: linear-gradient(90deg, #e5e7eb 0px, #f8fafc 40px, #e5e7eb 80px); + background-size: 220px 100%; + animation: summary-shimmer 1.3s linear infinite; +} + +@keyframes fade-in-up { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +main section, +main header { + animation: fade-in-up 0.45s ease-out both; +} + +main section:nth-of-type(2) { + animation-delay: 80ms; +} + +main section:nth-of-type(3) { + animation-delay: 160ms; +} + +@media (max-width: 1023px) { + main section, + main header { + animation-duration: 0.35s; + } +} diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..54a8a42c383d602ebe3cacf55ca2e89a79719f09 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,1288 @@ +import { useEffect, useRef, useState } from 'react' +import SearchBox from './components/SearchBox' +import FilterBar from './components/FilterBar' +import NewsArticle from './components/NewsArticle' +import SummaryBox from './components/SummaryBox' +import HomeNewsGrid from './components/HomeNewsGrid' +import apiService from './services/api.service' +import './App.css' + +const HISTORY_KEY = 'newsai-search-history' +const DAILY_SNAPSHOT_KEY = 'newsai-daily-snapshot' +const MAX_HISTORY_ITEMS = 6 +const SUMMARY_TTS_STORAGE_KEY = 'newsai-summary-tts-jobs' +const ARTICLE_TTS_STORAGE_KEY = 'newsai-article-tts-jobs' +const ACTIVE_TTS_STATUSES = new Set(['queued', 'processing']) + +const createIdleTtsState = () => ({ + key: '', + status: 'idle', + audioUrl: '', + error: '', + createdAt: '', + updatedAt: '', +}) + +const normalizeTtsState = (value) => { + if (!value || typeof value !== 'object') return createIdleTtsState() + + return { + key: typeof value.key === 'string' ? value.key : '', + status: typeof value.status === 'string' ? value.status : 'idle', + audioUrl: typeof value.audioUrl === 'string' ? value.audioUrl : '', + error: typeof value.error === 'string' ? value.error : '', + createdAt: typeof value.createdAt === 'string' ? value.createdAt : '', + updatedAt: typeof value.updatedAt === 'string' ? value.updatedAt : '', + } +} + +const normalizeTtsStateMap = (value) => { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + + return Object.entries(value).reduce((accumulator, [key, state]) => { + if (!key || typeof key !== 'string') return accumulator + accumulator[key] = normalizeTtsState(state) + return accumulator + }, {}) +} + +const loadTtsStateMapFromStorage = (storageKey) => { + if (typeof window === 'undefined') return {} + + try { + const raw = window.localStorage.getItem(storageKey) + if (!raw) return {} + const parsed = JSON.parse(raw) + return normalizeTtsStateMap(parsed) + } catch { + return {} + } +} + +const saveTtsStateMapToStorage = (storageKey, value) => { + if (typeof window === 'undefined') return + + try { + window.localStorage.setItem(storageKey, JSON.stringify(value)) + } catch { + // Ignore storage quota and browser privacy mode errors. + } +} + +const getSummarySignature = (text = '', voice = '') => (text.trim() + voice).toLowerCase().replace(/\s+/g, ' ').slice(0, 280) + +const getArticleTtsSlot = (articleUrl, index) => { + if (typeof articleUrl === 'string' && articleUrl.trim() && articleUrl !== '#') { + return articleUrl + } + return `local-article-${index}` +} + +const toTtsStateFromJob = (job, fallback = createIdleTtsState()) => { + return { + key: typeof job?.key === 'string' ? job.key : fallback.key, + status: typeof job?.status === 'string' ? job.status : fallback.status, + audioUrl: typeof job?.audioUrl === 'string' ? job.audioUrl : fallback.audioUrl, + error: typeof job?.error === 'string' ? job.error : '', + createdAt: typeof job?.createdAt === 'string' ? job.createdAt : fallback.createdAt, + updatedAt: typeof job?.updatedAt === 'string' ? job.updatedAt : fallback.updatedAt, + } +} + +const isSameTtsState = (left, right) => { + if (!left && !right) return true + if (!left || !right) return false + + return ( + left.key === right.key && + left.status === right.status && + left.audioUrl === right.audioUrl && + left.error === right.error && + left.createdAt === right.createdAt && + left.updatedAt === right.updatedAt + ) +} + +const getTtsErrorMessage = (error, fallbackMessage) => { + return error?.response?.data?.error || error?.response?.data?.details || error?.message || fallbackMessage +} + +const getLocalDayKey = (date = new Date()) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +}; + +const formatDateForQuery = (date = new Date()) => { + const day = String(date.getDate()).padStart(2, '0'); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const year = date.getFullYear(); + return `${day}/${month}/${year}`; +}; + +const getDailyAutoQuery = (date = new Date()) => `tin tức việt nam ${formatDateForQuery(date)}`; + +const createHistoryId = () => { + if (globalThis.crypto?.randomUUID) { + return globalThis.crypto.randomUUID(); + } + return `${Date.now()}-${Math.random().toString(16).slice(2)}`; +}; + +const normalizeHistoryEntry = (entry, index) => { + if (typeof entry === 'string') { + const query = entry.trim(); + if (!query) return null; + + return { + id: `legacy-${index}-${query.toLowerCase().replace(/\s+/g, '-')}`, + query, + createdAt: new Date(0).toISOString(), + dayKey: '', + autoDaily: false, + filters: { + voice: 'Bắc', + time: 'pd', + source: 'all', + }, + articles: [], + summary: '', + summaryReady: false, + totalArticles: 0, + }; + } + + if (!entry || typeof entry !== 'object') return null; + + const query = typeof entry.query === 'string' ? entry.query.trim() : ''; + if (!query) return null; + + return { + id: typeof entry.id === 'string' && entry.id ? entry.id : createHistoryId(), + query, + createdAt: typeof entry.createdAt === 'string' ? entry.createdAt : new Date().toISOString(), + dayKey: typeof entry.dayKey === 'string' ? entry.dayKey : '', + autoDaily: Boolean(entry.autoDaily), + filters: { + voice: entry.filters?.voice || 'Bắc', + time: entry.filters?.time || 'pd', + source: entry.filters?.source || 'all', + }, + articles: Array.isArray(entry.articles) ? entry.articles : [], + summary: typeof entry.summary === 'string' ? entry.summary : '', + summaryReady: + typeof entry.summaryReady === 'boolean' + ? entry.summaryReady + : Boolean(typeof entry.summary === 'string' && entry.summary.trim().length > 0), + totalArticles: Number.isFinite(entry.totalArticles) ? entry.totalArticles : 0, + }; +}; + +const loadHistoryFromStorage = () => { + if (typeof window === 'undefined') return []; + + try { + const rawHistory = window.localStorage.getItem(HISTORY_KEY); + if (!rawHistory) return []; + + const parsedHistory = JSON.parse(rawHistory); + if (!Array.isArray(parsedHistory)) return []; + + return parsedHistory + .map((entry, index) => normalizeHistoryEntry(entry, index)) + .filter((entry) => entry && !entry.autoDaily) + .slice(0, MAX_HISTORY_ITEMS); + } catch { + return []; + } +}; + +const loadDailySnapshotFromStorage = () => { + if (typeof window === 'undefined') return null; + + try { + const rawSnapshot = window.localStorage.getItem(DAILY_SNAPSHOT_KEY); + if (!rawSnapshot) return null; + + const parsedSnapshot = JSON.parse(rawSnapshot); + const normalizedSnapshot = normalizeHistoryEntry(parsedSnapshot, 0); + if (!normalizedSnapshot) return null; + + return { + ...normalizedSnapshot, + autoDaily: true, + }; + } catch { + return null; + } +}; + +const saveDailySnapshotToStorage = (entry) => { + if (typeof window === 'undefined') return; + + try { + if (!entry) { + window.localStorage.removeItem(DAILY_SNAPSHOT_KEY); + return; + } + + window.localStorage.setItem(DAILY_SNAPSHOT_KEY, JSON.stringify(entry)); + } catch { + // Ignore storage quota and browser privacy mode errors. + } +}; + +function App() { + const [loading, setLoading] = useState(false); + const [summaryLoading, setSummaryLoading] = useState(false); + const [error, setError] = useState(''); + const [articles, setArticles] = useState([]); + const [summary, setSummary] = useState(null); + const [totalArticles, setTotalArticles] = useState(0); + + // Individual article summaries + const [articleSummaries, setArticleSummaries] = useState({}); + const [articleSummaryLoading, setArticleSummaryLoading] = useState({}); + + // Filter states + const [voice, setVoice] = useState('Bắc'); + const [time, setTime] = useState('pd'); + const [source, setSource] = useState('all'); + const [searchQuery, setSearchQuery] = useState(''); + const [searchHistory, setSearchHistory] = useState([]); + const [historyReady, setHistoryReady] = useState(false); + const [summaryTtsJobs, setSummaryTtsJobs] = useState(() => loadTtsStateMapFromStorage(SUMMARY_TTS_STORAGE_KEY)); + const [articleTtsJobs, setArticleTtsJobs] = useState(() => loadTtsStateMapFromStorage(ARTICLE_TTS_STORAGE_KEY)); + const [isMobileSummaryOpen, setIsMobileSummaryOpen] = useState(false); + const [useDailyHomeLayout, setUseDailyHomeLayout] = useState(true); + const hasBootstrappedRef = useRef(false); + const handleSearchRef = useRef(() => {}); + + const closeMobileSummaryPanel = () => { + setIsMobileSummaryOpen(false); + }; + + const toggleMobileSummaryPanel = () => { + setIsMobileSummaryOpen((previous) => !previous); + }; + + useEffect(() => { + if (!historyReady || typeof window === 'undefined') return; + + try { + const manualHistory = searchHistory.filter((entry) => !entry.autoDaily); + window.localStorage.setItem(HISTORY_KEY, JSON.stringify(manualHistory)); + } catch { + // Ignore storage quota and browser privacy mode errors. + } + }, [searchHistory, historyReady]); + + useEffect(() => { + saveTtsStateMapToStorage(SUMMARY_TTS_STORAGE_KEY, summaryTtsJobs); + }, [summaryTtsJobs]); + + useEffect(() => { + saveTtsStateMapToStorage(ARTICLE_TTS_STORAGE_KEY, articleTtsJobs); + }, [articleTtsJobs]); + + useEffect(() => { + const pendingSummaryJobs = Object.entries(summaryTtsJobs).filter( + ([, state]) => state.key && ACTIVE_TTS_STATUSES.has(state.status) + ); + const pendingArticleJobs = Object.entries(articleTtsJobs).filter( + ([, state]) => state.key && ACTIVE_TTS_STATUSES.has(state.status) + ); + + if (pendingSummaryJobs.length === 0 && pendingArticleJobs.length === 0) { + return; + } + + let canceled = false; + let pollTimer = null; + + const fetchJobState = async (state) => { + try { + const job = await apiService.getTtsJob(state.key); + return toTtsStateFromJob(job, state); + } catch (error) { + if (error?.response?.status === 404) { + return { + ...state, + status: 'failed', + error: 'Không tìm thấy tiến trình TTS trên server.', + }; + } + + return state; + } + }; + + const pollPendingJobs = async () => { + const [summaryUpdates, articleUpdates] = await Promise.all([ + Promise.all( + pendingSummaryJobs.map(async ([slot, state]) => { + const nextState = await fetchJobState(state); + return [slot, nextState]; + }) + ), + Promise.all( + pendingArticleJobs.map(async ([slot, state]) => { + const nextState = await fetchJobState(state); + return [slot, nextState]; + }) + ), + ]); + + if (canceled) return; + + if (summaryUpdates.length > 0) { + setSummaryTtsJobs((previous) => { + let changed = false; + const next = { ...previous }; + + summaryUpdates.forEach(([slot, nextState]) => { + const currentState = previous[slot]; + if (!currentState || isSameTtsState(currentState, nextState)) return; + next[slot] = normalizeTtsState(nextState); + changed = true; + }); + + return changed ? next : previous; + }); + } + + if (articleUpdates.length > 0) { + setArticleTtsJobs((previous) => { + let changed = false; + const next = { ...previous }; + + articleUpdates.forEach(([slot, nextState]) => { + const currentState = previous[slot]; + if (!currentState || isSameTtsState(currentState, nextState)) return; + next[slot] = normalizeTtsState(nextState); + changed = true; + }); + + return changed ? next : previous; + }); + } + + pollTimer = window.setTimeout(pollPendingJobs, 2200); + }; + + pollPendingJobs(); + + return () => { + canceled = true; + if (pollTimer) { + window.clearTimeout(pollTimer); + } + }; + }, [summaryTtsJobs, articleTtsJobs]); + + const mapBraveResultToArticle = (result) => { + // Helper function to strip HTML tags + const stripHtml = (html) => { + if (!html) return ''; + const tmp = document.createElement('DIV'); + tmp.innerHTML = html; + return tmp.textContent || tmp.innerText || ''; + }; + + // Determine category color based on content or default + // Map từ Brave subtype sang UI style + const SUBTYPE_MAPPING = { + // Tin tức / Bài viết chung + article: { name: 'Tin tức', tone: 'news' }, + news: { name: 'Thời sự', tone: 'news' }, + + // Mua sắm / Sản phẩm + product: { name: 'Mua sắm', tone: 'commerce' }, + + // Ẩm thực / Công thức + recipe: { name: 'Ẩm thực', tone: 'culture' }, + + // Hỏi đáp / Diễn đàn + qa: { name: 'Hỏi đáp', tone: 'discussion' }, + discussion: { name: 'Thảo luận', tone: 'discussion' }, + + // Đánh giá / Review + review: { name: 'Review', tone: 'review' }, + + // Video (Cái này thường nằm ở type 'video_result' nhưng map luôn cho chắc) + video_result: { name: 'Video', tone: 'video' }, + + // Phim ảnh + movie: { name: 'Phim ảnh', tone: 'culture' }, + + // Mặc định (generic) + generic: { name: 'Kết quả', tone: 'generic' }, + }; + + const getCategoryStyle = (subtype) => { + const key = subtype?.toLowerCase() || 'generic'; + return SUBTYPE_MAPPING[key] || SUBTYPE_MAPPING.generic; + }; + + const style = getCategoryStyle(result.subtype); + + return { + category: style.name, + categoryTone: style.tone, + source: result.profile?.name || result.meta_url?.hostname || 'Unknown', + timeAgo: result.age || "", + title: stripHtml(result.title) || 'Không có tiêu đề', + description: stripHtml(result.description) || '', + imageUrl: result.thumbnail?.src || result.thumbnail?.original || 'https://placehold.co/400x300?text=No+Image', + imageAlt: stripHtml(result.title) || 'News image', + articleUrl: result.url || '#' + }; + }; + + const applyHistorySnapshot = (entry) => { + setError(''); + setLoading(false); + setSummaryLoading(false); + setArticleSummaries({}); + setArticleSummaryLoading({}); + + setArticles(Array.isArray(entry.articles) ? entry.articles : []); + setSummary(entry.summary || null); + setTotalArticles(entry.totalArticles || entry.articles?.length || 0); + }; + + const upsertHistoryEntry = ({ query, articles: nextArticles, summary: nextSummary, totalCount, autoDaily = false }) => { + const normalizedQuery = query.trim(); + if (!normalizedQuery || !Array.isArray(nextArticles) || nextArticles.length === 0) return; + + const entry = { + id: createHistoryId(), + query: normalizedQuery, + createdAt: new Date().toISOString(), + dayKey: getLocalDayKey(), + autoDaily, + filters: { + voice, + time, + source, + }, + articles: nextArticles, + summary: nextSummary || '', + summaryReady: Boolean(nextSummary && nextSummary.trim().length > 0), + totalArticles: totalCount || nextArticles.length, + }; + + if (autoDaily) { + saveDailySnapshotToStorage(entry); + return; + } + + setSearchHistory((previous) => { + return [entry, ...previous].slice(0, MAX_HISTORY_ITEMS); + }); + }; + + const handleHistorySelect = (entry) => { + if (!entry) return; + + const selectedEntry = searchHistory.find((item) => item.id === entry.id) || entry; + const hasSnapshot = (selectedEntry.articles?.length || 0) > 0 || Boolean(selectedEntry.summary); + const shouldUseHomeLayout = Boolean(selectedEntry.autoDaily && selectedEntry.dayKey === getLocalDayKey()); + setUseDailyHomeLayout(shouldUseHomeLayout); + + setSearchQuery(selectedEntry.query || ''); + setVoice(selectedEntry.filters?.voice || 'Bắc'); + setTime(selectedEntry.filters?.time || 'pd'); + setSource(selectedEntry.filters?.source || 'all'); + closeMobileSummaryPanel(); + + if (hasSnapshot) { + applyHistorySnapshot(selectedEntry); + + setSearchHistory((previous) => { + const filtered = previous.filter((item) => item.id !== selectedEntry.id); + return [selectedEntry, ...filtered].slice(0, MAX_HISTORY_ITEMS); + }); + return; + } + + handleSearch(selectedEntry.query, { autoDaily: false }); + }; + + const handleArticleSummarize = async (articleUrl, index) => { + const currentSummary = articleSummaries[index]; + + // If summary exists, toggle visibility + if (currentSummary?.content) { + setArticleSummaries(prev => ({ + ...prev, + [index]: { + ...prev[index], + visible: !prev[index].visible + } + })); + return; + } + + // If no summary yet, fetch it + setArticleSummaryLoading(prev => ({ ...prev, [index]: true })); + const articleTtsSlot = getArticleTtsSlot(articleUrl, index); + + try { + const data = await apiService.scrape(articleUrl); + const summaryData = await apiService.summarizeNews(data.text, data.title); + + setArticleSummaries(prev => ({ + ...prev, + [index]: { + content: summaryData.summary, + visible: true + } + })); + + setArticleTtsJobs((previous) => { + if (!previous[articleTtsSlot]) return previous; + const next = { ...previous }; + delete next[articleTtsSlot]; + return next; + }); + } catch (err) { + console.error('Article summarize error:', err); + setArticleSummaries(prev => ({ + ...prev, + [index]: { + content: 'Không thể tóm tắt bài viết này: ' + (err.response?.data?.error || err.message), + visible: true + } + })); + } finally { + setArticleSummaryLoading(prev => ({ ...prev, [index]: false })); + } + }; + + const handleGenerateSummaryTts = async () => { + const summaryText = typeof summary === 'string' ? summary.trim() : ''; + if (!summaryText) return; + + const signature = getSummarySignature(summaryText, voice); + if (!signature) return; + + const currentState = summaryTtsJobs[signature] || createIdleTtsState(); + if (ACTIVE_TTS_STATUSES.has(currentState.status)) return; + + const VOICE_MAP = { + 'Bắc': 'nam_bac.wav', + 'Nam': 'nu_nam.wav', + 'Trung': 'nam_trung.wav' + }; + + setSummaryTtsJobs((previous) => ({ + ...previous, + [signature]: { + ...normalizeTtsState(previous[signature]), + status: 'queued', + audioUrl: '', + error: '', + }, + })); + + try { + const speakerAudio = VOICE_MAP[voice] || 'nam_bac.wav'; + const createdJob = await apiService.createTtsJob(summaryText, { + language: 'vi', + speakerAudio + }); + + if (!createdJob?.key) { + throw new Error('Không nhận được key TTS từ server.'); + } + + setSummaryTtsJobs((previous) => ({ + ...previous, + [signature]: { + ...normalizeTtsState(previous[signature]), + key: createdJob.key, + status: createdJob.status || 'queued', + createdAt: createdJob.createdAt || previous[signature]?.createdAt || '', + error: '', + }, + })); + } catch (err) { + setSummaryTtsJobs((previous) => ({ + ...previous, + [signature]: { + ...normalizeTtsState(previous[signature]), + status: 'failed', + error: getTtsErrorMessage(err, 'Không thể tạo audio cho bản tóm tắt.'), + }, + })); + } + }; + + const handleGenerateArticleTts = async (articleUrl, index) => { + const summaryText = articleSummaries[index]?.content?.trim(); + const slot = getArticleTtsSlot(articleUrl, index); + + if (!summaryText || summaryText.startsWith('Không thể tóm tắt')) { + setArticleTtsJobs((previous) => ({ + ...previous, + [slot]: { + ...normalizeTtsState(previous[slot]), + status: 'failed', + error: 'Hãy tạo bản tóm tắt hợp lệ trước khi chuyển thành giọng nói.', + audioUrl: '', + }, + })); + return; + } + + const currentState = articleTtsJobs[slot] || createIdleTtsState(); + if (ACTIVE_TTS_STATUSES.has(currentState.status)) return; + + const VOICE_MAP = { + 'Bắc': 'nam_bac.wav', + 'Nam': 'nu_nam.wav', + 'Trung': 'nam_trung.wav' + }; + + try { + const speakerAudio = VOICE_MAP[voice] || 'nam_bac.wav'; + const createdJob = await apiService.createTtsJob(summaryText, { + language: 'vi', + speakerAudio + }); + + if (!createdJob?.key) { + throw new Error('Không nhận được key TTS từ server.'); + } + + setArticleTtsJobs((previous) => ({ + ...previous, + [slot]: { + ...normalizeTtsState(previous[slot]), + key: createdJob.key, + status: createdJob.status || 'queued', + createdAt: createdJob.createdAt || previous[slot]?.createdAt || '', + error: '', + }, + })); + } catch (err) { + setArticleTtsJobs((previous) => ({ + ...previous, + [slot]: { + ...normalizeTtsState(previous[slot]), + status: 'failed', + error: getTtsErrorMessage(err, 'Không thể tạo audio cho bài viết này.'), + }, + })); + } + }; + + const handleDailyCardSummarize = async (articleUrl) => { + if (!articleUrl || articleUrl === '#') return; + + setError(''); + setSummaryLoading(true); + + try { + const data = await apiService.scrape(articleUrl); + const summaryData = await apiService.summarizeNews(data.text, data.title); + setSummary(summaryData.summary || ''); + setTotalArticles(1); + setIsMobileSummaryOpen(true); + } catch (err) { + setError('Không thể tóm tắt bài viết này: ' + (err.response?.data?.error || err.message)); + } finally { + setSummaryLoading(false); + } + }; + + const handleSearch = async (rawQuery = searchQuery, options = {}) => { + const { autoDaily = false } = options; + const query = rawQuery.trim(); + if (!query) return; + + closeMobileSummaryPanel(); + setUseDailyHomeLayout(Boolean(autoDaily)); + setSearchQuery(query); + + setLoading(true); + setSummaryLoading(false); + setError(''); + setSummary(null); + setTotalArticles(0); + setArticles([]); + setArticleSummaries({}); + setArticleSummaryLoading({}); + const isUrl = /^https?:\/\//i.test(query); + let nextArticles = []; + let nextSummary = ''; + let nextTotalArticles = 0; + + try { + if (isUrl) { + // Scrape the URL and summarize + const data = await apiService.scrape(query); + console.log('Scraped data:', data); + + // Create article from scraped content + const article = { + category: "Tin tức", + categoryTone: 'news', + source: new URL(query).hostname, + timeAgo: "Vừa xong", + title: data.title || 'Không có tiêu đề', + description: data.text?.substring(0, 200) + '...' || '', + imageUrl: 'https://placehold.co/400x300?text=No+Image', + imageAlt: data.title || 'Scraped content', + articleUrl: query + }; + + nextArticles = [article]; + setArticles(nextArticles); + setLoading(false); + setSummaryLoading(true); + + // Tóm tắt bài báo đơn lẻ + try { + const summaryData = await apiService.summarizeNews(data.text, data.title); + nextSummary = summaryData.summary || ''; + nextTotalArticles = 1; + setSummary(nextSummary || null); + setTotalArticles(1); + } catch (err) { + console.error('Summarize error:', err); + setError('Không thể tóm tắt bài viết này.'); + } finally { + setSummaryLoading(false); + } + } else { + // Search first to show articles immediately + const searchOptions = { + freshness: time, + language: source === 'all' ? 'vi' : source + }; + + console.log('Searching...'); + const searchData = await apiService.search(query, searchOptions); + console.log('Search results:', searchData); + + // Check for family_friendly at the top level + if (searchData.web?.family_friendly === false || searchData.news?.family_friendly === false) { + setError('Kết quả tìm kiếm chứa nội dung không phù hợp. Vui lòng thử từ khóa khác.'); + setArticles([]); + setLoading(false); + return; + } + + // Map Brave results to articles and show immediately + let urls = []; + let hasUnsafeContent = false; + + if (searchData.news?.results && searchData.news.results.length > 0) { + // Check for unsafe content + const unsafeResults = searchData.news.results.filter(r => r.family_friendly === false); + if (unsafeResults.length > 0) { + hasUnsafeContent = true; + } + + const mappedArticles = searchData.news.results + .filter(result => { + // Loại bỏ nội dung không phù hợp + if (result.family_friendly === false) { + return false; + } + // Loại bỏ video và image results + const isVideoType = result.type === 'video_result' || result.subtype === 'video'; + const hasVideo = result.video !== undefined; + const isImage = result.type === 'image_result'; + const isVideoUrl = result.url?.includes('youtube.com') || + result.url?.includes('tiktok.com') || + result.url?.includes('youtu.be'); + return !isVideoType && !hasVideo && !isImage && !isVideoUrl; + }) + .slice(0, 10) + .map((result) => mapBraveResultToArticle(result)); + + // Check if all results were filtered out + if (mappedArticles.length === 0) { + if (hasUnsafeContent) { + setError('Kết quả tìm kiếm chứa nội dung không phù hợp. Vui lòng thử từ khóa khác.'); + } else { + setError('Không tìm thấy kết quả nào'); + } + setArticles([]); + setLoading(false); + return; + } + + nextArticles = mappedArticles; + setArticles(nextArticles); + urls = searchData.news.results + .filter(result => { + if (result.family_friendly === false) { + return false; + } + const isVideoType = result.type === 'video_result' || result.subtype === 'video'; + const hasVideo = result.video !== undefined; + const isImage = result.type === 'image_result'; + const isVideoUrl = result.url?.includes('youtube.com') || + result.url?.includes('tiktok.com') || + result.url?.includes('youtu.be'); + return !isVideoType && !hasVideo && !isImage && !isVideoUrl; + }) + .slice(0, 10) + .map(r => r.url); + } else if (searchData.web?.results && searchData.web.results.length > 0) { + // Check for unsafe content + const unsafeResults = searchData.web.results.filter(r => r.family_friendly === false); + if (unsafeResults.length > 0) { + hasUnsafeContent = true; + } + + const mappedArticles = searchData.web.results + .filter(result => { + // Loại bỏ nội dung không phù hợp + if (result.family_friendly === false) { + return false; + } + // Chỉ lấy search_result thông thường, bỏ video/image + const isSearchResult = result.type === 'search_result'; + const isVideoType = result.subtype === 'video'; + const hasVideo = result.video !== undefined; + const isImage = result.type === 'image_result'; + const isVideoUrl = result.url?.includes('youtube.com') || + result.url?.includes('tiktok.com') || + result.url?.includes('youtu.be'); + return isSearchResult && !isVideoType && !hasVideo && !isImage && !isVideoUrl; + }) + .slice(0, 10) + .map((result) => mapBraveResultToArticle(result)); + + // Check if all results were filtered out + if (mappedArticles.length === 0) { + if (hasUnsafeContent) { + setError('Kết quả tìm kiếm chứa nội dung không phù hợp. Vui lòng thử từ khóa khác.'); + } else { + setError('Không tìm thấy kết quả nào'); + } + setArticles([]); + setLoading(false); + return; + } + + nextArticles = mappedArticles; + setArticles(nextArticles); + urls = searchData.web.results + .filter(result => { + if (result.family_friendly === false) { + return false; + } + const isSearchResult = result.type === 'search_result'; + const isVideoType = result.subtype === 'video'; + const hasVideo = result.video !== undefined; + const isImage = result.type === 'image_result'; + const isVideoUrl = result.url?.includes('youtube.com') || + result.url?.includes('tiktok.com') || + result.url?.includes('youtu.be'); + return isSearchResult && !isVideoType && !hasVideo && !isImage && !isVideoUrl; + }) + .slice(0, 10) + .map(r => r.url); + } else { + setError('Không tìm thấy kết quả nào'); + setArticles([]); + setLoading(false); + return; + } + + // Now summarize in background + setLoading(false); // End search loading + setSummaryLoading(true); // Start summary loading + + try { + console.log('Summarizing articles...'); + const data = await apiService.scrapeAndSummarize(urls, query); + console.log('Summary results:', data); + + if (data.summary) { + nextSummary = data.summary; + nextTotalArticles = data.totalArticles || nextArticles.length; + setSummary(nextSummary); + setTotalArticles(nextTotalArticles); + } + } catch (err) { + console.error('Summarize error:', err); + setError('Không thể tóm tắt: ' + (err.response?.data?.error || err.message)); + } finally { + setSummaryLoading(false); + } + } + + if (nextArticles.length > 0) { + upsertHistoryEntry({ + query, + articles: nextArticles, + summary: nextSummary, + totalCount: nextTotalArticles || nextArticles.length, + autoDaily, + }); + } + } catch (err) { + setError(err.response?.data?.error || 'Đã xảy ra lỗi khi xử lý yêu cầu'); + console.error('Error:', err); + setLoading(false); + setSummaryLoading(false); + } finally { + // Ensure loading is stopped + if (isUrl) { + setLoading(false); + } + } + }; + + const handleDailyResummary = async () => { + if (loading || summaryLoading) return; + + const urls = articles + .map((article) => article.articleUrl) + .filter((url) => typeof url === 'string' && /^https?:\/\//i.test(url)); + + if (urls.length === 0) { + setError('Không có bài viết hợp lệ để tóm tắt lại.'); + return; + } + + const dailyQuery = getDailyAutoQuery(); + + setError(''); + setSummaryLoading(true); + + try { + const data = await apiService.scrapeAndSummarize(urls, dailyQuery); + const nextSummary = data.summary || ''; + + if (!nextSummary.trim()) { + setError('Không nhận được bản tóm tắt mới. Vui lòng thử lại.'); + return; + } + + const nextTotalArticles = data.totalArticles || urls.length; + setSummary(nextSummary); + setTotalArticles(nextTotalArticles); + + upsertHistoryEntry({ + query: dailyQuery, + articles, + summary: nextSummary, + totalCount: nextTotalArticles, + autoDaily: true, + }); + } catch (err) { + setError('Không thể tóm tắt lại: ' + (err.response?.data?.error || err.message)); + } finally { + setSummaryLoading(false); + } + }; + + handleSearchRef.current = handleSearch; + + const summarySignature = getSummarySignature(summary || ''); + const summaryTtsState = summarySignature + ? normalizeTtsState(summaryTtsJobs[summarySignature]) + : createIdleTtsState(); + + // Bootstrap cache on first render: + // 1) Load manual search history for sidebar + // 2) Load today's daily snapshot from dedicated storage + // 3) Otherwise trigger daily auto-search once + useEffect(() => { + if (hasBootstrappedRef.current) return; + hasBootstrappedRef.current = true; + + const persistedHistory = loadHistoryFromStorage(); + const persistedDailySnapshot = loadDailySnapshotFromStorage(); + setSearchHistory(persistedHistory); + setHistoryReady(true); + + const todayKey = getLocalDayKey(); + const dailyAutoQuery = getDailyAutoQuery(); + const todayAutoEntry = + persistedDailySnapshot && + persistedDailySnapshot.dayKey === todayKey && + persistedDailySnapshot.articles.length > 0 && + persistedDailySnapshot.summaryReady + ? persistedDailySnapshot + : null; + + if (todayAutoEntry) { + setSearchQuery(dailyAutoQuery); + setUseDailyHomeLayout(true); + setVoice(todayAutoEntry.filters?.voice || 'Bắc'); + setTime(todayAutoEntry.filters?.time || 'pd'); + setSource(todayAutoEntry.filters?.source || 'all'); + applyHistorySnapshot(todayAutoEntry); + return; + } + + setSearchQuery(dailyAutoQuery); + handleSearchRef.current(dailyAutoQuery, { autoDaily: true }); + }, []); + + return ( +
+ + +
+
+
+
+
+ neurology +
+
+

NewsAI

+

Digital Curator

+
+
+ +
+ {searchHistory.length > 0 ? searchHistory.map((item) => ( + + )) : ( +

Lịch sử tìm kiếm sẽ xuất hiện tại đây.

+ )} +
+
+ +
+
+ +
+ +
+ {error && ( +
+ {error} +
+ )} +
+
+ +
+
+
+

+ {useDailyHomeLayout ? 'Bản tin đầu ngày' : 'Kết quả tìm kiếm'} +

+ {!useDailyHomeLayout && ( +
+ Sắp xếp: Mới nhất + swap_vert +
+ )} +
+ + {useDailyHomeLayout ? ( + + ) : ( + <> + {loading && ( +
+ progress_activity +

Đang phân tích và tìm bài viết...

+
+ )} + + {!loading && articles.length === 0 && ( +
+ search +

+ Nhập từ khóa hoặc đường dẫn bài báo để bắt đầu. +

+
+ )} + + {!loading && articles.length > 0 && ( +
+ {articles.map((article, index) => ( + handleArticleSummarize(article.articleUrl, index)} + onGenerateTts={() => handleGenerateArticleTts(article.articleUrl, index)} + summary={articleSummaries[index]?.content} + summaryVisible={articleSummaries[index]?.visible} + summaryLoading={articleSummaryLoading[index]} + ttsStatus={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.status || 'idle'} + ttsAudioUrl={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.audioUrl || ''} + ttsError={articleTtsJobs[getArticleTtsSlot(article.articleUrl, index)]?.error || ''} + /> + ))} +
+ )} + + )} +
+ + +
+ +
+ +
+ +
+
+ +
event.stopPropagation()} + className={`absolute bottom-0 left-0 right-0 max-h-[88vh] overflow-y-auto rounded-t-2xl border-t-2 border-black bg-white p-4 transition-transform duration-300 ${isMobileSummaryOpen ? 'translate-y-0' : 'translate-y-full'}`} + > +
+

Tóm tắt thông minh

+ +
+ + +
+
+ + +
+
+
+ ) +} + +export default App diff --git a/src/AppHome.jsx b/src/AppHome.jsx new file mode 100644 index 0000000000000000000000000000000000000000..fd686295eaf225374c19e6d0718db912e80ba5f7 --- /dev/null +++ b/src/AppHome.jsx @@ -0,0 +1,73 @@ +import Navbar from './components/Navbar' +import Hero from './components/Hero' +import SearchBox from './components/SearchBox' +import FilterBar from './components/FilterBar' +import NewsArticle from './components/NewsArticle' +import './App.css' + +function App() { + const articles = [ + { + category: "Kinh tế", + categoryColor: "bg-blue-50 text-blue-600", + source: "VnExpress", + timeAgo: "2 giờ trước", + title: "Giá vàng SJC tiếp tục lập đỉnh mới, vượt mốc 80 triệu đồng/lượng", + description: "Sáng nay, giá vàng SJC trong nước tiếp tục đà tăng mạnh, chính thức vượt qua mốc lịch sử 80 triệu đồng mỗi lượng. Các chuyên gia nhận định biến động này chịu ảnh hưởng lớn từ thị trường thế giới và nhu cầu tích trữ cuối năm tăng cao...", + imageUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuAHQK_DJaRIYVpQqBaMCtZCwm0qJ2lIIAYE7BHijZwWo4YQGbUJWcmRrWwpzrLr7N0_w7b96S-dTcCT9QT2_hYX9e6Fn4iHCg5x4X6EhEzG8nDmPrcFEjsJFEz7lE55sy8KOyI4kMCpXEsuJoHUEhsMRrP1ZMFs5FjXnnA27RpA4EHJm4QOHmr75rlB6KkmQY1v1mxpQNj4-Zo4x8b4EuuXBIfhfYNN_L4HYMmtzSC-hnAdQCdGd5CjLC-r9Y4opNfJoYY1k9OkS1w", + imageAlt: "Vàng SJC", + voiceType: "Bắc", + articleUrl: "#" + }, + { + category: "Công nghệ", + categoryColor: "bg-green-50 text-green-600", + source: "Tuổi Trẻ", + timeAgo: "5 giờ trước", + title: "Việt Nam đặt mục tiêu phổ cập 5G vào năm 2025", + description: "Bộ Thông tin và Truyền thông vừa công bố lộ trình phổ cập mạng 5G trên toàn quốc. Theo đó, đến năm 2025, 100% dân số sẽ được tiếp cận với hạ tầng mạng di động thế hệ mới, mở đường cho phát triển kinh tế số...", + imageUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBrkQ2BBQBgsy-3HhjUw7R26kS3eKrNLBlhGWTid8HXuxX6X06qAwWsYThgHxKJxW-17cEo-28TZ0NUA3x2QQtl2PfWBmvgU8-CbbUj9R3bvyjNQPtEyH2GoCqqK73Py_sts5k24HWN5iO_OIwfdnIsa1sLHSsaqYGIrlzkuLOMyP2lfRQQnE7K4pFre3NTHZm0ZvJrwB_rzWi9AQDkT4CUPwOcCFSyLprwHNsHcQNiKI6ZbSVmXP7eljfX9JJubMcw93LZJjVMixg", + imageAlt: "Công nghệ 5G", + voiceType: "Nam", + articleUrl: "#" + }, + { + category: "Giao thông", + categoryColor: "bg-orange-50 text-orange-600", + source: "Dân Trí", + timeAgo: "8 giờ trước", + title: "Hoàn thành cao tốc Bắc - Nam đoạn Diễn Châu - Bãi Vọt", + description: "Dự án thành phần cao tốc Bắc - Nam phía Đông giai đoạn 1 đoạn Diễn Châu - Bãi Vọt đã chính thức thông xe kỹ thuật. Công trình giúp rút ngắn thời gian di chuyển từ Hà Nội về Nghệ An xuống còn hơn 3 giờ...", + imageUrl: "https://lh3.googleusercontent.com/aida-public/AB6AXuBvOWzv5Wt7dypGPCUuBQcNXQN3dD8RbYO5n_BONKalLmqWjB2uTRDhCM9BN1LhATZLw0a--nzP5bveNszmigBUh0qVhE-eJbcIsBIC9kV3cMnRp3XyDVRgUDESXrI8HaZPDNkKVWfuxBhgzxdFkDf5E_V4XUK30oiYNRaUDa4Z80G58HWM_5SrO22qJvgzHDQkFfysg2HM8ETD-R5Z0hxcMdWHV8mvAWbBTle_FP-zK6BzbZc7_gU5BS057_N-C22BADd9g4OOBA8", + imageAlt: "Cao tốc", + voiceType: "Trung", + articleUrl: "#" + } + ]; + + return ( +
+ +
+ +
+ + +
+
+

Kết quả tóm tắt mới nhất

+ {articles.map((article, index) => ( + + ))} +
+
+

+ AI có thể mắc lỗi. Hãy kiểm tra lại thông tin quan trọng. Được xây dựng bởi NewsAI Team +

+
+
+
+ ) +} + +export default App diff --git a/src/assets/react.svg b/src/assets/react.svg new file mode 100644 index 0000000000000000000000000000000000000000..6c87de9bb3358469122cc991d5cf578927246184 --- /dev/null +++ b/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/components/FilterBar.jsx b/src/components/FilterBar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a94a4f34a0d77e743b7b2fedefaa54969cce07cf --- /dev/null +++ b/src/components/FilterBar.jsx @@ -0,0 +1,57 @@ +function FilterBar({ voice, onVoiceChange, time, onTimeChange, source, onSourceChange }) { + return ( +
+ + + + + + + +
+ ); +} + +export default FilterBar; diff --git a/src/components/Hero.jsx b/src/components/Hero.jsx new file mode 100644 index 0000000000000000000000000000000000000000..ad48d0961832e90568cdc0b35101673503f685f7 --- /dev/null +++ b/src/components/Hero.jsx @@ -0,0 +1,10 @@ +function Hero() { + return ( +
+

Tóm tắt tin tức thông minh

+

Cập nhật nhanh chóng, nghe tin tức mọi lúc với giọng đọc đa vùng miền.

+
+ ); +} + +export default Hero; diff --git a/src/components/HomeNewsGrid.jsx b/src/components/HomeNewsGrid.jsx new file mode 100644 index 0000000000000000000000000000000000000000..60190ad12e8e6f1e23e96dc13db0a66506c4c826 --- /dev/null +++ b/src/components/HomeNewsGrid.jsx @@ -0,0 +1,233 @@ +import { useState } from 'react'; + +const TONE_BADGE_CLASS = { + news: 'bg-black text-white', + commerce: 'bg-emerald-100 text-emerald-700', + culture: 'bg-indigo-100 text-indigo-700', + discussion: 'bg-violet-100 text-violet-700', + review: 'bg-pink-100 text-pink-700', + video: 'bg-red-100 text-red-700', + generic: 'bg-slate-100 text-slate-700', +}; + +function HomeNewsGrid({ articles, loading, onSummarizeArticle }) { + const [copiedUrl, setCopiedUrl] = useState(''); + const featuredArticle = articles?.[0] || null; + const sideArticles = articles?.slice(1, 5) || []; + + const handleCopyLink = async (articleUrl) => { + if (!articleUrl || articleUrl === '#') return; + + try { + await navigator.clipboard.writeText(articleUrl); + setCopiedUrl(articleUrl); + window.setTimeout(() => setCopiedUrl(''), 1800); + } catch { + setCopiedUrl(''); + } + }; + + if (loading) { + return ( +
+
+
+
+
+
+
+ ); + } + + if (!featuredArticle) { + return ( +
+ search +

+ Chưa có dữ liệu tin tức đầu ngày. +

+
+ ); + } + + return ( +
+
+
+ {featuredArticle.imageAlt} +
+
+ + Featured + + + {featuredArticle.source} + +
+
+

+ {featuredArticle.title} +

+
+

+ {featuredArticle.timeAgo || 'Hôm nay'} • {featuredArticle.category} +

+ +
+ + article + + + +
+
+ {copiedUrl === featuredArticle.articleUrl && ( +

Đã copy link bài viết

+ )} +
+
+
+ +
+ {sideArticles[0] ? ( + <> +
+ + {sideArticles[0].category} + + {sideArticles[0].timeAgo || 'Hôm nay'} +
+
+ {sideArticles[0].imageAlt} +
+

{sideArticles[0].title}

+
+ {sideArticles[0].timeAgo || 'Hôm nay'} +
+ + article + + + +
+
+ {copiedUrl === sideArticles[0].articleUrl && ( +

Đã copy link bài viết

+ )} + + ) : ( +
+ newspaper +

Đang chuẩn bị tin tức đầu ngày...

+
+ )} +
+ + {sideArticles.slice(1).map((article) => ( +
+
+ {article.imageAlt} +
+
+ + {article.category} + +

{article.title}

+
+ {article.timeAgo || 'Hôm nay'} +
+ + article + + + +
+
+ {copiedUrl === article.articleUrl && ( +

Đã copy link bài viết

+ )} +
+
+ ))} +
+ ); +} + +export default HomeNewsGrid; diff --git a/src/components/HomePage.jsx b/src/components/HomePage.jsx new file mode 100644 index 0000000000000000000000000000000000000000..bd2f4b7a7ce8776fb3945a2bee6d347f9b4f7214 --- /dev/null +++ b/src/components/HomePage.jsx @@ -0,0 +1,195 @@ +import { useMemo, useState } from 'react'; + +const TONE_BADGE_CLASS = { + news: 'bg-black text-white', + commerce: 'bg-emerald-100 text-emerald-700', + culture: 'bg-indigo-100 text-indigo-700', + discussion: 'bg-violet-100 text-violet-700', + review: 'bg-pink-100 text-pink-700', + video: 'bg-red-100 text-red-700', + generic: 'bg-slate-100 text-slate-700', +}; + +function HomePage({ articles, loading, onSearch, onOpenWorkspace }) { + const [query, setQuery] = useState(''); + + const featuredArticle = articles?.[0] || null; + const sideArticles = useMemo(() => articles.slice(1, 5), [articles]); + + const handleSubmit = (event) => { + event.preventDefault(); + if (!query.trim() || !onSearch) return; + onSearch(query.trim()); + }; + + return ( +
+
+
+
+ NewsAI + +
+ + +
+
+ +
+
+

+ Chào buổi sáng, +
+ Khám phá tin tức AI hôm nay +

+ +
+ + search + + setQuery(event.target.value)} + placeholder="Tìm kiếm tin tức hoặc chủ đề AI..." + className="h-16 w-full rounded-full border border-black/10 bg-white pl-14 pr-5 text-base font-medium text-black shadow-[0px_12px_32px_rgba(25,28,30,0.06)] outline-none transition-all focus:border-black/30" + /> +
+ +
+ + +
+
+ +
+ {loading && ( +
+
+
+
+
+
+
+ )} + + {!loading && featuredArticle && ( +
+
+
+ {featuredArticle.imageAlt} +
+
+ + Featured + + + {featuredArticle.source} + +
+
+

+ {featuredArticle.title} +

+

{featuredArticle.timeAgo || 'Hôm nay'} • {featuredArticle.category}

+
+
+
+ +
+ {sideArticles[0] ? ( + <> +
+ + {sideArticles[0].category} + + {sideArticles[0].timeAgo || 'Hôm nay'} +
+
+ {sideArticles[0].imageAlt} +
+

{sideArticles[0].title}

+ + ) : ( +
+ newspaper +

Đang chuẩn bị tin tức đầu ngày...

+
+ )} +
+ + {sideArticles.slice(1).map((article) => ( +
+
+ {article.imageAlt} +
+
+ + {article.category} + +

{article.title}

+ {article.timeAgo || 'Hôm nay'} +
+
+ ))} +
+ )} + + {!loading && !featuredArticle && ( +
+ search +

+ Chưa có dữ liệu tin tức đầu ngày. Hãy thử tìm kiếm ngay. +

+
+ )} +
+
+
+ ); +} + +export default HomePage; diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx new file mode 100644 index 0000000000000000000000000000000000000000..634791efc68757ba438b9e6c8cf233ffe8c4490a --- /dev/null +++ b/src/components/Navbar.jsx @@ -0,0 +1,17 @@ +function Navbar() { + return ( + + ); +} + +export default Navbar; diff --git a/src/components/NewsArticle.jsx b/src/components/NewsArticle.jsx new file mode 100644 index 0000000000000000000000000000000000000000..2fb75988d7197a5a06570faf16d86919a522e76f --- /dev/null +++ b/src/components/NewsArticle.jsx @@ -0,0 +1,167 @@ +import { useState } from 'react'; + +const TONE_CLASSNAMES = { + news: 'bg-black text-white border-black', + commerce: 'bg-emerald-50 text-emerald-700 border-emerald-200', + culture: 'bg-indigo-50 text-indigo-700 border-indigo-200', + discussion: 'bg-violet-50 text-violet-700 border-violet-200', + review: 'bg-pink-50 text-pink-700 border-pink-200', + video: 'bg-red-50 text-red-700 border-red-200', + generic: 'bg-slate-50 text-slate-700 border-slate-200', +}; + +function NewsArticle({ + category, + categoryTone, + source, + timeAgo, + title, + description, + imageUrl, + imageAlt, + articleUrl, + onSummarize, + onGenerateTts, + summary, + summaryVisible, + summaryLoading, + ttsStatus = 'idle', + ttsAudioUrl = '', + ttsError = '', +}) { + const [copied, setCopied] = useState(false); + const hasSummary = !!summary; + const showSummary = hasSummary && summaryVisible; + const isGeneratingAudio = ttsStatus === 'queued' || ttsStatus === 'processing'; + const categoryClasses = TONE_CLASSNAMES[categoryTone] || TONE_CLASSNAMES.generic; + + const handleShare = async () => { + if (!articleUrl || articleUrl === '#') return; + + try { + await navigator.clipboard.writeText(articleUrl); + setCopied(true); + window.setTimeout(() => setCopied(false), 1800); + } catch { + setCopied(false); + } + }; + + return ( +
+
+
+ {imageAlt} +
+ +
+
+ + {category} + + {source} + + {timeAgo || 'Vừa xong'} +
+ +

+ {title} +

+ +

+ {description} +

+ +
+
+ + article + Đọc bài gốc + + + + + +
+ +
+ {copied && ( + + Đã copy + + )} + +
+
+
+
+ + {(summaryLoading || showSummary) && ( +
+ {summaryLoading ? ( +
+ progress_activity + Đang tóm tắt bài viết... +
+ ) : ( +
+
+ summarize +

Tóm tắt nhanh

+
+

{summary}

+ + {ttsError ? ( +

{ttsError}

+ ) : null} + + {ttsAudioUrl ? ( +
+
+ ) : null} +
+ )} +
+ )} +
+ ); +} + +export default NewsArticle; diff --git a/src/components/SearchBox.jsx b/src/components/SearchBox.jsx new file mode 100644 index 0000000000000000000000000000000000000000..653faa85eb672f19340fa4b4f476f58c6ce777e5 --- /dev/null +++ b/src/components/SearchBox.jsx @@ -0,0 +1,38 @@ +function SearchBox({ value, onChange, onSearch, loading }) { + const handleSubmit = (event) => { + event.preventDefault(); + if (!value?.trim() || !onSearch) return; + onSearch(value.trim()); + }; + + return ( +
+
+
+
+ search + onChange?.(event.target.value)} + disabled={loading} + placeholder="Nhập từ khóa hoặc dán URL của tin tức..." + className="w-full border-0 bg-transparent text-base font-medium text-black placeholder:text-slate-400 focus:ring-0 md:text-lg" + /> + +
+
+ + ); +} + +export default SearchBox; diff --git a/src/components/SummaryBox.jsx b/src/components/SummaryBox.jsx new file mode 100644 index 0000000000000000000000000000000000000000..7d83033cf1f1d00f09c9b6812a2bdb43b4f9387c --- /dev/null +++ b/src/components/SummaryBox.jsx @@ -0,0 +1,132 @@ +import { useState } from 'react'; + +function SummaryBox({ + summary, + totalArticles, + loading, + onResummarize, + resummarizeDisabled = false, + onGenerateTts, + ttsStatus = 'idle', + ttsAudioUrl = '', + ttsError = '', +}) { + const [copied, setCopied] = useState(false); + + const handleCopy = async () => { + if (!summary) return; + try { + await navigator.clipboard.writeText(summary); + setCopied(true); + window.setTimeout(() => setCopied(false), 2000); + } catch { + setCopied(false); + } + }; + + const renderedSummary = summary + ?.split('\n') + .map((line) => line.trim()) + .filter(Boolean); + + const hasSummary = renderedSummary?.length > 0; + const isGeneratingAudio = ttsStatus === 'queued' || ttsStatus === 'processing'; + + return ( +
+
+ +

Bản tóm tắt AI

+ {loading ? ( + + Đang tóm tắt... + + ) : totalArticles ? ( + + {totalArticles} bài + + ) : null} +
+ + {onResummarize ? ( +
+ +
+ ) : null} + + {loading ? ( +
+
+ progress_activity +

Đang tạo bản tóm tắt thông minh...

+
+
+
+
+
+
+ ) : !hasSummary ? ( +
+ auto_awesome +

+ Chọn hoặc tìm kiếm tin tức để tạo bản tóm tắt. +

+
+ ) : ( +
+
+ {renderedSummary?.map((line, index) => ( +

+ {line} +

+ ))} +
+ +
+ + +
+ + {ttsError ? ( +

{ttsError}

+ ) : null} + + {ttsAudioUrl ? ( +
+
+ ) : null} +
+ )} +
+ ); +} + +export default SummaryBox; diff --git a/src/index.css b/src/index.css new file mode 100644 index 0000000000000000000000000000000000000000..813dddee8d79f9286e23f7a7a357d61eec627002 --- /dev/null +++ b/src/index.css @@ -0,0 +1,41 @@ +:root { + --background: #f6f7fb; + --on-background: #0b0b0b; + --surface: #ffffff; + --outline: rgba(15, 23, 42, 0.16); + + font-family: 'Inter', sans-serif; + line-height: 1.5; + font-weight: 400; + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + width: 100%; + min-height: 100vh; + background: + radial-gradient(circle at 92% 4%, rgba(0, 0, 0, 0.05) 0, rgba(0, 0, 0, 0) 36%), + radial-gradient(circle at 4% 96%, rgba(15, 23, 42, 0.07) 0, rgba(15, 23, 42, 0) 34%), + var(--background); + color: var(--on-background); +} + +#root { + width: 100%; + min-height: 100vh; +} + +h1, +h2, +h3, +h4 { + font-family: 'Manrope', sans-serif; +} diff --git a/src/main.jsx b/src/main.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a3ca132b49680f76f868934416b5f5bea4c1edee --- /dev/null +++ b/src/main.jsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.jsx' + +createRoot(document.getElementById('root')).render( + + + , +) diff --git a/src/services/api.service.js b/src/services/api.service.js new file mode 100644 index 0000000000000000000000000000000000000000..0f54d107156408d7090aca92d69b42c85b2a667d --- /dev/null +++ b/src/services/api.service.js @@ -0,0 +1,93 @@ +import axios from 'axios'; + +const rawApiUrl = import.meta.env.VITE_API_URL || 'http://localhost:5000'; + +const API_URL = (() => { + const trimmedUrl = rawApiUrl.replace(/\/+$/, ''); + + try { + const parsedUrl = new URL(trimmedUrl); + + // If only an origin is provided, default to the backend API namespace. + if (!parsedUrl.pathname || parsedUrl.pathname === '/') { + parsedUrl.pathname = '/api'; + return parsedUrl.toString().replace(/\/+$/, ''); + } + + return trimmedUrl; + } catch { + // Support relative API URLs while keeping explicit path configuration intact. + if (!trimmedUrl || trimmedUrl === '.') return '/api'; + return trimmedUrl.startsWith('/') ? trimmedUrl : `/${trimmedUrl}`; + } +})(); + +class ApiService { + constructor() { + this.client = axios.create({ + baseURL: API_URL, + headers: { + 'Content-Type': 'application/json' + } + }); + } + + async search(query, options = {}) { + const response = await this.client.post('/search', { + query, + language: options.language, + freshness: options.freshness + }); + return response.data; + } + + async searchAndSummarize(query, options = {}) { + const response = await this.client.post('/search-summarize', { + query, + language: options.language, + freshness: options.freshness + }); + return response.data; + } + + async scrapeAndSummarize(urls, query = '') { + const response = await this.client.post('/scrape-summarize', { urls, query }); + return response.data; + } + + async scrape(url) { + const response = await this.client.post('/scrape', { url }); + return response.data; + } + + async generateWithGemini(prompt) { + const response = await this.client.post('/gemini', { prompt }); + return response.data; + } + + async summarizeNews(content, title) { + const response = await this.client.post('/gemini/summarize', { content, title }); + return response.data; + } + + async healthCheck() { + const response = await this.client.get('/health'); + return response.data; + } + + async createTtsJob(text, options = {}) { + const response = await this.client.post('/tts/jobs', { + text, + language: options.language || 'vi', + speaker_audio: options.speakerAudio + }); + return response.data; + } + + async getTtsJob(key) { + const response = await this.client.get(`/tts/jobs/${encodeURIComponent(key)}`); + return response.data; + } +} + +export default new ApiService(); diff --git a/tts_fastapi/Dockerfile b/tts_fastapi/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..67da16cecf59636132d9c93998654662eda57324 --- /dev/null +++ b/tts_fastapi/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements from root and subdirs as needed +COPY requirements.txt ./ +COPY backend/tts_fastapi/requirements.txt ./backend/tts_fastapi/ +COPY models/XTTSv2-Finetuning-for-New-Languages/requirements.txt ./models/XTTSv2-Finetuning-for-New-Languages/ + +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +WORKDIR /app/backend/tts_fastapi +EXPOSE 8000 + +CMD ["python3", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/tts_fastapi/README.md b/tts_fastapi/README.md new file mode 100644 index 0000000000000000000000000000000000000000..11cd7c71c395c8573d07b0ffcde30ec056231764 --- /dev/null +++ b/tts_fastapi/README.md @@ -0,0 +1,42 @@ +# vnTTS FastAPI (GPU) + +This folder provides a FastAPI service that loads `anhnh2002/vnTTS` and exposes: + +- `POST /v1/tts` for synchronous TTS inference. +- `POST /v1/tasks/tts` and `GET /v1/tasks/{task_id}` for async task dispatch. +- `GET /health` for runtime health checks. + +## 1) Prepare environment (Windows + GPU) + +Use Python 3.11 and run from repository root: + +```powershell +py -3.11 -m venv .venv311 +.\.venv311\Scripts\python.exe -m pip install --upgrade pip setuptools wheel +.\.venv311\Scripts\python.exe -m pip install torch torchaudio --index-url https://download.pytorch.org/whl/cu121 +.\.venv311\Scripts\python.exe -m pip install -r .\models\XTTSv2-Finetuning-for-New-Languages\requirements.txt +.\.venv311\Scripts\python.exe -m pip install -r .\backend\tts_fastapi\requirements.txt +``` + +## 2) Download model weights + +```powershell +.\.venv311\Scripts\python.exe -c "from huggingface_hub import snapshot_download; snapshot_download(repo_id='anhnh2002/vnTTS', repo_type='model', local_dir='models/vntts-runtime-model')" +``` + +## 3) Run FastAPI service + +```powershell +$env:PYTHONPATH="e:/Users/Admin/Documents/GitHub/Project-LT-ML-23KHDL1-HCMUS/models/XTTSv2-Finetuning-for-New-Languages" +$env:VNTTS_MODEL_DIR="e:/Users/Admin/Documents/GitHub/Project-LT-ML-23KHDL1-HCMUS/models/vntts-runtime-model" +$env:VNTTS_DEVICE="cuda:0" +.\.venv311\Scripts\python.exe -m uvicorn backend.tts_fastapi.app:app --host 127.0.0.1 --port 8001 +``` + +## 4) Smoke test + +```powershell +Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8001/v1/tts -ContentType "application/json" -Body '{"text":"Xin chao ban, day la ban thu am tu vnTTS.","language":"vi"}' +``` + +The API returns a JSON payload containing `audio_base64` and `sample_rate`. diff --git a/tts_fastapi/__init__.py b/tts_fastapi/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tts_fastapi/__pycache__/__init__.cpython-311.pyc b/tts_fastapi/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f73ecb00ee66d28cd50a57cd563c3374fcf6d65 Binary files /dev/null and b/tts_fastapi/__pycache__/__init__.cpython-311.pyc differ diff --git a/tts_fastapi/__pycache__/__init__.cpython-314.pyc b/tts_fastapi/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80cabebf7bbf98ef4132eeeaabb4a734945d4998 Binary files /dev/null and b/tts_fastapi/__pycache__/__init__.cpython-314.pyc differ diff --git a/tts_fastapi/__pycache__/app.cpython-311.pyc b/tts_fastapi/__pycache__/app.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b9fd8ea720c5fecc9b48ddeb97561bdbe89e0366 Binary files /dev/null and b/tts_fastapi/__pycache__/app.cpython-311.pyc differ diff --git a/tts_fastapi/__pycache__/app.cpython-314.pyc b/tts_fastapi/__pycache__/app.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ae2750b704a6eef637cdbf78dbdedbc36b9345a Binary files /dev/null and b/tts_fastapi/__pycache__/app.cpython-314.pyc differ diff --git a/tts_fastapi/app.py b/tts_fastapi/app.py new file mode 100644 index 0000000000000000000000000000000000000000..fcf4cf3002d695dcbe7452129c5bc470323bd51a --- /dev/null +++ b/tts_fastapi/app.py @@ -0,0 +1,624 @@ +import base64 +import io +import logging +import os +import re +import sys +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from threading import Lock +from typing import Dict, Literal, Optional, Tuple + +import soundfile as sf +import torch +from fastapi import BackgroundTasks, FastAPI, HTTPException +from huggingface_hub import snapshot_download +from num2words import num2words +from pydantic import BaseModel, Field +from underthesea import sent_tokenize, text_normalize +from vinorm import TTSnorm + +PROJECT_ROOT = Path(__file__).resolve().parents[2] +XTTS_REPO_PATH = Path( + os.getenv( + "XTTS_REPO_PATH", + str(PROJECT_ROOT / "models" / "XTTSv2-Finetuning-for-New-Languages"), + ) +) + +if str(XTTS_REPO_PATH) not in sys.path: + sys.path.insert(0, str(XTTS_REPO_PATH)) + +from TTS.tts.configs.xtts_config import XttsConfig # noqa: E402 +from TTS.tts.models.xtts import Xtts # noqa: E402 + +MODEL_REPO_ID = os.getenv("VNTTS_MODEL_REPO_ID", "anhnh2002/vnTTS") +MODEL_DIR = Path( + os.getenv("VNTTS_MODEL_DIR", str(PROJECT_ROOT / "models" / "vntts-runtime-model")) +) +DEFAULT_SPEAKER = os.getenv("VNTTS_DEFAULT_SPEAKER", "nu_nam.wav") +DEVICE = os.getenv("VNTTS_DEVICE", "cuda:0" if torch.cuda.is_available() else "cpu") +MAX_TASKS = int(os.getenv("VNTTS_MAX_TASKS", "100")) +SAMPLE_RATE = int(os.getenv("VNTTS_SAMPLE_RATE", "24000")) +DISABLE_VINORM = os.getenv("VNTTS_DISABLE_VINORM", "false").lower() in {"1", "true", "yes"} +CHUNK_MAX_WORDS = int(os.getenv("VNTTS_CHUNK_MAX_WORDS", "30")) +CHUNK_MIN_WORDS = int(os.getenv("VNTTS_CHUNK_MIN_WORDS", "15")) +CHUNK_CROSSFADE_MS = int(os.getenv("VNTTS_CHUNK_CROSSFADE_MS", "35")) + +LOGGER = logging.getLogger("vntts-fastapi") +_VINORM_FALLBACK_WARNED = False + +COMMON_ASCII_VI_TOKENS = { + "ngay": "ngày", + "luc": "lúc", + "gia": "giá", + "tang": "tăng", + "giam": "giảm", + "voi": "với", + "hom": "hôm", + "gio": "giờ", + "phut": "phút", + "dong": "đồng", + "phan": "phần", + "tram": "trăm", +} + +ROMAN_NUMERAL_VALUES = { + "I": 1, + "V": 5, + "X": 10, + "L": 50, + "C": 100, + "D": 500, + "M": 1000, +} + +TaskStatus = Literal["queued", "running", "completed", "failed"] + + +class TtsRequest(BaseModel): + text: str = Field(min_length=1, max_length=5000) + language: str = Field(default="vi") + speaker_audio: Optional[str] = None + + +class TtsResponse(BaseModel): + sample_rate: int + audio_base64: str + chunks: int + device: str + + +class TaskCreatedResponse(BaseModel): + task_id: str + status: TaskStatus + + +class TaskStatusResponse(BaseModel): + task_id: str + status: TaskStatus + created_at: float + updated_at: float + error: Optional[str] = None + result: Optional[TtsResponse] = None + + +@dataclass +class TaskRecord: + status: TaskStatus + created_at: float + updated_at: float + error: Optional[str] = None + result: Optional[dict] = None + + +def _int_to_vi_words(raw: str) -> str: + digits = raw.replace(".", "").replace(",", "") + if not digits.isdigit(): + return raw + + try: + return num2words(int(digits), lang="vi") + except Exception: + return raw + + +def _decimal_to_vi_words(raw: str) -> str: + if "," in raw: + parts = raw.split(",") + elif "." in raw: + parts = raw.split(".") + else: + return _int_to_vi_words(raw) + + if len(parts) != 2 or not parts[0].isdigit() or not parts[1].isdigit(): + return raw + + left = _int_to_vi_words(parts[0]) + right = " ".join(_int_to_vi_words(digit) for digit in parts[1]) + return f"{left} phẩy {right}" + + +def _number_token_to_vi(raw: str) -> str: + token = re.sub(r"\s+", "", raw) + + if re.fullmatch(r"\d{1,3}(?:[.,]\d{3})+", token): + return _int_to_vi_words(token) + + if re.fullmatch(r"\d+[.,]\d+", token): + return _decimal_to_vi_words(token) + + if token.isdigit(): + return _int_to_vi_words(token) + + return raw + + +def _roman_to_int(token: str) -> Optional[int]: + roman = token.upper() + if not re.fullmatch(r"M{0,4}(CM|CD|D?C{0,3})(XC|XL|L?X{0,3})(IX|IV|V?I{0,3})", roman): + return None + + total = 0 + i = 0 + while i < len(roman): + current = ROMAN_NUMERAL_VALUES[roman[i]] + if i + 1 < len(roman): + nxt = ROMAN_NUMERAL_VALUES[roman[i + 1]] + if nxt > current: + total += nxt - current + i += 2 + continue + + total += current + i += 1 + + return total if total > 0 else None + + +def _normalize_vi_custom_tokens(text: str) -> str: + normalized = text + + def contextual_roman_repl(match: re.Match[str]) -> str: + prefix = match.group(1) + roman = match.group(2) + value = _roman_to_int(roman) + if value is None: + return match.group(0) + return f"{prefix} {_int_to_vi_words(str(value))}" + + normalized = re.sub( + r"\b(chương|phần|mục|quý|thế kỷ|đợt|lần|tập|kỳ)\s+([IVXLCDMivxlcdm]+)\b", + contextual_roman_repl, + normalized, + flags=re.IGNORECASE, + ) + + def standalone_roman_repl(match: re.Match[str]) -> str: + roman = match.group(0) + value = _roman_to_int(roman) + if value is None: + return roman + return _int_to_vi_words(str(value)) + + normalized = re.sub(r"\b[IVXLCDM]{2,}\b", standalone_roman_repl, normalized) + + # Read acronym AI as separate letters for more natural TTS pronunciation. + normalized = re.sub(r"\bA[.]?I\b", "A I", normalized) + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + +def _match_case(word: str, replacement: str) -> str: + if word.isupper(): + return replacement.upper() + if word[:1].isupper(): + return replacement[:1].upper() + replacement[1:] + return replacement + + +def _accentize_common_vi_ascii_tokens(text: str) -> str: + updated = text + for ascii_word, vi_word in COMMON_ASCII_VI_TOKENS.items(): + pattern = rf"\b{re.escape(ascii_word)}\b" + updated = re.sub( + pattern, + lambda m: _match_case(m.group(0), vi_word), + updated, + flags=re.IGNORECASE, + ) + return updated + + +def _normalize_vi_text_fallback(text: str) -> str: + normalized = text_normalize(text) + normalized = _accentize_common_vi_ascii_tokens(normalized) + + def date_repl(match: re.Match[str]) -> str: + day, month, year = match.group(1), match.group(2), match.group(3) + return ( + f"ngày {_int_to_vi_words(day)} " + f"tháng {_int_to_vi_words(month)} " + f"năm {_int_to_vi_words(year)}" + ) + + normalized = re.sub( + r"\b(\d{1,2})\s*/\s*(\d{1,2})\s*/\s*(\d{2,4})\b", + date_repl, + normalized, + ) + + def time_repl(match: re.Match[str]) -> str: + hour, minute = match.group(1), match.group(2) + return f"{_int_to_vi_words(hour)} giờ {_int_to_vi_words(minute)} phút" + + normalized = re.sub(r"\b(\d{1,2})\s*:\s*(\d{2})\b", time_repl, normalized) + + normalized = re.sub( + r"\b(\d+(?:\s*[.,]\s*\d+)?)\s*%", + lambda m: f"{_number_token_to_vi(m.group(1))} phần trăm", + normalized, + ) + + normalized = re.sub( + r"\b(\d+(?:\s*[.,]\s*\d+)*)\s*(?:đ|d|đồng|dong|vnd|VND)\b", + lambda m: f"{_number_token_to_vi(m.group(1))} đồng", + normalized, + flags=re.IGNORECASE, + ) + + normalized = re.sub( + r"\b\d+\s*[.,]\s*\d+\b", + lambda m: _number_token_to_vi(m.group(0)), + normalized, + ) + + normalized = re.sub( + r"\b\d{1,3}(?:\s*[.,]\s*\d{3})*\b", + lambda m: _number_token_to_vi(m.group(0)), + normalized, + ) + + normalized = re.sub( + r"\b(ngày|tháng|năm|giờ|phút)\s+\1\b", + r"\1", + normalized, + flags=re.IGNORECASE, + ) + + normalized = re.sub(r"\s+", " ", normalized).strip() + return normalized + + +def preprocess_text(text: str, language: str = "vi") -> list[str]: + if language == "vi": + global _VINORM_FALLBACK_WARNED + if not DISABLE_VINORM: + try: + text = TTSnorm(text, unknown=False, lower=False, rule=True) + except OSError as exc: + # vinorm spawns an external process that may be incompatible on Windows. + if getattr(exc, "winerror", None) == 193 or "WinError 193" in str(exc): + if not _VINORM_FALLBACK_WARNED: + LOGGER.warning( + "vinorm normalization is not compatible on this environment; using rule-based fallback" + ) + _VINORM_FALLBACK_WARNED = True + text = _normalize_vi_text_fallback(text) + else: + raise + except UnicodeEncodeError: + if not _VINORM_FALLBACK_WARNED: + LOGGER.warning( + "vinorm normalization cannot encode current text on this environment; using rule-based fallback" + ) + _VINORM_FALLBACK_WARNED = True + text = _normalize_vi_text_fallback(text) + except Exception: + if not _VINORM_FALLBACK_WARNED: + LOGGER.warning( + "vinorm normalization failed unexpectedly; using rule-based fallback" + ) + _VINORM_FALLBACK_WARNED = True + text = _normalize_vi_text_fallback(text) + else: + text = _normalize_vi_text_fallback(text) + + text = _normalize_vi_custom_tokens(text) + + if language in ["ja", "zh-cn"]: + sentences = text.split("。") + else: + sentences = sent_tokenize(text) + + chunks: list[str] = [] + chunk_i = "" + len_chunk_i = 0 + + for sentence in sentences: + chunk_i += " " + sentence + len_chunk_i += len(sentence.split()) + + if len_chunk_i >= CHUNK_MAX_WORDS: + chunks.append(chunk_i.strip()) + chunk_i = "" + len_chunk_i = 0 + + if chunk_i.strip(): + if chunks and len_chunk_i < CHUNK_MIN_WORDS: + chunks[-1] += " " + chunk_i.strip() + else: + chunks.append(chunk_i.strip()) + + return chunks + + +def merge_wav_chunks(wav_chunks: list[torch.Tensor], sample_rate: int) -> torch.Tensor: + if len(wav_chunks) == 1: + return wav_chunks[0].float() + + crossfade_samples = max(0, int(sample_rate * CHUNK_CROSSFADE_MS / 1000)) + merged = wav_chunks[0].float() + + for wav_chunk in wav_chunks[1:]: + next_chunk = wav_chunk.float() + + if crossfade_samples <= 0: + merged = torch.cat((merged, next_chunk), dim=0) + continue + + # Keep enough signal in each side to avoid over-trimming short chunks. + overlap = min(crossfade_samples, merged.numel() // 4, next_chunk.numel() // 4) + if overlap <= 0: + merged = torch.cat((merged, next_chunk), dim=0) + continue + + fade_out = torch.linspace(1.0, 0.0, steps=overlap, dtype=merged.dtype) + fade_in = torch.linspace(0.0, 1.0, steps=overlap, dtype=next_chunk.dtype) + crossfaded = (merged[-overlap:] * fade_out) + (next_chunk[:overlap] * fade_in) + + merged = torch.cat((merged[:-overlap], crossfaded, next_chunk[overlap:]), dim=0) + + return merged + + +class VnTTSRuntime: + def __init__(self, model_repo_id: str, model_dir: Path, device: str): + self.model_repo_id = model_repo_id + self.model_dir = model_dir + self.device = device + self.model: Optional[Xtts] = None + self.config: Optional[XttsConfig] = None + self._init_lock = Lock() + self._inference_lock = Lock() + self._speaker_cache: Dict[str, Tuple[torch.Tensor, torch.Tensor]] = {} + + def is_loaded(self) -> bool: + return self.model is not None + + def _ensure_model_files(self) -> None: + required = [ + self.model_dir / "best_model.pth", + self.model_dir / "config.json", + self.model_dir / "vocab.json", + self.model_dir / DEFAULT_SPEAKER, + ] + if all(path.exists() for path in required): + return + + self.model_dir.mkdir(parents=True, exist_ok=True) + snapshot_download( + repo_id=self.model_repo_id, + repo_type="model", + local_dir=str(self.model_dir), + ) + + def _load_model(self) -> None: + if self.model is not None: + return + + with self._init_lock: + if self.model is not None: + return + + self._ensure_model_files() + + config = XttsConfig() + config.load_json(str(self.model_dir / "config.json")) + + model = Xtts.init_from_config(config) + model.load_checkpoint( + config, + checkpoint_path=str(self.model_dir / "best_model.pth"), + vocab_path=str(self.model_dir / "vocab.json"), + use_deepspeed=False, + strict=False, + ) + model.to(self.device) + model.eval() + + self.config = config + self.model = model + + def _resolve_speaker_path(self, speaker_audio: Optional[str]) -> Path: + if not speaker_audio: + speaker_path = self.model_dir / DEFAULT_SPEAKER + else: + candidate = Path(speaker_audio) + speaker_path = candidate if candidate.is_absolute() else self.model_dir / candidate + + if not speaker_path.exists(): + raise ValueError(f"Speaker audio not found: {speaker_path}") + + return speaker_path + + def _get_speaker_latents(self, speaker_path: Path) -> Tuple[torch.Tensor, torch.Tensor]: + if self.model is None or self.config is None: + raise RuntimeError("Model is not loaded") + + key = str(speaker_path.resolve()) + if key in self._speaker_cache: + return self._speaker_cache[key] + + gpt_cond_latent, speaker_embedding = self.model.get_conditioning_latents( + audio_path=key, + gpt_cond_len=self.model.config.gpt_cond_len, + max_ref_length=self.model.config.max_ref_len, + sound_norm_refs=self.model.config.sound_norm_refs, + ) + + self._speaker_cache[key] = (gpt_cond_latent, speaker_embedding) + return gpt_cond_latent, speaker_embedding + + def synthesize(self, request: TtsRequest) -> dict: + self._load_model() + + if self.model is None: + raise RuntimeError("Failed to initialize model") + + speaker_path = self._resolve_speaker_path(request.speaker_audio) + + with self._inference_lock: + gpt_cond_latent, speaker_embedding = self._get_speaker_latents(speaker_path) + chunks = preprocess_text(request.text, request.language) + + wav_chunks = [] + for text_chunk in chunks: + if not text_chunk.strip(): + continue + + wav_chunk = self.model.inference( + text=text_chunk, + language=request.language, + gpt_cond_latent=gpt_cond_latent, + speaker_embedding=speaker_embedding, + temperature=0.1, + speed=1.2, + length_penalty=1.0, + repetition_penalty=10.0, + top_k=10, + top_p=0.5, + ) + + wav_chunks.append(torch.tensor(wav_chunk["wav"])) + + if not wav_chunks: + raise ValueError("No audio chunk was generated") + + out_wav = merge_wav_chunks(wav_chunks, SAMPLE_RATE).unsqueeze(0).cpu().numpy()[0] + + audio_buffer = io.BytesIO() + sf.write(audio_buffer, out_wav, SAMPLE_RATE, format="WAV") + audio_bytes = audio_buffer.getvalue() + + return { + "sample_rate": SAMPLE_RATE, + "audio_base64": base64.b64encode(audio_bytes).decode("ascii"), + "chunks": len(wav_chunks), + "device": self.device, + } + + +runtime = VnTTSRuntime(model_repo_id=MODEL_REPO_ID, model_dir=MODEL_DIR, device=DEVICE) +app = FastAPI(title="vnTTS FastAPI", version="1.0.0") + +_task_store: Dict[str, TaskRecord] = {} +_task_lock = Lock() + + +def _trim_tasks_locked() -> None: + if len(_task_store) <= MAX_TASKS: + return + + overflow = len(_task_store) - MAX_TASKS + oldest_ids = sorted(_task_store.keys(), key=lambda item: _task_store[item].updated_at)[:overflow] + for task_id in oldest_ids: + _task_store.pop(task_id, None) + + +def _update_task(task_id: str, **kwargs) -> None: + with _task_lock: + task = _task_store.get(task_id) + if task is None: + return + + for key, value in kwargs.items(): + setattr(task, key, value) + + task.updated_at = time.time() + + +def _run_tts_task(task_id: str, payload: dict) -> None: + _update_task(task_id, status="running", error=None) + + try: + request = TtsRequest(**payload) + result = runtime.synthesize(request) + _update_task(task_id, status="completed", result=result, error=None) + except Exception as exc: + _update_task(task_id, status="failed", error=str(exc)) + + +@app.get("/health") +def health() -> dict: + return { + "status": "ok", + "cuda_available": torch.cuda.is_available(), + "device": DEVICE, + "model_loaded": runtime.is_loaded(), + "model_dir": str(MODEL_DIR), + } + + +@app.post("/v1/tts", response_model=TtsResponse) +def synthesize(request: TtsRequest) -> dict: + try: + return runtime.synthesize(request) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except Exception as exc: + LOGGER.exception("Error during synthesis") + raise HTTPException(status_code=500, detail=str(exc)) from exc + + +@app.post("/v1/tasks/tts", response_model=TaskCreatedResponse, status_code=202) +def create_tts_task(request: TtsRequest, background_tasks: BackgroundTasks) -> dict: + task_id = str(uuid.uuid4()) + now = time.time() + + with _task_lock: + _task_store[task_id] = TaskRecord( + status="queued", + created_at=now, + updated_at=now, + error=None, + result=None, + ) + _trim_tasks_locked() + + background_tasks.add_task(_run_tts_task, task_id, request.model_dump()) + + return { + "task_id": task_id, + "status": "queued", + } + + +@app.get("/v1/tasks/{task_id}", response_model=TaskStatusResponse) +def get_tts_task(task_id: str) -> dict: + with _task_lock: + task = _task_store.get(task_id) + + if task is None: + raise HTTPException(status_code=404, detail="Task not found") + + return { + "task_id": task_id, + "status": task.status, + "created_at": task.created_at, + "updated_at": task.updated_at, + "error": task.error, + "result": task.result, + } diff --git a/tts_fastapi/requirements.txt b/tts_fastapi/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..93a32d633e349249efec9753f84803ba5592dbb8 --- /dev/null +++ b/tts_fastapi/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.136.0 +uvicorn==0.44.0 +huggingface_hub==0.36.2 +num2words==0.5.14 diff --git a/vite.config.js b/vite.config.js new file mode 100644 index 0000000000000000000000000000000000000000..ea73791ff25e91189ae3d67cd404e5aa7a102fc3 --- /dev/null +++ b/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react()], +})