refactor: restructure into monorepo
Move flat src/ layout into packages/ monorepo: - packages/core: scraping, embeddings, storage, clustering, analysis - packages/cli: CLI and TUI interface - packages/web: Next.js web dashboard Add playwright screenshots, sqlite storage, and settings.
10
.gitignore
vendored
@ -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/
|
||||
|
||||
BIN
.playwright-mcp/after-cluster-click.png
Normal file
|
After Width: | Height: | Size: 71 KiB |
BIN
.playwright-mcp/command-palette.png
Normal file
|
After Width: | Height: | Size: 103 KiB |
BIN
.playwright-mcp/dashboard-home.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
.playwright-mcp/dashboard-loaded.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.playwright-mcp/full-page.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.playwright-mcp/problems-page.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
.playwright-mcp/problems-recluster-loading.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
.playwright-mcp/questions-page.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
.playwright-mcp/scrape-page.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
.playwright-mcp/toast-loading.png
Normal file
|
After Width: | Height: | Size: 62 KiB |
BIN
.playwright-mcp/toast-success.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
184
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=<your-key>
|
||||
OLLAMA_HOST=http://localhost:11434 # optional, defaults to this
|
||||
OLLAMA_HOST=http://localhost:11434
|
||||
ANTHROPIC_API_KEY=<your-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
|
||||
|
||||
|
||||
|
||||
554
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=="],
|
||||
}
|
||||
}
|
||||
|
||||
0
data/.gitkeep
Normal file
5
data/settings.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"summarizationModel": "llava-llama3:latest",
|
||||
"sentimentModel": "llava-llama3:latest",
|
||||
"embeddingModel": "nomic-embed-text"
|
||||
}
|
||||
19
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"
|
||||
}
|
||||
}
|
||||
|
||||
18
packages/cli/package.json
Normal file
@ -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:*"
|
||||
}
|
||||
}
|
||||
@ -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 <url> [pages] - scrape subreddit (e.g. scrape https://reddit.com/r/vfx/best 3)')
|
||||
console.log(' search <query> - 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)) {
|
||||
@ -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<boolean> {
|
||||
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<boolean> {
|
||||
|
||||
async function checkQdrant(): Promise<boolean> {
|
||||
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
|
||||
}
|
||||
@ -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'
|
||||
@ -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, {
|
||||
@ -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<void>
|
||||
@ -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, {
|
||||
25
packages/core/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
12
packages/core/src/analysis/index.ts
Normal file
@ -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'
|
||||
138
packages/core/src/analysis/questions.ts
Normal file
@ -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<number, ExtractedQuestion[]> {
|
||||
const questionsByCluster = new Map<number, ExtractedQuestion[]>()
|
||||
|
||||
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<ExtractedQuestion[]> {
|
||||
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<number>()
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
107
packages/core/src/analysis/scoring.ts
Normal file
@ -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<ScoringWeights>) {
|
||||
this.weights = { ...DEFAULT_WEIGHTS, ...weights }
|
||||
}
|
||||
|
||||
setWeights(weights: Partial<ScoringWeights>): void {
|
||||
this.weights = { ...this.weights, ...weights }
|
||||
}
|
||||
|
||||
enableSentiment(): void {
|
||||
this.sentimentAnalyzer = new SentimentAnalyzer()
|
||||
}
|
||||
|
||||
async scoreClustersAsync(clusters: Cluster[]): Promise<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
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
97
packages/core/src/analysis/sentiment.ts
Normal file
@ -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<string, number>
|
||||
|
||||
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<number> {
|
||||
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<number[]> {
|
||||
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<number> {
|
||||
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<boolean> {
|
||||
try {
|
||||
const result = await this.ollama.list()
|
||||
return result.models.some(m => m.name.startsWith(this.model))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
41
packages/core/src/analysis/similarity.ts
Normal file
@ -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),
|
||||
}
|
||||
}
|
||||
146
packages/core/src/analysis/summarizer.ts
Normal file
@ -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<ProblemSummary> {
|
||||
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<ProblemSummary[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
40
packages/core/src/analysis/types.ts
Normal file
@ -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
|
||||
}
|
||||
214
packages/core/src/clustering/hdbscan.ts
Normal file
@ -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<ClusteringResult> {
|
||||
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<number, ClusterPoint[]>()
|
||||
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<number>()
|
||||
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
|
||||
}
|
||||
}
|
||||
2
packages/core/src/clustering/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { ClusteringPipeline } from './hdbscan'
|
||||
export type { Cluster, ClusterPoint, ClusteringOptions, ClusteringResult } from './types'
|
||||
34
packages/core/src/clustering/types.ts
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
2
packages/core/src/embeddings/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { EmbeddingPipeline, VECTOR_DIM } from './ollama'
|
||||
export type { EmbeddedPoint, EmbeddingProgress } from './ollama'
|
||||
46
packages/core/src/index.ts
Normal file
@ -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'
|
||||
12
packages/core/src/scraper/index.ts
Normal file
@ -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'
|
||||
4
packages/core/src/storage/index.ts
Normal file
@ -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'
|
||||
@ -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<PointPayload[]> {
|
||||
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<ClusterPoint[]> {
|
||||
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 }
|
||||
274
packages/core/src/storage/sqlite.ts
Normal file
@ -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()
|
||||
}
|
||||
}
|
||||
8
packages/core/src/utils/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
export { delay, RateLimiter, fetchWithRetry } from './rate-limit'
|
||||
export {
|
||||
decodeHtmlEntities,
|
||||
stripHtml,
|
||||
cleanText,
|
||||
truncateText,
|
||||
prepareForEmbedding,
|
||||
} from './text'
|
||||
6
packages/web/next-env.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
14
packages/web/next.config.ts
Normal file
@ -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
|
||||
35
packages/web/package.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
5
packages/web/postcss.config.cjs
Normal file
@ -0,0 +1,5 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
37
packages/web/src/app/api/clusters/[id]/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
||||
108
packages/web/src/app/api/clusters/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
23
packages/web/src/app/api/clusters/similarity/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
92
packages/web/src/app/api/export/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
22
packages/web/src/app/api/questions/[id]/route.ts
Normal file
@ -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 })
|
||||
}
|
||||
}
|
||||
25
packages/web/src/app/api/questions/route.ts
Normal file
@ -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<string, { problem: string; questions: typeof questions }> = {}
|
||||
|
||||
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: {} })
|
||||
}
|
||||
}
|
||||
15
packages/web/src/app/api/scrape/history/route.ts
Normal file
@ -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: [] })
|
||||
}
|
||||
}
|
||||
68
packages/web/src/app/api/scrape/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
29
packages/web/src/app/api/search/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
92
packages/web/src/app/api/settings/route.ts
Normal file
@ -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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
30
packages/web/src/app/api/stats/route.ts
Normal file
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
68
packages/web/src/app/layout.tsx
Normal file
@ -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 (
|
||||
<html lang="en">
|
||||
<body className="min-h-screen bg-background antialiased">
|
||||
<Providers>
|
||||
<div className="flex min-h-screen">
|
||||
<aside className="w-64 border-r border-border bg-sidebar p-4">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-lg font-semibold text-sidebar-foreground">
|
||||
Reddit Trends
|
||||
</h1>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Press <kbd className="rounded bg-muted px-1 py-0.5">Ctrl+K</kbd> for commands
|
||||
</p>
|
||||
</div>
|
||||
<nav className="space-y-2">
|
||||
<a
|
||||
href="/"
|
||||
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
Dashboard
|
||||
</a>
|
||||
<a
|
||||
href="/problems"
|
||||
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
Problems
|
||||
</a>
|
||||
<a
|
||||
href="/questions"
|
||||
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
Questions
|
||||
</a>
|
||||
<a
|
||||
href="/scrape"
|
||||
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
Scrape
|
||||
</a>
|
||||
<a
|
||||
href="/settings"
|
||||
className="block rounded-lg px-3 py-2 text-sm font-medium text-sidebar-foreground hover:bg-sidebar-accent"
|
||||
>
|
||||
Settings
|
||||
</a>
|
||||
</nav>
|
||||
</aside>
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
86
packages/web/src/app/page.tsx
Normal file
@ -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<Stats | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Dashboard</h1>
|
||||
<p className="text-muted-foreground">Overview of your Reddit trend analysis</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="text-sm text-muted-foreground">Posts</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{stats?.posts.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="text-sm text-muted-foreground">Comments</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{stats?.comments.toLocaleString() ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="text-sm text-muted-foreground">Subreddits</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{stats?.subreddits.length ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="text-sm text-muted-foreground">Clusters</div>
|
||||
<div className="text-2xl font-semibold text-foreground">
|
||||
{stats?.clusters ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{stats && stats.subreddits.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-card p-4 shadow-sm">
|
||||
<div className="text-sm text-muted-foreground mb-2">Tracked Subreddits</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stats.subreddits.map(sub => (
|
||||
<span
|
||||
key={sub}
|
||||
className="rounded-full bg-secondary px-3 py-1 text-sm text-secondary-foreground"
|
||||
>
|
||||
r/{sub}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
816
packages/web/src/app/problems/page.tsx
Normal file
@ -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<DiscussionSample[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expandedBodies, setExpandedBodies] = useState<Set<string>>(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 (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
Loading discussions...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (samples.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground py-2">
|
||||
No discussion samples available. Re-run clustering to populate.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="text-sm font-medium text-muted-foreground">
|
||||
Discussion Samples ({samples.length})
|
||||
</div>
|
||||
{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 (
|
||||
<div
|
||||
key={sample.id}
|
||||
className="rounded-lg border border-border bg-background p-3 space-y-2"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded px-1.5 py-0.5 text-xs font-medium ${
|
||||
sample.type === 'post'
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-green-500/10 text-green-500'
|
||||
}`}
|
||||
>
|
||||
{sample.type}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
r/{sample.subreddit}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
u/{sample.author}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{sample.score} pts
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">•</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{formatDate(sample.created)}
|
||||
</span>
|
||||
</div>
|
||||
<a
|
||||
href={`https://reddit.com${sample.permalink}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-primary hover:underline shrink-0"
|
||||
>
|
||||
view on reddit →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{sample.title && (
|
||||
<div className="font-medium text-foreground">{sample.title}</div>
|
||||
)}
|
||||
|
||||
<div className="text-sm text-foreground whitespace-pre-wrap">
|
||||
{displayBody}
|
||||
{bodyTruncated && !isExpanded && '...'}
|
||||
</div>
|
||||
|
||||
{bodyTruncated && (
|
||||
<button
|
||||
onClick={() => toggleBody(sample.id)}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{isExpanded ? 'show less' : 'show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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<SimilarityData | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading correlation data...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data || data.matrix.length === 0) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="text-sm text-muted-foreground">No clusters to compare</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="space-y-3">
|
||||
<div className="overflow-x-auto">
|
||||
<div
|
||||
className="inline-grid gap-px bg-border"
|
||||
style={{
|
||||
gridTemplateColumns: `auto repeat(${n}, ${cellSize}px)`,
|
||||
gridTemplateRows: `auto repeat(${n}, ${cellSize}px)`,
|
||||
}}
|
||||
>
|
||||
{/* empty corner cell */}
|
||||
<div className="bg-card" />
|
||||
|
||||
{/* column headers */}
|
||||
{data.labels.map((label, j) => (
|
||||
<div
|
||||
key={`col-${j}`}
|
||||
className="bg-card flex items-end justify-center pb-1 px-1"
|
||||
style={{ height: cellSize * 2 }}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] text-muted-foreground origin-bottom-left whitespace-nowrap overflow-hidden text-ellipsis"
|
||||
style={{
|
||||
transform: 'rotate(-45deg)',
|
||||
maxWidth: cellSize * 2,
|
||||
}}
|
||||
title={label}
|
||||
>
|
||||
{label.slice(0, 15)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* rows */}
|
||||
{data.matrix.map((row, i) => (
|
||||
<React.Fragment key={`row-${i}`}>
|
||||
{/* row label */}
|
||||
<div
|
||||
className="bg-card flex items-center justify-end pr-2"
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<span
|
||||
className="text-[10px] text-muted-foreground truncate"
|
||||
title={data.labels[i]}
|
||||
>
|
||||
{data.labels[i].slice(0, 12)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* cells */}
|
||||
{row.map((value, j) => {
|
||||
const isHovered = hoveredCell?.i === i && hoveredCell?.j === j
|
||||
return (
|
||||
<div
|
||||
key={`cell-${i}-${j}`}
|
||||
className="relative cursor-pointer transition-all"
|
||||
style={{
|
||||
backgroundColor: getColor(value),
|
||||
width: cellSize,
|
||||
height: cellSize,
|
||||
outline: isHovered ? '2px solid #6366f1' : 'none',
|
||||
outlineOffset: '-1px',
|
||||
}}
|
||||
onMouseEnter={() => 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 && (
|
||||
<div className="absolute z-10 bg-card border border-border rounded px-2 py-1 text-xs shadow-lg whitespace-nowrap -translate-x-1/2 left-1/2 -top-8">
|
||||
{(value * 100).toFixed(0)}% similar
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* legend */}
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>0%</span>
|
||||
<div
|
||||
className="h-3 w-32 rounded"
|
||||
style={{
|
||||
background: 'linear-gradient(to right, white, #6366f1)',
|
||||
}}
|
||||
/>
|
||||
<span>100%</span>
|
||||
<span className="ml-2">keyword overlap</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 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<Problem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [expanded, setExpanded] = useState<number | null>(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<Weights>({
|
||||
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<string, number>()
|
||||
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<string, number>)
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const clusterControls = (
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||
<div className="text-sm font-medium text-foreground">Clustering Settings</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
Similarity Threshold: {similarityThreshold.toFixed(2)}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0.3"
|
||||
max="0.9"
|
||||
step="0.05"
|
||||
value={similarityThreshold}
|
||||
onChange={e => setSimilarityThreshold(parseFloat(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>Loose (0.3)</span>
|
||||
<span>Strict (0.9)</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
Min Cluster Size
|
||||
</label>
|
||||
<select
|
||||
value={minClusterSize}
|
||||
onChange={e => setMinClusterSize(parseInt(e.target.value))}
|
||||
className="w-full rounded border border-border bg-background px-2 py-1 text-sm text-foreground"
|
||||
>
|
||||
<option value={2}>2 discussions</option>
|
||||
<option value={3}>3 discussions</option>
|
||||
<option value={5}>5 discussions</option>
|
||||
<option value={10}>10 discussions</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const weightControls = (
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||
<div className="text-sm font-medium text-foreground">Impact Score Weights</div>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
Engagement: {(weights.engagement * 100).toFixed(0)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={weights.engagement}
|
||||
onChange={e => updateWeight('engagement', parseFloat(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
Velocity: {(weights.velocity * 100).toFixed(0)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={weights.velocity}
|
||||
onChange={e => updateWeight('velocity', parseFloat(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-muted-foreground mb-1">
|
||||
Sentiment: {(weights.sentiment * 100).toFixed(0)}%
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
value={weights.sentiment}
|
||||
onChange={e => updateWeight('sentiment', parseFloat(e.target.value))}
|
||||
className="w-full accent-primary"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (problems.length === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Problem Explorer</h1>
|
||||
<p className="text-muted-foreground">View and analyze problem clusters</p>
|
||||
</div>
|
||||
{clusterControls}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleRecluster}
|
||||
disabled={clustering}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{clustering ? 'Clustering...' : 'Run Clustering'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
||||
<p className="text-muted-foreground">No problems found. Adjust settings and run clustering.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Problem Explorer</h1>
|
||||
<p className="text-muted-foreground">
|
||||
{sortedProblems.length} problem clusters identified
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRecluster}
|
||||
disabled={clustering}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{clustering ? 'Clustering...' : 'Re-cluster'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{clusterControls}
|
||||
{weightControls}
|
||||
</div>
|
||||
|
||||
{sortedProblems.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<div className="text-lg font-medium text-foreground">Problem Analytics</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-sm font-medium text-foreground mb-4">
|
||||
Top Problems by Impact Score
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={chartData.impact}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={100}
|
||||
tick={{ fill: CHART_COLORS.text, fontSize: 11 }}
|
||||
/>
|
||||
<YAxis tick={{ fill: CHART_COLORS.text }} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: `1px solid ${CHART_COLORS.grid}`,
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="impact" fill={CHART_COLORS.primary} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-sm font-medium text-foreground mb-4">
|
||||
Discussion Distribution by Subreddit
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={chartData.subreddits}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
labelLine={false}
|
||||
label={({ name, percent }) =>
|
||||
`${name} (${(percent * 100).toFixed(0)}%)`
|
||||
}
|
||||
outerRadius={80}
|
||||
dataKey="value"
|
||||
>
|
||||
{chartData.subreddits.map((_, index) => (
|
||||
<Cell
|
||||
key={`cell-${index}`}
|
||||
fill={CHART_COLORS.palette[index % CHART_COLORS.palette.length]}
|
||||
/>
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: `1px solid ${CHART_COLORS.grid}`,
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-sm font-medium text-foreground mb-4">
|
||||
Cluster Size Distribution
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<BarChart data={chartData.sizes}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke={CHART_COLORS.grid} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
tick={{ fill: CHART_COLORS.text }}
|
||||
/>
|
||||
<YAxis tick={{ fill: CHART_COLORS.text }} allowDecimals={false} />
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#fff',
|
||||
border: `1px solid ${CHART_COLORS.grid}`,
|
||||
borderRadius: '6px',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" fill={CHART_COLORS.accent} radius={[4, 4, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-sm font-medium text-foreground mb-4">Key Metrics</div>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{sortedProblems.length}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total Clusters</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{sortedProblems.reduce((sum, p) => sum + p.size, 0).toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total Discussions</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{sortedProblems
|
||||
.reduce((sum, p) => sum + p.totalEngagement, 0)
|
||||
.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Total Upvotes</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-foreground">
|
||||
{new Set(sortedProblems.flatMap(p => p.subreddits)).size}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">Unique Subreddits</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* correlation heatmap */}
|
||||
<div className="rounded-lg border border-border bg-card p-4">
|
||||
<div className="text-sm font-medium text-foreground mb-4">
|
||||
Problem Cluster Correlation
|
||||
</div>
|
||||
<CorrelationHeatmap
|
||||
onCellClick={([id1, id2]) => {
|
||||
// expand first cluster that isn't already expanded
|
||||
if (expanded !== id1) {
|
||||
setExpanded(id1)
|
||||
} else if (expanded !== id2) {
|
||||
setExpanded(id2)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Problem
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Impact
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Discussions
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Upvotes
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
||||
Last Active
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedProblems.map(problem => (
|
||||
<React.Fragment key={problem.clusterId}>
|
||||
<tr
|
||||
onClick={() => setExpanded(expanded === problem.clusterId ? null : problem.clusterId)}
|
||||
className="border-t border-border hover:bg-accent/50 cursor-pointer"
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-foreground">{problem.problem}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{problem.subreddits.map(s => `r/${s}`).join(', ')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-foreground">
|
||||
<span className="inline-flex items-center rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary">
|
||||
{((problem.impactScore || 0) * 100).toFixed(0)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-foreground">{problem.size}</td>
|
||||
<td className="px-4 py-3 text-foreground">
|
||||
{problem.totalEngagement.toLocaleString()}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-foreground">{formatDate(problem.lastActive)}</td>
|
||||
</tr>
|
||||
{expanded === problem.clusterId && (
|
||||
<tr className="border-t border-border bg-muted/50">
|
||||
<td colSpan={5} className="px-4 py-4">
|
||||
<div className="space-y-4">
|
||||
<p className="text-foreground">{problem.description}</p>
|
||||
|
||||
<DiscussionSamples clusterId={problem.clusterId} />
|
||||
|
||||
{problem.sampleQuestions.length > 0 && (
|
||||
<div>
|
||||
<div className="text-sm font-medium text-muted-foreground mb-1">
|
||||
Sample Questions:
|
||||
</div>
|
||||
<ul className="list-disc list-inside text-sm text-foreground">
|
||||
{problem.sampleQuestions.map((q, i) => (
|
||||
<li key={i}>{q}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
14
packages/web/src/app/providers.tsx
Normal file
@ -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 (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<CommandPalette />
|
||||
</ToastProvider>
|
||||
)
|
||||
}
|
||||
164
packages/web/src/app/questions/page.tsx
Normal file
@ -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<GroupedQuestions>({})
|
||||
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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const clusterIds = Object.keys(grouped)
|
||||
const totalQuestions = clusterIds.reduce((sum, id) => {
|
||||
return sum + (grouped[id]?.questions.length ?? 0)
|
||||
}, 0)
|
||||
|
||||
if (totalQuestions === 0) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Question Bank</h1>
|
||||
<p className="text-muted-foreground">Extracted questions from discussions</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-border bg-card p-8 text-center">
|
||||
<p className="text-muted-foreground">No questions found. Run clustering first.</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Question Bank</h1>
|
||||
<p className="text-muted-foreground">{totalQuestions} questions extracted</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setFilter('all')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${
|
||||
filter === 'all'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-secondary-foreground'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setFilter('unanswered')}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium ${
|
||||
filter === 'unanswered'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-secondary text-secondary-foreground'
|
||||
}`}
|
||||
>
|
||||
Unanswered
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div
|
||||
key={clusterId}
|
||||
className="rounded-lg border border-border bg-card overflow-hidden"
|
||||
>
|
||||
<div className="bg-muted px-4 py-3 font-medium text-foreground">
|
||||
{cluster.problem}
|
||||
</div>
|
||||
<ul className="divide-y divide-border">
|
||||
{filteredQuestions.map(q => (
|
||||
<li key={q.id} className="flex items-start gap-3 px-4 py-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={q.addressed}
|
||||
onChange={() => handleToggleAddressed(q.id, q.addressed)}
|
||||
className="mt-1 h-4 w-4 rounded border-border"
|
||||
/>
|
||||
<div className="flex-1">
|
||||
<p
|
||||
className={`text-foreground ${
|
||||
q.addressed ? 'line-through text-muted-foreground' : ''
|
||||
}`}
|
||||
>
|
||||
{q.text}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{q.engagement} upvotes
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
152
packages/web/src/app/scrape/page.tsx
Normal file
@ -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<ScrapeHistory[]>([])
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Scrape Manager</h1>
|
||||
<p className="text-muted-foreground">Scrape subreddits and manage data collection</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Subreddit URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={url}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-1">
|
||||
Pages to Scrape
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
value={pages}
|
||||
onChange={e => 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}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleScrape}
|
||||
disabled={isLoading || !url}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{isLoading ? 'Scraping...' : 'Start Scrape'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{history.length > 0 && (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
<div className="bg-muted px-4 py-3 font-medium text-foreground">
|
||||
Scrape History
|
||||
</div>
|
||||
<table className="w-full">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
|
||||
Subreddit
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
|
||||
Posts
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
|
||||
Comments
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
|
||||
Started
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-sm font-medium text-muted-foreground">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{history.map(h => (
|
||||
<tr key={h.id} className="border-t border-border">
|
||||
<td className="px-4 py-2 text-foreground">r/{h.subreddit}</td>
|
||||
<td className="px-4 py-2 text-foreground">{h.postsScraped}</td>
|
||||
<td className="px-4 py-2 text-foreground">{h.commentsScraped}</td>
|
||||
<td className="px-4 py-2 text-foreground">{formatDate(h.startedAt)}</td>
|
||||
<td className="px-4 py-2">
|
||||
{h.completedAt ? (
|
||||
<span className="text-green-600">Complete</span>
|
||||
) : (
|
||||
<span className="text-yellow-600">In Progress</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
203
packages/web/src/app/settings/page.tsx
Normal file
@ -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<Settings | null>(null)
|
||||
const [models, setModels] = useState<Model[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<div className="text-muted-foreground">Loading...</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-foreground">Settings</h1>
|
||||
<p className="text-muted-foreground">Configure models and preferences</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6 space-y-6">
|
||||
<div>
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Ollama Models</h2>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{models.length} models available locally
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{settings && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Summarization Model
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Used for generating problem summaries and extracting questions from clusters
|
||||
</p>
|
||||
<select
|
||||
value={settings.summarizationModel}
|
||||
onChange={e => updateSetting('summarizationModel', e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground"
|
||||
>
|
||||
{models.map(m => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} ({formatSize(m.size)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Sentiment Model
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Used for analyzing sentiment of discussions (positive/negative/neutral)
|
||||
</p>
|
||||
<select
|
||||
value={settings.sentimentModel}
|
||||
onChange={e => updateSetting('sentimentModel', e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground"
|
||||
>
|
||||
{models.map(m => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} ({formatSize(m.size)})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-foreground mb-2">
|
||||
Embedding Model
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground mb-2">
|
||||
Used for converting text to vectors for semantic search and clustering
|
||||
</p>
|
||||
<select
|
||||
value={settings.embeddingModel}
|
||||
onChange={e => updateSetting('embeddingModel', e.target.value)}
|
||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-foreground"
|
||||
>
|
||||
{models.filter(m => m.name.includes('embed') || m.name.includes('nomic')).length > 0
|
||||
? models.filter(m => m.name.includes('embed') || m.name.includes('nomic')).map(m => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} ({formatSize(m.size)})
|
||||
</option>
|
||||
))
|
||||
: models.map(m => (
|
||||
<option key={m.name} value={m.name}>
|
||||
{m.name} ({formatSize(m.size)})
|
||||
</option>
|
||||
))
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="pt-4 border-t border-border">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Saving...' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-border bg-card p-6">
|
||||
<h2 className="text-lg font-medium text-foreground mb-4">Available Models</h2>
|
||||
<div className="space-y-2">
|
||||
{models.map(m => (
|
||||
<div key={m.name} className="flex justify-between items-center text-sm">
|
||||
<span className="text-foreground font-mono">{m.name}</span>
|
||||
<span className="text-muted-foreground">{formatSize(m.size)}</span>
|
||||
</div>
|
||||
))}
|
||||
{models.length === 0 && (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
No models found. Make sure Ollama is running and has models installed.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -0,0 +1,7 @@
|
||||
'use client'
|
||||
|
||||
import { CommandPalette } from './command-palette'
|
||||
|
||||
export function CommandPaletteWrapper() {
|
||||
return <CommandPalette />
|
||||
}
|
||||
174
packages/web/src/components/controls/command-palette.tsx
Normal file
@ -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 (
|
||||
<div className="fixed inset-0 z-50 bg-black/50" onClick={() => setOpen(false)}>
|
||||
<div
|
||||
className="fixed left-1/2 top-1/4 w-full max-w-lg -translate-x-1/2 rounded-xl border border-border bg-popover p-0 shadow-2xl"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<Command className="flex flex-col">
|
||||
<div className="flex items-center border-b border-border px-4">
|
||||
<Command.Input
|
||||
value={search}
|
||||
onValueChange={setSearch}
|
||||
placeholder="Type a command or search..."
|
||||
className="flex-1 bg-transparent py-3 text-foreground outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
</div>
|
||||
<Command.List className="max-h-72 overflow-y-auto p-2">
|
||||
<Command.Empty className="py-6 text-center text-sm text-muted-foreground">
|
||||
No results found.
|
||||
</Command.Empty>
|
||||
|
||||
<Command.Group heading="Navigation" className="text-xs text-muted-foreground mb-2">
|
||||
<Command.Item
|
||||
onSelect={() => 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
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => 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
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => 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
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => 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
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
<Command.Group heading="Actions" className="text-xs text-muted-foreground mb-2 mt-4">
|
||||
<Command.Item
|
||||
onSelect={() => 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
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => 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
|
||||
</Command.Item>
|
||||
<Command.Item
|
||||
onSelect={() => 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)
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
|
||||
{search && onSearch && (
|
||||
<Command.Group heading="Search" className="text-xs text-muted-foreground mb-2 mt-4">
|
||||
<Command.Item
|
||||
onSelect={() => 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}"
|
||||
</Command.Item>
|
||||
</Command.Group>
|
||||
)}
|
||||
</Command.List>
|
||||
<div className="border-t border-border px-4 py-2 text-xs text-muted-foreground">
|
||||
Press <kbd className="rounded bg-muted px-1 py-0.5">Esc</kbd> to close
|
||||
</div>
|
||||
</Command>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
98
packages/web/src/components/ui/toast.tsx
Normal file
@ -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<ToastContextValue | null>(null)
|
||||
|
||||
export function ToastProvider({ children }: { children: ReactNode }) {
|
||||
const [toasts, setToasts] = useState<Toast[]>([])
|
||||
|
||||
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 (
|
||||
<ToastContext.Provider value={{ toasts, addToast, removeToast, updateToast }}>
|
||||
{children}
|
||||
<ToastContainer toasts={toasts} removeToast={removeToast} />
|
||||
</ToastContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2">
|
||||
{toasts.map(toast => (
|
||||
<div
|
||||
key={toast.id}
|
||||
className={`
|
||||
flex items-center gap-3 rounded-lg px-4 py-3 shadow-lg
|
||||
${toast.type === 'error' ? 'bg-red-500 text-white' : ''}
|
||||
${toast.type === 'success' ? 'bg-green-600 text-white' : ''}
|
||||
${toast.type === 'loading' ? 'bg-card text-foreground border border-border' : ''}
|
||||
${toast.type === 'info' ? 'bg-card text-foreground border border-border' : ''}
|
||||
`}
|
||||
>
|
||||
{toast.type === 'loading' && (
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-muted-foreground border-t-foreground" />
|
||||
)}
|
||||
<span className="text-sm">{toast.message}</span>
|
||||
{toast.type !== 'loading' && (
|
||||
<button
|
||||
onClick={() => removeToast(toast.id)}
|
||||
className="ml-2 text-current opacity-70 hover:opacity-100"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
6
packages/web/src/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
172
packages/web/src/styles/globals.css
Normal file
@ -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);
|
||||
}
|
||||
40
packages/web/tsconfig.json
Normal file
@ -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"
|
||||
]
|
||||
}
|
||||