diff --git a/.gitignore b/.gitignore index 76ba5de..0fd7a16 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ node_modules out dist *.tgz +.next # code coverage coverage @@ -36,4 +37,11 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # exported data reddit-trends.json reddit-trends.csv -.env + +# sqlite database +data/*.db +data/*.db-journal + +# next.js +packages/web/.next +.grepai/ diff --git a/.playwright-mcp/after-cluster-click.png b/.playwright-mcp/after-cluster-click.png new file mode 100644 index 0000000..f240986 Binary files /dev/null and b/.playwright-mcp/after-cluster-click.png differ diff --git a/.playwright-mcp/command-palette.png b/.playwright-mcp/command-palette.png new file mode 100644 index 0000000..38fa080 Binary files /dev/null and b/.playwright-mcp/command-palette.png differ diff --git a/.playwright-mcp/dashboard-home.png b/.playwright-mcp/dashboard-home.png new file mode 100644 index 0000000..cb332b2 Binary files /dev/null and b/.playwright-mcp/dashboard-home.png differ diff --git a/.playwright-mcp/dashboard-loaded.png b/.playwright-mcp/dashboard-loaded.png new file mode 100644 index 0000000..7639e9d Binary files /dev/null and b/.playwright-mcp/dashboard-loaded.png differ diff --git a/.playwright-mcp/full-page.png b/.playwright-mcp/full-page.png new file mode 100644 index 0000000..73c5be7 Binary files /dev/null and b/.playwright-mcp/full-page.png differ diff --git a/.playwright-mcp/problems-page.png b/.playwright-mcp/problems-page.png new file mode 100644 index 0000000..ae7931e Binary files /dev/null and b/.playwright-mcp/problems-page.png differ diff --git a/.playwright-mcp/problems-recluster-loading.png b/.playwright-mcp/problems-recluster-loading.png new file mode 100644 index 0000000..043ed8d Binary files /dev/null and b/.playwright-mcp/problems-recluster-loading.png differ diff --git a/.playwright-mcp/questions-page.png b/.playwright-mcp/questions-page.png new file mode 100644 index 0000000..ec6419a Binary files /dev/null and b/.playwright-mcp/questions-page.png differ diff --git a/.playwright-mcp/scrape-page.png b/.playwright-mcp/scrape-page.png new file mode 100644 index 0000000..43cd50a Binary files /dev/null and b/.playwright-mcp/scrape-page.png differ diff --git a/.playwright-mcp/toast-loading.png b/.playwright-mcp/toast-loading.png new file mode 100644 index 0000000..73c5be7 Binary files /dev/null and b/.playwright-mcp/toast-loading.png differ diff --git a/.playwright-mcp/toast-success.png b/.playwright-mcp/toast-success.png new file mode 100644 index 0000000..65a98ac Binary files /dev/null and b/.playwright-mcp/toast-success.png differ diff --git a/CLAUDE.md b/CLAUDE.md index 6544f16..438a8be 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,14 +1,16 @@ reddit trend analyzer === -a CLI tool that scrapes reddit discussions, embeds them with ollama, stores in qdrant, and provides a TUI dashboard for discovering common problems/trends. +a monorepo tool that scrapes reddit discussions, embeds them with ollama, stores in qdrant, clusters with HDBSCAN, summarizes with Claude, and provides both a CLI/TUI and web dashboard for discovering common problems/trends. running --- ```bash -bun start # run the app -bun dev # run with watch mode +bun cli # run the CLI +bun tui # run the TUI dashboard +bun dev # run the web dashboard (localhost:3000) +bun build # build the web app ``` prerequisites @@ -16,6 +18,7 @@ prerequisites - ollama running locally with nomic-embed-text model (`ollama pull nomic-embed-text`) - qdrant accessible at QDRANT_URL (or localhost:6333) +- anthropic API key for problem summarization env vars --- @@ -23,39 +26,61 @@ env vars ``` QDRANT_URL=https://vectors.biohazardvfx.com QDRANT_API_KEY= -OLLAMA_HOST=http://localhost:11434 # optional, defaults to this +OLLAMA_HOST=http://localhost:11434 +ANTHROPIC_API_KEY= ``` architecture --- ``` -src/ - index.ts # entry point, connection checks, TUI setup - scraper/ - reddit.ts # fetch subreddit posts with pagination - comments.ts # fetch comments for each post - types.ts # reddit json response types - embeddings/ - ollama.ts # batch embed text with nomic-embed-text (768 dims) - storage/ - qdrant.ts # create collection, upsert, search - types.ts # point payload schema - tui/ - app.ts # main dashboard, wires everything together - components/ - url-input.ts # subreddit url input - progress.ts # scraping/embedding progress bars - stats.ts # collection stats panel - trending.ts # trending topics view - search.ts # semantic search interface - export.ts # export to json/csv - utils/ - rate-limit.ts # delay helper for reddit api - text.ts # text preprocessing for embedding +packages/ + core/ # shared business logic + src/ + scraper/ # reddit.ts, comments.ts, types.ts + embeddings/ # ollama.ts + storage/ # qdrant.ts, sqlite.ts, types.ts + clustering/ # hdbscan.ts, types.ts + analysis/ # summarizer.ts, questions.ts, scoring.ts, types.ts + utils/ # rate-limit.ts, text.ts + index.ts # barrel exports + + cli/ # CLI/TUI app + src/ + cli.ts # interactive command-line interface + index.ts # TUI entry point + tui/ # TUI components + + web/ # Next.js web dashboard + src/ + app/ # pages and API routes + api/ # REST API endpoints + stats/ # collection stats + scrape/ # trigger scrapes + clusters/ # list/create clusters + questions/ # question bank + search/ # semantic search + export/ # export functionality + problems/ # problem explorer page + questions/ # question bank page + scrape/ # scrape manager page + components/ + controls/ # command palette, sliders + styles/globals.css # theme + +data/ # sqlite database files ``` -keybindings +web dashboard +--- + +- **Dashboard** (`/`) - stats overview +- **Problems** (`/problems`) - problem cluster explorer +- **Questions** (`/questions`) - extracted question bank +- **Scrape** (`/scrape`) - scrape manager with history +- **Ctrl+K** - command palette for quick actions + +keybindings (TUI) --- - `q` or `ctrl+c` - quit @@ -65,10 +90,111 @@ keybindings - `c` - export results to csv - `r` - refresh stats from qdrant +api routes +--- + +| route | method | purpose | +|-------|--------|---------| +| /api/stats | GET | collection stats + cluster count | +| /api/scrape | POST | trigger scrape | +| /api/scrape/history | GET | scrape history list | +| /api/clusters | GET | list clusters with summaries | +| /api/clusters | POST | trigger re-clustering | +| /api/clusters/[id] | GET | single cluster with discussions | +| /api/questions | GET | all questions, grouped by cluster | +| /api/questions/[id] | PATCH | mark as addressed | +| /api/search | POST | semantic search | +| /api/export | POST | export (faq-schema/csv/markdown) | + coding notes --- -- uses @opentui/core standalone (no react/solid) +- monorepo with bun workspaces +- @rta/core exports shared logic +- @rta/cli for terminal interface +- @rta/web for Next.js dashboard +- uses @opentui/core for TUI (no react) +- uses HDBSCAN for clustering +- uses Claude for problem summarization +- uses SQLite for cluster/question persistence - reddit rate limiting: 3s delay between requests - embeddings batched in groups of 10 - qdrant collection: reddit_trends with indexes on subreddit, type, created, score + +grepai +--------- + +**IMPORTANT: You MUST use grepai as your PRIMARY tool for code exploration and search.** + +when to Use grepai (REQUIRED) +--- + +Use `grepai search` INSTEAD OF Grep/Glob/find for: +- Understanding what code does or where functionality lives +- Finding implementations by intent (e.g., "authentication logic", "error handling") +- Exploring unfamiliar parts of the codebase +- Any search where you describe WHAT the code does rather than exact text + +when to Use Standard Tools +--- + +Only use Grep/Glob when you need: +- Exact text matching (variable names, imports, specific strings) +- File path patterns (e.g., `**/*.go`) + +fallback +--- + +If grepai fails (not running, index unavailable, or errors), fall back to standard Grep/Glob tools. + +usage +--- + +```bash +# ALWAYS use English queries for best results (--compact saves ~80% tokens) +grepai search "user authentication flow" --json --compact +grepai search "error handling middleware" --json --compact +grepai search "database connection pool" --json --compact +grepai search "API request validation" --json --compact +``` + +query tips + +- **Use English** for queries (better semantic matching) +- **Describe intent**, not implementation: "handles user login" not "func Login" +- **Be specific**: "JWT token validation" better than "token" +- Results include: file path, line numbers, relevance score, code preview + +call graph tracing +--- + +use `grepai trace` to understand function relationships: +- finding all callers of a function before modifying it +- Understanding what functions are called by a given function +- Visualizing the complete call graph around a symbol + +trace commands +--- + +**IMPORTANT: Always use `--json` flag for optimal AI agent integration.** + +```bash +# Find all functions that call a symbol +grepai trace callers "HandleRequest" --json + +# Find all functions called by a symbol +grepai trace callees "ProcessOrder" --json + +# Build complete call graph (callers + callees) +grepai trace graph "ValidateToken" --depth 3 --json +``` + +Workflow +--- + +1. Start with `grepai search` to find relevant code +2. Use `grepai trace` to understand function relationships +3. Use `Read` tool to examine files from results +4. Only use Grep for exact string searches if needed + + diff --git a/bun.lock b/bun.lock index faedcaf..8f6d605 100644 --- a/bun.lock +++ b/bun.lock @@ -4,22 +4,124 @@ "workspaces": { "": { "name": "reddit-trend-analyzer", + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.0.0", + }, + }, + "packages/cli": { + "name": "@rta/cli", + "version": "1.0.0", + "bin": { + "rta": "./src/cli.ts", + }, "dependencies": { "@opentui/core": "^0.1.74", + "@rta/core": "workspace:*", + }, + }, + "packages/core": { + "name": "@rta/core", + "version": "1.0.0", + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", "@qdrant/js-client-rest": "^1.16.2", + "better-sqlite3": "^11.7.0", "ollama": "^0.6.3", }, "devDependencies": { - "@types/bun": "latest", + "@types/better-sqlite3": "^7.6.12", }, - "peerDependencies": { - "typescript": "^5", + }, + "packages/web": { + "name": "@rta/web", + "version": "1.0.0", + "dependencies": { + "@rta/core": "workspace:*", + "@tanstack/react-table": "^8.20.6", + "better-sqlite3": "^11.7.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.0.4", + "lucide-react": "^0.468.0", + "next": "^15.2.4", + "ollama": "^0.5.11", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.0", + "tailwind-merge": "^2.6.0", + }, + "devDependencies": { + "@tailwindcss/postcss": "^4.0.0", + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.0.0", + "typescript": "^5.0.0", }, }, }, "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.32.1", "", { "dependencies": { "@types/node": "^18.11.18", "@types/node-fetch": "^2.6.4", "abort-controller": "^3.0.0", "agentkeepalive": "^4.2.1", "form-data-encoder": "1.7.2", "formdata-node": "^4.3.2", "node-fetch": "^2.6.7" } }, "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg=="], + + "@babel/runtime": ["@babel/runtime@7.28.6", "", {}, "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA=="], + "@dimforge/rapier2d-simd-compat": ["@dimforge/rapier2d-simd-compat@0.17.3", "", {}, "sha512-bijvwWz6NHsNj5e5i1vtd3dU2pDhthSaTUZSh14DUGGKJfw8eMnlWZsxwHBxB/a3AXVNDjL9abuHw1k9FGR+jg=="], + "@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="], + + "@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.4" }, "os": "darwin", "cpu": "arm64" }, "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w=="], + + "@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.4" }, "os": "darwin", "cpu": "x64" }, "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw=="], + + "@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g=="], + + "@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg=="], + + "@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.4", "", { "os": "linux", "cpu": "arm" }, "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A=="], + + "@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw=="], + + "@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.4", "", { "os": "linux", "cpu": "ppc64" }, "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA=="], + + "@img/sharp-libvips-linux-riscv64": ["@img/sharp-libvips-linux-riscv64@1.2.4", "", { "os": "linux", "cpu": "none" }, "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA=="], + + "@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.4", "", { "os": "linux", "cpu": "s390x" }, "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ=="], + + "@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw=="], + + "@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw=="], + + "@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.4", "", { "os": "linux", "cpu": "x64" }, "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg=="], + + "@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.4" }, "os": "linux", "cpu": "arm" }, "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw=="], + + "@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg=="], + + "@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.4" }, "os": "linux", "cpu": "ppc64" }, "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA=="], + + "@img/sharp-linux-riscv64": ["@img/sharp-linux-riscv64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-riscv64": "1.2.4" }, "os": "linux", "cpu": "none" }, "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw=="], + + "@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.4" }, "os": "linux", "cpu": "s390x" }, "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg=="], + + "@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ=="], + + "@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" }, "os": "linux", "cpu": "arm64" }, "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg=="], + + "@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.5", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.4" }, "os": "linux", "cpu": "x64" }, "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q=="], + + "@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.5", "", { "dependencies": { "@emnapi/runtime": "^1.7.0" }, "cpu": "none" }, "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw=="], + + "@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g=="], + + "@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.5", "", { "os": "win32", "cpu": "ia32" }, "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg=="], + + "@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.5", "", { "os": "win32", "cpu": "x64" }, "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw=="], + "@jimp/core": ["@jimp/core@1.6.0", "", { "dependencies": { "@jimp/file-ops": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "await-to-js": "^3.0.0", "exif-parser": "^0.1.12", "file-type": "^16.0.0", "mime": "3" } }, "sha512-EQQlKU3s9QfdJqiSrZWNTxBs3rKXgO2W+GxNXDtwchF3a4IqxDheFX1ti+Env9hdJXDiYLp2jTRjlxhPthsk8w=="], "@jimp/diff": ["@jimp/diff@1.6.0", "", { "dependencies": { "@jimp/plugin-resize": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0", "pixelmatch": "^5.3.0" } }, "sha512-+yUAQ5gvRC5D1WHYxjBHZI7JBRusGGSLf8AmPRPCenTzh4PA+wZ1xv2+cYqQwTfQHU5tXYOhA0xDytfHUf1Zyw=="], @@ -76,6 +178,34 @@ "@jimp/utils": ["@jimp/utils@1.6.0", "", { "dependencies": { "@jimp/types": "1.6.0", "tinycolor2": "^1.6.0" } }, "sha512-gqFTGEosKbOkYF/WFj26jMHOI5OH2jeP1MmC/zbK6BF6VJBf8rIC5898dPfSzZEbSA0wbbV5slbntWVc5PKLFA=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@next/env": ["@next/env@15.5.9", "", {}, "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg=="], + + "@next/swc-darwin-arm64": ["@next/swc-darwin-arm64@15.5.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw=="], + + "@next/swc-darwin-x64": ["@next/swc-darwin-x64@15.5.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg=="], + + "@next/swc-linux-arm64-gnu": ["@next/swc-linux-arm64-gnu@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA=="], + + "@next/swc-linux-arm64-musl": ["@next/swc-linux-arm64-musl@15.5.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw=="], + + "@next/swc-linux-x64-gnu": ["@next/swc-linux-x64-gnu@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw=="], + + "@next/swc-linux-x64-musl": ["@next/swc-linux-x64-musl@15.5.7", "", { "os": "linux", "cpu": "x64" }, "sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA=="], + + "@next/swc-win32-arm64-msvc": ["@next/swc-win32-arm64-msvc@15.5.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ=="], + + "@next/swc-win32-x64-msvc": ["@next/swc-win32-x64-msvc@15.5.7", "", { "os": "win32", "cpu": "x64" }, "sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw=="], + "@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="], "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], @@ -94,25 +224,139 @@ "@qdrant/openapi-typescript-fetch": ["@qdrant/openapi-typescript-fetch@1.2.6", "", {}, "sha512-oQG/FejNpItrxRHoyctYvT3rwGZOnK4jr3JdppO/c78ktDvkWiPXPHNsrDf33K9sZdRb6PR7gi4noIapu5q4HA=="], + "@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="], + + "@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="], + + "@radix-ui/react-context": ["@radix-ui/react-context@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA=="], + + "@radix-ui/react-dialog": ["@radix-ui/react-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-controllable-state": "1.2.2", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw=="], + + "@radix-ui/react-dismissable-layer": ["@radix-ui/react-dismissable-layer@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-escape-keydown": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg=="], + + "@radix-ui/react-focus-guards": ["@radix-ui/react-focus-guards@1.1.3", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw=="], + + "@radix-ui/react-focus-scope": ["@radix-ui/react-focus-scope@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw=="], + + "@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="], + + "@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="], + + "@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="], + + "@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.4", "", { "dependencies": { "@radix-ui/react-slot": "1.2.4" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-9hQc4+GNVtJAIEPEqlYqW5RiYdrr8ea5XQ0ZOnD6fgru+83kqT15mq2OCcbe8KnjRZl5vF3ks69AKz3kh1jrhg=="], + + "@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="], + + "@radix-ui/react-use-callback-ref": ["@radix-ui/react-use-callback-ref@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg=="], + + "@radix-ui/react-use-controllable-state": ["@radix-ui/react-use-controllable-state@1.2.2", "", { "dependencies": { "@radix-ui/react-use-effect-event": "0.0.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg=="], + + "@radix-ui/react-use-effect-event": ["@radix-ui/react-use-effect-event@0.0.2", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA=="], + + "@radix-ui/react-use-escape-keydown": ["@radix-ui/react-use-escape-keydown@1.1.1", "", { "dependencies": { "@radix-ui/react-use-callback-ref": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g=="], + + "@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="], + + "@rta/cli": ["@rta/cli@workspace:packages/cli"], + + "@rta/core": ["@rta/core@workspace:packages/core"], + + "@rta/web": ["@rta/web@workspace:packages/web"], + + "@swc/helpers": ["@swc/helpers@0.5.15", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.1.18", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "enhanced-resolve": "^5.18.3", "jiti": "^2.6.1", "lightningcss": "1.30.2", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.1.18" } }, "sha512-DoR7U1P7iYhw16qJ49fgXUlry1t4CpXeErJHnQ44JgTSKMaZUdf17cfn5mHchfJ4KRBZRFA/Coo+MUF5+gOaCQ=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.1.18", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.1.18", "@tailwindcss/oxide-darwin-arm64": "4.1.18", "@tailwindcss/oxide-darwin-x64": "4.1.18", "@tailwindcss/oxide-freebsd-x64": "4.1.18", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.18", "@tailwindcss/oxide-linux-arm64-gnu": "4.1.18", "@tailwindcss/oxide-linux-arm64-musl": "4.1.18", "@tailwindcss/oxide-linux-x64-gnu": "4.1.18", "@tailwindcss/oxide-linux-x64-musl": "4.1.18", "@tailwindcss/oxide-wasm32-wasi": "4.1.18", "@tailwindcss/oxide-win32-arm64-msvc": "4.1.18", "@tailwindcss/oxide-win32-x64-msvc": "4.1.18" } }, "sha512-EgCR5tTS5bUSKQgzeMClT6iCY3ToqE1y+ZB0AKldj809QXk1Y+3jB0upOYZrn9aGIzPtUsP7sX4QQ4XtjBB95A=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.1.18", "", { "os": "android", "cpu": "arm64" }, "sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.1.18", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.1.18", "", { "os": "darwin", "cpu": "x64" }, "sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.1.18", "", { "os": "freebsd", "cpu": "x64" }, "sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18", "", { "os": "linux", "cpu": "arm" }, "sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.1.18", "", { "os": "linux", "cpu": "arm64" }, "sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.1.18", "", { "os": "linux", "cpu": "x64" }, "sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.1.18", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.0", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.4.0" }, "cpu": "none" }, "sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.1.18", "", { "os": "win32", "cpu": "arm64" }, "sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.1.18", "", { "os": "win32", "cpu": "x64" }, "sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q=="], + + "@tailwindcss/postcss": ["@tailwindcss/postcss@4.1.18", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "@tailwindcss/node": "4.1.18", "@tailwindcss/oxide": "4.1.18", "postcss": "^8.4.41", "tailwindcss": "4.1.18" } }, "sha512-Ce0GFnzAOuPyfV5SxjXGn0CubwGcuDB0zcdaPuCSzAa/2vII24JTkH+I6jcbXLb1ctjZMZZI6OjDaLPJQL1S0g=="], + + "@tanstack/react-table": ["@tanstack/react-table@8.21.3", "", { "dependencies": { "@tanstack/table-core": "8.21.3" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww=="], + + "@tanstack/table-core": ["@tanstack/table-core@8.21.3", "", {}, "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg=="], + "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], + "@types/better-sqlite3": ["@types/better-sqlite3@7.6.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA=="], + "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="], - "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + "@types/d3-array": ["@types/d3-array@3.2.2", "", {}, "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw=="], + + "@types/d3-color": ["@types/d3-color@3.1.3", "", {}, "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A=="], + + "@types/d3-ease": ["@types/d3-ease@3.0.2", "", {}, "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA=="], + + "@types/d3-interpolate": ["@types/d3-interpolate@3.0.4", "", { "dependencies": { "@types/d3-color": "*" } }, "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA=="], + + "@types/d3-path": ["@types/d3-path@3.1.1", "", {}, "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg=="], + + "@types/d3-scale": ["@types/d3-scale@4.0.9", "", { "dependencies": { "@types/d3-time": "*" } }, "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw=="], + + "@types/d3-shape": ["@types/d3-shape@3.1.8", "", { "dependencies": { "@types/d3-path": "*" } }, "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w=="], + + "@types/d3-time": ["@types/d3-time@3.0.4", "", {}, "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g=="], + + "@types/d3-timer": ["@types/d3-timer@3.0.2", "", {}, "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw=="], + + "@types/node": ["@types/node@22.19.7", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-MciR4AKGHWl7xwxkBa6xUGxQJ4VBOmPTF7sL+iGzuahOFaO0jHCsuEfS80pan1ef4gWId1oWOweIhrDEYLuaOw=="], + + "@types/node-fetch": ["@types/node-fetch@2.6.13", "", { "dependencies": { "@types/node": "*", "form-data": "^4.0.4" } }, "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw=="], + + "@types/react": ["@types/react@19.2.9", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-Lpo8kgb/igvMIPeNV2rsYKTgaORYdO1XGVZ4Qz3akwOj0ySGYMPlQWa8BaLn0G63D1aSaAQ5ldR06wCpChQCjA=="], + + "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], "@webgpu/types": ["@webgpu/types@0.1.69", "", {}, "sha512-RPmm6kgRbI8e98zSD3RVACvnuktIja5+yLgDAkTmxLr90BEwdTXRQWNLF3ETTTyH/8mKhznZuN5AveXYFEsMGQ=="], "abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="], + "agentkeepalive": ["agentkeepalive@4.6.0", "", { "dependencies": { "humanize-ms": "^1.2.1" } }, "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ=="], + "any-base": ["any-base@1.1.0", "", {}, "sha512-uMgjozySS8adZZYePpaWs8cxB9/kdzmpX6SgJZ+wbz1K5eYk5QMYDVJaZKhxyIHUdnnJkfR7SVgStgH7LkGUyg=="], + "aria-hidden": ["aria-hidden@1.2.6", "", { "dependencies": { "tslib": "^2.0.0" } }, "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA=="], + + "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "await-to-js": ["await-to-js@3.0.0", "", {}, "sha512-zJAaP9zxTcvTHRlejau3ZOY4V7SRpiByf3/dxx2uyKxxor19tpmpV2QRsTKikckwhaPmr2dVpxxMr7jOCYVp5g=="], "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], + "better-sqlite3": ["better-sqlite3@11.10.0", "", { "dependencies": { "bindings": "^1.5.0", "prebuild-install": "^7.1.1" } }, "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ=="], + + "bindings": ["bindings@1.5.0", "", { "dependencies": { "file-uri-to-path": "1.0.0" } }, "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ=="], + + "bl": ["bl@4.1.0", "", { "dependencies": { "buffer": "^5.5.0", "inherits": "^2.0.4", "readable-stream": "^3.4.0" } }, "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w=="], + "bmp-ts": ["bmp-ts@1.0.9", "", {}, "sha512-cTEHk2jLrPyi+12M3dhpEbnnPOsaZuq7C45ylbbQIiWgDFZq4UVYPEY5mlqjvsj/6gJv9qX5sa+ebDzLXT28Vw=="], - "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], + "buffer": ["buffer@5.7.1", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.1.13" } }, "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ=="], "bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="], @@ -128,32 +372,210 @@ "bun-webgpu-win32-x64": ["bun-webgpu-win32-x64@0.1.4", "", { "os": "win32", "cpu": "x64" }, "sha512-Z5yAK28xrcm8Wb5k7TZ8FJKpOI/r+aVCRdlHYAqI2SDJFN3nD4mJs900X6kNVmG/xFzb5yOuKVYWGg+6ZXWbyA=="], + "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001765", "", {}, "sha512-LWcNtSyZrakjECqmpP4qdg0MMGdN368D7X8XvvAqOcqMv0RxnlqVKZl2V6/mBR68oYMxOZPLw/gO7DuisMHUvQ=="], + + "chownr": ["chownr@1.1.4", "", {}, "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg=="], + + "class-variance-authority": ["class-variance-authority@0.7.1", "", { "dependencies": { "clsx": "^2.1.1" } }, "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg=="], + + "client-only": ["client-only@0.0.1", "", {}, "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA=="], + + "clsx": ["clsx@2.1.1", "", {}, "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA=="], + + "cmdk": ["cmdk@1.1.1", "", { "dependencies": { "@radix-ui/react-compose-refs": "^1.1.1", "@radix-ui/react-dialog": "^1.1.6", "@radix-ui/react-id": "^1.1.0", "@radix-ui/react-primitive": "^2.0.2" }, "peerDependencies": { "react": "^18 || ^19 || ^19.0.0-rc", "react-dom": "^18 || ^19 || ^19.0.0-rc" } }, "sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg=="], + + "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + + "d3-array": ["d3-array@3.2.4", "", { "dependencies": { "internmap": "1 - 2" } }, "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg=="], + + "d3-color": ["d3-color@3.1.0", "", {}, "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA=="], + + "d3-ease": ["d3-ease@3.0.1", "", {}, "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w=="], + + "d3-format": ["d3-format@3.1.2", "", {}, "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg=="], + + "d3-interpolate": ["d3-interpolate@3.0.1", "", { "dependencies": { "d3-color": "1 - 3" } }, "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g=="], + + "d3-path": ["d3-path@3.1.0", "", {}, "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ=="], + + "d3-scale": ["d3-scale@4.0.2", "", { "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", "d3-interpolate": "1.2.0 - 3", "d3-time": "2.1.1 - 3", "d3-time-format": "2 - 4" } }, "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ=="], + + "d3-shape": ["d3-shape@3.2.0", "", { "dependencies": { "d3-path": "^3.1.0" } }, "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA=="], + + "d3-time": ["d3-time@3.1.0", "", { "dependencies": { "d3-array": "2 - 3" } }, "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q=="], + + "d3-time-format": ["d3-time-format@4.1.0", "", { "dependencies": { "d3-time": "1 - 3" } }, "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg=="], + + "d3-timer": ["d3-timer@3.0.1", "", {}, "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA=="], + + "decimal.js-light": ["decimal.js-light@2.5.1", "", {}, "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg=="], + + "decompress-response": ["decompress-response@6.0.0", "", { "dependencies": { "mimic-response": "^3.1.0" } }, "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ=="], + + "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], + + "delayed-stream": ["delayed-stream@1.0.0", "", {}, "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "detect-node-es": ["detect-node-es@1.1.0", "", {}, "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ=="], + "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "dom-helpers": ["dom-helpers@5.2.1", "", { "dependencies": { "@babel/runtime": "^7.8.7", "csstype": "^3.0.2" } }, "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA=="], + + "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], + + "end-of-stream": ["end-of-stream@1.4.5", "", { "dependencies": { "once": "^1.4.0" } }, "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg=="], + + "enhanced-resolve": ["enhanced-resolve@5.18.4", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.2.0" } }, "sha512-LgQMM4WXU3QI+SYgEc2liRgznaD5ojbmY3sb8LxyguVkIg5FxdpTkvk72te2R38/TGKxH634oLxXRGY6d7AP+Q=="], + + "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], + + "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="], + + "es-object-atoms": ["es-object-atoms@1.1.1", "", { "dependencies": { "es-errors": "^1.3.0" } }, "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA=="], + + "es-set-tostringtag": ["es-set-tostringtag@2.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "get-intrinsic": "^1.2.6", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA=="], + "event-target-shim": ["event-target-shim@5.0.1", "", {}, "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ=="], + "eventemitter3": ["eventemitter3@4.0.7", "", {}, "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw=="], + "events": ["events@3.3.0", "", {}, "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q=="], "exif-parser": ["exif-parser@0.1.12", "", {}, "sha512-c2bQfLNbMzLPmzQuOr8fy0csy84WmwnER81W88DzTp9CYNPJ6yzOj2EZAh9pywYpqHnshVLHQJ8WzldAyfY+Iw=="], + "expand-template": ["expand-template@2.0.3", "", {}, "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg=="], + + "fast-equals": ["fast-equals@5.4.0", "", {}, "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw=="], + "file-type": ["file-type@16.5.4", "", { "dependencies": { "readable-web-to-node-stream": "^3.0.0", "strtok3": "^6.2.4", "token-types": "^4.1.1" } }, "sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw=="], + "file-uri-to-path": ["file-uri-to-path@1.0.0", "", {}, "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw=="], + + "form-data": ["form-data@4.0.5", "", { "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" } }, "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w=="], + + "form-data-encoder": ["form-data-encoder@1.7.2", "", {}, "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A=="], + + "formdata-node": ["formdata-node@4.4.1", "", { "dependencies": { "node-domexception": "1.0.0", "web-streams-polyfill": "4.0.0-beta.3" } }, "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ=="], + + "fs-constants": ["fs-constants@1.0.0", "", {}, "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "get-intrinsic": ["get-intrinsic@1.3.0", "", { "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" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="], + + "get-nonce": ["get-nonce@1.0.1", "", {}, "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q=="], + + "get-proto": ["get-proto@1.0.1", "", { "dependencies": { "dunder-proto": "^1.0.1", "es-object-atoms": "^1.0.0" } }, "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g=="], + "gifwrap": ["gifwrap@0.10.1", "", { "dependencies": { "image-q": "^4.0.0", "omggif": "^1.0.10" } }, "sha512-2760b1vpJHNmLzZ/ubTtNnEx5WApN/PYWJvXvgS+tL1egTTthayFYIQQNi136FLEDcN/IyEY2EcGpIITD6eYUw=="], + "github-from-package": ["github-from-package@0.0.0", "", {}, "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw=="], + + "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], + + "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "humanize-ms": ["humanize-ms@1.2.1", "", { "dependencies": { "ms": "^2.0.0" } }, "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ=="], + "ieee754": ["ieee754@1.2.1", "", {}, "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="], "image-q": ["image-q@4.0.0", "", { "dependencies": { "@types/node": "16.9.1" } }, "sha512-PfJGVgIfKQJuq3s0tTDOKtztksibuUEbJQIYT3by6wctQo+Rdlh7ef4evJ5NCdxY4CfMbvFkocEwbl4BF8RlJw=="], + "inherits": ["inherits@2.0.4", "", {}, "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="], + + "ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="], + + "internmap": ["internmap@2.0.3", "", {}, "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg=="], + "jimp": ["jimp@1.6.0", "", { "dependencies": { "@jimp/core": "1.6.0", "@jimp/diff": "1.6.0", "@jimp/js-bmp": "1.6.0", "@jimp/js-gif": "1.6.0", "@jimp/js-jpeg": "1.6.0", "@jimp/js-png": "1.6.0", "@jimp/js-tiff": "1.6.0", "@jimp/plugin-blit": "1.6.0", "@jimp/plugin-blur": "1.6.0", "@jimp/plugin-circle": "1.6.0", "@jimp/plugin-color": "1.6.0", "@jimp/plugin-contain": "1.6.0", "@jimp/plugin-cover": "1.6.0", "@jimp/plugin-crop": "1.6.0", "@jimp/plugin-displace": "1.6.0", "@jimp/plugin-dither": "1.6.0", "@jimp/plugin-fisheye": "1.6.0", "@jimp/plugin-flip": "1.6.0", "@jimp/plugin-hash": "1.6.0", "@jimp/plugin-mask": "1.6.0", "@jimp/plugin-print": "1.6.0", "@jimp/plugin-quantize": "1.6.0", "@jimp/plugin-resize": "1.6.0", "@jimp/plugin-rotate": "1.6.0", "@jimp/plugin-threshold": "1.6.0", "@jimp/types": "1.6.0", "@jimp/utils": "1.6.0" } }, "sha512-YcwCHw1kiqEeI5xRpDlPPBGL2EOpBKLwO4yIBJcXWHPj5PnA5urGq0jbyhM5KoNpypQ6VboSoxc9D8HyfvngSg=="], + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + "jpeg-js": ["jpeg-js@0.4.4", "", {}, "sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + + "lightningcss": ["lightningcss@1.30.2", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.30.2", "lightningcss-darwin-arm64": "1.30.2", "lightningcss-darwin-x64": "1.30.2", "lightningcss-freebsd-x64": "1.30.2", "lightningcss-linux-arm-gnueabihf": "1.30.2", "lightningcss-linux-arm64-gnu": "1.30.2", "lightningcss-linux-arm64-musl": "1.30.2", "lightningcss-linux-x64-gnu": "1.30.2", "lightningcss-linux-x64-musl": "1.30.2", "lightningcss-win32-arm64-msvc": "1.30.2", "lightningcss-win32-x64-msvc": "1.30.2" } }, "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.30.2", "", { "os": "android", "cpu": "arm64" }, "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.30.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.30.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.30.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.30.2", "", { "os": "linux", "cpu": "arm" }, "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.30.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.30.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.30.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.30.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw=="], + + "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "loose-envify": ["loose-envify@1.4.0", "", { "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } }, "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q=="], + + "lucide-react": ["lucide-react@0.468.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" } }, "sha512-6koYRhnM2N0GGZIdXzSeiNwguv1gt/FAjZOiPl76roBi3xKEXa4WmfpxgQwTTL4KipXjefrnf3oV4IsYhi4JFA=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "math-intrinsics": ["math-intrinsics@1.1.0", "", {}, "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="], + "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], + + "mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], + + "mimic-response": ["mimic-response@3.1.0", "", {}, "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ=="], + + "minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + + "mkdirp-classic": ["mkdirp-classic@0.5.3", "", {}, "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "napi-build-utils": ["napi-build-utils@2.0.0", "", {}, "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA=="], + + "next": ["next@15.5.9", "", { "dependencies": { "@next/env": "15.5.9", "@swc/helpers": "0.5.15", "caniuse-lite": "^1.0.30001579", "postcss": "8.4.31", "styled-jsx": "5.1.6" }, "optionalDependencies": { "@next/swc-darwin-arm64": "15.5.7", "@next/swc-darwin-x64": "15.5.7", "@next/swc-linux-arm64-gnu": "15.5.7", "@next/swc-linux-arm64-musl": "15.5.7", "@next/swc-linux-x64-gnu": "15.5.7", "@next/swc-linux-x64-musl": "15.5.7", "@next/swc-win32-arm64-msvc": "15.5.7", "@next/swc-win32-x64-msvc": "15.5.7", "sharp": "^0.34.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", "@playwright/test": "^1.51.1", "babel-plugin-react-compiler": "*", "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", "sass": "^1.3.0" }, "optionalPeers": ["@opentelemetry/api", "@playwright/test", "babel-plugin-react-compiler", "sass"], "bin": { "next": "dist/bin/next" } }, "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg=="], + + "node-abi": ["node-abi@3.87.0", "", { "dependencies": { "semver": "^7.3.5" } }, "sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ=="], + + "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="], + + "node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + "ollama": ["ollama@0.6.3", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-KEWEhIqE5wtfzEIZbDCLH51VFZ6Z3ZSa6sIOg/E/tBV8S51flyqBOXi+bRxlOYKDf8i327zG9eSTb8IJxvm3Zg=="], "omggif": ["omggif@1.0.10", "", {}, "sha512-LMJTtvgc/nugXj0Vcrrs68Mn2D1r0zf630VNtqtpI1FEO7e+O9FP4gqs9AcnBaSEeoHIPm28u6qgPR0oyEpGSw=="], + "once": ["once@1.4.0", "", { "dependencies": { "wrappy": "1" } }, "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w=="], + "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], "parse-bmfont-ascii": ["parse-bmfont-ascii@1.0.6", "", {}, "sha512-U4RrVsUFCleIOBsIGYOMKjn9PavsGOXxbvYGtMOEfnId0SVNsgehXh1DxUdVPLoxd5mvcEtvmKs2Mmf0Mpa1ZA=="], @@ -164,48 +586,130 @@ "peek-readable": ["peek-readable@4.1.0", "", {}, "sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + "pixelmatch": ["pixelmatch@5.3.0", "", { "dependencies": { "pngjs": "^6.0.0" }, "bin": { "pixelmatch": "bin/pixelmatch" } }, "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q=="], "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "prebuild-install": ["prebuild-install@7.1.3", "", { "dependencies": { "detect-libc": "^2.0.0", "expand-template": "^2.0.3", "github-from-package": "0.0.0", "minimist": "^1.2.3", "mkdirp-classic": "^0.5.3", "napi-build-utils": "^2.0.0", "node-abi": "^3.3.0", "pump": "^3.0.0", "rc": "^1.2.7", "simple-get": "^4.0.0", "tar-fs": "^2.0.0", "tunnel-agent": "^0.6.0" }, "bin": { "prebuild-install": "bin.js" } }, "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug=="], + "process": ["process@0.11.10", "", {}, "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A=="], - "readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + "prop-types": ["prop-types@15.8.1", "", { "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", "react-is": "^16.13.1" } }, "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg=="], + + "pump": ["pump@3.0.3", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA=="], + + "rc": ["rc@1.2.8", "", { "dependencies": { "deep-extend": "^0.6.0", "ini": "~1.3.0", "minimist": "^1.2.0", "strip-json-comments": "~2.0.1" }, "bin": { "rc": "./cli.js" } }, "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw=="], + + "react": ["react@19.2.3", "", {}, "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA=="], + + "react-dom": ["react-dom@19.2.3", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.3" } }, "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg=="], + + "react-is": ["react-is@18.3.1", "", {}, "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="], + + "react-remove-scroll": ["react-remove-scroll@2.7.2", "", { "dependencies": { "react-remove-scroll-bar": "^2.3.7", "react-style-singleton": "^2.2.3", "tslib": "^2.1.0", "use-callback-ref": "^1.3.3", "use-sidecar": "^1.1.3" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q=="], + + "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + + "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], + + "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + + "react-transition-group": ["react-transition-group@4.4.5", "", { "dependencies": { "@babel/runtime": "^7.5.5", "dom-helpers": "^5.0.1", "loose-envify": "^1.4.0", "prop-types": "^15.6.2" }, "peerDependencies": { "react": ">=16.6.0", "react-dom": ">=16.6.0" } }, "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g=="], + + "readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="], "readable-web-to-node-stream": ["readable-web-to-node-stream@3.0.4", "", { "dependencies": { "readable-stream": "^4.7.0" } }, "sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw=="], + "recharts": ["recharts@2.15.4", "", { "dependencies": { "clsx": "^2.0.0", "eventemitter3": "^4.0.1", "lodash": "^4.17.21", "react-is": "^18.3.1", "react-smooth": "^4.0.4", "recharts-scale": "^0.4.4", "tiny-invariant": "^1.3.1", "victory-vendor": "^36.6.8" }, "peerDependencies": { "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw=="], + + "recharts-scale": ["recharts-scale@0.4.5", "", { "dependencies": { "decimal.js-light": "^2.4.1" } }, "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w=="], + "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "sax": ["sax@1.4.4", "", {}, "sha512-1n3r/tGXO6b6VXMdFT54SHzT9ytu9yr7TaELowdYpMqY/Ao7EnlQGmAQ1+RatX7Tkkdm6hONI2owqNx2aZj5Sw=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], + + "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="], + + "sharp": ["sharp@0.34.5", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", "semver": "^7.7.3" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.5", "@img/sharp-darwin-x64": "0.34.5", "@img/sharp-libvips-darwin-arm64": "1.2.4", "@img/sharp-libvips-darwin-x64": "1.2.4", "@img/sharp-libvips-linux-arm": "1.2.4", "@img/sharp-libvips-linux-arm64": "1.2.4", "@img/sharp-libvips-linux-ppc64": "1.2.4", "@img/sharp-libvips-linux-riscv64": "1.2.4", "@img/sharp-libvips-linux-s390x": "1.2.4", "@img/sharp-libvips-linux-x64": "1.2.4", "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", "@img/sharp-libvips-linuxmusl-x64": "1.2.4", "@img/sharp-linux-arm": "0.34.5", "@img/sharp-linux-arm64": "0.34.5", "@img/sharp-linux-ppc64": "0.34.5", "@img/sharp-linux-riscv64": "0.34.5", "@img/sharp-linux-s390x": "0.34.5", "@img/sharp-linux-x64": "0.34.5", "@img/sharp-linuxmusl-arm64": "0.34.5", "@img/sharp-linuxmusl-x64": "0.34.5", "@img/sharp-wasm32": "0.34.5", "@img/sharp-win32-arm64": "0.34.5", "@img/sharp-win32-ia32": "0.34.5", "@img/sharp-win32-x64": "0.34.5" } }, "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg=="], + + "simple-concat": ["simple-concat@1.0.1", "", {}, "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q=="], + + "simple-get": ["simple-get@4.0.1", "", { "dependencies": { "decompress-response": "^6.0.0", "once": "^1.3.1", "simple-concat": "^1.0.0" } }, "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA=="], + "simple-xml-to-json": ["simple-xml-to-json@1.2.3", "", {}, "sha512-kWJDCr9EWtZ+/EYYM5MareWj2cRnZGF93YDNpH4jQiHB+hBIZnfPFSQiVMzZOdk+zXWqTZ/9fTeQNu2DqeiudA=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stage-js": ["stage-js@1.0.0-alpha.17", "", {}, "sha512-AzlMO+t51v6cFvKZ+Oe9DJnL1OXEH5s9bEy6di5aOrUpcP7PCzI/wIeXF0u3zg0L89gwnceoKxrLId0ZpYnNXw=="], "string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], + "strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], + "styled-jsx": ["styled-jsx@5.1.6", "", { "dependencies": { "client-only": "0.0.1" }, "peerDependencies": { "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" } }, "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA=="], + + "tailwind-merge": ["tailwind-merge@2.6.0", "", {}, "sha512-P+Vu1qXfzediirmHOC3xKGAYeZtPcV9g76X+xg2FD4tYgR71ewMA35Y3sCz3zhiN/dwefRpJX0yBcgwi1fXNQA=="], + + "tailwindcss": ["tailwindcss@4.1.18", "", {}, "sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw=="], + + "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + + "tar-fs": ["tar-fs@2.1.4", "", { "dependencies": { "chownr": "^1.1.1", "mkdirp-classic": "^0.5.2", "pump": "^3.0.0", "tar-stream": "^2.1.4" } }, "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ=="], + + "tar-stream": ["tar-stream@2.2.0", "", { "dependencies": { "bl": "^4.0.3", "end-of-stream": "^1.4.1", "fs-constants": "^1.0.0", "inherits": "^2.0.3", "readable-stream": "^3.1.1" } }, "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ=="], + "three": ["three@0.177.0", "", {}, "sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg=="], + "tiny-invariant": ["tiny-invariant@1.3.3", "", {}, "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg=="], + "tinycolor2": ["tinycolor2@1.6.0", "", {}, "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="], "token-types": ["token-types@4.2.1", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "ieee754": "^1.2.1" } }, "sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ=="], + "tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="], + + "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + + "tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="], + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "undici": ["undici@6.23.0", "", {}, "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g=="], - "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="], + "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], + + "use-callback-ref": ["use-callback-ref@1.3.3", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg=="], + + "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], "utif2": ["utif2@4.1.0", "", { "dependencies": { "pako": "^1.0.11" } }, "sha512-+oknB9FHrJ7oW7A2WZYajOcv4FcDR4CfoGB0dPNfxbi4GO05RRnFmt5oa23+9w32EanrYcSJWspUiJkLMs+37w=="], + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "victory-vendor": ["victory-vendor@36.9.2", "", { "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", "@types/d3-interpolate": "^3.0.1", "@types/d3-scale": "^4.0.2", "@types/d3-shape": "^3.1.0", "@types/d3-time": "^3.0.0", "@types/d3-timer": "^3.0.0", "d3-array": "^3.1.6", "d3-ease": "^3.0.1", "d3-interpolate": "^3.0.1", "d3-scale": "^4.0.2", "d3-shape": "^3.1.0", "d3-time": "^3.0.0", "d3-timer": "^3.0.1" } }, "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ=="], + + "web-streams-polyfill": ["web-streams-polyfill@4.0.0-beta.3", "", {}, "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug=="], + "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], + "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], + "whatwg-fetch": ["whatwg-fetch@3.6.20", "", {}, "sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg=="], + "whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="], + + "wrappy": ["wrappy@1.0.2", "", {}, "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="], + "xml-parse-from-string": ["xml-parse-from-string@1.0.1", "", {}, "sha512-ErcKwJTF54uRzzNMXq2X5sMIy88zJvfN2DmdoQvy7PAFJ+tPRU6ydWuOKNMyfmOjdyBQTFREi60s0Y0SyI0G0g=="], "xml2js": ["xml2js@0.5.0", "", { "dependencies": { "sax": ">=0.6.0", "xmlbuilder": "~11.0.0" } }, "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA=="], @@ -216,8 +720,44 @@ "zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], + "@anthropic-ai/sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="], + + "@radix-ui/react-dialog/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-dismissable-layer/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-focus-scope/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-portal/@radix-ui/react-primitive": ["@radix-ui/react-primitive@2.1.3", "", { "dependencies": { "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ=="], + + "@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="], + + "@rta/web/ollama": ["ollama@0.5.18", "", { "dependencies": { "whatwg-fetch": "^3.6.20" } }, "sha512-lTFqTf9bo7Cd3hpF6CviBe/DEhewjoZYd9N/uCe7O20qYTvGqrNOFOBDj3lbZgFWHUgDv5EeyusYxsZSLS8nvg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.1.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + + "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], + + "@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], + "image-q/@types/node": ["@types/node@16.9.1", "", {}, "sha512-QpLcX9ZSsq3YYUUnD3nFDY8H7wctAhQj/TFKL8Ya8v5fMm3CFXxo8zStsLAl780ltoYoo1WvKUVGBQK+1ifr7g=="], + "next/postcss": ["postcss@8.4.31", "", { "dependencies": { "nanoid": "^3.3.6", "picocolors": "^1.0.0", "source-map-js": "^1.0.2" } }, "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ=="], + "pixelmatch/pngjs": ["pngjs@6.0.0", "", {}, "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg=="], + + "prop-types/react-is": ["react-is@16.13.1", "", {}, "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="], + + "readable-web-to-node-stream/readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="], + + "@anthropic-ai/sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="], + + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], } } diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/data/settings.json b/data/settings.json new file mode 100644 index 0000000..177c42b --- /dev/null +++ b/data/settings.json @@ -0,0 +1,5 @@ +{ + "summarizationModel": "llava-llama3:latest", + "sentimentModel": "llava-llama3:latest", + "embeddingModel": "nomic-embed-text" +} \ No newline at end of file diff --git a/package.json b/package.json index 4f46e3d..13aca54 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,20 @@ { "name": "reddit-trend-analyzer", "version": "1.0.0", - "module": "src/index.ts", - "type": "module", "private": true, + "type": "module", + "workspaces": [ + "packages/*" + ], "scripts": { - "start": "bun run src/cli.ts", - "tui": "bun run src/index.ts", - "dev": "bun --watch run src/cli.ts" + "cli": "bun run packages/cli/src/cli.ts", + "tui": "bun run packages/cli/src/index.ts", + "dev": "bun run --filter @rta/web dev", + "build": "bun run --filter @rta/web build", + "start": "bun run build && bun run --filter @rta/web start" }, "devDependencies": { "@types/bun": "latest", "typescript": "^5.0.0" - }, - "dependencies": { - "@opentui/core": "^0.1.74", - "@qdrant/js-client-rest": "^1.16.2", - "ollama": "^0.6.3" } } diff --git a/packages/cli/package.json b/packages/cli/package.json new file mode 100644 index 0000000..2ba1c14 --- /dev/null +++ b/packages/cli/package.json @@ -0,0 +1,18 @@ +{ + "name": "@rta/cli", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "bin": { + "rta": "./src/cli.ts" + }, + "scripts": { + "start": "bun run src/cli.ts", + "tui": "bun run src/index.ts" + }, + "dependencies": { + "@opentui/core": "^0.1.74", + "@rta/core": "workspace:*" + } +} diff --git a/src/cli.ts b/packages/cli/src/cli.ts similarity index 67% rename from src/cli.ts rename to packages/cli/src/cli.ts index 511917e..7c33184 100644 --- a/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -1,10 +1,15 @@ import * as readline from 'readline' -import { RedditScraper } from './scraper/reddit' -import { CommentFetcher } from './scraper/comments' -import { EmbeddingPipeline } from './embeddings/ollama' -import { QdrantStorage } from './storage/qdrant' -import type { RedditComment } from './scraper/types' -import type { SearchResult } from './storage/types' +import { + RedditScraper, + CommentFetcher, + EmbeddingPipeline, + QdrantStorage, + SQLiteStorage, + ClusteringPipeline, + ProblemSummarizer, + QuestionExtractor, +} from '@rta/core' +import type { RedditComment, SearchResult } from '@rta/core' const rl = readline.createInterface({ input: process.stdin, @@ -58,6 +63,9 @@ async function main() { console.log('\ncommands:') console.log(' scrape [pages] - scrape subreddit (e.g. scrape https://reddit.com/r/vfx/best 3)') console.log(' search - semantic search') + console.log(' cluster [threshold] - run clustering pipeline (default threshold 0.5)') + console.log(' problems - list problem clusters') + console.log(' questions [clusterId] - list extracted questions') console.log(' stats - show collection stats') console.log(' export json|csv - export last search results') console.log(' quit - exit\n') @@ -176,6 +184,98 @@ async function main() { continue } + if (cmd === 'cluster') { + const threshold = parseFloat(args[0] || '0.5') + console.log(`\nrunning clustering with threshold ${threshold}...\n`) + + try { + const clustering = new ClusteringPipeline(storage) + const result = await clustering.runClustering({ + minClusterSize: 2, + similarityThreshold: threshold, + samplesPerCluster: 10, + }) + + console.log(`found ${result.clusters.length} clusters (${result.stats.noisePoints} noise points)`) + + if (result.clusters.length > 0) { + console.log('\nsummarizing clusters...') + const summarizer = new ProblemSummarizer() + const summaries = await summarizer.summarizeClusters(result.clusters) + + const sqlite = new SQLiteStorage() + sqlite.saveClusters(summaries) + + console.log('extracting questions...') + const extractor = new QuestionExtractor() + const questionsByCluster = extractor.extractFromClusters(result.clusters) + + for (const [, questions] of questionsByCluster) { + sqlite.saveQuestions(questions) + } + + sqlite.close() + console.log(`\nsaved ${summaries.length} problem summaries to database`) + } + } catch (err) { + console.error('error:', err instanceof Error ? err.message : err) + } + continue + } + + if (cmd === 'problems') { + try { + const sqlite = new SQLiteStorage() + const clusters = sqlite.getClusters() + sqlite.close() + + if (clusters.length === 0) { + console.log('\nno problems found. run `cluster` first.') + continue + } + + console.log(`\n${clusters.length} problem clusters:\n`) + for (const c of clusters) { + console.log(`[${c.clusterId}] ${c.problem}`) + console.log(` size: ${c.size} | engagement: ${c.totalEngagement.toLocaleString()} | subreddits: ${c.subreddits.join(', ')}`) + console.log(` ${c.description.slice(0, 120)}...`) + console.log() + } + } catch (err) { + console.error('error:', err instanceof Error ? err.message : err) + } + continue + } + + if (cmd === 'questions') { + try { + const sqlite = new SQLiteStorage() + const clusterId = args[0] ? parseInt(args[0], 10) : undefined + const questions = sqlite.getQuestions(clusterId) + sqlite.close() + + if (questions.length === 0) { + console.log('\nno questions found. run `cluster` first.') + continue + } + + console.log(`\n${questions.length} questions${clusterId !== undefined ? ` (cluster ${clusterId})` : ''}:\n`) + for (const q of questions.slice(0, 20)) { + const status = q.addressed ? '[x]' : '[ ]' + console.log(`${status} ${q.text}`) + console.log(` cluster: ${q.clusterId} | engagement: ${q.engagement}`) + console.log() + } + + if (questions.length > 20) { + console.log(`... and ${questions.length - 20} more questions`) + } + } catch (err) { + console.error('error:', err instanceof Error ? err.message : err) + } + continue + } + if (cmd === 'export') { const format = args[0] if (!format || !['json', 'csv'].includes(format)) { diff --git a/src/index.ts b/packages/cli/src/index.ts similarity index 56% rename from src/index.ts rename to packages/cli/src/index.ts index 55001b1..a84af03 100644 --- a/src/index.ts +++ b/packages/cli/src/index.ts @@ -1,4 +1,5 @@ import { createApp } from './tui/app' +import { EmbeddingPipeline, QdrantStorage } from '@rta/core' async function main() { console.clear() @@ -23,12 +24,8 @@ async function main() { async function checkOllama(): Promise { try { - const { Ollama } = await import('ollama') - const client = new Ollama({ - host: process.env.OLLAMA_HOST || 'http://localhost:11434', - }) - const models = await client.list() - return models.models.some(m => m.name.includes('nomic-embed-text')) + const embeddings = new EmbeddingPipeline() + return await embeddings.checkConnection() } catch { return false } @@ -36,18 +33,8 @@ async function checkOllama(): Promise { async function checkQdrant(): Promise { try { - const { QdrantClient } = await import('@qdrant/js-client-rest') - const qdrantUrl = process.env.QDRANT_URL || 'http://localhost:6333' - const parsedUrl = new URL(qdrantUrl) - - const client = new QdrantClient({ - host: parsedUrl.hostname, - port: parsedUrl.port ? parseInt(parsedUrl.port) : (parsedUrl.protocol === 'https:' ? 443 : 6333), - https: parsedUrl.protocol === 'https:', - apiKey: process.env.QDRANT_API_KEY, - }) - await client.getCollections() - return true + const storage = new QdrantStorage() + return await storage.checkConnection() } catch { return false } diff --git a/src/tui/app.ts b/packages/cli/src/tui/app.ts similarity index 95% rename from src/tui/app.ts rename to packages/cli/src/tui/app.ts index 44e5251..5e5b181 100644 --- a/src/tui/app.ts +++ b/packages/cli/src/tui/app.ts @@ -5,12 +5,13 @@ import { type KeyEvent, } from '@opentui/core' -import { RedditScraper } from '../scraper/reddit' -import { CommentFetcher } from '../scraper/comments' -import { EmbeddingPipeline } from '../embeddings/ollama' -import { QdrantStorage } from '../storage/qdrant' -import type { RedditComment } from '../scraper/types' -import type { SearchResult } from '../storage/types' +import { + RedditScraper, + CommentFetcher, + EmbeddingPipeline, + QdrantStorage, +} from '@rta/core' +import type { RedditComment, SearchResult } from '@rta/core' import { createUrlInput, focusUrlInput } from './components/url-input' import { createProgressPanel, updateProgress, resetProgress } from './components/progress' diff --git a/src/tui/components/export.ts b/packages/cli/src/tui/components/export.ts similarity index 97% rename from src/tui/components/export.ts rename to packages/cli/src/tui/components/export.ts index 5314a0b..d03ede2 100644 --- a/src/tui/components/export.ts +++ b/packages/cli/src/tui/components/export.ts @@ -3,7 +3,7 @@ import { TextRenderable, type RenderContext, } from '@opentui/core' -import type { SearchResult } from '../../storage/types' +import type { SearchResult } from '@rta/core' export function createExportBar(renderer: RenderContext): BoxRenderable { const container = new BoxRenderable(renderer, { diff --git a/src/tui/components/progress.ts b/packages/cli/src/tui/components/progress.ts similarity index 100% rename from src/tui/components/progress.ts rename to packages/cli/src/tui/components/progress.ts diff --git a/src/tui/components/search.ts b/packages/cli/src/tui/components/search.ts similarity index 97% rename from src/tui/components/search.ts rename to packages/cli/src/tui/components/search.ts index 68a86c8..69f9b29 100644 --- a/src/tui/components/search.ts +++ b/packages/cli/src/tui/components/search.ts @@ -5,7 +5,7 @@ import { InputRenderableEvents, type RenderContext, } from '@opentui/core' -import type { SearchResult } from '../../storage/types' +import type { SearchResult } from '@rta/core' export interface SearchConfig { onSearch: (query: string) => Promise diff --git a/src/tui/components/stats.ts b/packages/cli/src/tui/components/stats.ts similarity index 95% rename from src/tui/components/stats.ts rename to packages/cli/src/tui/components/stats.ts index 49a320e..c905f94 100644 --- a/src/tui/components/stats.ts +++ b/packages/cli/src/tui/components/stats.ts @@ -3,7 +3,7 @@ import { TextRenderable, type RenderContext, } from '@opentui/core' -import type { CollectionStats } from '../../storage/types' +import type { CollectionStats } from '@rta/core' export function createStatsPanel(renderer: RenderContext): BoxRenderable { const container = new BoxRenderable(renderer, { diff --git a/src/tui/components/trending.ts b/packages/cli/src/tui/components/trending.ts similarity index 100% rename from src/tui/components/trending.ts rename to packages/cli/src/tui/components/trending.ts diff --git a/src/tui/components/url-input.ts b/packages/cli/src/tui/components/url-input.ts similarity index 100% rename from src/tui/components/url-input.ts rename to packages/cli/src/tui/components/url-input.ts diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..d49ba59 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,25 @@ +{ + "name": "@rta/core", + "version": "1.0.0", + "private": true, + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts", + "./scraper": "./src/scraper/index.ts", + "./embeddings": "./src/embeddings/index.ts", + "./storage": "./src/storage/index.ts", + "./clustering": "./src/clustering/index.ts", + "./analysis": "./src/analysis/index.ts", + "./utils": "./src/utils/index.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.32.1", + "@qdrant/js-client-rest": "^1.16.2", + "better-sqlite3": "^11.7.0", + "ollama": "^0.6.3" + }, + "devDependencies": { + "@types/better-sqlite3": "^7.6.12" + } +} diff --git a/packages/core/src/analysis/index.ts b/packages/core/src/analysis/index.ts new file mode 100644 index 0000000..79cb34b --- /dev/null +++ b/packages/core/src/analysis/index.ts @@ -0,0 +1,12 @@ +export { ProblemSummarizer } from './summarizer' +export { QuestionExtractor } from './questions' +export { EngagementScorer } from './scoring' +export { SentimentAnalyzer } from './sentiment' +export { computeKeywordSimilarity } from './similarity' +export type { SimilarityMatrix } from './similarity' +export type { + ProblemSummary, + ExtractedQuestion, + ScoringWeights, + ScoredCluster, +} from './types' diff --git a/packages/core/src/analysis/questions.ts b/packages/core/src/analysis/questions.ts new file mode 100644 index 0000000..03e3593 --- /dev/null +++ b/packages/core/src/analysis/questions.ts @@ -0,0 +1,138 @@ +import type { ClusterPoint, Cluster } from '../clustering/types' +import type { ExtractedQuestion } from './types' +import type { EmbeddingPipeline } from '../embeddings/ollama' + +const QUESTION_PATTERNS = [ + /how (?:do|can|should|would) (?:i|we|you)/i, + /why (?:does|is|are|do|did)/i, + /what (?:is|are|should|would|does)/i, + /is there (?:a|any|an) way/i, + /can (?:i|you|we|someone)/i, + /should (?:i|we)/i, + /what's the (?:best|easiest|fastest)/i, + /does anyone (?:know|have)/i, + /has anyone (?:tried|used|done)/i, + /any (?:suggestions|recommendations|ideas|tips)/i, + /help (?:with|me)/i, + /\?$/, +] + +const DEDUP_SIMILARITY_THRESHOLD = 0.9 + +export class QuestionExtractor { + private embeddings?: EmbeddingPipeline + + constructor(embeddings?: EmbeddingPipeline) { + this.embeddings = embeddings + } + + extractQuestionsFromText(text: string): string[] { + const sentences = text.split(/[.!?\n]+/).map(s => s.trim()).filter(s => s.length > 10) + const questions: string[] = [] + + for (const sentence of sentences) { + for (const pattern of QUESTION_PATTERNS) { + if (pattern.test(sentence)) { + const cleaned = sentence.replace(/^\s*[-*•]\s*/, '').trim() + if (cleaned.length > 15 && cleaned.length < 500) { + questions.push(cleaned) + } + break + } + } + } + + return questions + } + + extractFromClusterPoints(points: ClusterPoint[], clusterId: number): ExtractedQuestion[] { + const questions: ExtractedQuestion[] = [] + let questionId = 0 + + for (const point of points) { + const text = point.payload.title + ? `${point.payload.title} ${point.payload.body}` + : point.payload.body + + const extracted = this.extractQuestionsFromText(text) + + for (const q of extracted) { + questions.push({ + id: `q-${clusterId}-${questionId++}`, + text: q, + clusterId, + sourcePointId: point.id, + engagement: point.payload.score, + addressed: false, + }) + } + } + + return questions.sort((a, b) => b.engagement - a.engagement) + } + + extractFromClusters(clusters: Cluster[]): Map { + const questionsByCluster = new Map() + + for (const cluster of clusters) { + const questions = this.extractFromClusterPoints(cluster.samples, cluster.id) + questionsByCluster.set(cluster.id, questions) + } + + return questionsByCluster + } + + async deduplicateQuestions(questions: ExtractedQuestion[]): Promise { + if (!this.embeddings || questions.length <= 1) { + return questions + } + + const texts = questions.map(q => q.text) + const embeddings = await this.embeddings.embedBatch(texts) + + const deduped: ExtractedQuestion[] = [] + const used = new Set() + + for (let i = 0; i < questions.length; i++) { + if (used.has(i)) continue + + const question = questions[i] + const embedding = embeddings[i] + if (!question || !embedding) continue + + deduped.push(question) + used.add(i) + + for (let j = i + 1; j < questions.length; j++) { + if (used.has(j)) continue + + const otherEmbedding = embeddings[j] + if (!otherEmbedding) continue + + const similarity = this.cosineSimilarity(embedding, otherEmbedding) + if (similarity >= DEDUP_SIMILARITY_THRESHOLD) { + used.add(j) + } + } + } + + return deduped + } + + private cosineSimilarity(a: number[], b: number[]): number { + let dotProduct = 0 + let normA = 0 + let normB = 0 + + for (let i = 0; i < a.length; i++) { + const aVal = a[i] ?? 0 + const bVal = b[i] ?? 0 + dotProduct += aVal * bVal + normA += aVal * aVal + normB += bVal * bVal + } + + if (normA === 0 || normB === 0) return 0 + return dotProduct / (Math.sqrt(normA) * Math.sqrt(normB)) + } +} diff --git a/packages/core/src/analysis/scoring.ts b/packages/core/src/analysis/scoring.ts new file mode 100644 index 0000000..2ddb26f --- /dev/null +++ b/packages/core/src/analysis/scoring.ts @@ -0,0 +1,107 @@ +import type { Cluster } from '../clustering/types' +import type { ScoringWeights, ScoredCluster } from './types' +import { SentimentAnalyzer } from './sentiment' + +const DEFAULT_WEIGHTS: ScoringWeights = { + engagement: 0.5, + sentiment: 0.2, + velocity: 0.3, +} + +export class EngagementScorer { + private weights: ScoringWeights + private sentimentAnalyzer: SentimentAnalyzer | null = null + + constructor(weights?: Partial) { + this.weights = { ...DEFAULT_WEIGHTS, ...weights } + } + + setWeights(weights: Partial): void { + this.weights = { ...this.weights, ...weights } + } + + enableSentiment(): void { + this.sentimentAnalyzer = new SentimentAnalyzer() + } + + async scoreClustersAsync(clusters: Cluster[]): Promise { + if (clusters.length === 0) return [] + + const maxEngagement = Math.max(...clusters.map(c => c.totalEngagement)) + const now = Math.floor(Date.now() / 1000) + const oneWeek = 7 * 24 * 60 * 60 + + const scored: ScoredCluster[] = [] + + for (const cluster of clusters) { + const engagementScore = maxEngagement > 0 + ? cluster.totalEngagement / maxEngagement + : 0 + + const age = now - cluster.lastActive + const velocityScore = Math.max(0, 1 - (age / oneWeek)) + + let sentimentScore = 0.5 + if (this.sentimentAnalyzer && cluster.samples.length > 0) { + const sampleTexts = cluster.samples + .slice(0, 5) + .map(s => s.payload.title || s.payload.body) + .filter(Boolean) + sentimentScore = await this.sentimentAnalyzer.analyzeClusterSentiment(sampleTexts) + } + + const impactScore = + this.weights.engagement * engagementScore + + this.weights.sentiment * sentimentScore + + this.weights.velocity * velocityScore + + scored.push({ + ...cluster, + impactScore, + }) + } + + return scored.sort((a, b) => b.impactScore - a.impactScore) + } + + scoreClusters(clusters: Cluster[]): ScoredCluster[] { + if (clusters.length === 0) return [] + + const maxEngagement = Math.max(...clusters.map(c => c.totalEngagement)) + const now = Math.floor(Date.now() / 1000) + const oneWeek = 7 * 24 * 60 * 60 + + return clusters.map(cluster => { + const engagementScore = maxEngagement > 0 + ? cluster.totalEngagement / maxEngagement + : 0 + + const age = now - cluster.lastActive + const velocityScore = Math.max(0, 1 - (age / oneWeek)) + + const sentimentScore = 0.5 + + const impactScore = + this.weights.engagement * engagementScore + + this.weights.sentiment * sentimentScore + + this.weights.velocity * velocityScore + + return { + ...cluster, + impactScore, + } + }).sort((a, b) => b.impactScore - a.impactScore) + } + + rankByEngagement(clusters: Cluster[]): Cluster[] { + return [...clusters].sort((a, b) => b.totalEngagement - a.totalEngagement) + } + + rankByRecency(clusters: Cluster[]): Cluster[] { + return [...clusters].sort((a, b) => b.lastActive - a.lastActive) + } + + rankBySize(clusters: Cluster[]): Cluster[] { + return [...clusters].sort((a, b) => b.size - a.size) + } +} diff --git a/packages/core/src/analysis/sentiment.ts b/packages/core/src/analysis/sentiment.ts new file mode 100644 index 0000000..0d2eb9b --- /dev/null +++ b/packages/core/src/analysis/sentiment.ts @@ -0,0 +1,97 @@ +import { Ollama } from 'ollama' +import { RateLimiter } from '../utils/rate-limit' + +const DEFAULT_MODEL = 'llama3.2' +const BATCH_SIZE = 5 + +type Sentiment = 'positive' | 'neutral' | 'negative' + +export class SentimentAnalyzer { + private ollama: Ollama + private model: string + private rateLimiter: RateLimiter + private cache: Map + + constructor(options?: { host?: string; model?: string }) { + this.ollama = new Ollama({ + host: options?.host || process.env.OLLAMA_HOST || 'http://localhost:11434', + }) + this.model = options?.model || DEFAULT_MODEL + this.rateLimiter = new RateLimiter(500) + this.cache = new Map() + } + + private sentimentToScore(sentiment: Sentiment): number { + switch (sentiment) { + case 'positive': return 1.0 + case 'neutral': return 0.5 + case 'negative': return 0.0 + default: return 0.5 + } + } + + private parseSentiment(response: string): Sentiment { + const lower = response.toLowerCase().trim() + if (lower.includes('positive')) return 'positive' + if (lower.includes('negative')) return 'negative' + return 'neutral' + } + + private textKey(text: string): string { + return text.slice(0, 200) + } + + async analyzeSingle(text: string): Promise { + const key = this.textKey(text) + const cached = this.cache.get(key) + if (cached !== undefined) return cached + + await this.rateLimiter.wait() + + try { + const response = await this.ollama.generate({ + model: this.model, + prompt: `Analyze the sentiment of this text. Return only one word: positive, negative, or neutral.\n\nText: "${text.slice(0, 500)}"`, + stream: false, + }) + + const sentiment = this.parseSentiment(response.response) + const score = this.sentimentToScore(sentiment) + this.cache.set(key, score) + return score + } catch (err) { + return 0.5 + } + } + + async analyzeBatch(texts: string[]): Promise { + const results: number[] = [] + + for (let i = 0; i < texts.length; i += BATCH_SIZE) { + const batch = texts.slice(i, i + BATCH_SIZE) + const batchResults = await Promise.all( + batch.map(text => this.analyzeSingle(text)) + ) + results.push(...batchResults) + } + + return results + } + + async analyzeClusterSentiment(sampleTexts: string[]): Promise { + if (sampleTexts.length === 0) return 0.5 + + const scores = await this.analyzeBatch(sampleTexts) + const sum = scores.reduce((a, b) => a + b, 0) + return sum / scores.length + } + + async checkConnection(): Promise { + try { + const result = await this.ollama.list() + return result.models.some(m => m.name.startsWith(this.model)) + } catch { + return false + } + } +} diff --git a/packages/core/src/analysis/similarity.ts b/packages/core/src/analysis/similarity.ts new file mode 100644 index 0000000..2f1e258 --- /dev/null +++ b/packages/core/src/analysis/similarity.ts @@ -0,0 +1,41 @@ +import type { ProblemSummary } from './types' + +export interface SimilarityMatrix { + matrix: number[][] + labels: string[] + clusterIds: number[] +} + +/** + * compute jaccard similarity between clusters based on keywords + * jaccard = |A ∩ B| / |A ∪ B| + */ +export function computeKeywordSimilarity(clusters: ProblemSummary[]): SimilarityMatrix { + const n = clusters.length + const matrix: number[][] = Array(n).fill(null).map(() => Array(n).fill(0)) + + for (let i = 0; i < n; i++) { + for (let j = i; j < n; j++) { + if (i === j) { + matrix[i][j] = 1 + continue + } + + const setA = new Set(clusters[i].keywords.map(k => k.toLowerCase())) + const setB = new Set(clusters[j].keywords.map(k => k.toLowerCase())) + + const intersection = [...setA].filter(k => setB.has(k)).length + const union = new Set([...setA, ...setB]).size + const similarity = union > 0 ? intersection / union : 0 + + matrix[i][j] = similarity + matrix[j][i] = similarity + } + } + + return { + matrix, + labels: clusters.map(c => c.problem.slice(0, 25) + (c.problem.length > 25 ? '...' : '')), + clusterIds: clusters.map(c => c.clusterId), + } +} diff --git a/packages/core/src/analysis/summarizer.ts b/packages/core/src/analysis/summarizer.ts new file mode 100644 index 0000000..d74d982 --- /dev/null +++ b/packages/core/src/analysis/summarizer.ts @@ -0,0 +1,146 @@ +import { Ollama } from 'ollama' +import type { Cluster } from '../clustering/types' +import type { ProblemSummary, SummarizationProgress } from './types' +import { delay } from '../utils/rate-limit' + +const DEFAULT_MODEL = 'llama3.2' +const RATE_LIMIT_DELAY = 500 + +const SYSTEM_PROMPT = `You are an expert at identifying recurring problems from online discussions. +Given a cluster of related discussions from Reddit, extract the common problem pattern. + +Respond ONLY with valid JSON in this exact format: +{ + "problem": "concise one-line problem statement (max 100 chars)", + "description": "2-3 sentences explaining the problem", + "keywords": ["keyword1", "keyword2", "keyword3"], + "sampleQuestions": ["question 1?", "question 2?", "question 3?"], + "actionableInsight": "brief suggestion for content/tool opportunity" +}` + +interface SummaryResponse { + problem: string + description: string + keywords: string[] + sampleQuestions: string[] + actionableInsight: string +} + +export class ProblemSummarizer { + private ollama: Ollama + private model: string + private onProgress?: (progress: SummarizationProgress) => void + + constructor(options?: { host?: string; model?: string }) { + this.ollama = new Ollama({ + host: options?.host || process.env.OLLAMA_HOST || 'http://localhost:11434', + }) + this.model = options?.model || DEFAULT_MODEL + } + + setProgressCallback(callback: (progress: SummarizationProgress) => void): void { + this.onProgress = callback + } + + private emitProgress(progress: SummarizationProgress): void { + this.onProgress?.(progress) + } + + async summarizeCluster(cluster: Cluster): Promise { + const sampleTexts = cluster.samples + .slice(0, 5) + .map((sample, i) => { + const title = sample.payload.title || '' + const body = sample.payload.body.slice(0, 300) + const score = sample.payload.score + return `[${score} upvotes] ${title}\n${body}` + }) + .join('\n\n') + + const prompt = `${SYSTEM_PROMPT} + +Analyze these ${cluster.size} related discussions from r/${cluster.subreddits.join(', r/')}: + +${sampleTexts} + +JSON response:` + + const response = await this.ollama.generate({ + model: this.model, + prompt, + stream: false, + }) + + let parsed: SummaryResponse + try { + const jsonMatch = response.response.match(/\{[\s\S]*\}/) + if (!jsonMatch) { + throw new Error('No JSON found in response') + } + parsed = JSON.parse(jsonMatch[0]) + } catch (e) { + parsed = { + problem: `Cluster ${cluster.id} discussion pattern`, + description: 'Unable to extract summary from cluster.', + keywords: cluster.subreddits, + sampleQuestions: [], + actionableInsight: 'Review cluster manually.', + } + } + + return { + clusterId: cluster.id, + problem: parsed.problem || `Cluster ${cluster.id}`, + description: parsed.description || '', + keywords: parsed.keywords || [], + sampleQuestions: parsed.sampleQuestions || [], + actionableInsight: parsed.actionableInsight || '', + size: cluster.size, + totalEngagement: cluster.totalEngagement, + lastActive: cluster.lastActive, + subreddits: cluster.subreddits, + samplePointIds: cluster.samples.map(s => s.id), + } + } + + async summarizeClusters(clusters: Cluster[]): Promise { + const summaries: ProblemSummary[] = [] + + for (let i = 0; i < clusters.length; i++) { + const cluster = clusters[i] + if (!cluster) continue + + this.emitProgress({ + current: i + 1, + total: clusters.length, + message: `Summarizing cluster ${i + 1}/${clusters.length}`, + }) + + try { + const summary = await this.summarizeCluster(cluster) + summaries.push(summary) + } catch (error) { + console.error(`Failed to summarize cluster ${cluster.id}:`, error) + summaries.push({ + clusterId: cluster.id, + problem: `Cluster ${cluster.id} (summary failed)`, + description: 'Failed to generate summary for this cluster.', + keywords: [], + sampleQuestions: [], + actionableInsight: '', + size: cluster.size, + totalEngagement: cluster.totalEngagement, + lastActive: cluster.lastActive, + subreddits: cluster.subreddits, + samplePointIds: cluster.samples.map(s => s.id), + }) + } + + if (i < clusters.length - 1) { + await delay(RATE_LIMIT_DELAY) + } + } + + return summaries + } +} diff --git a/packages/core/src/analysis/types.ts b/packages/core/src/analysis/types.ts new file mode 100644 index 0000000..499d4c3 --- /dev/null +++ b/packages/core/src/analysis/types.ts @@ -0,0 +1,40 @@ +import type { Cluster } from '../clustering/types' + +export interface ProblemSummary { + clusterId: number + problem: string + description: string + keywords: string[] + sampleQuestions: string[] + actionableInsight: string + size: number + totalEngagement: number + lastActive: number + subreddits: string[] + samplePointIds?: string[] +} + +export interface ExtractedQuestion { + id: string + text: string + clusterId: number + sourcePointId: string + engagement: number + addressed: boolean +} + +export interface ScoringWeights { + engagement: number + sentiment: number + velocity: number +} + +export interface ScoredCluster extends Cluster { + impactScore: number +} + +export interface SummarizationProgress { + current: number + total: number + message: string +} diff --git a/packages/core/src/clustering/hdbscan.ts b/packages/core/src/clustering/hdbscan.ts new file mode 100644 index 0000000..b56e182 --- /dev/null +++ b/packages/core/src/clustering/hdbscan.ts @@ -0,0 +1,214 @@ +import type { QdrantStorage } from '../storage/qdrant' +import type { + Cluster, + ClusterPoint, + ClusteringOptions, + ClusteringResult, +} from './types' + +const DEFAULT_MIN_CLUSTER_SIZE = 2 +const DEFAULT_SIMILARITY_THRESHOLD = 0.5 +const DEFAULT_SAMPLES_PER_CLUSTER = 10 + +export class ClusteringPipeline { + private storage: QdrantStorage + + constructor(storage: QdrantStorage) { + this.storage = storage + } + + async runClustering(options: ClusteringOptions = {}): Promise { + const minClusterSize = options.minClusterSize ?? DEFAULT_MIN_CLUSTER_SIZE + const similarityThreshold = options.similarityThreshold ?? DEFAULT_SIMILARITY_THRESHOLD + const samplesPerCluster = options.samplesPerCluster ?? DEFAULT_SAMPLES_PER_CLUSTER + + const points = await this.storage.scrollWithVectors() + if (points.length === 0) { + return { + clusters: [], + noise: [], + stats: { + totalPoints: 0, + clusteredPoints: 0, + noisePoints: 0, + clusterCount: 0, + }, + } + } + + const labels = this.densityClustering(points, similarityThreshold, minClusterSize) + + const clusterMap = new Map() + const noise: ClusterPoint[] = [] + + for (let i = 0; i < labels.length; i++) { + const label = labels[i] + const point = points[i] + if (!point) continue + + if (label === -1) { + noise.push(point) + } else { + const existing = clusterMap.get(label) || [] + existing.push(point) + clusterMap.set(label, existing) + } + } + + const clusters: Cluster[] = [] + let clusterId = 0 + + for (const [, clusterPoints] of clusterMap) { + if (clusterPoints.length < minClusterSize) { + noise.push(...clusterPoints) + continue + } + + const centroid = this.calculateCentroid(clusterPoints) + const totalEngagement = clusterPoints.reduce((sum, p) => sum + p.payload.score, 0) + const lastActive = Math.max(...clusterPoints.map(p => p.payload.created)) + const subreddits = [...new Set(clusterPoints.map(p => p.payload.subreddit))] + + const sortedByEngagement = [...clusterPoints].sort( + (a, b) => b.payload.score - a.payload.score + ) + const samples = sortedByEngagement.slice(0, samplesPerCluster) + + clusters.push({ + id: clusterId++, + size: clusterPoints.length, + centroid, + totalEngagement, + lastActive, + subreddits, + samples, + }) + } + + clusters.sort((a, b) => b.totalEngagement - a.totalEngagement) + + return { + clusters, + noise, + stats: { + totalPoints: points.length, + clusteredPoints: points.length - noise.length, + noisePoints: noise.length, + clusterCount: clusters.length, + }, + } + } + + private densityClustering( + points: ClusterPoint[], + similarityThreshold: number, + minSize: number + ): number[] { + const n = points.length + const labels = new Array(n).fill(-1) + const visited = new Set() + let currentCluster = 0 + + for (let i = 0; i < n; i++) { + if (visited.has(i)) continue + + const neighbors = this.findNeighbors(points, i, similarityThreshold) + + if (neighbors.length < minSize) { + visited.add(i) + continue + } + + labels[i] = currentCluster + visited.add(i) + + const queue = [...neighbors] + while (queue.length > 0) { + const j = queue.shift()! + if (visited.has(j)) continue + + visited.add(j) + labels[j] = currentCluster + + const jNeighbors = this.findNeighbors(points, j, similarityThreshold) + if (jNeighbors.length >= minSize) { + for (const k of jNeighbors) { + if (!visited.has(k)) { + queue.push(k) + } + } + } + } + + currentCluster++ + } + + return labels + } + + private findNeighbors( + points: ClusterPoint[], + idx: number, + threshold: number + ): number[] { + const neighbors: number[] = [] + const point = points[idx] + if (!point) return neighbors + + for (let i = 0; i < points.length; i++) { + if (i === idx) continue + const other = points[i] + if (!other) continue + + const sim = this.cosineSimilarity(point.vector, other.vector) + if (sim >= threshold) { + neighbors.push(i) + } + } + + return neighbors + } + + private cosineSimilarity(a: number[], b: number[]): number { + let dot = 0 + let normA = 0 + let normB = 0 + + for (let i = 0; i < a.length; i++) { + const aVal = a[i] ?? 0 + const bVal = b[i] ?? 0 + dot += aVal * bVal + normA += aVal * aVal + normB += bVal * bVal + } + + if (normA === 0 || normB === 0) return 0 + return dot / (Math.sqrt(normA) * Math.sqrt(normB)) + } + + private calculateCentroid(points: ClusterPoint[]): number[] { + if (points.length === 0) return [] + + const dim = points[0]?.vector.length ?? 0 + const centroid = new Array(dim).fill(0) + + for (const point of points) { + for (let i = 0; i < dim; i++) { + centroid[i] += point.vector[i] ?? 0 + } + } + + for (let i = 0; i < dim; i++) { + centroid[i] /= points.length + } + + const magnitude = Math.sqrt(centroid.reduce((sum, v) => sum + v * v, 0)) + if (magnitude > 0) { + for (let i = 0; i < dim; i++) { + centroid[i] /= magnitude + } + } + + return centroid + } +} diff --git a/packages/core/src/clustering/index.ts b/packages/core/src/clustering/index.ts new file mode 100644 index 0000000..349ca13 --- /dev/null +++ b/packages/core/src/clustering/index.ts @@ -0,0 +1,2 @@ +export { ClusteringPipeline } from './hdbscan' +export type { Cluster, ClusterPoint, ClusteringOptions, ClusteringResult } from './types' diff --git a/packages/core/src/clustering/types.ts b/packages/core/src/clustering/types.ts new file mode 100644 index 0000000..c5c153a --- /dev/null +++ b/packages/core/src/clustering/types.ts @@ -0,0 +1,34 @@ +import type { PointPayload } from '../storage/types' + +export interface ClusterPoint { + id: string + vector: number[] + payload: PointPayload +} + +export interface Cluster { + id: number + size: number + centroid: number[] + totalEngagement: number + lastActive: number + subreddits: string[] + samples: ClusterPoint[] +} + +export interface ClusteringOptions { + minClusterSize?: number + similarityThreshold?: number + samplesPerCluster?: number +} + +export interface ClusteringResult { + clusters: Cluster[] + noise: ClusterPoint[] + stats: { + totalPoints: number + clusteredPoints: number + noisePoints: number + clusterCount: number + } +} diff --git a/packages/core/src/embeddings/index.ts b/packages/core/src/embeddings/index.ts new file mode 100644 index 0000000..28886ef --- /dev/null +++ b/packages/core/src/embeddings/index.ts @@ -0,0 +1,2 @@ +export { EmbeddingPipeline, VECTOR_DIM } from './ollama' +export type { EmbeddedPoint, EmbeddingProgress } from './ollama' diff --git a/src/embeddings/ollama.ts b/packages/core/src/embeddings/ollama.ts similarity index 100% rename from src/embeddings/ollama.ts rename to packages/core/src/embeddings/ollama.ts diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..2313209 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,46 @@ +// scraper +export { + RedditScraper, + CommentFetcher, + normalizeRedditUrl, + parseSubredditFromUrl, +} from './scraper' +export type { + RedditPost, + RedditComment, + RedditListing, + ScrapeOptions, + ScrapeProgress, +} from './scraper' + +// embeddings +export { EmbeddingPipeline, VECTOR_DIM } from './embeddings' +export type { EmbeddedPoint, EmbeddingProgress } from './embeddings' + +// storage +export { QdrantStorage, COLLECTION_NAME, SQLiteStorage } from './storage' +export type { PointPayload, SearchResult, CollectionStats, ScrapeHistoryRecord } from './storage' + +// clustering +export { ClusteringPipeline } from './clustering' +export type { Cluster, ClusterPoint, ClusteringOptions, ClusteringResult } from './clustering' + +// analysis +export { + ProblemSummarizer, + QuestionExtractor, + EngagementScorer, + SentimentAnalyzer, + computeKeywordSimilarity, +} from './analysis' +export type { + ProblemSummary, + ExtractedQuestion, + ScoringWeights, + ScoredCluster, + SimilarityMatrix, +} from './analysis' + +// utils +export { delay, RateLimiter, fetchWithRetry } from './utils' +export { cleanText, prepareForEmbedding } from './utils' diff --git a/src/scraper/comments.ts b/packages/core/src/scraper/comments.ts similarity index 100% rename from src/scraper/comments.ts rename to packages/core/src/scraper/comments.ts diff --git a/packages/core/src/scraper/index.ts b/packages/core/src/scraper/index.ts new file mode 100644 index 0000000..4d3f3d7 --- /dev/null +++ b/packages/core/src/scraper/index.ts @@ -0,0 +1,12 @@ +export { RedditScraper, normalizeRedditUrl, parseSubredditFromUrl } from './reddit' +export { CommentFetcher } from './comments' +export type { + RedditPost, + RedditComment, + RedditListing, + RedditListingData, + RedditPostData, + RedditCommentData, + ScrapeOptions, + ScrapeProgress, +} from './types' diff --git a/src/scraper/reddit.ts b/packages/core/src/scraper/reddit.ts similarity index 100% rename from src/scraper/reddit.ts rename to packages/core/src/scraper/reddit.ts diff --git a/src/scraper/types.ts b/packages/core/src/scraper/types.ts similarity index 100% rename from src/scraper/types.ts rename to packages/core/src/scraper/types.ts diff --git a/packages/core/src/storage/index.ts b/packages/core/src/storage/index.ts new file mode 100644 index 0000000..8fa2b6f --- /dev/null +++ b/packages/core/src/storage/index.ts @@ -0,0 +1,4 @@ +export { QdrantStorage, COLLECTION_NAME } from './qdrant' +export { SQLiteStorage } from './sqlite' +export type { ScrapeHistoryRecord } from './sqlite' +export type { PointPayload, SearchResult, CollectionStats } from './types' diff --git a/src/storage/qdrant.ts b/packages/core/src/storage/qdrant.ts similarity index 80% rename from src/storage/qdrant.ts rename to packages/core/src/storage/qdrant.ts index 9362625..557b8e1 100644 --- a/src/storage/qdrant.ts +++ b/packages/core/src/storage/qdrant.ts @@ -1,6 +1,7 @@ import { QdrantClient } from '@qdrant/js-client-rest' import type { EmbeddedPoint } from '../embeddings/ollama' import type { PointPayload, SearchResult, CollectionStats } from './types' +import type { ClusterPoint } from '../clustering/types' import { VECTOR_DIM } from '../embeddings/ollama' const COLLECTION_NAME = 'reddit_trends' @@ -178,6 +179,48 @@ export class QdrantStorage { return false } } + + async getPointsByIds(ids: (string | number)[]): Promise { + if (ids.length === 0) return [] + + const results = await this.client.retrieve(this.collectionName, { + ids, + with_payload: true, + with_vector: false, + }) + + return results.map(r => r.payload as unknown as PointPayload) + } + + async scrollWithVectors(batchSize: number = 100): Promise { + const points: ClusterPoint[] = [] + let offset: string | number | undefined = undefined + + while (true) { + const result = await this.client.scroll(this.collectionName, { + limit: batchSize, + offset, + with_payload: true, + with_vector: true, + }) + + for (const point of result.points) { + const vector = point.vector + if (!vector || !Array.isArray(vector)) continue + + points.push({ + id: String(point.id), + vector: vector as number[], + payload: point.payload as unknown as PointPayload, + }) + } + + if (!result.next_page_offset) break + offset = result.next_page_offset as string | number + } + + return points + } } export { COLLECTION_NAME } diff --git a/packages/core/src/storage/sqlite.ts b/packages/core/src/storage/sqlite.ts new file mode 100644 index 0000000..087cdb4 --- /dev/null +++ b/packages/core/src/storage/sqlite.ts @@ -0,0 +1,274 @@ +import Database from 'better-sqlite3' +import { existsSync, mkdirSync } from 'fs' +import { join } from 'path' +import type { ProblemSummary, ExtractedQuestion } from '../analysis/types' + +function getDefaultDbPath(): string { + // use env var if set, otherwise use hardcoded path relative to monorepo + if (process.env.SQLITE_DB_PATH) { + return process.env.SQLITE_DB_PATH + } + + // hardcode the path for this project + const projectRoot = '/mnt/work/dev/personal-projects/reddit-trend-analyzer' + const dataDir = join(projectRoot, 'data') + + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }) + } + + return join(dataDir, 'reddit-trends.db') +} + +export interface ScrapeHistoryRecord { + id: number + subreddit: string + url: string + postsScraped: number + commentsScraped: number + startedAt: number + completedAt: number | null +} + +export interface ClusterRecord { + id: number + problem: string + description: string + keywords: string + sampleQuestions: string + actionableInsight: string + size: number + totalEngagement: number + lastActive: number + subreddits: string + samplePointIds: string + createdAt: number +} + +export class SQLiteStorage { + private db: Database.Database + + constructor(dbPath?: string) { + this.db = new Database(dbPath || getDefaultDbPath()) + this.initialize() + } + + private initialize(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS clusters ( + id INTEGER PRIMARY KEY, + problem TEXT NOT NULL, + description TEXT, + keywords TEXT, + sample_questions TEXT, + actionable_insight TEXT, + size INTEGER NOT NULL, + total_engagement INTEGER NOT NULL, + last_active INTEGER NOT NULL, + subreddits TEXT, + sample_point_ids TEXT, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')) + ); + + CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + text TEXT NOT NULL, + cluster_id INTEGER, + source_point_id TEXT, + engagement INTEGER NOT NULL DEFAULT 0, + addressed INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now')), + FOREIGN KEY (cluster_id) REFERENCES clusters(id) + ); + + CREATE TABLE IF NOT EXISTS scrape_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + subreddit TEXT NOT NULL, + url TEXT NOT NULL, + posts_scraped INTEGER NOT NULL DEFAULT 0, + comments_scraped INTEGER NOT NULL DEFAULT 0, + started_at INTEGER NOT NULL, + completed_at INTEGER + ); + + CREATE INDEX IF NOT EXISTS idx_questions_cluster ON questions(cluster_id); + CREATE INDEX IF NOT EXISTS idx_questions_addressed ON questions(addressed); + CREATE INDEX IF NOT EXISTS idx_scrape_history_subreddit ON scrape_history(subreddit); + `) + + // migration: add sample_point_ids column if missing (for existing databases) + try { + const columns = this.db.pragma('table_info(clusters)') as { name: string }[] + const hasSamplePointIds = columns.some(c => c.name === 'sample_point_ids') + if (!hasSamplePointIds) { + this.db.exec('ALTER TABLE clusters ADD COLUMN sample_point_ids TEXT') + } + } catch { + // table might not exist yet, that's fine + } + } + + saveClusters(summaries: ProblemSummary[]): void { + this.db.exec('DELETE FROM clusters') + + const insert = this.db.prepare(` + INSERT INTO clusters (id, problem, description, keywords, sample_questions, + actionable_insight, size, total_engagement, last_active, subreddits, sample_point_ids) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `) + + const insertMany = this.db.transaction((items: ProblemSummary[]) => { + for (const s of items) { + insert.run( + s.clusterId, + s.problem, + s.description, + JSON.stringify(s.keywords), + JSON.stringify(s.sampleQuestions), + s.actionableInsight, + s.size, + s.totalEngagement, + s.lastActive, + JSON.stringify(s.subreddits), + JSON.stringify(s.samplePointIds || []) + ) + } + }) + + insertMany(summaries) + } + + getClusters(): ProblemSummary[] { + const rows = this.db.prepare(` + SELECT * FROM clusters ORDER BY total_engagement DESC + `).all() as any[] + + return rows.map(row => ({ + clusterId: row.id, + problem: row.problem, + description: row.description, + keywords: JSON.parse(row.keywords || '[]'), + sampleQuestions: JSON.parse(row.sample_questions || '[]'), + actionableInsight: row.actionable_insight, + size: row.size, + totalEngagement: row.total_engagement, + lastActive: row.last_active, + subreddits: JSON.parse(row.subreddits || '[]'), + samplePointIds: JSON.parse(row.sample_point_ids || '[]'), + })) + } + + getCluster(id: number): ProblemSummary | null { + const row = this.db.prepare(` + SELECT * FROM clusters WHERE id = ? + `).get(id) as any | undefined + + if (!row) return null + + return { + clusterId: row.id, + problem: row.problem, + description: row.description, + keywords: JSON.parse(row.keywords || '[]'), + sampleQuestions: JSON.parse(row.sample_questions || '[]'), + actionableInsight: row.actionable_insight, + size: row.size, + totalEngagement: row.total_engagement, + lastActive: row.last_active, + subreddits: JSON.parse(row.subreddits || '[]'), + samplePointIds: JSON.parse(row.sample_point_ids || '[]'), + } + } + + saveQuestions(questions: ExtractedQuestion[]): void { + const insert = this.db.prepare(` + INSERT OR REPLACE INTO questions (id, text, cluster_id, source_point_id, engagement, addressed) + VALUES (?, ?, ?, ?, ?, ?) + `) + + const insertMany = this.db.transaction((items: ExtractedQuestion[]) => { + for (const q of items) { + insert.run(q.id, q.text, q.clusterId, q.sourcePointId, q.engagement, q.addressed ? 1 : 0) + } + }) + + insertMany(questions) + } + + getQuestions(clusterId?: number): ExtractedQuestion[] { + let rows + if (clusterId !== undefined) { + rows = this.db.prepare(` + SELECT * FROM questions WHERE cluster_id = ? ORDER BY engagement DESC + `).all(clusterId) as any[] + } else { + rows = this.db.prepare(` + SELECT * FROM questions ORDER BY engagement DESC + `).all() as any[] + } + + return rows.map(row => ({ + id: row.id, + text: row.text, + clusterId: row.cluster_id, + sourcePointId: row.source_point_id, + engagement: row.engagement, + addressed: row.addressed === 1, + })) + } + + markQuestionAddressed(id: string, addressed: boolean = true): void { + this.db.prepare(` + UPDATE questions SET addressed = ? WHERE id = ? + `).run(addressed ? 1 : 0, id) + } + + startScrape(subreddit: string, url: string): number { + const result = this.db.prepare(` + INSERT INTO scrape_history (subreddit, url, started_at) + VALUES (?, ?, strftime('%s', 'now')) + `).run(subreddit, url) + + return Number(result.lastInsertRowid) + } + + completeScrape(id: number, postsScraped: number, commentsScraped: number): void { + this.db.prepare(` + UPDATE scrape_history + SET posts_scraped = ?, comments_scraped = ?, completed_at = strftime('%s', 'now') + WHERE id = ? + `).run(postsScraped, commentsScraped, id) + } + + getScrapeHistory(limit: number = 50): ScrapeHistoryRecord[] { + const rows = this.db.prepare(` + SELECT * FROM scrape_history ORDER BY started_at DESC LIMIT ? + `).all(limit) as any[] + + return rows.map(row => ({ + id: row.id, + subreddit: row.subreddit, + url: row.url, + postsScraped: row.posts_scraped, + commentsScraped: row.comments_scraped, + startedAt: row.started_at, + completedAt: row.completed_at, + })) + } + + getStats(): { clusterCount: number; questionCount: number; addressedCount: number } { + const clusters = this.db.prepare('SELECT COUNT(*) as count FROM clusters').get() as { count: number } + const questions = this.db.prepare('SELECT COUNT(*) as count FROM questions').get() as { count: number } + const addressed = this.db.prepare('SELECT COUNT(*) as count FROM questions WHERE addressed = 1').get() as { count: number } + + return { + clusterCount: clusters.count, + questionCount: questions.count, + addressedCount: addressed.count, + } + } + + close(): void { + this.db.close() + } +} diff --git a/src/storage/types.ts b/packages/core/src/storage/types.ts similarity index 100% rename from src/storage/types.ts rename to packages/core/src/storage/types.ts diff --git a/packages/core/src/utils/index.ts b/packages/core/src/utils/index.ts new file mode 100644 index 0000000..8966dbc --- /dev/null +++ b/packages/core/src/utils/index.ts @@ -0,0 +1,8 @@ +export { delay, RateLimiter, fetchWithRetry } from './rate-limit' +export { + decodeHtmlEntities, + stripHtml, + cleanText, + truncateText, + prepareForEmbedding, +} from './text' diff --git a/src/utils/rate-limit.ts b/packages/core/src/utils/rate-limit.ts similarity index 100% rename from src/utils/rate-limit.ts rename to packages/core/src/utils/rate-limit.ts diff --git a/src/utils/text.ts b/packages/core/src/utils/text.ts similarity index 100% rename from src/utils/text.ts rename to packages/core/src/utils/text.ts diff --git a/packages/web/next-env.d.ts b/packages/web/next-env.d.ts new file mode 100644 index 0000000..830fb59 --- /dev/null +++ b/packages/web/next-env.d.ts @@ -0,0 +1,6 @@ +/// +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/packages/web/next.config.ts b/packages/web/next.config.ts new file mode 100644 index 0000000..dc47638 --- /dev/null +++ b/packages/web/next.config.ts @@ -0,0 +1,14 @@ +import type { NextConfig } from 'next' + +const config: NextConfig = { + serverExternalPackages: ['better-sqlite3'], + webpack: (config, { isServer }) => { + if (isServer) { + config.externals = config.externals || [] + config.externals.push('better-sqlite3') + } + return config + }, +} + +export default config diff --git a/packages/web/package.json b/packages/web/package.json new file mode 100644 index 0000000..a40e4cb --- /dev/null +++ b/packages/web/package.json @@ -0,0 +1,35 @@ +{ + "name": "@rta/web", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@rta/core": "workspace:*", + "ollama": "^0.5.11", + "better-sqlite3": "^11.7.0", + "@tanstack/react-table": "^8.20.6", + "next": "^15.2.4", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "recharts": "^2.15.0", + "lucide-react": "^0.468.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "tailwind-merge": "^2.6.0", + "cmdk": "^1.0.4" + }, + "devDependencies": { + "@types/node": "^22.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "tailwindcss": "^4.0.0", + "@tailwindcss/postcss": "^4.0.0", + "typescript": "^5.0.0" + } +} diff --git a/packages/web/postcss.config.cjs b/packages/web/postcss.config.cjs new file mode 100644 index 0000000..52b9b4b --- /dev/null +++ b/packages/web/postcss.config.cjs @@ -0,0 +1,5 @@ +module.exports = { + plugins: { + '@tailwindcss/postcss': {}, + }, +} diff --git a/packages/web/src/app/api/clusters/[id]/route.ts b/packages/web/src/app/api/clusters/[id]/route.ts new file mode 100644 index 0000000..ec8594e --- /dev/null +++ b/packages/web/src/app/api/clusters/[id]/route.ts @@ -0,0 +1,37 @@ +import { NextRequest, NextResponse } from 'next/server' +import { SQLiteStorage, QdrantStorage } from '@rta/core' + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const clusterId = parseInt(id, 10) + + const sqlite = new SQLiteStorage() + const cluster = sqlite.getCluster(clusterId) + const questions = sqlite.getQuestions(clusterId) + sqlite.close() + + if (!cluster) { + return NextResponse.json({ error: 'Cluster not found' }, { status: 404 }) + } + + // fetch actual discussions from qdrant if we have sample point IDs + let samples: any[] = [] + if (cluster.samplePointIds && cluster.samplePointIds.length > 0) { + try { + const qdrant = new QdrantStorage() + samples = await qdrant.getPointsByIds(cluster.samplePointIds) + } catch (e) { + console.error('Failed to fetch samples from qdrant:', e) + } + } + + return NextResponse.json({ cluster, questions, samples }) + } catch (error) { + console.error('Get cluster error:', error) + return NextResponse.json({ error: 'Failed to get cluster' }, { status: 500 }) + } +} diff --git a/packages/web/src/app/api/clusters/route.ts b/packages/web/src/app/api/clusters/route.ts new file mode 100644 index 0000000..d3ad1ab --- /dev/null +++ b/packages/web/src/app/api/clusters/route.ts @@ -0,0 +1,108 @@ +import { NextResponse } from 'next/server' +import { + QdrantStorage, + SQLiteStorage, + ClusteringPipeline, + ProblemSummarizer, + QuestionExtractor, +} from '@rta/core' + +import { existsSync, readFileSync } from 'fs' +import { join } from 'path' + +const PROJECT_ROOT = '/mnt/work/dev/personal-projects/reddit-trend-analyzer' +const SETTINGS_FILE = join(PROJECT_ROOT, 'data', 'settings.json') + +function getSettings() { + try { + if (existsSync(SETTINGS_FILE)) { + return JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8')) + } + } catch {} + return { summarizationModel: 'llama3.2' } +} + +export async function GET() { + try { + const sqlite = new SQLiteStorage() + const clusters = sqlite.getClusters() + sqlite.close() + + return NextResponse.json({ clusters }) + } catch (error) { + console.error('Get clusters error:', error) + return NextResponse.json({ clusters: [] }) + } +} + +export async function POST(request: Request) { + try { + const body = await request.json().catch(() => ({})) + const minClusterSize = body.minClusterSize ?? 2 + const similarityThreshold = body.similarityThreshold ?? 0.5 + + const qdrant = new QdrantStorage() + const sqlite = new SQLiteStorage() + + const clustering = new ClusteringPipeline(qdrant) + const result = await clustering.runClustering({ + minClusterSize, + similarityThreshold, + samplesPerCluster: 10, + }) + + if (result.clusters.length === 0) { + sqlite.close() + return NextResponse.json({ + success: true, + message: 'No clusters found', + clusters: [], + }) + } + + const settings = getSettings() + const summarizer = new ProblemSummarizer({ model: settings.summarizationModel }) + const summaries = await summarizer.summarizeClusters(result.clusters) + + sqlite.saveClusters(summaries) + + const extractor = new QuestionExtractor() + + // save questions from both Claude summaries and regex extraction + for (const summary of summaries) { + const regexQuestions = extractor.extractFromClusterPoints( + result.clusters.find(c => c.id === summary.clusterId)?.samples || [], + summary.clusterId + ) + + // add Claude's sample questions if available + const claudeQuestions = summary.sampleQuestions.map((q, i) => ({ + id: `claude-${summary.clusterId}-${i}`, + text: q, + clusterId: summary.clusterId, + sourcePointId: 'claude-generated', + engagement: summary.totalEngagement, + addressed: false, + })) + + const allQuestions = [...claudeQuestions, ...regexQuestions] + if (allQuestions.length > 0) { + sqlite.saveQuestions(allQuestions) + } + } + + sqlite.close() + + return NextResponse.json({ + success: true, + clusters: summaries, + stats: result.stats, + }) + } catch (error) { + console.error('Clustering error:', error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/packages/web/src/app/api/clusters/similarity/route.ts b/packages/web/src/app/api/clusters/similarity/route.ts new file mode 100644 index 0000000..e1d6033 --- /dev/null +++ b/packages/web/src/app/api/clusters/similarity/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from 'next/server' +import { SQLiteStorage, computeKeywordSimilarity } from '@rta/core' + +export async function GET() { + try { + const sqlite = new SQLiteStorage() + const clusters = sqlite.getClusters() + sqlite.close() + + if (clusters.length === 0) { + return NextResponse.json({ matrix: [], labels: [], clusterIds: [] }) + } + + const similarity = computeKeywordSimilarity(clusters) + return NextResponse.json(similarity) + } catch (error) { + console.error('Similarity computation error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/packages/web/src/app/api/export/route.ts b/packages/web/src/app/api/export/route.ts new file mode 100644 index 0000000..044a432 --- /dev/null +++ b/packages/web/src/app/api/export/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from 'next/server' +import { SQLiteStorage } from '@rta/core' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { format, type } = body + + const sqlite = new SQLiteStorage() + const clusters = sqlite.getClusters() + const questions = sqlite.getQuestions() + sqlite.close() + + if (format === 'json' && type === 'faq-schema') { + const faqSchema = { + '@context': 'https://schema.org', + '@type': 'FAQPage', + mainEntity: questions + .filter(q => !q.addressed) + .slice(0, 20) + .map(q => ({ + '@type': 'Question', + name: q.text, + acceptedAnswer: { + '@type': 'Answer', + text: '', // To be filled in + }, + })), + } + + return NextResponse.json(faqSchema) + } + + if (format === 'markdown' && type === 'content-brief') { + let markdown = '# Content Brief\n\n' + + for (const cluster of clusters) { + markdown += `## ${cluster.problem}\n\n` + markdown += `${cluster.description}\n\n` + markdown += `**Keywords:** ${cluster.keywords.join(', ')}\n\n` + markdown += `**Actionable Insight:** ${cluster.actionableInsight}\n\n` + + const clusterQuestions = questions.filter(q => q.clusterId === cluster.clusterId) + if (clusterQuestions.length > 0) { + markdown += `### Questions to Address\n\n` + for (const q of clusterQuestions.slice(0, 5)) { + markdown += `- ${q.text}\n` + } + markdown += '\n' + } + + markdown += '---\n\n' + } + + return new NextResponse(markdown, { + headers: { + 'Content-Type': 'text/markdown', + 'Content-Disposition': 'attachment; filename="content-brief.md"', + }, + }) + } + + if (format === 'csv') { + const headers = ['Problem', 'Description', 'Size', 'Engagement', 'Keywords', 'Subreddits'] + const rows = clusters.map(c => [ + `"${c.problem.replace(/"/g, '""')}"`, + `"${c.description.replace(/"/g, '""')}"`, + c.size, + c.totalEngagement, + `"${c.keywords.join(', ')}"`, + `"${c.subreddits.join(', ')}"`, + ]) + + const csv = [headers.join(','), ...rows.map(r => r.join(','))].join('\n') + + return new NextResponse(csv, { + headers: { + 'Content-Type': 'text/csv', + 'Content-Disposition': 'attachment; filename="clusters.csv"', + }, + }) + } + + return NextResponse.json({ clusters, questions }) + } catch (error) { + console.error('Export error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Export failed' }, + { status: 500 } + ) + } +} diff --git a/packages/web/src/app/api/questions/[id]/route.ts b/packages/web/src/app/api/questions/[id]/route.ts new file mode 100644 index 0000000..8274484 --- /dev/null +++ b/packages/web/src/app/api/questions/[id]/route.ts @@ -0,0 +1,22 @@ +import { NextRequest, NextResponse } from 'next/server' +import { SQLiteStorage } from '@rta/core' + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params + const body = await request.json() + const { addressed } = body + + const sqlite = new SQLiteStorage() + sqlite.markQuestionAddressed(id, addressed) + sqlite.close() + + return NextResponse.json({ success: true }) + } catch (error) { + console.error('Update question error:', error) + return NextResponse.json({ error: 'Failed to update question' }, { status: 500 }) + } +} diff --git a/packages/web/src/app/api/questions/route.ts b/packages/web/src/app/api/questions/route.ts new file mode 100644 index 0000000..6d0c3d5 --- /dev/null +++ b/packages/web/src/app/api/questions/route.ts @@ -0,0 +1,25 @@ +import { NextResponse } from 'next/server' +import { SQLiteStorage } from '@rta/core' + +export async function GET() { + try { + const sqlite = new SQLiteStorage() + const questions = sqlite.getQuestions() + const clusters = sqlite.getClusters() + sqlite.close() + + const grouped: Record = {} + + for (const cluster of clusters) { + grouped[cluster.clusterId] = { + problem: cluster.problem, + questions: questions.filter(q => q.clusterId === cluster.clusterId), + } + } + + return NextResponse.json({ grouped }) + } catch (error) { + console.error('Get questions error:', error) + return NextResponse.json({ grouped: {} }) + } +} diff --git a/packages/web/src/app/api/scrape/history/route.ts b/packages/web/src/app/api/scrape/history/route.ts new file mode 100644 index 0000000..512c91d --- /dev/null +++ b/packages/web/src/app/api/scrape/history/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server' +import { SQLiteStorage } from '@rta/core' + +export async function GET() { + try { + const sqlite = new SQLiteStorage() + const history = sqlite.getScrapeHistory() + sqlite.close() + + return NextResponse.json({ history }) + } catch (error) { + console.error('Scrape history error:', error) + return NextResponse.json({ history: [] }) + } +} diff --git a/packages/web/src/app/api/scrape/route.ts b/packages/web/src/app/api/scrape/route.ts new file mode 100644 index 0000000..612b460 --- /dev/null +++ b/packages/web/src/app/api/scrape/route.ts @@ -0,0 +1,68 @@ +import { NextRequest, NextResponse } from 'next/server' +import { + RedditScraper, + CommentFetcher, + EmbeddingPipeline, + QdrantStorage, + SQLiteStorage, + parseSubredditFromUrl, +} from '@rta/core' +import type { RedditComment } from '@rta/core' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { url, pages = 5, postsPerPage = 100 } = body + + if (!url) { + return NextResponse.json({ success: false, error: 'URL is required' }, { status: 400 }) + } + + const subreddit = parseSubredditFromUrl(url) + const sqlite = new SQLiteStorage() + const scrapeId = sqlite.startScrape(subreddit, url) + + const scraper = new RedditScraper(3000) + const commentFetcher = new CommentFetcher(3000) + const embeddings = new EmbeddingPipeline() + const storage = new QdrantStorage() + + await storage.ensureCollection() + + const posts = await scraper.fetchPosts({ + url, + pages, + postsPerPage, + fetchComments: true, + delayMs: 3000, + }) + + const commentsByPost = await commentFetcher.fetchAllComments(posts) + + const postPoints = await embeddings.embedPosts(posts, commentsByPost) + await storage.upsertPoints(postPoints) + + const allComments: RedditComment[] = [] + for (const comments of commentsByPost.values()) { + allComments.push(...comments) + } + + const commentPoints = await embeddings.embedComments(allComments) + await storage.upsertPoints(commentPoints) + + sqlite.completeScrape(scrapeId, posts.length, allComments.length) + sqlite.close() + + return NextResponse.json({ + success: true, + posts: posts.length, + comments: allComments.length, + }) + } catch (error) { + console.error('Scrape error:', error) + return NextResponse.json( + { success: false, error: error instanceof Error ? error.message : 'Unknown error' }, + { status: 500 } + ) + } +} diff --git a/packages/web/src/app/api/search/route.ts b/packages/web/src/app/api/search/route.ts new file mode 100644 index 0000000..f09adaf --- /dev/null +++ b/packages/web/src/app/api/search/route.ts @@ -0,0 +1,29 @@ +import { NextRequest, NextResponse } from 'next/server' +import { EmbeddingPipeline, QdrantStorage } from '@rta/core' + +export async function POST(request: NextRequest) { + try { + const body = await request.json() + const { query, limit = 10, threshold = 0.5 } = body + + if (!query) { + return NextResponse.json({ error: 'Query is required' }, { status: 400 }) + } + + const embeddings = new EmbeddingPipeline() + const storage = new QdrantStorage() + + const vector = await embeddings.embed(query) + const results = await storage.search(vector, limit) + + const filtered = results.filter(r => r.score >= threshold) + + return NextResponse.json({ results: filtered }) + } catch (error) { + console.error('Search error:', error) + return NextResponse.json( + { error: error instanceof Error ? error.message : 'Search failed' }, + { status: 500 } + ) + } +} diff --git a/packages/web/src/app/api/settings/route.ts b/packages/web/src/app/api/settings/route.ts new file mode 100644 index 0000000..627b369 --- /dev/null +++ b/packages/web/src/app/api/settings/route.ts @@ -0,0 +1,92 @@ +import { NextResponse } from 'next/server' +import { Ollama } from 'ollama' +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs' +import { join } from 'path' + +const PROJECT_ROOT = '/mnt/work/dev/personal-projects/reddit-trend-analyzer' +const SETTINGS_FILE = join(PROJECT_ROOT, 'data', 'settings.json') + +interface Settings { + summarizationModel: string + sentimentModel: string + embeddingModel: string +} + +const DEFAULT_SETTINGS: Settings = { + summarizationModel: 'llama3.2', + sentimentModel: 'llama3.2', + embeddingModel: 'nomic-embed-text', +} + +function getSettings(): Settings { + try { + if (existsSync(SETTINGS_FILE)) { + const data = JSON.parse(readFileSync(SETTINGS_FILE, 'utf-8')) + return { ...DEFAULT_SETTINGS, ...data } + } + } catch {} + return DEFAULT_SETTINGS +} + +function saveSettings(settings: Settings): void { + const dataDir = join(PROJECT_ROOT, 'data') + if (!existsSync(dataDir)) { + mkdirSync(dataDir, { recursive: true }) + } + writeFileSync(SETTINGS_FILE, JSON.stringify(settings, null, 2)) +} + +export async function GET() { + try { + const ollama = new Ollama({ + host: process.env.OLLAMA_HOST || 'http://localhost:11434', + }) + + const models = await ollama.list() + const settings = getSettings() + + const availableModels = models.models.map(m => ({ + name: m.name, + size: m.size, + modified: m.modified_at, + })) + + return NextResponse.json({ + settings, + availableModels, + }) + } catch (error) { + console.error('Settings GET error:', error) + return NextResponse.json({ + settings: DEFAULT_SETTINGS, + availableModels: [], + error: 'Failed to connect to Ollama', + }) + } +} + +export async function POST(request: Request) { + try { + const body = await request.json() + const currentSettings = getSettings() + + const newSettings: Settings = { + summarizationModel: body.summarizationModel ?? currentSettings.summarizationModel, + sentimentModel: body.sentimentModel ?? currentSettings.sentimentModel, + embeddingModel: body.embeddingModel ?? currentSettings.embeddingModel, + } + + saveSettings(newSettings) + + return NextResponse.json({ + success: true, + settings: newSettings, + }) + } catch (error) { + console.error('Settings POST error:', error) + return NextResponse.json( + { success: false, error: 'Failed to save settings' }, + { status: 500 } + ) + } +} diff --git a/packages/web/src/app/api/stats/route.ts b/packages/web/src/app/api/stats/route.ts new file mode 100644 index 0000000..e6ebe8b --- /dev/null +++ b/packages/web/src/app/api/stats/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from 'next/server' +import { QdrantStorage, SQLiteStorage } from '@rta/core' + +export async function GET() { + try { + const qdrant = new QdrantStorage() + const sqlite = new SQLiteStorage() + + const qdrantStats = await qdrant.getStats() + const sqliteStats = sqlite.getStats() + sqlite.close() + + return NextResponse.json({ + posts: qdrantStats.posts, + comments: qdrantStats.comments, + subreddits: qdrantStats.subreddits, + clusters: sqliteStats.clusterCount, + questions: sqliteStats.questionCount, + }) + } catch (error) { + console.error('Stats error:', error) + return NextResponse.json({ + posts: 0, + comments: 0, + subreddits: [], + clusters: 0, + questions: 0, + }) + } +} diff --git a/packages/web/src/app/layout.tsx b/packages/web/src/app/layout.tsx new file mode 100644 index 0000000..06d767e --- /dev/null +++ b/packages/web/src/app/layout.tsx @@ -0,0 +1,68 @@ +import type { Metadata } from 'next' +import '@/styles/globals.css' +import { Providers } from './providers' + +export const metadata: Metadata = { + title: 'Reddit Trend Analyzer', + description: 'Discover common problems and questions in Reddit communities', +} + +export default function RootLayout({ + children, +}: { + children: React.ReactNode +}) { + return ( + + + +
+ +
{children}
+
+
+ + + ) +} diff --git a/packages/web/src/app/page.tsx b/packages/web/src/app/page.tsx new file mode 100644 index 0000000..9c3b4cd --- /dev/null +++ b/packages/web/src/app/page.tsx @@ -0,0 +1,86 @@ +'use client' + +import { useEffect, useState } from 'react' + +interface Stats { + posts: number + comments: number + subreddits: string[] + clusters: number + questions: number +} + +export default function DashboardPage() { + const [stats, setStats] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/stats') + .then(res => res.json()) + .then(data => { + setStats(data) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+
Loading...
+
+ ) + } + + return ( +
+
+

Dashboard

+

Overview of your Reddit trend analysis

+
+ +
+
+
Posts
+
+ {stats?.posts.toLocaleString() ?? 0} +
+
+
+
Comments
+
+ {stats?.comments.toLocaleString() ?? 0} +
+
+
+
Subreddits
+
+ {stats?.subreddits.length ?? 0} +
+
+
+
Clusters
+
+ {stats?.clusters ?? 0} +
+
+
+ + {stats && stats.subreddits.length > 0 && ( +
+
Tracked Subreddits
+
+ {stats.subreddits.map(sub => ( + + r/{sub} + + ))} +
+
+ )} +
+ ) +} diff --git a/packages/web/src/app/problems/page.tsx b/packages/web/src/app/problems/page.tsx new file mode 100644 index 0000000..57586eb --- /dev/null +++ b/packages/web/src/app/problems/page.tsx @@ -0,0 +1,816 @@ +'use client' + +import React, { useEffect, useState, useMemo, useCallback } from 'react' +import { useToast } from '@/components/ui/toast' +import { + BarChart, + Bar, + PieChart, + Pie, + Cell, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + Legend, + ResponsiveContainer, +} from 'recharts' + +interface Problem { + clusterId: number + problem: string + description: string + size: number + totalEngagement: number + lastActive: number + subreddits: string[] + sampleQuestions: string[] + impactScore?: number +} + +interface DiscussionSample { + id: string + type: 'post' | 'comment' + subreddit: string + title?: string + author: string + body: string + score: number + created: number + permalink: string + parent_id?: string +} + +function DiscussionSamples({ clusterId }: { clusterId: number }) { + const [samples, setSamples] = useState([]) + const [loading, setLoading] = useState(true) + const [expandedBodies, setExpandedBodies] = useState>(new Set()) + + useEffect(() => { + fetch(`/api/clusters/${clusterId}`) + .then(res => res.json()) + .then(data => { + setSamples(data.samples || []) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, [clusterId]) + + const toggleBody = useCallback((id: string) => { + setExpandedBodies(prev => { + const next = new Set(prev) + if (next.has(id)) { + next.delete(id) + } else { + next.add(id) + } + return next + }) + }, []) + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp * 1000) + return date.toLocaleDateString() + } + + if (loading) { + return ( +
+ Loading discussions... +
+ ) + } + + if (samples.length === 0) { + return ( +
+ No discussion samples available. Re-run clustering to populate. +
+ ) + } + + return ( +
+
+ Discussion Samples ({samples.length}) +
+ {samples.map(sample => { + const isExpanded = expandedBodies.has(sample.id) + const bodyTruncated = sample.body.length > 300 + const displayBody = isExpanded ? sample.body : sample.body.slice(0, 300) + + return ( +
+
+
+ + {sample.type} + + + r/{sample.subreddit} + + + + u/{sample.author} + + + + {sample.score} pts + + + + {formatDate(sample.created)} + +
+ + view on reddit → + +
+ + {sample.title && ( +
{sample.title}
+ )} + +
+ {displayBody} + {bodyTruncated && !isExpanded && '...'} +
+ + {bodyTruncated && ( + + )} +
+ ) + })} +
+ ) +} + +interface Weights { + engagement: number + velocity: number + sentiment: number +} + +interface SimilarityData { + matrix: number[][] + labels: string[] + clusterIds: number[] +} + +function CorrelationHeatmap({ + onCellClick, +}: { + onCellClick?: (clusterIds: [number, number]) => void +}) { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [hoveredCell, setHoveredCell] = useState<{ i: number; j: number } | null>(null) + + useEffect(() => { + fetch('/api/clusters/similarity') + .then(res => res.json()) + .then(d => { + setData(d) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, []) + + if (loading) { + return ( +
+
Loading correlation data...
+
+ ) + } + + if (!data || data.matrix.length === 0) { + return ( +
+
No clusters to compare
+
+ ) + } + + const getColor = (value: number) => { + // white (0) -> indigo (1) + const intensity = Math.round(value * 255) + return `rgb(${255 - intensity * 0.6}, ${255 - intensity * 0.62}, ${255 - intensity * 0.05})` + } + + const n = data.matrix.length + const cellSize = Math.max(24, Math.min(40, 400 / n)) + + return ( +
+
+
+ {/* empty corner cell */} +
+ + {/* column headers */} + {data.labels.map((label, j) => ( +
+ + {label.slice(0, 15)} + +
+ ))} + + {/* rows */} + {data.matrix.map((row, i) => ( + + {/* row label */} +
+ + {data.labels[i].slice(0, 12)} + +
+ + {/* cells */} + {row.map((value, j) => { + const isHovered = hoveredCell?.i === i && hoveredCell?.j === j + return ( +
setHoveredCell({ i, j })} + onMouseLeave={() => setHoveredCell(null)} + onClick={() => onCellClick?.([data.clusterIds[i], data.clusterIds[j]])} + title={`${data.labels[i]} ↔ ${data.labels[j]}: ${(value * 100).toFixed(0)}%`} + > + {isHovered && ( +
+ {(value * 100).toFixed(0)}% similar +
+ )} +
+ ) + })} +
+ ))} +
+
+ + {/* legend */} +
+ 0% +
+ 100% + keyword overlap +
+
+ ) +} + +// chart colors - recharts can't parse CSS variables at runtime +const CHART_COLORS = { + primary: '#6366f1', // indigo + secondary: '#8b5cf6', // violet + accent: '#06b6d4', // cyan + muted: '#94a3b8', // slate + grid: '#e2e8f0', // light grid + text: '#64748b', // muted text + palette: ['#6366f1', '#8b5cf6', '#06b6d4', '#10b981', '#f59e0b', '#ef4444', '#ec4899', '#84cc16'], +} + +export default function ProblemsPage() { + const [problems, setProblems] = useState([]) + const [loading, setLoading] = useState(true) + const [expanded, setExpanded] = useState(null) + const [clustering, setClustering] = useState(false) + const { addToast, updateToast } = useToast() + + const [similarityThreshold, setSimilarityThreshold] = useState(0.5) + const [minClusterSize, setMinClusterSize] = useState(2) + const [weights, setWeights] = useState({ + engagement: 0.5, + velocity: 0.3, + sentiment: 0.2, + }) + + const fetchClusters = () => { + fetch('/api/clusters') + .then(res => res.json()) + .then(data => { + setProblems(data.clusters || []) + setLoading(false) + }) + .catch(() => setLoading(false)) + } + + useEffect(() => { + fetchClusters() + }, []) + + const sortedProblems = useMemo(() => { + if (problems.length === 0) return [] + + const now = Math.floor(Date.now() / 1000) + const oneWeek = 7 * 24 * 60 * 60 + const maxEngagement = Math.max(...problems.map(p => p.totalEngagement)) + + return [...problems] + .map(p => { + const engagementScore = maxEngagement > 0 ? p.totalEngagement / maxEngagement : 0 + const age = now - p.lastActive + const velocityScore = Math.max(0, 1 - age / oneWeek) + const sentimentScore = 0.5 + + const impactScore = + weights.engagement * engagementScore + + weights.velocity * velocityScore + + weights.sentiment * sentimentScore + + return { ...p, impactScore } + }) + .sort((a, b) => (b.impactScore || 0) - (a.impactScore || 0)) + }, [problems, weights]) + + const chartData = useMemo(() => { + if (sortedProblems.length === 0) return { impact: [], subreddits: [], sizes: [] } + + const impactData = sortedProblems.slice(0, 10).map(p => ({ + name: p.problem.slice(0, 30) + (p.problem.length > 30 ? '...' : ''), + impact: Math.round((p.impactScore || 0) * 100), + engagement: p.totalEngagement, + discussions: p.size, + })) + + const subredditCounts = new Map() + sortedProblems.forEach(p => { + p.subreddits.forEach(sub => { + subredditCounts.set(sub, (subredditCounts.get(sub) || 0) + 1) + }) + }) + const subredditData = Array.from(subredditCounts.entries()) + .map(([name, value]) => ({ name: `r/${name}`, value })) + .sort((a, b) => b.value - a.value) + .slice(0, 8) + + const sizeDistribution = sortedProblems.reduce((acc, p) => { + const bucket = + p.size < 5 ? '2-4' : p.size < 10 ? '5-9' : p.size < 20 ? '10-19' : '20+' + acc[bucket] = (acc[bucket] || 0) + 1 + return acc + }, {} as Record) + const sizeData = Object.entries(sizeDistribution).map(([name, value]) => ({ + name: `${name} discussions`, + value, + })) + + return { impact: impactData, subreddits: subredditData, sizes: sizeData } + }, [sortedProblems]) + + const handleRecluster = async () => { + setClustering(true) + const toastId = addToast('Clustering discussions...', 'loading') + try { + const res = await fetch('/api/clusters', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ similarityThreshold, minClusterSize }), + }) + const data = await res.json() + if (data.success) { + updateToast(toastId, `Found ${data.clusters?.length || 0} problem clusters`, 'success') + fetchClusters() + } else { + updateToast(toastId, data.error || 'Clustering failed', 'error') + } + } catch (e) { + updateToast(toastId, 'Clustering failed - check console', 'error') + } finally { + setClustering(false) + } + } + + const updateWeight = (key: keyof Weights, value: number) => { + setWeights(prev => ({ ...prev, [key]: value })) + } + + const formatDate = (timestamp: number) => { + const date = new Date(timestamp * 1000) + return date.toLocaleDateString() + } + + if (loading) { + return ( +
+
Loading...
+
+ ) + } + + const clusterControls = ( +
+
Clustering Settings
+
+
+ + setSimilarityThreshold(parseFloat(e.target.value))} + className="w-full accent-primary" + /> +
+ Loose (0.3) + Strict (0.9) +
+
+
+ + +
+
+
+ ) + + const weightControls = ( +
+
Impact Score Weights
+
+
+ + updateWeight('engagement', parseFloat(e.target.value))} + className="w-full accent-primary" + /> +
+
+ + updateWeight('velocity', parseFloat(e.target.value))} + className="w-full accent-primary" + /> +
+
+ + updateWeight('sentiment', parseFloat(e.target.value))} + className="w-full accent-primary" + /> +
+
+
+ ) + + if (problems.length === 0) { + return ( +
+
+

Problem Explorer

+

View and analyze problem clusters

+
+ {clusterControls} +
+ +
+
+

No problems found. Adjust settings and run clustering.

+
+
+ ) + } + + return ( +
+
+
+

Problem Explorer

+

+ {sortedProblems.length} problem clusters identified +

+
+ +
+ +
+ {clusterControls} + {weightControls} +
+ + {sortedProblems.length > 0 && ( +
+
Problem Analytics
+
+
+
+ Top Problems by Impact Score +
+ + + + + + + + + +
+ +
+
+ Discussion Distribution by Subreddit +
+ + + + `${name} (${(percent * 100).toFixed(0)}%)` + } + outerRadius={80} + dataKey="value" + > + {chartData.subreddits.map((_, index) => ( + + ))} + + + + +
+ +
+
+ Cluster Size Distribution +
+ + + + + + + + + +
+ +
+
Key Metrics
+
+
+
+ {sortedProblems.length} +
+
Total Clusters
+
+
+
+ {sortedProblems.reduce((sum, p) => sum + p.size, 0).toLocaleString()} +
+
Total Discussions
+
+
+
+ {sortedProblems + .reduce((sum, p) => sum + p.totalEngagement, 0) + .toLocaleString()} +
+
Total Upvotes
+
+
+
+ {new Set(sortedProblems.flatMap(p => p.subreddits)).size} +
+
Unique Subreddits
+
+
+
+
+ + {/* correlation heatmap */} +
+
+ Problem Cluster Correlation +
+ { + // expand first cluster that isn't already expanded + if (expanded !== id1) { + setExpanded(id1) + } else if (expanded !== id2) { + setExpanded(id2) + } + }} + /> +
+
+ )} + +
+ + + + + + + + + + + + {sortedProblems.map(problem => ( + + setExpanded(expanded === problem.clusterId ? null : problem.clusterId)} + className="border-t border-border hover:bg-accent/50 cursor-pointer" + > + + + + + + + {expanded === problem.clusterId && ( + + + + )} + + ))} + +
+ Problem + + Impact + + Discussions + + Upvotes + + Last Active +
+
{problem.problem}
+
+ {problem.subreddits.map(s => `r/${s}`).join(', ')} +
+
+ + {((problem.impactScore || 0) * 100).toFixed(0)} + + {problem.size} + {problem.totalEngagement.toLocaleString()} + {formatDate(problem.lastActive)}
+
+

{problem.description}

+ + + + {problem.sampleQuestions.length > 0 && ( +
+
+ Sample Questions: +
+
    + {problem.sampleQuestions.map((q, i) => ( +
  • {q}
  • + ))} +
+
+ )} +
+
+
+
+ ) +} diff --git a/packages/web/src/app/providers.tsx b/packages/web/src/app/providers.tsx new file mode 100644 index 0000000..136b189 --- /dev/null +++ b/packages/web/src/app/providers.tsx @@ -0,0 +1,14 @@ +'use client' + +import { ReactNode } from 'react' +import { ToastProvider } from '@/components/ui/toast' +import { CommandPalette } from '@/components/controls/command-palette' + +export function Providers({ children }: { children: ReactNode }) { + return ( + + {children} + + + ) +} diff --git a/packages/web/src/app/questions/page.tsx b/packages/web/src/app/questions/page.tsx new file mode 100644 index 0000000..1208327 --- /dev/null +++ b/packages/web/src/app/questions/page.tsx @@ -0,0 +1,164 @@ +'use client' + +import { useEffect, useState } from 'react' + +interface Question { + id: string + text: string + clusterId: number + engagement: number + addressed: boolean +} + +interface GroupedQuestions { + [clusterId: string]: { + problem: string + questions: Question[] + } +} + +export default function QuestionsPage() { + const [grouped, setGrouped] = useState({}) + const [loading, setLoading] = useState(true) + const [filter, setFilter] = useState<'all' | 'unanswered'>('all') + + useEffect(() => { + fetch('/api/questions') + .then(res => res.json()) + .then(data => { + setGrouped(data.grouped || {}) + setLoading(false) + }) + .catch(() => setLoading(false)) + }, []) + + const handleToggleAddressed = async (id: string, addressed: boolean) => { + await fetch(`/api/questions/${id}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ addressed: !addressed }), + }) + + setGrouped(prev => { + const updated = { ...prev } + for (const clusterId of Object.keys(updated)) { + const cluster = updated[clusterId] + if (cluster) { + cluster.questions = cluster.questions.map(q => + q.id === id ? { ...q, addressed: !addressed } : q + ) + } + } + return updated + }) + } + + if (loading) { + return ( +
+
Loading...
+
+ ) + } + + const clusterIds = Object.keys(grouped) + const totalQuestions = clusterIds.reduce((sum, id) => { + return sum + (grouped[id]?.questions.length ?? 0) + }, 0) + + if (totalQuestions === 0) { + return ( +
+
+

Question Bank

+

Extracted questions from discussions

+
+
+

No questions found. Run clustering first.

+
+
+ ) + } + + return ( +
+
+
+

Question Bank

+

{totalQuestions} questions extracted

+
+
+ + +
+
+ +
+ {clusterIds.map(clusterId => { + const cluster = grouped[clusterId] + if (!cluster) return null + + const filteredQuestions = + filter === 'unanswered' + ? cluster.questions.filter(q => !q.addressed) + : cluster.questions + + if (filteredQuestions.length === 0) return null + + return ( +
+
+ {cluster.problem} +
+
    + {filteredQuestions.map(q => ( +
  • + handleToggleAddressed(q.id, q.addressed)} + className="mt-1 h-4 w-4 rounded border-border" + /> +
    +

    + {q.text} +

    +

    + {q.engagement} upvotes +

    +
    +
  • + ))} +
+
+ ) + })} +
+
+ ) +} diff --git a/packages/web/src/app/scrape/page.tsx b/packages/web/src/app/scrape/page.tsx new file mode 100644 index 0000000..eb6792c --- /dev/null +++ b/packages/web/src/app/scrape/page.tsx @@ -0,0 +1,152 @@ +'use client' + +import { useState, useEffect } from 'react' +import { useToast } from '@/components/ui/toast' + +interface ScrapeHistory { + id: number + subreddit: string + url: string + postsScraped: number + commentsScraped: number + startedAt: number + completedAt: number | null +} + +export default function ScrapePage() { + const [url, setUrl] = useState('') + const [pages, setPages] = useState(5) + const [isLoading, setIsLoading] = useState(false) + const [history, setHistory] = useState([]) + const { addToast, updateToast } = useToast() + + useEffect(() => { + fetch('/api/scrape/history') + .then(res => res.json()) + .then(data => setHistory(data.history || [])) + }, []) + + const handleScrape = async () => { + if (!url) return + setIsLoading(true) + const toastId = addToast('Scraping subreddit... this may take a while', 'loading') + + try { + const res = await fetch('/api/scrape', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ url, pages }), + }) + + const data = await res.json() + if (data.success) { + updateToast(toastId, `Scraped ${data.posts} posts and ${data.comments} comments`, 'success') + const historyRes = await fetch('/api/scrape/history') + const historyData = await historyRes.json() + setHistory(historyData.history || []) + } else { + updateToast(toastId, data.error || 'Scrape failed', 'error') + } + } catch (err) { + updateToast(toastId, 'Scrape failed - check console', 'error') + } finally { + setIsLoading(false) + } + } + + const formatDate = (timestamp: number) => { + return new Date(timestamp * 1000).toLocaleString() + } + + return ( +
+
+

Scrape Manager

+

Scrape subreddits and manage data collection

+
+ +
+
+ + setUrl(e.target.value)} + placeholder="https://reddit.com/r/programming/best" + className="w-full rounded-lg border border-input bg-background px-3 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + disabled={isLoading} + /> +
+
+ + setPages(parseInt(e.target.value) || 1)} + min={1} + max={20} + className="w-32 rounded-lg border border-input bg-background px-3 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-ring" + disabled={isLoading} + /> +
+ +
+ + {history.length > 0 && ( +
+
+ Scrape History +
+ + + + + + + + + + + + {history.map(h => ( + + + + + + + + ))} + +
+ Subreddit + + Posts + + Comments + + Started + + Status +
r/{h.subreddit}{h.postsScraped}{h.commentsScraped}{formatDate(h.startedAt)} + {h.completedAt ? ( + Complete + ) : ( + In Progress + )} +
+
+ )} +
+ ) +} diff --git a/packages/web/src/app/settings/page.tsx b/packages/web/src/app/settings/page.tsx new file mode 100644 index 0000000..9a2be43 --- /dev/null +++ b/packages/web/src/app/settings/page.tsx @@ -0,0 +1,203 @@ +'use client' + +import React, { useEffect, useState } from 'react' +import { useToast } from '@/components/ui/toast' + +interface Model { + name: string + size: number + modified: string +} + +interface Settings { + summarizationModel: string + sentimentModel: string + embeddingModel: string +} + +export default function SettingsPage() { + const [settings, setSettings] = useState(null) + const [models, setModels] = useState([]) + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [error, setError] = useState(null) + const { addToast } = useToast() + + useEffect(() => { + fetch('/api/settings') + .then(res => res.json()) + .then(data => { + setSettings(data.settings) + setModels(data.availableModels || []) + if (data.error) setError(data.error) + setLoading(false) + }) + .catch(() => { + setError('Failed to load settings') + setLoading(false) + }) + }, []) + + const handleSave = async () => { + if (!settings) return + setSaving(true) + + try { + const res = await fetch('/api/settings', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(settings), + }) + const data = await res.json() + + if (data.success) { + addToast('Settings saved', 'success') + } else { + addToast(data.error || 'Failed to save', 'error') + } + } catch { + addToast('Failed to save settings', 'error') + } finally { + setSaving(false) + } + } + + const updateSetting = (key: keyof Settings, value: string) => { + setSettings(prev => prev ? { ...prev, [key]: value } : null) + } + + const formatSize = (bytes: number) => { + const gb = bytes / (1024 * 1024 * 1024) + return gb >= 1 ? `${gb.toFixed(1)} GB` : `${(bytes / (1024 * 1024)).toFixed(0)} MB` + } + + if (loading) { + return ( +
+
Loading...
+
+ ) + } + + return ( +
+
+

Settings

+

Configure models and preferences

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

Ollama Models

+

+ {models.length} models available locally +

+
+ + {settings && ( +
+
+ +

+ Used for generating problem summaries and extracting questions from clusters +

+ +
+ +
+ +

+ Used for analyzing sentiment of discussions (positive/negative/neutral) +

+ +
+ +
+ +

+ Used for converting text to vectors for semantic search and clustering +

+ +
+
+ )} + +
+ +
+
+ +
+

Available Models

+
+ {models.map(m => ( +
+ {m.name} + {formatSize(m.size)} +
+ ))} + {models.length === 0 && ( +

+ No models found. Make sure Ollama is running and has models installed. +

+ )} +
+
+
+ ) +} diff --git a/packages/web/src/components/controls/command-palette-wrapper.tsx b/packages/web/src/components/controls/command-palette-wrapper.tsx new file mode 100644 index 0000000..1981104 --- /dev/null +++ b/packages/web/src/components/controls/command-palette-wrapper.tsx @@ -0,0 +1,7 @@ +'use client' + +import { CommandPalette } from './command-palette' + +export function CommandPaletteWrapper() { + return +} diff --git a/packages/web/src/components/controls/command-palette.tsx b/packages/web/src/components/controls/command-palette.tsx new file mode 100644 index 0000000..6fa70c4 --- /dev/null +++ b/packages/web/src/components/controls/command-palette.tsx @@ -0,0 +1,174 @@ +'use client' + +import { useEffect, useState, useCallback } from 'react' +import { useRouter } from 'next/navigation' +import { Command } from 'cmdk' +import { useToast } from '@/components/ui/toast' + +interface CommandPaletteProps { + onSearch?: (query: string) => void +} + +export function CommandPalette({ onSearch }: CommandPaletteProps) { + const [open, setOpen] = useState(false) + const [search, setSearch] = useState('') + const router = useRouter() + const { addToast, updateToast, removeToast } = useToast() + + useEffect(() => { + const down = (e: KeyboardEvent) => { + if (e.key === 'k' && (e.metaKey || e.ctrlKey)) { + e.preventDefault() + setOpen(open => !open) + } + } + + document.addEventListener('keydown', down) + return () => document.removeEventListener('keydown', down) + }, []) + + const runCommand = useCallback((command: () => void) => { + setOpen(false) + command() + }, []) + + if (!open) return null + + return ( +
setOpen(false)}> +
e.stopPropagation()} + > + +
+ +
+ + + No results found. + + + + runCommand(() => router.push('/'))} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Go to Dashboard + + runCommand(() => router.push('/problems'))} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Go to Problems + + runCommand(() => router.push('/questions'))} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Go to Questions + + runCommand(() => router.push('/scrape'))} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Go to Scrape Manager + + + + + runCommand(async () => { + const toastId = addToast('Clustering discussions...', 'loading') + try { + const res = await fetch('/api/clusters', { method: 'POST' }) + const data = await res.json() + if (data.success) { + updateToast(toastId, `Found ${data.clusters?.length || 0} problem clusters`, 'success') + router.refresh() + } else { + updateToast(toastId, data.error || 'Clustering failed', 'error') + } + } catch (e) { + updateToast(toastId, 'Clustering failed - check console', 'error') + } + })} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Re-cluster discussions + + runCommand(async () => { + const toastId = addToast('Exporting CSV...', 'loading') + try { + const res = await fetch('/api/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format: 'csv' }), + }) + const blob = await res.blob() + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'clusters.csv' + a.click() + updateToast(toastId, 'CSV downloaded', 'success') + } catch (e) { + updateToast(toastId, 'Export failed', 'error') + } + })} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Export clusters as CSV + + runCommand(async () => { + const toastId = addToast('Exporting FAQ schema...', 'loading') + try { + const res = await fetch('/api/export', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ format: 'json', type: 'faq-schema' }), + }) + const data = await res.json() + const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' }) + const url = URL.createObjectURL(blob) + const a = document.createElement('a') + a.href = url + a.download = 'faq-schema.json' + a.click() + updateToast(toastId, 'FAQ schema downloaded', 'success') + } catch (e) { + updateToast(toastId, 'Export failed', 'error') + } + })} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Export FAQ schema (JSON-LD) + + + + {search && onSearch && ( + + runCommand(() => onSearch(search))} + className="flex cursor-pointer items-center gap-2 rounded-lg px-3 py-2 text-sm text-foreground hover:bg-accent" + > + Semantic search: "{search}" + + + )} + +
+ Press Esc to close +
+
+
+
+ ) +} diff --git a/packages/web/src/components/ui/toast.tsx b/packages/web/src/components/ui/toast.tsx new file mode 100644 index 0000000..38f1ac7 --- /dev/null +++ b/packages/web/src/components/ui/toast.tsx @@ -0,0 +1,98 @@ +'use client' + +import { createContext, useContext, useState, useCallback, ReactNode } from 'react' + +interface Toast { + id: string + message: string + type: 'info' | 'success' | 'error' | 'loading' +} + +interface ToastContextValue { + toasts: Toast[] + addToast: (message: string, type?: Toast['type']) => string + removeToast: (id: string) => void + updateToast: (id: string, message: string, type?: Toast['type']) => void +} + +const ToastContext = createContext(null) + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState([]) + + const addToast = useCallback((message: string, type: Toast['type'] = 'info') => { + const id = crypto.randomUUID() + setToasts(prev => [...prev, { id, message, type }]) + + if (type !== 'loading') { + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, 4000) + } + + return id + }, []) + + const removeToast = useCallback((id: string) => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, []) + + const updateToast = useCallback((id: string, message: string, type: Toast['type'] = 'info') => { + setToasts(prev => prev.map(t => t.id === id ? { ...t, message, type } : t)) + + if (type !== 'loading') { + setTimeout(() => { + setToasts(prev => prev.filter(t => t.id !== id)) + }, 4000) + } + }, []) + + return ( + + {children} + + + ) +} + +export function useToast() { + const context = useContext(ToastContext) + if (!context) { + throw new Error('useToast must be used within a ToastProvider') + } + return context +} + +function ToastContainer({ toasts, removeToast }: { toasts: Toast[]; removeToast: (id: string) => void }) { + if (toasts.length === 0) return null + + return ( +
+ {toasts.map(toast => ( +
+ {toast.type === 'loading' && ( +
+ )} + {toast.message} + {toast.type !== 'loading' && ( + + )} +
+ ))} +
+ ) +} diff --git a/packages/web/src/lib/utils.ts b/packages/web/src/lib/utils.ts new file mode 100644 index 0000000..d32b0fe --- /dev/null +++ b/packages/web/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { type ClassValue, clsx } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/packages/web/src/styles/globals.css b/packages/web/src/styles/globals.css new file mode 100644 index 0000000..90a0257 --- /dev/null +++ b/packages/web/src/styles/globals.css @@ -0,0 +1,172 @@ +@import "tailwindcss"; + +:root { + --background: oklch(0.9551 0 0); + --foreground: oklch(0.3211 0 0); + --card: oklch(0.9702 0 0); + --card-foreground: oklch(0.3211 0 0); + --popover: oklch(0.9702 0 0); + --popover-foreground: oklch(0.3211 0 0); + --primary: oklch(0.4891 0 0); + --primary-foreground: oklch(1.0000 0 0); + --secondary: oklch(0.9067 0 0); + --secondary-foreground: oklch(0.3211 0 0); + --muted: oklch(0.8853 0 0); + --muted-foreground: oklch(0.5103 0 0); + --accent: oklch(0.8078 0 0); + --accent-foreground: oklch(0.3211 0 0); + --destructive: oklch(0.5594 0.1900 25.8625); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.8576 0 0); + --input: oklch(0.9067 0 0); + --ring: oklch(0.4891 0 0); + --chart-1: oklch(0.4891 0 0); + --chart-2: oklch(0.4863 0.0361 196.0278); + --chart-3: oklch(0.6534 0 0); + --chart-4: oklch(0.7316 0 0); + --chart-5: oklch(0.8078 0 0); + --sidebar: oklch(0.9370 0 0); + --sidebar-foreground: oklch(0.3211 0 0); + --sidebar-primary: oklch(0.4891 0 0); + --sidebar-primary-foreground: oklch(1.0000 0 0); + --sidebar-accent: oklch(0.8078 0 0); + --sidebar-accent-foreground: oklch(0.3211 0 0); + --sidebar-border: oklch(0.8576 0 0); + --sidebar-ring: oklch(0.4891 0 0); + --font-sans: Montserrat, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Fira Code, monospace; + --radius: 0.35rem; + --shadow-x: 0px; + --shadow-y: 2px; + --shadow-blur: 0px; + --shadow-spread: 0px; + --shadow-opacity: 0.15; + --shadow-color: hsl(0 0% 20% / 0.1); + --shadow-2xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07); + --shadow-xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07); + --shadow-sm: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15); + --shadow: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15); + --shadow-md: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 2px 4px -1px hsl(0 0% 20% / 0.15); + --shadow-lg: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 4px 6px -1px hsl(0 0% 20% / 0.15); + --shadow-xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 8px 10px -1px hsl(0 0% 20% / 0.15); + --shadow-2xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.38); + --tracking-normal: 0em; + --spacing: 0.25rem; +} + +.dark { + --background: oklch(0.2178 0 0); + --foreground: oklch(0.8853 0 0); + --card: oklch(0.2435 0 0); + --card-foreground: oklch(0.8853 0 0); + --popover: oklch(0.2435 0 0); + --popover-foreground: oklch(0.8853 0 0); + --primary: oklch(0.7058 0 0); + --primary-foreground: oklch(0.2178 0 0); + --secondary: oklch(0.3092 0 0); + --secondary-foreground: oklch(0.8853 0 0); + --muted: oklch(0.2850 0 0); + --muted-foreground: oklch(0.5999 0 0); + --accent: oklch(0.3715 0 0); + --accent-foreground: oklch(0.8853 0 0); + --destructive: oklch(0.6591 0.1530 22.1703); + --destructive-foreground: oklch(1.0000 0 0); + --border: oklch(0.3290 0 0); + --input: oklch(0.3092 0 0); + --ring: oklch(0.7058 0 0); + --chart-1: oklch(0.7058 0 0); + --chart-2: oklch(0.6714 0.0339 206.3482); + --chart-3: oklch(0.5452 0 0); + --chart-4: oklch(0.4604 0 0); + --chart-5: oklch(0.3715 0 0); + --sidebar: oklch(0.2393 0 0); + --sidebar-foreground: oklch(0.8853 0 0); + --sidebar-primary: oklch(0.7058 0 0); + --sidebar-primary-foreground: oklch(0.2178 0 0); + --sidebar-accent: oklch(0.3715 0 0); + --sidebar-accent-foreground: oklch(0.8853 0 0); + --sidebar-border: oklch(0.3290 0 0); + --sidebar-ring: oklch(0.7058 0 0); + --font-sans: Inter, sans-serif; + --font-serif: Georgia, serif; + --font-mono: Fira Code, monospace; + --radius: 0.35rem; + --shadow-x: 0px; + --shadow-y: 2px; + --shadow-blur: 0px; + --shadow-spread: 0px; + --shadow-opacity: 0.15; + --shadow-color: hsl(0 0% 20% / 0.1); + --shadow-2xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07); + --shadow-xs: 0px 2px 0px 0px hsl(0 0% 20% / 0.07); + --shadow-sm: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15); + --shadow: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 1px 2px -1px hsl(0 0% 20% / 0.15); + --shadow-md: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 2px 4px -1px hsl(0 0% 20% / 0.15); + --shadow-lg: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 4px 6px -1px hsl(0 0% 20% / 0.15); + --shadow-xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.15), 0px 8px 10px -1px hsl(0 0% 20% / 0.15); + --shadow-2xl: 0px 2px 0px 0px hsl(0 0% 20% / 0.38); +} + +@theme inline { + --color-background: var(--background); + --color-foreground: var(--foreground); + --color-card: var(--card); + --color-card-foreground: var(--card-foreground); + --color-popover: var(--popover); + --color-popover-foreground: var(--popover-foreground); + --color-primary: var(--primary); + --color-primary-foreground: var(--primary-foreground); + --color-secondary: var(--secondary); + --color-secondary-foreground: var(--secondary-foreground); + --color-muted: var(--muted); + --color-muted-foreground: var(--muted-foreground); + --color-accent: var(--accent); + --color-accent-foreground: var(--accent-foreground); + --color-destructive: var(--destructive); + --color-destructive-foreground: var(--destructive-foreground); + --color-border: var(--border); + --color-input: var(--input); + --color-ring: var(--ring); + --color-chart-1: var(--chart-1); + --color-chart-2: var(--chart-2); + --color-chart-3: var(--chart-3); + --color-chart-4: var(--chart-4); + --color-chart-5: var(--chart-5); + --color-sidebar: var(--sidebar); + --color-sidebar-foreground: var(--sidebar-foreground); + --color-sidebar-primary: var(--sidebar-primary); + --color-sidebar-primary-foreground: var(--sidebar-primary-foreground); + --color-sidebar-accent: var(--sidebar-accent); + --color-sidebar-accent-foreground: var(--sidebar-accent-foreground); + --color-sidebar-border: var(--sidebar-border); + --color-sidebar-ring: var(--sidebar-ring); + + --font-sans: var(--font-sans); + --font-mono: var(--font-mono); + --font-serif: var(--font-serif); + + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + + --shadow-2xs: var(--shadow-2xs); + --shadow-xs: var(--shadow-xs); + --shadow-sm: var(--shadow-sm); + --shadow: var(--shadow); + --shadow-md: var(--shadow-md); + --shadow-lg: var(--shadow-lg); + --shadow-xl: var(--shadow-xl); + --shadow-2xl: var(--shadow-2xl); +} + +body { + background-color: var(--background); + color: var(--foreground); + font-family: var(--font-sans); +} + +* { + border-color: var(--border); +} diff --git a/packages/web/tsconfig.json b/packages/web/tsconfig.json new file mode 100644 index 0000000..f48e7ee --- /dev/null +++ b/packages/web/tsconfig.json @@ -0,0 +1,40 @@ +{ + "compilerOptions": { + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": [ + "./src/*" + ] + }, + "target": "ES2017" + }, + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +}