Compare commits
106 commits
main
...
railway-ex
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
051e58c23d | ||
|
|
c4161f1d35 | ||
|
|
31b29c306f | ||
|
|
0f758aecca | ||
|
|
e384951aa8 | ||
|
|
e97ecfaa31 | ||
|
|
5b5cffa248 | ||
|
|
722cb6af0b | ||
|
|
0e907338ed | ||
|
|
f6812b2267 | ||
|
|
9abff1f203 | ||
|
|
5b5be16770 | ||
|
|
177f8136a0 | ||
|
|
07809784db | ||
|
|
f50b747e45 | ||
|
|
addb6ba06e | ||
|
|
56a6128ac6 | ||
|
|
2c2eac7992 | ||
|
|
801b0d81b7 | ||
|
|
564543274d | ||
|
|
daedf035af | ||
|
|
fd04674501 | ||
|
|
1642ec07d9 | ||
|
|
dc4846c7b1 | ||
|
|
a39f28546a | ||
|
|
b4e22723bb | ||
|
|
709c2c20db | ||
|
|
c200a10015 | ||
|
|
84c6a579b2 | ||
|
|
bf8d27a59c | ||
|
|
f0ecdab824 | ||
|
|
787410a7e0 | ||
|
|
e5ba9fa441 | ||
|
|
b00eae9fa2 | ||
|
|
148207bcaa | ||
|
|
8562eb5b38 | ||
|
|
934c8e9c0b | ||
|
|
19837ae800 | ||
|
|
d3f8adc0ea | ||
|
|
d0110f990d | ||
|
|
c6b4456baf | ||
|
|
4094c661be | ||
|
|
d339637ebf | ||
|
|
9f85898937 | ||
|
|
45b477397f | ||
|
|
cc14476a36 | ||
|
|
8b5757c9aa | ||
|
|
54b504f661 | ||
|
|
d4ec877c6c | ||
|
|
e8ef703c6e | ||
|
|
f127b47f08 | ||
|
|
c18d1b7ea6 | ||
|
|
b6cc98d1e0 | ||
|
|
b95edd8541 | ||
|
|
1e61ba1551 | ||
|
|
b7d1accc6f | ||
|
|
09df09c4df | ||
|
|
d3b928c97e | ||
|
|
3450d7a6d5 | ||
|
|
2158ee6dfd | ||
|
|
0612eac5e2 | ||
|
|
fdd1b2c304 | ||
|
|
635fc96cac | ||
|
|
fcd6667357 | ||
|
|
14cd668a2b | ||
|
|
493cd96d7b | ||
|
|
2bf8565c1d | ||
|
|
7fab0440b3 | ||
|
|
bc34f50705 | ||
|
|
4febe71d76 | ||
|
|
8e8e511e5a | ||
|
|
054bac9a53 | ||
|
|
4146f279e6 | ||
|
|
4dc53338f2 | ||
|
|
251442f1d4 | ||
|
|
c8ccab5cb8 | ||
|
|
0b4a314bd1 | ||
|
|
61ac2aa5dd | ||
|
|
47ddc4dea7 | ||
|
|
45da32e8ec | ||
|
|
266a6ef74c | ||
|
|
2a05e68aa3 | ||
|
|
78c72b85f3 | ||
|
|
59da95ce85 | ||
|
|
1cdbf8c891 | ||
|
|
c0383f5440 | ||
|
|
ad4fde7e79 | ||
|
|
4705af6b1a | ||
|
|
8f3c6a2910 | ||
|
|
fdfc721d40 | ||
|
|
cc13e7cb09 | ||
|
|
492a7221c9 | ||
|
|
664bbdad57 | ||
|
|
140a2fa218 | ||
|
|
2ce37ae2aa | ||
|
|
b176ae54df | ||
|
|
d73db1c333 | ||
|
|
ca57de533a | ||
|
|
6eb51d1371 | ||
|
|
91f950d453 | ||
|
|
48c8775acf | ||
|
|
5feac0d2db | ||
|
|
c52a044b47 | ||
|
|
f9ba1c094e | ||
|
|
e38e627a07 | ||
|
|
7f5d307c0f |
186 changed files with 56914 additions and 1694 deletions
|
|
@ -105,8 +105,8 @@ export default {
|
|||
from: {},
|
||||
to: {
|
||||
couldNotResolve: true,
|
||||
// Allow TypeScript path aliases that are resolved by the TS compiler
|
||||
pathNot: ['^~/'],
|
||||
// Allow TypeScript path aliases and workspace packages that are resolved by the TS compiler
|
||||
pathNot: ['^~/', '^@/', '^@tpmjs/'],
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
41
.env.vercel.production
Normal file
41
.env.vercel.production
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Created by Vercel CLI
|
||||
CRON_SECRET="CRON_SECRET=6c806d35cf6212f489c76414d38d2b6acbc44590ac78bb08aadea28dd04a29d0\n"
|
||||
DATABASE_URL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
||||
DATABASE_URL_UNPOOLED="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
||||
NEXT_PUBLIC_STACK_PROJECT_ID="d786bd3a-a31d-4c6b-9497-5d6803dd9d86"
|
||||
NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY="pck_hafmpkaj047z331x5azv8bk5zggfnbgdedbj9pfqh1rn0"
|
||||
NX_DAEMON="false"
|
||||
PGDATABASE="neondb"
|
||||
PGHOST="ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech"
|
||||
PGHOST_UNPOOLED="ep-broad-darkness-a4lml85k.us-east-1.aws.neon.tech"
|
||||
PGPASSWORD="npg_euvYo4OTi1lX"
|
||||
PGUSER="neondb_owner"
|
||||
POSTGRES_DATABASE="neondb"
|
||||
POSTGRES_HOST="ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech"
|
||||
POSTGRES_PASSWORD="npg_euvYo4OTi1lX"
|
||||
POSTGRES_PRISMA_URL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb?connect_timeout=15&sslmode=require"
|
||||
POSTGRES_URL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
||||
POSTGRES_URL_NON_POOLING="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k.us-east-1.aws.neon.tech/neondb?sslmode=require"
|
||||
POSTGRES_URL_NO_SSL="postgresql://neondb_owner:npg_euvYo4OTi1lX@ep-broad-darkness-a4lml85k-pooler.us-east-1.aws.neon.tech/neondb"
|
||||
POSTGRES_USER="neondb_owner"
|
||||
STACK_SECRET_SERVER_KEY="ssk_p05kwe938wx13rpera9xf1fewc816dwkbq658xcsbwj1g"
|
||||
TURBO_CACHE="remote:rw"
|
||||
TURBO_DOWNLOAD_LOCAL_ENABLED="true"
|
||||
TURBO_REMOTE_ONLY="true"
|
||||
TURBO_RUN_SUMMARY="true"
|
||||
VERCEL="1"
|
||||
VERCEL_ENV="production"
|
||||
VERCEL_GIT_COMMIT_AUTHOR_LOGIN=""
|
||||
VERCEL_GIT_COMMIT_AUTHOR_NAME=""
|
||||
VERCEL_GIT_COMMIT_MESSAGE=""
|
||||
VERCEL_GIT_COMMIT_REF=""
|
||||
VERCEL_GIT_COMMIT_SHA=""
|
||||
VERCEL_GIT_PREVIOUS_SHA=""
|
||||
VERCEL_GIT_PROVIDER=""
|
||||
VERCEL_GIT_PULL_REQUEST_ID=""
|
||||
VERCEL_GIT_REPO_ID=""
|
||||
VERCEL_GIT_REPO_OWNER=""
|
||||
VERCEL_GIT_REPO_SLUG=""
|
||||
VERCEL_OIDC_TOKEN="eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6Im1yay00MzAyZWMxYjY3MGY0OGE5OGFkNjFkYWRlNGEyM2JlNyJ9.eyJpc3MiOiJodHRwczovL29pZGMudmVyY2VsLmNvbS90cG1qcyIsInN1YiI6Im93bmVyOnRwbWpzOnByb2plY3Q6dHBtanMtd2ViOmVudmlyb25tZW50OmRldmVsb3BtZW50Iiwic2NvcGUiOiJvd25lcjp0cG1qczpwcm9qZWN0OnRwbWpzLXdlYjplbnZpcm9ubWVudDpkZXZlbG9wbWVudCIsImF1ZCI6Imh0dHBzOi8vdmVyY2VsLmNvbS90cG1qcyIsIm93bmVyIjoidHBtanMiLCJvd25lcl9pZCI6InRlYW1femtHV0NXYjdWakhvbmk2VmJ5ZmQyc3c4IiwicHJvamVjdCI6InRwbWpzLXdlYiIsInByb2plY3RfaWQiOiJwcmpfNWd1MEkwVzFjUFhkQ3ozd1RjQ0ZIejQzNUJ0MCIsImVudmlyb25tZW50IjoiZGV2ZWxvcG1lbnQiLCJwbGFuIjoicHJvIiwidXNlcl9pZCI6IkxKZk05VzdIdlljb2gyclVCaXRWd283ViIsIm5iZiI6MTc2NDM4OTAxMiwiaWF0IjoxNzY0Mzg5MDEyLCJleHAiOjE3NjQ0MzIyMTJ9.OF4IHrcmteA2lU1tkqHO1a9ITGGrCjCo29G8jI991q8_SQjgHZHVqcBj3AYVKZJDh6BjHib4HyNKdjO8nwUblF2dCFbYDv6y4hwB6jHNpsz32BE1JDKcXEJOKPtg_tBOFUDKtzMkPk7VOPWDVYw8Tz4_HZ_MR3SNoy1Pk9AFL-hEl3E-zR3bAYMDB8tKrIm9y9K4sZF6efMU7BR_J6Bf-i3IsbbrH-Axgq5dewlpogf-xHWmWaTXoUp6UFejNKhSMXqg3sAWTnizYeSGc2Ut6zNuAYPumUPBdQ37Kk7vuRNwS1h7RJz3vtEg6aOuw0-Ld0LdF-tWkfDGsVqanR_sxw"
|
||||
VERCEL_TARGET_ENV="production"
|
||||
VERCEL_URL=""
|
||||
18
.github/workflows/health-check.yml
vendored
Normal file
18
.github/workflows/health-check.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Daily Health Check
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at 2am UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
health-check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger health check sync
|
||||
run: |
|
||||
curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/health-check" \
|
||||
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-f -s -S -w "\nHTTP Status: %{http_code}\n"
|
||||
18
.github/workflows/sync-changes.yml
vendored
Normal file
18
.github/workflows/sync-changes.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Sync NPM Changes Feed
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 2 minutes
|
||||
- cron: '*/2 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-changes:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger changes feed sync
|
||||
run: |
|
||||
curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/changes" \
|
||||
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-f -s -S -w "\nHTTP Status: %{http_code}\n"
|
||||
131
.github/workflows/sync-keyword.yml
vendored
Normal file
131
.github/workflows/sync-keyword.yml
vendored
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
name: Sync NPM Keyword Search
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 15 minutes
|
||||
- cron: '*/15 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-keyword:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger keyword search sync
|
||||
id: sync
|
||||
run: |
|
||||
# Call the sync API and capture response
|
||||
response=$(curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/keyword" \
|
||||
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-f -s -S)
|
||||
|
||||
echo "Response: $response"
|
||||
|
||||
# Extract data using jq
|
||||
processed=$(echo "$response" | jq -r '.data.processed')
|
||||
skipped=$(echo "$response" | jq -r '.data.skipped')
|
||||
errors=$(echo "$response" | jq -r '.data.errors')
|
||||
packagesFound=$(echo "$response" | jq -r '.data.packagesFound')
|
||||
durationMs=$(echo "$response" | jq -r '.data.durationMs')
|
||||
|
||||
# Extract and display error messages
|
||||
errorMessages=$(echo "$response" | jq -r '.data.errorMessages[]?' 2>/dev/null || echo "")
|
||||
|
||||
if [ -n "$errorMessages" ]; then
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "⚠️ SYNC ERRORS ($errors total):"
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
echo "$response" | jq -r '.data.errorMessages[]?' | while IFS= read -r error; do
|
||||
echo " • $error"
|
||||
done
|
||||
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
|
||||
fi
|
||||
|
||||
# Set outputs for Discord notification
|
||||
echo "processed=$processed" >> $GITHUB_OUTPUT
|
||||
echo "skipped=$skipped" >> $GITHUB_OUTPUT
|
||||
echo "errors=$errors" >> $GITHUB_OUTPUT
|
||||
echo "packagesFound=$packagesFound" >> $GITHUB_OUTPUT
|
||||
echo "durationMs=$durationMs" >> $GITHUB_OUTPUT
|
||||
|
||||
# Store error messages for Discord (first 3, truncated)
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
errorSummary=$(echo "$response" | jq -r '.data.errorMessages[0:3]? | join("\n• ")' 2>/dev/null || echo "")
|
||||
if [ -n "$errorSummary" ]; then
|
||||
# Save to file to preserve newlines
|
||||
echo "• $errorSummary" > /tmp/error_summary.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
# Store skipped packages for Discord
|
||||
if [ "$skipped" -gt 0 ]; then
|
||||
skippedList=$(echo "$response" | jq -r '.data.skippedPackages[]? | "\(.name) (by \(.author)) - \(.reason)"' 2>/dev/null | paste -sd "\n" - || echo "")
|
||||
if [ -n "$skippedList" ]; then
|
||||
echo "$skippedList" > /tmp/skipped_packages.txt
|
||||
fi
|
||||
fi
|
||||
|
||||
# Determine status emoji
|
||||
if [ "$errors" -gt 0 ]; then
|
||||
echo "status_emoji=⚠️" >> $GITHUB_OUTPUT
|
||||
echo "status_color=16776960" >> $GITHUB_OUTPUT # Yellow
|
||||
else
|
||||
echo "status_emoji=✅" >> $GITHUB_OUTPUT
|
||||
echo "status_color=5763719" >> $GITHUB_OUTPUT # Green
|
||||
fi
|
||||
|
||||
- name: Send Discord notification
|
||||
if: always()
|
||||
run: |
|
||||
# Format duration
|
||||
duration_sec=$(echo "scale=2; ${{ steps.sync.outputs.durationMs }} / 1000" | bc)
|
||||
|
||||
# Build Discord payload using jq for proper JSON escaping
|
||||
# Read optional data
|
||||
error_text=""
|
||||
skipped_text=""
|
||||
|
||||
if [ -f /tmp/error_summary.txt ] && [ ${{ steps.sync.outputs.errors }} -gt 0 ]; then
|
||||
error_text=$(cat /tmp/error_summary.txt | head -c 800)
|
||||
fi
|
||||
|
||||
if [ -f /tmp/skipped_packages.txt ] && [ ${{ steps.sync.outputs.skipped }} -gt 0 ]; then
|
||||
skipped_text=$(cat /tmp/skipped_packages.txt)
|
||||
fi
|
||||
|
||||
# Build fields array dynamically
|
||||
base_fields='[
|
||||
{ "name": "📦 Packages Found", "value": "${{ steps.sync.outputs.packagesFound }}", "inline": true },
|
||||
{ "name": "✨ Processed", "value": "${{ steps.sync.outputs.processed }}", "inline": true },
|
||||
{ "name": "⏭️ Skipped", "value": "${{ steps.sync.outputs.skipped }}", "inline": true },
|
||||
{ "name": "❌ Errors", "value": "${{ steps.sync.outputs.errors }}", "inline": true },
|
||||
{ "name": "⏱️ Duration", "value": "'"${duration_sec}s"'", "inline": true },
|
||||
{ "name": "🔗 Run", "value": "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
|
||||
]'
|
||||
|
||||
# Create payload with dynamic fields
|
||||
payload=$(jq -n \
|
||||
--arg title "${{ steps.sync.outputs.status_emoji }} NPM Keyword Search Sync" \
|
||||
--argjson color ${{ steps.sync.outputs.status_color }} \
|
||||
--argjson baseFields "$base_fields" \
|
||||
--arg error_text "$error_text" \
|
||||
--arg skipped_text "$skipped_text" \
|
||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
||||
'
|
||||
{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
color: $color,
|
||||
fields: (
|
||||
$baseFields +
|
||||
(if $skipped_text != "" then [{ name: "📋 Skipped Packages", value: $skipped_text, inline: false }] else [] end) +
|
||||
(if $error_text != "" then [{ name: "🔍 Error Details", value: ("```\n" + $error_text + "\n```"), inline: false }] else [] end)
|
||||
),
|
||||
timestamp: $timestamp
|
||||
}]
|
||||
}')
|
||||
|
||||
# Send to Discord
|
||||
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload"
|
||||
97
.github/workflows/sync-manual.yml
vendored
Normal file
97
.github/workflows/sync-manual.yml
vendored
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
name: Sync Manual Tools
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run daily at midnight UTC
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
# Run on pushes to main that modify manual-tools.ts
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'manual-tools.ts'
|
||||
- 'sync-manual-tools.ts'
|
||||
|
||||
jobs:
|
||||
sync-manual:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 8
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm --filter=@tpmjs/db db:generate
|
||||
|
||||
- name: Run manual tools sync
|
||||
id: sync
|
||||
run: |
|
||||
# Run the sync script and capture output
|
||||
output=$(pnpm tsx sync-manual-tools.ts 2>&1)
|
||||
echo "$output"
|
||||
|
||||
# Extract statistics from output
|
||||
processed=$(echo "$output" | grep "Processed:" | awk '{print $2}')
|
||||
skipped=$(echo "$output" | grep "Skipped:" | awk '{print $2}')
|
||||
errors=$(echo "$output" | grep "Errors:" | awk '{print $2}')
|
||||
total=$(echo "$output" | grep "Total manual tools:" | awk '{print $4}')
|
||||
|
||||
# Set outputs for Discord notification
|
||||
echo "processed=${processed:-0}" >> $GITHUB_OUTPUT
|
||||
echo "skipped=${skipped:-0}" >> $GITHUB_OUTPUT
|
||||
echo "errors=${errors:-0}" >> $GITHUB_OUTPUT
|
||||
echo "total=${total:-0}" >> $GITHUB_OUTPUT
|
||||
|
||||
# Determine status
|
||||
if [ "${errors:-0}" -gt 0 ]; then
|
||||
echo "status_emoji=⚠️" >> $GITHUB_OUTPUT
|
||||
echo "status_color=16776960" >> $GITHUB_OUTPUT # Yellow
|
||||
else
|
||||
echo "status_emoji=✅" >> $GITHUB_OUTPUT
|
||||
echo "status_color=5763719" >> $GITHUB_OUTPUT # Green
|
||||
fi
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
|
||||
- name: Send Discord notification
|
||||
if: always()
|
||||
run: |
|
||||
# Build Discord payload
|
||||
payload=$(jq -n \
|
||||
--arg title "${{ steps.sync.outputs.status_emoji }} Manual Tools Sync" \
|
||||
--argjson color ${{ steps.sync.outputs.status_color }} \
|
||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
||||
'
|
||||
{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
color: $color,
|
||||
fields: [
|
||||
{ name: "📦 Total Tools", value: "${{ steps.sync.outputs.total }}", inline: true },
|
||||
{ name: "✨ Processed", value: "${{ steps.sync.outputs.processed }}", inline: true },
|
||||
{ name: "⏭️ Skipped", value: "${{ steps.sync.outputs.skipped }}", inline: true },
|
||||
{ name: "❌ Errors", value: "${{ steps.sync.outputs.errors }}", inline: true },
|
||||
{ name: "🔗 Run", value: "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", inline: true }
|
||||
],
|
||||
timestamp: $timestamp
|
||||
}]
|
||||
}')
|
||||
|
||||
# Send to Discord
|
||||
curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload"
|
||||
18
.github/workflows/sync-metrics.yml
vendored
Normal file
18
.github/workflows/sync-metrics.yml
vendored
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: Sync NPM Metrics
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every hour
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-metrics:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger metrics sync
|
||||
run: |
|
||||
curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/metrics" \
|
||||
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-f -s -S -w "\nHTTP Status: %{http_code}\n"
|
||||
264
.github/workflows/sync-vercel-registry.yml
vendored
Normal file
264
.github/workflows/sync-vercel-registry.yml
vendored
Normal file
|
|
@ -0,0 +1,264 @@
|
|||
name: Sync Vercel AI Registry
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every hour
|
||||
- cron: '0 * * * *'
|
||||
workflow_dispatch:
|
||||
# Run on pushes to main that modify the sync script
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- 'sync-vercel-registry.ts'
|
||||
|
||||
jobs:
|
||||
sync-vercel:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 10.14.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '22'
|
||||
cache: 'pnpm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
echo "📦 Installing dependencies..."
|
||||
pnpm install --frozen-lockfile
|
||||
echo "✅ Dependencies installed"
|
||||
|
||||
- name: Run Vercel registry sync
|
||||
id: sync
|
||||
run: |
|
||||
echo "════════════════════════════════════════"
|
||||
echo "🚀 Starting Vercel AI Registry Sync"
|
||||
echo "════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "📅 Time: $(date -u '+%Y-%m-%d %H:%M:%S UTC')"
|
||||
echo "🔑 OpenAI API Key: ${OPENAI_API_KEY:0:8}..."
|
||||
echo ""
|
||||
|
||||
# Run the sync script and capture output
|
||||
output=$(pnpm tsx sync-vercel-registry.ts 2>&1)
|
||||
exit_code=$?
|
||||
|
||||
echo "$output"
|
||||
echo ""
|
||||
|
||||
# Extract statistics from output
|
||||
processed=$(echo "$output" | grep "Processed:" | tail -1 | awk '{print $2}')
|
||||
skipped=$(echo "$output" | grep "Skipped:" | tail -1 | awk '{print $2}')
|
||||
errors=$(echo "$output" | grep "Errors:" | tail -1 | awk '{print $2}')
|
||||
total=$(echo "$output" | grep "Total:" | tail -1 | awk '{print $2}')
|
||||
|
||||
# Set default values if extraction failed
|
||||
processed=${processed:-0}
|
||||
skipped=${skipped:-0}
|
||||
errors=${errors:-0}
|
||||
total=${total:-0}
|
||||
|
||||
echo "════════════════════════════════════════"
|
||||
echo "📊 Sync Statistics"
|
||||
echo "════════════════════════════════════════"
|
||||
echo "✨ Processed: $processed"
|
||||
echo "⏭️ Skipped: $skipped"
|
||||
echo "❌ Errors: $errors"
|
||||
echo "📦 Total: $total"
|
||||
echo "════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Set outputs for later steps
|
||||
echo "processed=$processed" >> $GITHUB_OUTPUT
|
||||
echo "skipped=$skipped" >> $GITHUB_OUTPUT
|
||||
echo "errors=$errors" >> $GITHUB_OUTPUT
|
||||
echo "total=$total" >> $GITHUB_OUTPUT
|
||||
echo "exit_code=$exit_code" >> $GITHUB_OUTPUT
|
||||
|
||||
# Check if manual-tools.ts was modified
|
||||
if git diff --quiet manual-tools.ts; then
|
||||
echo "has_changes=false" >> $GITHUB_OUTPUT
|
||||
echo "ℹ️ No changes to manual-tools.ts"
|
||||
else
|
||||
echo "has_changes=true" >> $GITHUB_OUTPUT
|
||||
echo "✅ manual-tools.ts was modified"
|
||||
echo ""
|
||||
echo "📝 Changes preview:"
|
||||
git diff --stat manual-tools.ts
|
||||
echo ""
|
||||
git diff manual-tools.ts | head -50
|
||||
fi
|
||||
|
||||
# Determine status for notifications
|
||||
if [ "$exit_code" -ne 0 ]; then
|
||||
echo "status_emoji=❌" >> $GITHUB_OUTPUT
|
||||
echo "status_color=15158332" >> $GITHUB_OUTPUT # Red
|
||||
echo "status_text=Failed" >> $GITHUB_OUTPUT
|
||||
elif [ "$errors" -gt 0 ]; then
|
||||
echo "status_emoji=⚠️" >> $GITHUB_OUTPUT
|
||||
echo "status_color=16776960" >> $GITHUB_OUTPUT # Yellow
|
||||
echo "status_text=Completed with errors" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "status_emoji=✅" >> $GITHUB_OUTPUT
|
||||
echo "status_color=5763719" >> $GITHUB_OUTPUT # Green
|
||||
echo "status_text=Success" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
# Exit with the original exit code
|
||||
exit $exit_code
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
- name: Commit and push changes
|
||||
if: steps.sync.outputs.has_changes == 'true'
|
||||
run: |
|
||||
echo "════════════════════════════════════════"
|
||||
echo "📝 Committing changes to manual-tools.ts"
|
||||
echo "════════════════════════════════════════"
|
||||
echo ""
|
||||
|
||||
# Configure git
|
||||
git config --local user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git config --local user.name "github-actions[bot]"
|
||||
|
||||
# Show what's being committed
|
||||
echo "📋 Files to commit:"
|
||||
git status --short
|
||||
echo ""
|
||||
|
||||
# Commit changes
|
||||
git add manual-tools.ts
|
||||
|
||||
# Create commit message
|
||||
COMMIT_MSG="chore: sync ${{ steps.sync.outputs.processed }} new tools from Vercel AI registry
|
||||
|
||||
Added ${{ steps.sync.outputs.processed }} tools from Vercel AI SDK registry:
|
||||
- Total tools in registry: ${{ steps.sync.outputs.total }}
|
||||
- Already synced: ${{ steps.sync.outputs.skipped }}
|
||||
- Newly added: ${{ steps.sync.outputs.processed }}
|
||||
- Errors: ${{ steps.sync.outputs.errors }}
|
||||
|
||||
🤖 Automated by GitHub Actions
|
||||
Run: https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
git commit -m "$COMMIT_MSG"
|
||||
|
||||
echo "✅ Changes committed"
|
||||
echo ""
|
||||
|
||||
# Push changes
|
||||
echo "📤 Pushing to remote..."
|
||||
git push
|
||||
|
||||
echo "✅ Changes pushed successfully"
|
||||
echo "════════════════════════════════════════"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Send Discord notification
|
||||
if: always()
|
||||
run: |
|
||||
echo "════════════════════════════════════════"
|
||||
echo "📢 Sending Discord notification"
|
||||
echo "════════════════════════════════════════"
|
||||
|
||||
# Build fields array
|
||||
base_fields='[
|
||||
{ "name": "📦 Total Tools", "value": "${{ steps.sync.outputs.total }}", "inline": true },
|
||||
{ "name": "✨ Processed", "value": "${{ steps.sync.outputs.processed }}", "inline": true },
|
||||
{ "name": "⏭️ Skipped", "value": "${{ steps.sync.outputs.skipped }}", "inline": true },
|
||||
{ "name": "❌ Errors", "value": "${{ steps.sync.outputs.errors }}", "inline": true },
|
||||
{ "name": "📝 Changes", "value": "${{ steps.sync.outputs.has_changes }}", "inline": true },
|
||||
{ "name": "🔗 Run", "value": "[View Logs](https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }})", "inline": true }
|
||||
]'
|
||||
|
||||
# Add commit info if changes were made
|
||||
if [ "${{ steps.sync.outputs.has_changes }}" = "true" ]; then
|
||||
commit_sha=$(git rev-parse HEAD)
|
||||
commit_url="https://github.com/${{ github.repository }}/commit/${commit_sha}"
|
||||
additional_fields='[
|
||||
{ "name": "💾 Commit", "value": "['"${commit_sha:0:7}"']('"$commit_url"')", "inline": false }
|
||||
]'
|
||||
|
||||
# Merge fields
|
||||
all_fields=$(jq -n --argjson base "$base_fields" --argjson additional "$additional_fields" '$base + $additional')
|
||||
else
|
||||
all_fields="$base_fields"
|
||||
fi
|
||||
|
||||
# Create Discord embed
|
||||
payload=$(jq -n \
|
||||
--arg title "${{ steps.sync.outputs.status_emoji }} Vercel AI Registry Sync - ${{ steps.sync.outputs.status_text }}" \
|
||||
--argjson color ${{ steps.sync.outputs.status_color }} \
|
||||
--argjson fields "$all_fields" \
|
||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
||||
--arg description "Synced Vercel AI SDK tools registry with TPMJS manual tools" \
|
||||
'
|
||||
{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
description: $description,
|
||||
color: $color,
|
||||
fields: $fields,
|
||||
timestamp: $timestamp,
|
||||
footer: {
|
||||
text: "Vercel AI Registry Sync"
|
||||
}
|
||||
}]
|
||||
}')
|
||||
|
||||
echo "📤 Sending payload to Discord..."
|
||||
|
||||
# Send to Discord
|
||||
response=$(curl -X POST "${{ secrets.DISCORD_WEBHOOK }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
-w "\nHTTP Status: %{http_code}\n" \
|
||||
-s)
|
||||
|
||||
echo "$response"
|
||||
|
||||
if echo "$response" | grep -q "HTTP Status: 2"; then
|
||||
echo "✅ Discord notification sent successfully"
|
||||
else
|
||||
echo "⚠️ Discord notification may have failed"
|
||||
fi
|
||||
|
||||
echo "════════════════════════════════════════"
|
||||
env:
|
||||
DISCORD_WEBHOOK: ${{ secrets.DISCORD_WEBHOOK }}
|
||||
|
||||
- name: Summary
|
||||
if: always()
|
||||
run: |
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
echo "📊 Workflow Summary"
|
||||
echo "════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "Status: ${{ steps.sync.outputs.status_text }}"
|
||||
echo "Tools Processed: ${{ steps.sync.outputs.processed }}"
|
||||
echo "Tools Skipped: ${{ steps.sync.outputs.skipped }}"
|
||||
echo "Errors: ${{ steps.sync.outputs.errors }}"
|
||||
echo "Total in Registry: ${{ steps.sync.outputs.total }}"
|
||||
echo "Changes Made: ${{ steps.sync.outputs.has_changes }}"
|
||||
echo ""
|
||||
|
||||
if [ "${{ steps.sync.outputs.has_changes }}" = "true" ]; then
|
||||
echo "✅ New tools added to manual-tools.ts and committed"
|
||||
else
|
||||
echo "ℹ️ No new tools found - manual-tools.ts is up to date"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "════════════════════════════════════════"
|
||||
421
CLAUDE.md
421
CLAUDE.md
|
|
@ -678,4 +678,423 @@ API timeouts in serverless environments often stem from build configuration issu
|
|||
4. Use `vercel inspect` to verify lambda deployment
|
||||
5. Test database performance locally before deploying
|
||||
|
||||
The full working implementation is live at [tpmjs.com](https://tpmjs.com).
|
||||
The full working implementation is live at [tpmjs.com](https://tpmjs.com).
|
||||
|
||||
---
|
||||
|
||||
## NPM Package Syncing System
|
||||
|
||||
TPMJS.com automatically mirrors npm packages with the `tpmjs-tool` keyword to keep the tool registry up-to-date. This section documents how the syncing system works.
|
||||
|
||||
### Overview
|
||||
|
||||
The sync system uses three automated strategies running on Vercel Cron to discover and update TPMJS tools:
|
||||
|
||||
1. **Changes Feed** - Monitors npm's real-time changes feed for all package updates
|
||||
2. **Keyword Search** - Actively searches npm for packages with the `tpmjs-tool` keyword
|
||||
3. **Metrics Sync** - Updates download stats and calculates quality scores
|
||||
|
||||
### Sync Endpoints
|
||||
|
||||
All sync endpoints are located in `apps/web/src/app/api/sync/`:
|
||||
|
||||
#### 1. Changes Feed Sync (`/api/sync/changes`)
|
||||
|
||||
**Purpose:** Monitors npm's changes feed to catch new packages and updates in real-time.
|
||||
|
||||
**Schedule:** Every 2 minutes (`*/2 * * * *`)
|
||||
|
||||
**How it works:**
|
||||
1. Fetches the last checkpoint sequence number from the database
|
||||
2. Calls npm's `/_changes` endpoint with `since=<lastSeq>` (limit 100 per run)
|
||||
3. For each changed package, fetches full metadata with `fetchLatestPackageWithMetadata()`
|
||||
4. Validates that the package has a valid `tpmjs` field using `validateTpmjsField()`
|
||||
5. Upserts the tool to the database with `discoveryMethod: 'changes-feed'`
|
||||
6. Updates the checkpoint with the new sequence number for next run
|
||||
|
||||
**Key Features:**
|
||||
- Uses checkpoints to track progress and avoid reprocessing
|
||||
- Processes up to 100 changes per run to avoid timeouts
|
||||
- Logs all sync operations to `syncLog` table
|
||||
- Requires `Authorization: Bearer <CRON_SECRET>` header
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"processed": 5,
|
||||
"skipped": 93,
|
||||
"errors": 0,
|
||||
"lastSeq": "12345678",
|
||||
"pending": 1250,
|
||||
"durationMs": 2834
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Keyword Search Sync (`/api/sync/keyword`)
|
||||
|
||||
**Purpose:** Actively searches npm for packages with the `tpmjs-tool` keyword.
|
||||
|
||||
**Schedule:** Every 15 minutes (`*/15 * * * *`)
|
||||
|
||||
**How it works:**
|
||||
1. Searches npm registry for packages with keyword `tpmjs-tool` (up to 250 results)
|
||||
2. Fetches full metadata for each package
|
||||
3. Validates the `tpmjs` field
|
||||
4. Upserts tools with `discoveryMethod: 'keyword'`
|
||||
5. Updates checkpoint with last run timestamp
|
||||
|
||||
**Key Features:**
|
||||
- Catches packages that might be missed by changes feed
|
||||
- Useful for backfilling existing packages
|
||||
- Processes up to 250 packages per run
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"processed": 12,
|
||||
"skipped": 3,
|
||||
"errors": 0,
|
||||
"packagesFound": 15,
|
||||
"durationMs": 4521
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Metrics Sync (`/api/sync/metrics`)
|
||||
|
||||
**Purpose:** Updates download statistics and calculates quality scores for all tools.
|
||||
|
||||
**Schedule:** Every hour (`0 * * * *`)
|
||||
|
||||
**How it works:**
|
||||
1. Fetches all tools from the database
|
||||
2. For each tool, calls `fetchDownloadStats()` to get last 30 days of downloads
|
||||
3. Calculates quality score based on:
|
||||
- Tier (rich = 0.6, minimal = 0.4)
|
||||
- Downloads (logarithmic scale, max 0.3)
|
||||
- GitHub stars (logarithmic scale, max 0.1)
|
||||
4. Updates `npmDownloadsLastMonth` and `qualityScore` fields
|
||||
|
||||
**Quality Score Formula:**
|
||||
```typescript
|
||||
function calculateQualityScore(params: {
|
||||
tier: string;
|
||||
downloads: number;
|
||||
githubStars: number;
|
||||
}): number {
|
||||
const tierScore = tier === 'rich' ? 0.6 : 0.4;
|
||||
const downloadsScore = Math.min(0.3, Math.log10(downloads + 1) / 10);
|
||||
const starsScore = Math.min(0.1, Math.log10(githubStars + 1) / 10);
|
||||
return Math.min(1.0, tierScore + downloadsScore + starsScore);
|
||||
}
|
||||
```
|
||||
|
||||
**Example Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"processed": 25,
|
||||
"skipped": 0,
|
||||
"errors": 0,
|
||||
"totalTools": 25,
|
||||
"durationMs": 8234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Automated Sync Configuration
|
||||
|
||||
The sync system can run via two methods:
|
||||
|
||||
#### Option 1: Vercel Cron (Primary)
|
||||
|
||||
Cron jobs are configured in `vercel.json` at the repository root:
|
||||
|
||||
```json
|
||||
{
|
||||
"crons": [
|
||||
{
|
||||
"path": "/api/sync/changes",
|
||||
"schedule": "*/2 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/keyword",
|
||||
"schedule": "*/15 * * * *"
|
||||
},
|
||||
{
|
||||
"path": "/api/sync/metrics",
|
||||
"schedule": "0 * * * *"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Native Vercel integration
|
||||
- Automatic authentication with `CRON_SECRET`
|
||||
- Same infrastructure as the app
|
||||
- No setup required (works automatically on deploy)
|
||||
|
||||
#### Option 2: GitHub Actions (Backup)
|
||||
|
||||
A GitHub Actions workflow (`.github/workflows/sync.yml`) provides redundancy:
|
||||
|
||||
```yaml
|
||||
name: NPM Package Sync
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '*/2 * * * *' # Changes feed
|
||||
- cron: '*/15 * * * *' # Keyword search
|
||||
- cron: '0 * * * *' # Metrics
|
||||
workflow_dispatch: # Manual trigger
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Redundancy if Vercel Cron fails
|
||||
- Manual trigger via GitHub UI
|
||||
- Free on GitHub (included in free tier)
|
||||
- Runs from GitHub's infrastructure
|
||||
|
||||
**Setup:**
|
||||
|
||||
1. Add secrets to GitHub repository settings:
|
||||
- `VERCEL_PRODUCTION_URL` - Your production URL (e.g., `https://tpmjs.com`)
|
||||
- `CRON_SECRET` - Same secret used in Vercel environment variables
|
||||
|
||||
2. Enable GitHub Actions in repository settings
|
||||
|
||||
3. The workflow will run automatically on schedule OR manually via:
|
||||
- GitHub Actions tab → NPM Package Sync → Run workflow → Select sync type
|
||||
|
||||
**Schedule Breakdown:**
|
||||
- Changes feed: Every 2 minutes (30 times per hour)
|
||||
- Keyword search: Every 15 minutes (4 times per hour)
|
||||
- Metrics: Every hour (once per hour)
|
||||
|
||||
**Recommendation:** Use Vercel Cron as primary and GitHub Actions as backup. Both can run simultaneously - the sync endpoints are idempotent.
|
||||
|
||||
### Database Schema
|
||||
|
||||
The sync system uses these Prisma models:
|
||||
|
||||
**`Tool` - The main tool registry:**
|
||||
```prisma
|
||||
model Tool {
|
||||
id String @id @default(cuid())
|
||||
npmPackageName String @unique
|
||||
npmVersion String
|
||||
npmDownloadsLastMonth Int @default(0)
|
||||
qualityScore Float?
|
||||
discoveryMethod String // 'changes-feed' | 'keyword'
|
||||
tier String // 'minimal' | 'rich'
|
||||
// ... other fields
|
||||
|
||||
@@index([qualityScore])
|
||||
@@index([npmDownloadsLastMonth])
|
||||
}
|
||||
```
|
||||
|
||||
**`SyncCheckpoint` - Tracks sync progress:**
|
||||
```prisma
|
||||
model SyncCheckpoint {
|
||||
id String @id @default(cuid())
|
||||
source String @unique // 'changes-feed' | 'keyword-search' | 'metrics'
|
||||
checkpoint Json // { lastSeq: string, lastRun: string, ... }
|
||||
}
|
||||
```
|
||||
|
||||
**`SyncLog` - Records all sync operations:**
|
||||
```prisma
|
||||
model SyncLog {
|
||||
id String @id @default(cuid())
|
||||
source String
|
||||
status String // 'success' | 'partial' | 'error'
|
||||
processed Int
|
||||
skipped Int
|
||||
errors Int
|
||||
message String?
|
||||
metadata Json?
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
```
|
||||
|
||||
### Manual Sync Triggers
|
||||
|
||||
To manually trigger a sync (useful for testing or debugging):
|
||||
|
||||
```bash
|
||||
# Trigger changes feed sync
|
||||
curl -X POST https://tpmjs.com/api/sync/changes \
|
||||
-H "Authorization: Bearer $CRON_SECRET"
|
||||
|
||||
# Trigger keyword search
|
||||
curl -X POST https://tpmjs.com/api/sync/keyword \
|
||||
-H "Authorization: Bearer $CRON_SECRET"
|
||||
|
||||
# Trigger metrics update
|
||||
curl -X POST https://tpmjs.com/api/sync/metrics \
|
||||
-H "Authorization: Bearer $CRON_SECRET"
|
||||
```
|
||||
|
||||
**Note:** You need the `CRON_SECRET` environment variable set in Vercel. The endpoints return 401 Unauthorized without it.
|
||||
|
||||
### Monitoring Sync Health
|
||||
|
||||
Check sync logs in the database:
|
||||
|
||||
```typescript
|
||||
// Get recent sync operations
|
||||
const recentSyncs = await prisma.syncLog.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
// Check last successful sync for each source
|
||||
const checkpoints = await prisma.syncCheckpoint.findMany();
|
||||
```
|
||||
|
||||
**Sync Log Example:**
|
||||
```json
|
||||
{
|
||||
"id": "clx...",
|
||||
"source": "changes-feed",
|
||||
"status": "success",
|
||||
"processed": 5,
|
||||
"skipped": 93,
|
||||
"errors": 0,
|
||||
"message": "Successfully processed 5 packages",
|
||||
"metadata": {
|
||||
"durationMs": 2834,
|
||||
"lastSeq": "12345678",
|
||||
"pending": 1250
|
||||
},
|
||||
"createdAt": "2025-11-30T12:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
All sync endpoints follow this error handling pattern:
|
||||
|
||||
1. **Partial Success:** If some packages fail but others succeed, status is `partial`
|
||||
2. **Complete Failure:** If the entire sync fails, status is `error`
|
||||
3. **Error Messages:** First 3 errors are included in the response
|
||||
4. **Logging:** All operations are logged to `syncLog` regardless of success
|
||||
|
||||
**Example Partial Failure:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"data": {
|
||||
"processed": 5,
|
||||
"skipped": 2,
|
||||
"errors": 3,
|
||||
"durationMs": 5234
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The sync log will contain:
|
||||
```json
|
||||
{
|
||||
"status": "partial",
|
||||
"message": "Processed with errors: Failed to process pkg1: Network timeout; Failed to process pkg2: Invalid tpmjs field; ..."
|
||||
}
|
||||
```
|
||||
|
||||
### Configuration
|
||||
|
||||
Required environment variables in Vercel:
|
||||
|
||||
```bash
|
||||
# Database connection
|
||||
DATABASE_URL="postgresql://..."
|
||||
|
||||
# Cron job authentication
|
||||
CRON_SECRET="your-secret-key"
|
||||
```
|
||||
|
||||
**Important:** Vercel Cron automatically adds the `Authorization: Bearer $CRON_SECRET` header when calling the endpoints. No manual configuration needed.
|
||||
|
||||
### Performance Considerations
|
||||
|
||||
**Timeouts:**
|
||||
- All sync routes have `maxDuration: 300` (5 minutes)
|
||||
- Changes feed processes max 100 packages per run to avoid timeouts
|
||||
- Keyword search processes max 250 packages per run
|
||||
- Metrics sync processes all tools but runs only once per hour
|
||||
|
||||
**Rate Limiting:**
|
||||
- npm API has rate limits - be cautious when testing manually
|
||||
- Vercel Cron jobs run from Vercel's infrastructure (different IP than dev)
|
||||
- Consider implementing exponential backoff for npm API errors
|
||||
|
||||
**Cold Starts:**
|
||||
- First request to each sync endpoint may be slow due to Prisma initialization
|
||||
- Subsequent requests are faster with warm Prisma Client
|
||||
- This is acceptable for background cron jobs
|
||||
|
||||
### Debugging Sync Issues
|
||||
|
||||
**Check if cron jobs are running:**
|
||||
|
||||
```bash
|
||||
# View recent deployments
|
||||
vercel ls
|
||||
|
||||
# Check logs for a specific deployment
|
||||
vercel logs <deployment-url>
|
||||
|
||||
# Filter for sync-related logs
|
||||
vercel logs <deployment-url> | grep sync
|
||||
```
|
||||
|
||||
**Common issues:**
|
||||
|
||||
1. **"Unauthorized" errors:** Check that `CRON_SECRET` is set in Vercel environment variables
|
||||
2. **Timeouts:** Reduce batch size in changes feed (currently 100)
|
||||
3. **Missing packages:** Check `syncLog` for errors during processing
|
||||
4. **Stale data:** Verify metrics sync is running every hour
|
||||
|
||||
**Test sync locally:**
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
pnpm dev --filter=@tpmjs/web
|
||||
|
||||
# Trigger sync (requires CRON_SECRET in .env.local)
|
||||
curl -X POST http://localhost:3000/api/sync/changes \
|
||||
-H "Authorization: Bearer $CRON_SECRET"
|
||||
```
|
||||
|
||||
### Package Discovery Flow
|
||||
|
||||
Here's how a new TPMJS tool gets discovered:
|
||||
|
||||
1. **Developer publishes package to npm** with `tpmjs-tool` keyword and `tpmjs` field in package.json
|
||||
2. **Within 2 minutes:** Changes feed sync picks it up from npm's `/_changes` endpoint
|
||||
3. **Validation:** `validateTpmjsField()` checks that the `tpmjs` field meets requirements
|
||||
4. **Database Insert:** Tool is upserted with initial data
|
||||
5. **Within 1 hour:** Metrics sync updates download stats and calculates quality score
|
||||
6. **Visible on tpmjs.com:** Tool appears in search results and category pages
|
||||
|
||||
**Backup Discovery:** If changes feed misses a package, the keyword search (every 15 minutes) will catch it.
|
||||
|
||||
### Future Improvements
|
||||
|
||||
Potential enhancements to the sync system:
|
||||
|
||||
- [ ] Add webhook endpoint for instant npm package notifications
|
||||
- [ ] Implement exponential backoff for npm API rate limits
|
||||
- [ ] Add Slack/Discord notifications for sync failures
|
||||
- [ ] Create admin dashboard to monitor sync health
|
||||
- [ ] Support GitHub stars syncing (requires GitHub API integration)
|
||||
- [ ] Add sync metrics to Vercel Analytics
|
||||
- [ ] Implement differential sync to reduce database writes
|
||||
306
DENO_NODE_PACKAGE_ISSUE.md
Normal file
306
DENO_NODE_PACKAGE_ISSUE.md
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
# Running `ai-sdk-tool-code-execution` in Deno - Compatibility Issue
|
||||
|
||||
## Problem Summary
|
||||
|
||||
We need to run the npm package `ai-sdk-tool-code-execution` in a Deno runtime environment on Railway. The package requires Node.js built-ins (`node:sqlite`, `undici`) that don't exist in Deno, and we're looking for a solution to make it work.
|
||||
|
||||
## Environment
|
||||
|
||||
- **Runtime:** Deno 1.39.0 on Railway
|
||||
- **Package:** `ai-sdk-tool-code-execution@0.0.2`
|
||||
- **Import Method:** Dynamic imports via esm.sh CDN
|
||||
- **Use Case:** Remote code execution for AI SDK tools
|
||||
|
||||
## What We're Trying to Do
|
||||
|
||||
We have a Deno server that dynamically imports npm packages at runtime to provide AI SDK tools. The workflow is:
|
||||
|
||||
1. User requests a tool (e.g., `executeCode`)
|
||||
2. Deno server fetches the package from esm.sh or npm
|
||||
3. Server loads the tool's schema and execution function
|
||||
4. Server executes the tool with user-provided parameters
|
||||
|
||||
## The Package We Need
|
||||
|
||||
**Package:** `ai-sdk-tool-code-execution`
|
||||
**Version:** `0.0.2`
|
||||
**Description:** Execute Python code in a sandboxed environment using Vercel Sandbox
|
||||
**npm URL:** https://www.npmjs.com/package/ai-sdk-tool-code-execution
|
||||
**CDN URLs:**
|
||||
- esm.sh: `https://esm.sh/ai-sdk-tool-code-execution@0.0.2`
|
||||
- jsdelivr: `https://cdn.jsdelivr.net/npm/ai-sdk-tool-code-execution@0.0.2/+esm`
|
||||
|
||||
**Dependencies (from package.json):**
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"ai": "^4.0.18",
|
||||
"better-sqlite3": "^11.8.1",
|
||||
"undici": "^7.16.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Issue:** The package depends on:
|
||||
- `better-sqlite3` → which requires `node:sqlite` (Node.js built-in)
|
||||
- `undici` → HTTP client that uses Node.js internals
|
||||
|
||||
## What We've Tried
|
||||
|
||||
### Attempt 1: Deno npm: Specifier (Node.js Compatibility Mode)
|
||||
|
||||
**Code:**
|
||||
```typescript
|
||||
const npmUrl = `npm:ai-sdk-tool-code-execution@0.0.2`;
|
||||
const module = await import(npmUrl);
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```
|
||||
Loading unprepared module: npm:ai-sdk-tool-code-execution@0.0.2
|
||||
```
|
||||
|
||||
**Why it failed:** Deno's npm compatibility requires the package to be "prepared" (downloaded/cached) before import. Dynamic imports of unprepared npm packages fail.
|
||||
|
||||
### Attempt 2: esm.sh with Node.js Target
|
||||
|
||||
**Code:**
|
||||
```typescript
|
||||
const esmUrl = `https://esm.sh/ai-sdk-tool-code-execution@0.0.2?target=esnext`;
|
||||
const module = await import(esmUrl);
|
||||
```
|
||||
|
||||
**Error:**
|
||||
```
|
||||
Module not found "https://esm.sh/node:sqlite?target=esnext"
|
||||
at https://esm.sh/undici@^7.16.0?target=esnext:25:8
|
||||
```
|
||||
|
||||
**Why it failed:** The package code imports `node:sqlite` which esm.sh tries to load from `https://esm.sh/node:sqlite?target=esnext`, but `node:sqlite` is a Node.js built-in, not an npm package.
|
||||
|
||||
### Attempt 3: Multi-Strategy with Fallback
|
||||
|
||||
**Code:**
|
||||
```typescript
|
||||
let module;
|
||||
let importError;
|
||||
|
||||
// Strategy 1: npm: specifier
|
||||
try {
|
||||
const npmUrl = `npm:${packageName}@${version}`;
|
||||
module = await import(npmUrl);
|
||||
} catch (error) {
|
||||
importError = error;
|
||||
|
||||
// Strategy 2: esm.sh with esnext target
|
||||
try {
|
||||
const esmUrl = `https://esm.sh/${packageName}@${version}?target=esnext`;
|
||||
module = await import(esmUrl);
|
||||
} catch (esmError) {
|
||||
return { success: false, error: esmError.message };
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Result:** Both strategies fail with the same errors as above.
|
||||
|
||||
## Current Deno Configuration
|
||||
|
||||
**`deno.json`:**
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"lib": ["deno.window"],
|
||||
"strict": true
|
||||
},
|
||||
"nodeModulesDir": true,
|
||||
"unstable": ["byonm"],
|
||||
"imports": {
|
||||
"zod-to-json-schema": "https://esm.sh/zod-to-json-schema@3.25.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key Settings:**
|
||||
- `nodeModulesDir: true` - Creates `node_modules` directory for npm packages
|
||||
- `unstable: ["byonm"]` - Enables "Bring Your Own Node Modules" mode
|
||||
|
||||
## Full Error Details
|
||||
|
||||
### npm: Strategy Error
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Failed to import package: ...",
|
||||
"details": {
|
||||
"npmError": "Loading unprepared module: npm:ai-sdk-tool-code-execution@0.0.2, imported from: file:///app/server.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### esm.sh Strategy Error
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Failed to import package: Module not found \"https://esm.sh/node:sqlite?target=esnext\"",
|
||||
"details": {
|
||||
"esmError": "Module not found \"https://esm.sh/node:sqlite?target=esnext\".\n at https://esm.sh/undici@^7.16.0?target=esnext:25:8"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Technical Deep Dive
|
||||
|
||||
### Why This Package Needs Node.js
|
||||
|
||||
1. **better-sqlite3** - Native Node.js addon for SQLite
|
||||
- Uses `node:sqlite` built-in
|
||||
- Compiled C++ bindings
|
||||
- Not available in Deno without Node compatibility layer
|
||||
|
||||
2. **undici** - Modern HTTP client for Node.js
|
||||
- Uses Node.js streams and buffer APIs
|
||||
- Optimized for Node.js internals
|
||||
- May work in Deno with polyfills, but blocked by sqlite dependency
|
||||
|
||||
### Deno's Node.js Compatibility
|
||||
|
||||
Deno supports many Node.js built-ins via `node:*` imports:
|
||||
- `node:fs`, `node:path`, `node:http`, `node:crypto`, etc.
|
||||
|
||||
**BUT** it does NOT support:
|
||||
- `node:sqlite` (not a standard Node.js built-in)
|
||||
- Native addons (`.node` files)
|
||||
- Some advanced internal APIs
|
||||
|
||||
### The Import Flow
|
||||
|
||||
1. **Deno tries to import** `npm:ai-sdk-tool-code-execution@0.0.2`
|
||||
2. **Package resolves to** esm.sh or npm registry
|
||||
3. **Package imports** `better-sqlite3`
|
||||
4. **better-sqlite3 imports** `node:sqlite`
|
||||
5. **FAILURE:** `node:sqlite` doesn't exist in Deno or esm.sh
|
||||
|
||||
## Questions for ChatGPT
|
||||
|
||||
1. **Can Deno's npm compatibility layer handle `better-sqlite3` or `node:sqlite`?**
|
||||
- Is there a Deno-compatible SQLite library we could alias?
|
||||
- Can we use import maps to redirect `node:sqlite` to a Deno polyfill?
|
||||
|
||||
2. **Can we "prepare" the npm module in Deno before dynamic import?**
|
||||
- Is there a way to pre-cache npm packages in Deno?
|
||||
- Can we use `deno vendor` or similar to prepare the package?
|
||||
|
||||
3. **Can esm.sh or other CDNs provide Node.js built-in polyfills?**
|
||||
- Does esm.sh have a mode that bundles Node.js built-ins?
|
||||
- Are there CDN parameters we're missing?
|
||||
|
||||
4. **Could we use Deno's `--node-modules-dir` flag differently?**
|
||||
- Should we install the package via npm/pnpm first?
|
||||
- Can we point Deno to pre-installed node_modules?
|
||||
|
||||
5. **Is there a way to patch/bundle the package to remove Node.js dependencies?**
|
||||
- Could we create a Deno-compatible fork?
|
||||
- Are there tools to transpile Node.js packages to Deno?
|
||||
|
||||
6. **Alternative: Different code execution package?**
|
||||
- Are there Deno-native code execution tools?
|
||||
- Could we use WebAssembly or browser-based sandboxing?
|
||||
|
||||
## What Would Success Look Like
|
||||
|
||||
**Ideal outcome:**
|
||||
```typescript
|
||||
// This should work in Deno:
|
||||
const module = await import('npm:ai-sdk-tool-code-execution@0.0.2');
|
||||
const { executeCode } = module;
|
||||
|
||||
// And this should execute:
|
||||
const result = await executeCode.execute({
|
||||
code: 'print(fibonacci(10))',
|
||||
language: 'python'
|
||||
});
|
||||
```
|
||||
|
||||
**Acceptable outcome:**
|
||||
```typescript
|
||||
// Some preparation step, then:
|
||||
const module = await import('https://esm.sh/ai-sdk-tool-code-execution@0.0.2');
|
||||
// Works without errors
|
||||
```
|
||||
|
||||
## Repository Context
|
||||
|
||||
**Project:** TPMJS - Tool Package Manager for AI SDK
|
||||
**Server:** `apps/railway-executor/server.ts`
|
||||
**Config:** `apps/railway-executor/deno.json`
|
||||
**Deployment:** Railway with Deno runtime
|
||||
|
||||
**Server Code (Simplified):**
|
||||
```typescript
|
||||
async function loadAndDescribe(req: Request): Promise<Response> {
|
||||
const { packageName, exportName, version, importUrl } = await req.json();
|
||||
|
||||
// Try npm: specifier first
|
||||
try {
|
||||
const npmUrl = `npm:${packageName}@${version}`;
|
||||
const module = await import(npmUrl);
|
||||
const tool = module[exportName];
|
||||
return Response.json({ success: true, tool });
|
||||
} catch (error) {
|
||||
// Try esm.sh fallback
|
||||
const esmUrl = `https://esm.sh/${packageName}@${version}?target=esnext`;
|
||||
const module = await import(esmUrl);
|
||||
const tool = module[exportName];
|
||||
return Response.json({ success: true, tool });
|
||||
}
|
||||
}
|
||||
|
||||
Deno.serve({ port: 3001 }, handler);
|
||||
```
|
||||
|
||||
## Live Error Logs
|
||||
|
||||
**Request:**
|
||||
```bash
|
||||
curl -X POST https://endearing-commitment-production.up.railway.app/load-and-describe \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"packageName": "ai-sdk-tool-code-execution",
|
||||
"exportName": "executeCode",
|
||||
"version": "0.0.2",
|
||||
"importUrl": "https://esm.sh/ai-sdk-tool-code-execution@0.0.2"
|
||||
}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": "Failed to import package: Module not found \"https://esm.sh/node:sqlite?target=esnext\"",
|
||||
"details": {
|
||||
"npmError": "Loading unprepared module: npm:ai-sdk-tool-code-execution@0.0.2",
|
||||
"esmError": "Module not found \"https://esm.sh/node:sqlite?target=esnext\""
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Context
|
||||
|
||||
- We successfully load other packages (e.g., `@tpmjs/hello`, `zod-to-json-schema`)
|
||||
- Only packages with Node.js built-in dependencies fail
|
||||
- Switching to Node.js would work, but we prefer Deno's security model
|
||||
- This is for a production tool registry serving AI SDK tools to users
|
||||
|
||||
## Related Resources
|
||||
|
||||
- **Deno npm compatibility:** https://deno.com/manual/node/npm_specifiers
|
||||
- **Deno Node built-ins:** https://deno.com/manual/node/node_specifiers
|
||||
- **esm.sh documentation:** https://esm.sh/
|
||||
- **Package source:** https://www.npmjs.com/package/ai-sdk-tool-code-execution
|
||||
- **Deno SQLite libraries:** https://deno.land/x/sqlite@v3.8
|
||||
|
||||
---
|
||||
|
||||
**Question for ChatGPT:** Is there any way to make `ai-sdk-tool-code-execution` work in Deno, given these constraints? If not, what's the closest alternative that would work in Deno's runtime?
|
||||
864
DYNAMIC_IMPORT_ISSUE.md
Normal file
864
DYNAMIC_IMPORT_ISSUE.md
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
# Dynamic Import Issue: Cannot Import ESM Modules from CDN in Next.js Server-Side API Route
|
||||
|
||||
## Executive Summary
|
||||
|
||||
We're building a dynamic tool loading system where AI agents can discover and load tools at runtime from npm packages via esm.sh CDN. The system successfully searches and finds relevant tools, but fails when trying to dynamically import them using `import()` in a Next.js App Router API route.
|
||||
|
||||
**Error**: `Error: Cannot find module 'unknown'` with code `MODULE_NOT_FOUND`
|
||||
|
||||
**Critical Question**: How can we dynamically import ESM modules from external URLs (like esm.sh) in Next.js 16 App Router API routes running in Node.js runtime?
|
||||
|
||||
---
|
||||
|
||||
## System Architecture
|
||||
|
||||
### High-Level Flow
|
||||
|
||||
```
|
||||
1. User sends message → "use firecrawl to search for ajax davis"
|
||||
2. Chat API extracts query → "use firecrawl to search for ajax davis"
|
||||
3. Pre-flight search → Calls searchTpmjsToolsTool.execute({ query, limit: 5 })
|
||||
4. Search API returns → Top 5 matching tools from database (BM25-like scoring)
|
||||
5. Dynamic loading → Tries to import tools from esm.sh URLs ❌ FAILS HERE
|
||||
6. Agent uses tools → Would pass loaded tools to AI model
|
||||
```
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- **Framework**: Next.js 16.0.4
|
||||
- **Build Tool**: Turbopack (default in Next.js 15+)
|
||||
- **Runtime**: Node.js (not edge)
|
||||
- **Package Manager**: pnpm (monorepo with workspaces)
|
||||
- **Deployment Target**: Vercel (eventually, currently local dev)
|
||||
- **AI SDK**: Vercel AI SDK v6.0.0-beta.124
|
||||
- **Model**: OpenAI GPT-4o-mini via `streamText()`
|
||||
|
||||
### Monorepo Structure
|
||||
|
||||
```
|
||||
tpmjs/
|
||||
├── apps/
|
||||
│ ├── playground/ # Next.js app with chat interface
|
||||
│ │ └── src/
|
||||
│ │ ├── app/api/chat/route.ts # Where dynamic import fails
|
||||
│ │ └── lib/dynamic-tool-loader.ts
|
||||
│ └── web/ # Tool registry website
|
||||
│ └── src/app/api/tools/search/route.ts
|
||||
└── packages/
|
||||
└── tools/
|
||||
├── hello/ # Static tool (works fine)
|
||||
└── search-registry/ # Meta-tool for searching registry
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Detailed Code Implementation
|
||||
|
||||
### File 1: `apps/playground/src/lib/dynamic-tool-loader.ts`
|
||||
|
||||
**Purpose**: Load tools dynamically from esm.sh CDN
|
||||
|
||||
```typescript
|
||||
// Cache for imported tool modules (process-level)
|
||||
const moduleCache = new Map<string, any>();
|
||||
|
||||
// Cache for per-conversation active tools
|
||||
const conversationTools = new Map<string, Set<string>>();
|
||||
|
||||
/**
|
||||
* Generate cache key for a tool
|
||||
*/
|
||||
function getCacheKey(packageName: string, exportName: string): string {
|
||||
return `${packageName}::${exportName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that an import is a valid AI SDK tool
|
||||
*/
|
||||
function isValidTool(value: any): boolean {
|
||||
return (
|
||||
value &&
|
||||
typeof value === 'object' &&
|
||||
typeof value.description === 'string' &&
|
||||
typeof value.execute === 'function'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import a tool from ESM CDN
|
||||
*
|
||||
* THIS IS WHERE IT FAILS ❌
|
||||
*/
|
||||
export async function loadToolDynamically(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
version: string,
|
||||
importUrl?: string
|
||||
): Promise<any | null> {
|
||||
const cacheKey = getCacheKey(packageName, exportName);
|
||||
|
||||
// Check cache first
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Cache hit: ${cacheKey}`);
|
||||
return moduleCache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Build import URL
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
|
||||
try {
|
||||
console.log(`📦 Importing: ${url}`);
|
||||
// Example: https://esm.sh/firecrawl-aisdk@0.7.2
|
||||
|
||||
// Dynamic import with @vite-ignore to bypass bundler
|
||||
const module = await import(/* @vite-ignore */ url);
|
||||
|
||||
console.log(`🔍 Module imported successfully`);
|
||||
console.log(`🔍 Module type: ${typeof module}`);
|
||||
console.log(`🔍 Module keys: ${Object.keys(module).join(', ')}`);
|
||||
console.log(`🔍 Looking for export: "${exportName}"`);
|
||||
console.log(`🔍 Export exists: ${exportName in module}`);
|
||||
console.log(`🔍 Export type: ${typeof module[exportName]}`);
|
||||
|
||||
// Get the specific export
|
||||
const tool = module[exportName];
|
||||
|
||||
if (!tool) {
|
||||
console.error(`❌ Export "${exportName}" not found in module. Available exports:`, Object.keys(module));
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`🔍 Tool structure:`, {
|
||||
hasDescription: 'description' in tool,
|
||||
hasExecute: 'execute' in tool,
|
||||
hasInputSchema: 'inputSchema' in tool,
|
||||
keys: Object.keys(tool),
|
||||
});
|
||||
|
||||
if (!isValidTool(tool)) {
|
||||
console.error(`❌ Invalid tool structure: ${exportName} from ${packageName}`);
|
||||
console.error(` Tool:`, tool);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Cache successful import
|
||||
moduleCache.set(cacheKey, tool);
|
||||
console.log(`✅ Loaded: ${cacheKey}`);
|
||||
|
||||
return tool;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load ${packageName}#${exportName}:`, error);
|
||||
console.error(` URL: ${url}`);
|
||||
console.error(` Stack:`, error instanceof Error ? error.stack : 'No stack trace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple tools in parallel
|
||||
*/
|
||||
export async function loadToolsBatch(
|
||||
toolMetadata: Array<{
|
||||
packageName: string;
|
||||
exportName: string;
|
||||
version: string;
|
||||
importUrl?: string;
|
||||
}>
|
||||
): Promise<Record<string, any>> {
|
||||
const promises = toolMetadata.map((meta) =>
|
||||
loadToolDynamically(
|
||||
meta.packageName,
|
||||
meta.exportName,
|
||||
meta.version,
|
||||
meta.importUrl
|
||||
).then((tool) => ({
|
||||
key: getCacheKey(meta.packageName, meta.exportName),
|
||||
tool,
|
||||
}))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
const tools: Record<string, any> = {};
|
||||
for (const { key, tool } of results) {
|
||||
if (tool) {
|
||||
tools[key] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
```
|
||||
|
||||
### File 2: `apps/playground/src/app/api/chat/route.ts`
|
||||
|
||||
**Purpose**: Main chat API that orchestrates tool discovery and loading
|
||||
|
||||
```typescript
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { type UIMessage, convertToModelMessages, stepCountIs, streamText } from 'ai';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { NextResponse } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import { loadAllTools, sanitizeToolName } from '~/lib/tool-loader';
|
||||
import { searchTpmjsToolsTool } from '@tpmjs/search-registry';
|
||||
import {
|
||||
loadToolsBatch,
|
||||
addConversationTools,
|
||||
} from '~/lib/dynamic-tool-loader';
|
||||
|
||||
export const runtime = 'nodejs'; // ⚠️ Important: We're using Node.js runtime, not edge
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Initialize OpenAI provider
|
||||
const openai = createOpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// Add conversation state tracking (in-memory for MVP)
|
||||
const conversationStates = new Map<string, { loadedTools: Record<string, any> }>();
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Chat with AI agent that can execute TPMJS tools
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
console.log('📥 Request body:', JSON.stringify(body, null, 2));
|
||||
|
||||
const messages: UIMessage[] = body.messages || [];
|
||||
const conversationId: string = body.conversationId || 'default';
|
||||
|
||||
console.log(`🔑 Conversation ID: ${conversationId}`);
|
||||
|
||||
// Get or create conversation state
|
||||
if (!conversationStates.has(conversationId)) {
|
||||
console.log('✨ Creating new conversation state');
|
||||
conversationStates.set(conversationId, { loadedTools: {} });
|
||||
}
|
||||
const state = conversationStates.get(conversationId)!;
|
||||
console.log(`📊 Current loaded tools in conversation: ${Object.keys(state.loadedTools).length}`);
|
||||
|
||||
// 1. Load static tools + search tool
|
||||
const staticTools = await loadAllTools();
|
||||
console.log(`🔧 Loaded ${Object.keys(staticTools).length} static tools`);
|
||||
|
||||
staticTools.searchTpmjsTools = searchTpmjsToolsTool;
|
||||
console.log('✅ Added searchTpmjsTools to static tools');
|
||||
|
||||
// 2. Extract user query from last message for tool search
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let userQuery = '';
|
||||
if (lastMessage?.role === 'user') {
|
||||
const parts = (lastMessage as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
userQuery = part.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`💬 User query: "${userQuery}"`);
|
||||
|
||||
// 3. Automatically search for relevant tools based on the user's message
|
||||
if (userQuery && userQuery.trim().length > 0) {
|
||||
console.log('🔎 Searching for relevant tools...');
|
||||
|
||||
try {
|
||||
const searchResult = await searchTpmjsToolsTool.execute({
|
||||
query: userQuery,
|
||||
limit: 5, // Get top 5 relevant tools
|
||||
}, {} as any);
|
||||
|
||||
console.log(`📦 Found ${searchResult.matchCount} matching tools`);
|
||||
|
||||
if (searchResult.tools && searchResult.tools.length > 0) {
|
||||
console.log(`🔧 Tools found:`, searchResult.tools.map((t: any) => `${t.packageName}/${t.exportName}`));
|
||||
|
||||
// Dynamically load tools from esm.sh
|
||||
console.log(`📥 Loading ${searchResult.tools.length} tools dynamically...`);
|
||||
|
||||
const toolsToLoad = searchResult.tools.map((meta: any) => ({
|
||||
packageName: meta.packageName,
|
||||
exportName: meta.exportName,
|
||||
version: meta.version,
|
||||
importUrl: meta.importUrl,
|
||||
}));
|
||||
|
||||
try {
|
||||
// ❌ THIS IS WHERE IT FAILS
|
||||
const loadedTools = await loadToolsBatch(toolsToLoad);
|
||||
console.log(`✅ Successfully loaded ${Object.keys(loadedTools).length} tools`);
|
||||
|
||||
// Add sanitized tools to conversation state
|
||||
for (const [key, tool] of Object.entries(loadedTools)) {
|
||||
const [pkg, exp] = key.split('::');
|
||||
const sanitizedKey = sanitizeToolName(`${pkg}-${exp}`);
|
||||
state.loadedTools[sanitizedKey] = tool;
|
||||
console.log(`✅ Added to conversation: ${sanitizedKey}`);
|
||||
}
|
||||
|
||||
// Track for this conversation
|
||||
addConversationTools(conversationId, Object.keys(state.loadedTools));
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading tools:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No matching tools found for this query');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error searching for tools:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Merge with conversation's dynamically loaded tools
|
||||
const allTools: Record<string, any> = { ...staticTools, ...state.loadedTools };
|
||||
|
||||
// 5. Build system prompt with available tools
|
||||
const toolsList = Object.keys(allTools)
|
||||
.map((name) => {
|
||||
const tool = allTools[name] as { description?: string } | undefined;
|
||||
return `- ${name}: ${tool?.description || 'No description'}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const system = `You are a helpful AI assistant that can use TPMJS tools to help users.
|
||||
|
||||
Available tools:
|
||||
${toolsList}
|
||||
|
||||
When you use a tool, you MUST always follow up with a natural language answer to the user summarizing the result.`;
|
||||
|
||||
// 6. Stream response with all available tools
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
system,
|
||||
messages: convertToModelMessages(messages),
|
||||
tools: allTools,
|
||||
stopWhen: stepCountIs(5),
|
||||
});
|
||||
|
||||
return result.toUIMessageStreamResponse();
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### File 3: Example Tool Metadata (from search API)
|
||||
|
||||
When we search for "firecrawl", the search API returns:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"query": "firecrawl ajax davis",
|
||||
"results": {
|
||||
"total": 29,
|
||||
"returned": 5,
|
||||
"tools": [
|
||||
{
|
||||
"id": "cm4abc123",
|
||||
"exportName": "searchTool",
|
||||
"description": "Search the web using Firecrawl's search API",
|
||||
"qualityScore": 0.85,
|
||||
"package": {
|
||||
"npmPackageName": "firecrawl-aisdk",
|
||||
"npmVersion": "0.7.2",
|
||||
"category": "web-scraping",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"env": "server"
|
||||
},
|
||||
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2",
|
||||
"cdnUrl": "https://cdn.jsdelivr.net/npm/firecrawl-aisdk@0.7.2/+esm"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
So we're trying to:
|
||||
```typescript
|
||||
const module = await import('https://esm.sh/firecrawl-aisdk@0.7.2');
|
||||
const tool = module.searchTool; // Get the exported tool
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## The Error
|
||||
|
||||
### Console Output
|
||||
|
||||
```
|
||||
📥 Loading 5 tools dynamically...
|
||||
📦 Importing: https://esm.sh/firecrawl-aisdk@0.7.2
|
||||
❌ Failed to load firecrawl-aisdk#searchTool: Error: Cannot find module 'unknown'
|
||||
at <unknown> (.next/dev/server/chunks/[root-of-the-server]__746deca2._.js:357:23)
|
||||
at loadToolDynamically (.next/dev/server/chunks/[root-of-the-server]__746deca2._.js:360:11)
|
||||
at <unknown> (src/lib/dynamic-tool-loader.ts:108:5)
|
||||
at Array.map (<anonymous>)
|
||||
at loadToolsBatch (src/lib/dynamic-tool-loader.ts:107:33)
|
||||
at POST (src/app/api/chat/route.ts:104:53)
|
||||
{
|
||||
code: 'MODULE_NOT_FOUND'
|
||||
}
|
||||
URL: https://esm.sh/firecrawl-aisdk@0.7.2
|
||||
Stack: Error: Cannot find module 'unknown'
|
||||
at /Users/ajaxdavis/repos/tpmjs/tpmjs/apps/playground/.next/dev/server/chunks/[root-of-the-server]__746deca2._.js:357:23
|
||||
at loadToolDynamically (/Users/ajaxdavis/repos/tpmjs/tpmjs/apps/playground/.next/dev/server/chunks/[root-of-the-server]__746deca2._.js:360:11)
|
||||
```
|
||||
|
||||
### Key Observations
|
||||
|
||||
1. **Error happens immediately** - Never gets to our debug logs after `await import()`
|
||||
2. **Error is MODULE_NOT_FOUND** - Treating URL as a module path
|
||||
3. **Error says "unknown"** - Not even using the actual module name
|
||||
4. **Code is in .next/dev/server/chunks/** - Next.js/Turbopack transformed our code
|
||||
5. **Same error for all packages** - firecrawl-aisdk, @exalabs/ai-sdk, etc.
|
||||
|
||||
---
|
||||
|
||||
## Verification: The URL Works
|
||||
|
||||
### Manual Test 1: Browser
|
||||
|
||||
```
|
||||
Visit: https://esm.sh/firecrawl-aisdk@0.7.2
|
||||
```
|
||||
|
||||
Returns valid ESM module:
|
||||
```javascript
|
||||
/* esm.sh - firecrawl-aisdk@0.7.2 */
|
||||
import * as __1$ from "/v135/@ai-sdk/provider-utils@2.0.8/...";
|
||||
// ... rest of module code
|
||||
export { searchTool, scrapeTool, crawlTool };
|
||||
```
|
||||
|
||||
### Manual Test 2: Plain Node.js Script
|
||||
|
||||
Create `test-import.mjs`:
|
||||
```javascript
|
||||
const module = await import('https://esm.sh/firecrawl-aisdk@0.7.2');
|
||||
console.log('Module:', module);
|
||||
console.log('Exports:', Object.keys(module));
|
||||
```
|
||||
|
||||
Run: `node test-import.mjs`
|
||||
|
||||
**Expected**: Would work in plain Node.js with `--experimental-network-imports` flag
|
||||
**In Next.js**: Can't even get this far
|
||||
|
||||
---
|
||||
|
||||
## What We've Tried
|
||||
|
||||
### Attempt 1: `/* @vite-ignore */` Comment
|
||||
```typescript
|
||||
const module = await import(/* @vite-ignore */ url);
|
||||
```
|
||||
**Result**: Still fails with MODULE_NOT_FOUND
|
||||
|
||||
### Attempt 2: `/* webpackIgnore: true */` Comment
|
||||
```typescript
|
||||
const module = await import(/* webpackIgnore: true */ url);
|
||||
```
|
||||
**Result**: Still fails with MODULE_NOT_FOUND
|
||||
|
||||
### Attempt 3: Force Dynamic Runtime
|
||||
```typescript
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
```
|
||||
**Result**: Still fails (we're already using this)
|
||||
|
||||
### Attempt 4: Verify esm.sh Works
|
||||
- Tested URLs in browser: ✅ Works
|
||||
- All packages return valid ESM: ✅ Valid
|
||||
- esm.sh is accessible: ✅ Reachable
|
||||
|
||||
### Attempt 5: Check Static Imports
|
||||
```typescript
|
||||
import { helloWorldTool } from '@tpmjs/hello';
|
||||
```
|
||||
**Result**: Works perfectly (but bundled at build time)
|
||||
|
||||
---
|
||||
|
||||
## Configuration Files
|
||||
|
||||
### `apps/playground/next.config.ts`
|
||||
|
||||
```typescript
|
||||
import type { NextConfig } from 'next';
|
||||
|
||||
const config: NextConfig = {
|
||||
reactStrictMode: true,
|
||||
transpilePackages: ['@tpmjs/ui'],
|
||||
experimental: {
|
||||
turbo: {
|
||||
// Using Turbopack (Next.js 15+ default)
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
```
|
||||
|
||||
### `apps/playground/package.json` (relevant parts)
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@tpmjs/playground",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "^1.0.15",
|
||||
"@tpmjs/hello": "workspace:*",
|
||||
"@tpmjs/search-registry": "workspace:*",
|
||||
"ai": "6.0.0-beta.124",
|
||||
"next": "16.0.4",
|
||||
"react": "19.0.0"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `turbo.json` (monorepo config)
|
||||
|
||||
```json
|
||||
{
|
||||
"$schema": "https://turbo.build/schema.json",
|
||||
"tasks": {
|
||||
"dev": {
|
||||
"cache": false,
|
||||
"persistent": true
|
||||
},
|
||||
"build": {
|
||||
"dependsOn": ["^build"],
|
||||
"outputs": [".next/**", "dist/**"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Why This Matters
|
||||
|
||||
### The Bigger Picture
|
||||
|
||||
We're building a **self-referential tool discovery system**:
|
||||
|
||||
1. **Tool Registry** (tpmjs.com) - Indexes all TPMJS-compatible tools from npm
|
||||
2. **Search Tool** - AI SDK tool that searches the registry
|
||||
3. **Dynamic Loader** - Loads found tools at runtime
|
||||
4. **AI Agent** - Uses dynamically loaded tools
|
||||
|
||||
This creates infinite extensibility:
|
||||
- No need to bundle all possible tools
|
||||
- Tools can be published to npm independently
|
||||
- System discovers and loads tools as needed
|
||||
- Bundle size stays small
|
||||
|
||||
### Use Case Example
|
||||
|
||||
```
|
||||
User: "Search Wikipedia for quantum computing"
|
||||
↓
|
||||
System searches registry: Finds "wikipedia-aisdk" tool
|
||||
↓
|
||||
System loads tool: import('https://esm.sh/wikipedia-aisdk@1.0.0')
|
||||
↓
|
||||
AI uses tool: wikipediaSearchTool.execute({ query: "quantum computing" })
|
||||
↓
|
||||
User gets answer with Wikipedia citations
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Possible Root Causes
|
||||
|
||||
### Hypothesis 1: Turbopack Doesn't Support Dynamic Import URLs
|
||||
- Turbopack intercepts all `import()` calls
|
||||
- Transforms them to module resolution
|
||||
- Doesn't handle external URLs
|
||||
|
||||
### Hypothesis 2: Next.js Security Restriction
|
||||
- Next.js blocks dynamic imports from external URLs for security
|
||||
- Prevents arbitrary code execution
|
||||
- No way to whitelist esm.sh
|
||||
|
||||
### Hypothesis 3: Dev Mode Only Issue
|
||||
- Turbopack dev mode has more restrictions
|
||||
- Production webpack build might work
|
||||
- But we need dev mode to work too
|
||||
|
||||
### Hypothesis 4: Node.js Runtime Limitation in Next.js
|
||||
- Next.js Node.js runtime is sandboxed
|
||||
- Dynamic imports are intercepted before reaching Node.js
|
||||
- Plain Node.js would work with --experimental-network-imports
|
||||
|
||||
---
|
||||
|
||||
## Alternative Approaches We're Considering
|
||||
|
||||
### Option A: Fetch + VM Module
|
||||
```typescript
|
||||
import { SourceTextModule } from 'vm';
|
||||
|
||||
const response = await fetch(url);
|
||||
const code = await response.text();
|
||||
const module = new SourceTextModule(code);
|
||||
await module.link(() => {});
|
||||
await module.evaluate();
|
||||
const exports = module.namespace;
|
||||
```
|
||||
|
||||
**Pros**: Bypasses import() entirely
|
||||
**Cons**: Complex, security concerns, might not work in Next.js
|
||||
|
||||
### Option B: Separate Microservice
|
||||
```typescript
|
||||
// New service: tool-loader-service (Express or Fastify)
|
||||
POST /load-tool
|
||||
Body: { packageName, exportName, version }
|
||||
Response: { tool: <serialized tool object> }
|
||||
```
|
||||
|
||||
**Pros**: Full control, definitely works
|
||||
**Cons**: Extra infrastructure, latency, complexity
|
||||
|
||||
### Option C: Switch to Edge Runtime
|
||||
```typescript
|
||||
export const runtime = 'edge'; // Instead of 'nodejs'
|
||||
```
|
||||
|
||||
**Pros**: Edge might have different import behavior
|
||||
**Cons**: Edge has limitations (no Node.js APIs), might still not work
|
||||
|
||||
### Option D: Pre-bundle Common Tools
|
||||
```typescript
|
||||
// Generate static imports for top 100 tools
|
||||
import { tool1 } from 'package1';
|
||||
import { tool2 } from 'package2';
|
||||
// ... etc
|
||||
```
|
||||
|
||||
**Pros**: Definitely works
|
||||
**Cons**: Defeats the purpose, huge bundle size
|
||||
|
||||
### Option E: Use unpkg or jsdelivr with Different Strategy
|
||||
```typescript
|
||||
// Fetch raw code, eval in isolated context
|
||||
const response = await fetch(`https://unpkg.com/${pkg}@${ver}/dist/index.mjs`);
|
||||
const code = await response.text();
|
||||
const exports = evalInContext(code);
|
||||
```
|
||||
|
||||
**Pros**: More control
|
||||
**Cons**: Same security/execution issues
|
||||
|
||||
---
|
||||
|
||||
## Specific Questions for ChatGPT
|
||||
|
||||
### Question 1: Is This Possible?
|
||||
**Can Next.js 16 App Router API routes (Node.js runtime) dynamically import ESM modules from external URLs using `import()`?**
|
||||
|
||||
If yes:
|
||||
- What configuration is needed?
|
||||
- Are there security allowlists?
|
||||
- Does it work in both dev and production?
|
||||
|
||||
If no:
|
||||
- Why not?
|
||||
- What's the recommended alternative?
|
||||
- Is this a Turbopack limitation or Next.js design?
|
||||
|
||||
### Question 2: Turbopack Behavior
|
||||
**Does Turbopack intercept all `import()` calls, even with magic comments?**
|
||||
|
||||
We've tried:
|
||||
- `/* @vite-ignore */`
|
||||
- `/* webpackIgnore: true */`
|
||||
|
||||
None work. Is there a Turbopack-specific comment or config?
|
||||
|
||||
### Question 3: Edge vs Node Runtime
|
||||
**Would switching to edge runtime change import behavior?**
|
||||
|
||||
```typescript
|
||||
export const runtime = 'edge'; // vs 'nodejs'
|
||||
```
|
||||
|
||||
Does edge runtime allow dynamic imports from URLs?
|
||||
|
||||
### Question 4: Best Practice
|
||||
**What's the recommended way to implement dynamic tool loading in Next.js?**
|
||||
|
||||
Given constraints:
|
||||
- Need to load arbitrary npm packages at runtime
|
||||
- Packages are ESM modules from CDN
|
||||
- Can't pre-bundle all possibilities
|
||||
- Need to work in production on Vercel
|
||||
|
||||
### Question 5: Security Model
|
||||
**Is Next.js intentionally blocking this for security?**
|
||||
|
||||
- Is there a whitelist for allowed CDNs?
|
||||
- Can we configure allowed import sources?
|
||||
- Is this related to CSP or other security headers?
|
||||
|
||||
---
|
||||
|
||||
## Environment Details
|
||||
|
||||
### Versions
|
||||
```json
|
||||
{
|
||||
"next": "16.0.4",
|
||||
"react": "19.0.0",
|
||||
"turbo": "2.6.1",
|
||||
"pnpm": "9.15.0",
|
||||
"node": "v20.11.0",
|
||||
"ai": "6.0.0-beta.124"
|
||||
}
|
||||
```
|
||||
|
||||
### Operating System
|
||||
- **OS**: macOS (Darwin 23.5.0)
|
||||
- **Architecture**: arm64 (Apple Silicon)
|
||||
|
||||
### Development Commands
|
||||
```bash
|
||||
# Start dev server
|
||||
pnpm dev --filter=@tpmjs/playground
|
||||
|
||||
# Output
|
||||
▲ Next.js 16.0.4 (Turbopack)
|
||||
- Local: http://localhost:3001
|
||||
- Network: http://192.168.0.25:3001
|
||||
✓ Ready in 2.5s
|
||||
```
|
||||
|
||||
### Build Output Structure
|
||||
```
|
||||
apps/playground/.next/
|
||||
├── dev/
|
||||
│ └── server/
|
||||
│ └── chunks/
|
||||
│ └── [root-of-the-server]__746deca2._.js # ← Error originates here
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Success Criteria
|
||||
|
||||
### What We Need Working
|
||||
|
||||
```typescript
|
||||
// In Next.js API route (Node.js runtime)
|
||||
const url = 'https://esm.sh/firecrawl-aisdk@0.7.2';
|
||||
const module = await import(url);
|
||||
const tool = module.searchTool;
|
||||
|
||||
console.log(tool.description); // "Search the web using Firecrawl's search API"
|
||||
console.log(typeof tool.execute); // "function"
|
||||
|
||||
// Tool is ready to use with AI SDK
|
||||
const result = await tool.execute({ query: "test" }, context);
|
||||
```
|
||||
|
||||
### Acceptable Outcomes
|
||||
|
||||
1. ✅ **Best**: Dynamic `import()` works with configuration change
|
||||
2. ✅ **Good**: Alternative approach that doesn't require microservice
|
||||
3. ✅ **Acceptable**: Workaround that works in production even if dev is tricky
|
||||
4. ❌ **Unacceptable**: "You can't do this in Next.js" without alternative
|
||||
|
||||
---
|
||||
|
||||
## Additional Context
|
||||
|
||||
### Why Not Just Bundle Everything?
|
||||
|
||||
Currently have ~30 tools in registry, growing to 100s or 1000s:
|
||||
- Bundle size would be massive (10+ MB)
|
||||
- Most tools won't be used in most conversations
|
||||
- Tools are published independently by community
|
||||
- Want instant availability of new tools without redeploying
|
||||
|
||||
### Why esm.sh Specifically?
|
||||
|
||||
- ✅ Converts any npm package to ESM
|
||||
- ✅ Handles dependencies automatically
|
||||
- ✅ Fast CDN with caching
|
||||
- ✅ No build step required
|
||||
- ✅ Version pinning built-in
|
||||
|
||||
But we're flexible - if jsdelivr, unpkg, or another approach works better, we'll use it.
|
||||
|
||||
### Static Imports Work Fine
|
||||
|
||||
This works perfectly (but defeats the purpose):
|
||||
```typescript
|
||||
import { searchTool } from 'firecrawl-aisdk';
|
||||
```
|
||||
|
||||
The tools themselves are fine. We just can't load them dynamically.
|
||||
|
||||
---
|
||||
|
||||
## What We're Hoping For
|
||||
|
||||
### Ideal Answer Format
|
||||
|
||||
1. **Root cause**: Why it's failing
|
||||
2. **Solution**: How to fix it (with code example)
|
||||
3. **Configuration**: Any Next.js config needed
|
||||
4. **Limitations**: What won't work / tradeoffs
|
||||
5. **Alternatives**: If dynamic import truly impossible
|
||||
|
||||
### We're Happy to Try
|
||||
|
||||
- Different CDN (unpkg, jsdelivr, etc.)
|
||||
- Different import strategy (fetch + eval, vm module, etc.)
|
||||
- Different runtime (edge if it works)
|
||||
- Different Next.js version (if specific version supports this)
|
||||
- Webpack instead of Turbopack (if webpack handles this better)
|
||||
|
||||
We just need a path forward that enables runtime tool loading in a production Next.js app on Vercel.
|
||||
|
||||
---
|
||||
|
||||
## Files to Reference
|
||||
|
||||
All code is in this monorepo:
|
||||
- `apps/playground/src/lib/dynamic-tool-loader.ts` - Import logic
|
||||
- `apps/playground/src/app/api/chat/route.ts` - API route
|
||||
- `apps/playground/next.config.ts` - Next.js config
|
||||
- `DYNAMIC_IMPORT_ISSUE.md` - This document
|
||||
|
||||
---
|
||||
|
||||
## Thank You
|
||||
|
||||
This is a critical blocker for our dynamic tool loading system. Any insights, workarounds, or alternative approaches would be immensely helpful!
|
||||
988
DYNAMIC_TOOL_LOADING_PRD.md
Normal file
988
DYNAMIC_TOOL_LOADING_PRD.md
Normal file
|
|
@ -0,0 +1,988 @@
|
|||
# Dynamic Tool Loading System - Product Requirements Document
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Build a self-referential tool discovery system where AI agents can search the TPMJS registry, find relevant tools, and dynamically import them during conversation. This creates a "meta-tool" that makes the entire TPMJS ecosystem available to any agent at runtime.
|
||||
|
||||
**Core Innovation:** An AI agent can discover and load tools on-demand by searching the registry, rather than having all tools pre-loaded. This enables infinite tool extensibility without bundle size concerns.
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
### Current Limitations
|
||||
|
||||
1. **Static Tool Loading**: Playground requires all tools to be hardcoded in `tool-loader.ts`
|
||||
2. **Bundle Size**: Loading many tools increases bundle size and initialization time
|
||||
3. **Discovery Gap**: Agents can't discover new tools that match their current task
|
||||
4. **Manual Updates**: Adding tools requires code changes and redeployment
|
||||
|
||||
### User Pain Points
|
||||
|
||||
- Users want agents to access the full TPMJS registry without manual configuration
|
||||
- Developers want to publish tools that are immediately available to all agents
|
||||
- Agents need context-aware tool selection based on the conversation
|
||||
|
||||
---
|
||||
|
||||
## Solution Overview
|
||||
|
||||
### The Meta-Tool: `searchTpmjsTools`
|
||||
|
||||
A TPMJS tool that searches the TPMJS registry and returns tool metadata needed for dynamic import.
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
User: "Search Wikipedia for quantum computing"
|
||||
↓
|
||||
Agent: Calls searchTpmjsTools("wikipedia search")
|
||||
↓
|
||||
API: Returns tools matching "wikipedia" (BM25 search)
|
||||
↓
|
||||
Playground: Dynamically imports matching tools
|
||||
↓
|
||||
Agent: Now has Wikipedia tools available, uses them
|
||||
```
|
||||
|
||||
### Key Components
|
||||
|
||||
1. **`@tpmjs/search-registry`** - NPM package exporting `searchTpmjsToolsTool`
|
||||
2. **`/api/tools/search`** - New API endpoint with BM25 full-text search
|
||||
3. **Playground Dynamic Loader** - Runtime tool import system
|
||||
4. **Tool Import Strategy** - ESM CDN imports or bundled approach
|
||||
|
||||
---
|
||||
|
||||
## Technical Architecture
|
||||
|
||||
### Component 1: Search Tool Package
|
||||
|
||||
**Package:** `packages/tools/search-registry/`
|
||||
|
||||
```typescript
|
||||
// packages/tools/search-registry/src/index.ts
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const searchTpmjsToolsTool = tool({
|
||||
description: 'Search the TPMJS tool registry to find AI SDK tools. Use this when you need a tool that isn\'t currently available. Returns tool metadata including package names and descriptions.',
|
||||
parameters: z.object({
|
||||
query: z.string().describe('Search query (e.g., "weather", "database", "wikipedia")'),
|
||||
category: z.enum([
|
||||
'text-analysis',
|
||||
'code-generation',
|
||||
'data-processing',
|
||||
'image-generation',
|
||||
'audio-processing',
|
||||
'search',
|
||||
'integration',
|
||||
'other'
|
||||
]).optional().describe('Filter by tool category'),
|
||||
limit: z.number().min(1).max(20).default(10).describe('Max number of tools to return'),
|
||||
}),
|
||||
execute: async ({ query, category, limit }) => {
|
||||
// Call TPMJS search API
|
||||
const params = new URLSearchParams({
|
||||
q: query,
|
||||
limit: String(limit),
|
||||
...(category && { category }),
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://tpmjs.com/api/tools/search?${params}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Search failed: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Return structured tool metadata
|
||||
return {
|
||||
query,
|
||||
matchCount: data.tools.length,
|
||||
tools: data.tools.map((tool: any) => ({
|
||||
packageName: tool.package.npmPackageName,
|
||||
exportName: tool.exportName,
|
||||
description: tool.description,
|
||||
category: tool.package.category,
|
||||
qualityScore: tool.qualityScore,
|
||||
frameworks: tool.package.frameworks,
|
||||
env: tool.package.env,
|
||||
})),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
**Package Metadata:**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@tpmjs/search-registry",
|
||||
"version": "0.1.0",
|
||||
"description": "AI SDK tool for searching the TPMJS tool registry",
|
||||
"keywords": ["tpmjs-tool", "ai", "search"],
|
||||
"tpmjs": {
|
||||
"category": "search",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "searchTpmjsToolsTool",
|
||||
"description": "Search the TPMJS tool registry to find AI SDK tools by keyword, category, or description. Returns tool metadata for dynamic loading.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "query",
|
||||
"type": "string",
|
||||
"description": "Search query (keywords, tool names, descriptions)",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "category",
|
||||
"type": "string",
|
||||
"description": "Filter by category (text-analysis, search, etc.)",
|
||||
"required": false
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"type": "number",
|
||||
"description": "Maximum number of results (1-20, default 10)",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "object",
|
||||
"description": "Search results with tool metadata for dynamic import"
|
||||
},
|
||||
"aiAgent": {
|
||||
"useCase": "Use this tool when you need a tool that isn't currently available. For example, if asked to search Wikipedia but you don't have a Wikipedia tool, search for 'wikipedia' to find and load it.",
|
||||
"examples": [
|
||||
"Search for 'weather' tools when asked about weather",
|
||||
"Search for 'database' tools when working with data",
|
||||
"Search for 'code' tools when generating code"
|
||||
],
|
||||
"limitations": "Returns metadata only - the playground handles actual tool loading"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Component 2: BM25 Search API Endpoint
|
||||
|
||||
**File:** `apps/web/src/app/api/tools/search/route.ts`
|
||||
|
||||
**Requirements:**
|
||||
|
||||
1. **Full-Text Search with BM25**
|
||||
- Search across: tool description, package name, npm description, npm keywords
|
||||
- BM25 scoring for relevance ranking
|
||||
- Category filtering
|
||||
- Quality score boosting (rich tier tools rank higher)
|
||||
|
||||
2. **Search Implementation Options**
|
||||
|
||||
**Option A: PostgreSQL Full-Text Search**
|
||||
```sql
|
||||
-- Add tsvector column to tools table
|
||||
ALTER TABLE tools ADD COLUMN search_vector tsvector;
|
||||
|
||||
-- Create GIN index for fast full-text search
|
||||
CREATE INDEX tools_search_idx ON tools USING GIN(search_vector);
|
||||
|
||||
-- Update search vector on insert/update
|
||||
CREATE TRIGGER tools_search_update
|
||||
BEFORE INSERT OR UPDATE ON tools
|
||||
FOR EACH ROW EXECUTE FUNCTION
|
||||
tsvector_update_trigger(search_vector, 'pg_catalog.english',
|
||||
description);
|
||||
```
|
||||
|
||||
**Option B: JavaScript BM25 Library**
|
||||
```typescript
|
||||
import { BM25 } from 'bm25';
|
||||
|
||||
// Load all tools into memory (cached)
|
||||
const tools = await prisma.tool.findMany({
|
||||
include: { package: true },
|
||||
});
|
||||
|
||||
// Build BM25 index
|
||||
const documents = tools.map(tool => ({
|
||||
id: tool.id,
|
||||
text: `${tool.description} ${tool.package.npmPackageName} ${tool.package.npmDescription} ${tool.package.npmKeywords.join(' ')}`,
|
||||
}));
|
||||
|
||||
const bm25 = new BM25(documents);
|
||||
const results = bm25.search(query);
|
||||
```
|
||||
|
||||
**Option C: Hybrid Approach**
|
||||
- Use PostgreSQL `LIKE` for exact matches (fastest)
|
||||
- Fall back to BM25 for fuzzy/semantic search
|
||||
- Cache search results in Redis
|
||||
|
||||
3. **API Response Format**
|
||||
|
||||
```typescript
|
||||
// GET /api/tools/search?q=weather&category=integration&limit=10
|
||||
|
||||
{
|
||||
"success": true,
|
||||
"query": "weather",
|
||||
"filters": {
|
||||
"category": "integration"
|
||||
},
|
||||
"results": {
|
||||
"total": 23,
|
||||
"returned": 10,
|
||||
"tools": [
|
||||
{
|
||||
"id": "clx...",
|
||||
"exportName": "getWeatherTool",
|
||||
"description": "Get current weather data for any location using OpenWeatherMap API",
|
||||
"qualityScore": 0.85,
|
||||
"package": {
|
||||
"npmPackageName": "@tpmjs/weather",
|
||||
"npmVersion": "1.2.0",
|
||||
"category": "integration",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"env": [
|
||||
{
|
||||
"name": "OPENWEATHER_API_KEY",
|
||||
"description": "OpenWeatherMap API key",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"npmRepository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/user/weather-tool"
|
||||
},
|
||||
"isOfficial": false
|
||||
},
|
||||
// Include everything needed for dynamic import
|
||||
"importUrl": "https://esm.sh/@tpmjs/weather@1.2.0",
|
||||
"cdnUrl": "https://cdn.jsdelivr.net/npm/@tpmjs/weather@1.2.0/+esm"
|
||||
}
|
||||
// ... more tools
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Component 3: Dynamic Tool Loader (Playground)
|
||||
|
||||
**File:** `apps/playground/src/lib/dynamic-tool-loader.ts`
|
||||
|
||||
**Requirements:**
|
||||
|
||||
1. **Runtime ESM Import**
|
||||
```typescript
|
||||
async function loadToolDynamically(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
version: string
|
||||
) {
|
||||
// Option 1: ESM CDN (esm.sh, unpkg, jsdelivr)
|
||||
const cdnUrl = `https://esm.sh/${packageName}@${version}`;
|
||||
|
||||
try {
|
||||
const module = await import(/* @vite-ignore */ cdnUrl);
|
||||
const tool = module[exportName];
|
||||
|
||||
if (!isValidTool(tool)) {
|
||||
throw new Error(`Invalid tool: ${exportName}`);
|
||||
}
|
||||
|
||||
return tool;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load ${packageName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. **Tool Caching Strategy**
|
||||
```typescript
|
||||
// Cache loaded tools to avoid redundant imports
|
||||
const toolCache = new Map<string, any>();
|
||||
|
||||
function getCacheKey(packageName: string, exportName: string): string {
|
||||
return `${packageName}::${exportName}`;
|
||||
}
|
||||
|
||||
async function loadToolWithCache(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
version: string
|
||||
) {
|
||||
const key = getCacheKey(packageName, exportName);
|
||||
|
||||
if (toolCache.has(key)) {
|
||||
return toolCache.get(key);
|
||||
}
|
||||
|
||||
const tool = await loadToolDynamically(packageName, exportName, version);
|
||||
|
||||
if (tool) {
|
||||
toolCache.set(key, tool);
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Tool Registry Integration**
|
||||
```typescript
|
||||
// Merge static tools + dynamically loaded tools
|
||||
async function getAllAvailableTools(
|
||||
staticTools: Record<string, any>,
|
||||
searchResults: SearchResult[]
|
||||
): Promise<Record<string, any>> {
|
||||
const allTools = { ...staticTools };
|
||||
|
||||
// Load tools from search results
|
||||
for (const result of searchResults) {
|
||||
const tool = await loadToolWithCache(
|
||||
result.package.npmPackageName,
|
||||
result.exportName,
|
||||
result.package.npmVersion
|
||||
);
|
||||
|
||||
if (tool) {
|
||||
const key = sanitizeToolName(
|
||||
`${result.package.npmPackageName}-${result.exportName}`
|
||||
);
|
||||
allTools[key] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
return allTools;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Component 4: Playground Chat Integration
|
||||
|
||||
**File:** `apps/playground/src/app/api/chat/route.ts`
|
||||
|
||||
**Flow:**
|
||||
|
||||
1. **Initial Tool Set**
|
||||
- Load static tools (hardcoded in tool-loader)
|
||||
- Always include `searchTpmjsToolsTool` in initial set
|
||||
|
||||
2. **Agent Invokes Search**
|
||||
- Agent calls `searchTpmjsToolsTool` with query
|
||||
- Search API returns matching tool metadata
|
||||
- Response includes tool metadata
|
||||
|
||||
3. **Dynamic Loading Trigger**
|
||||
- Detect when agent successfully calls `searchTpmjsToolsTool`
|
||||
- Extract tool metadata from response
|
||||
- Load tools dynamically before next agent turn
|
||||
|
||||
4. **Tool Availability Update**
|
||||
- Merge dynamically loaded tools into available tool set
|
||||
- Agent can now use newly loaded tools in subsequent turns
|
||||
|
||||
**Implementation:**
|
||||
|
||||
```typescript
|
||||
// apps/playground/src/app/api/chat/route.ts
|
||||
export async function POST(req: Request) {
|
||||
const { messages } = await req.json();
|
||||
|
||||
// 1. Load static tools + search tool
|
||||
let availableTools = await loadAllTools(); // static
|
||||
availableTools['searchTpmjsTools'] = searchTpmjsToolsTool; // meta-tool
|
||||
|
||||
// 2. Create streamText with current tools
|
||||
const result = streamText({
|
||||
model: openai('gpt-4'),
|
||||
messages,
|
||||
tools: availableTools,
|
||||
maxSteps: 10, // Allow multiple tool call rounds
|
||||
|
||||
onStepFinish: async (step) => {
|
||||
// 3. Check if agent called searchTpmjsToolsTool
|
||||
for (const toolCall of step.toolCalls) {
|
||||
if (toolCall.toolName === 'searchTpmjsTools') {
|
||||
const searchResults = toolCall.result?.tools || [];
|
||||
|
||||
// 4. Dynamically load tools from search results
|
||||
console.log(`Loading ${searchResults.length} tools dynamically...`);
|
||||
|
||||
for (const toolMeta of searchResults) {
|
||||
const tool = await loadToolWithCache(
|
||||
toolMeta.packageName,
|
||||
toolMeta.exportName,
|
||||
'latest' // or toolMeta.version
|
||||
);
|
||||
|
||||
if (tool) {
|
||||
const key = sanitizeToolName(
|
||||
`${toolMeta.packageName}-${toolMeta.exportName}`
|
||||
);
|
||||
availableTools[key] = tool;
|
||||
console.log(`✅ Loaded: ${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Update tool registry for subsequent steps
|
||||
// Note: This requires AI SDK to support dynamic tool updates
|
||||
// May need to restart the streamText with updated tools
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technical Challenges & Solutions
|
||||
|
||||
### Challenge 1: AI SDK Doesn't Support Dynamic Tool Updates Mid-Stream
|
||||
|
||||
**Problem:** Vercel AI SDK's `streamText` sets tools at initialization. Can't add tools after streaming starts.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
**Option A: Multi-Turn Pattern**
|
||||
```typescript
|
||||
// Turn 1: Agent searches for tools
|
||||
// Turn 2: Agent uses loaded tools
|
||||
|
||||
// Detect search tool call, return early
|
||||
if (hasSearchToolCall) {
|
||||
return new Response(JSON.stringify({
|
||||
type: 'tools_loaded',
|
||||
tools: searchResults,
|
||||
message: 'Tools loaded. Please continue your request.',
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Pre-Flight Search (Recommended)**
|
||||
```typescript
|
||||
// Before calling streamText, analyze user message
|
||||
const needsTools = await analyzeMessageForToolNeeds(userMessage);
|
||||
|
||||
if (needsTools.length > 0) {
|
||||
// Pre-load tools based on intent
|
||||
const searchResults = await searchTools(needsTools);
|
||||
const dynamicTools = await loadToolsFromResults(searchResults);
|
||||
availableTools = { ...staticTools, ...dynamicTools };
|
||||
}
|
||||
|
||||
// Now call streamText with full tool set
|
||||
const result = streamText({
|
||||
model,
|
||||
messages,
|
||||
tools: availableTools,
|
||||
});
|
||||
```
|
||||
|
||||
**Option C: Agent-Driven Two-Phase**
|
||||
```typescript
|
||||
// Phase 1: Planning
|
||||
const planResult = await generateText({
|
||||
model,
|
||||
messages: [
|
||||
{ role: 'system', content: 'Analyze this request and determine what tools are needed. Call searchTpmjsTools if needed.' },
|
||||
...messages,
|
||||
],
|
||||
tools: { searchTpmjsTools },
|
||||
});
|
||||
|
||||
// Phase 2: Execution with loaded tools
|
||||
const executionResult = await streamText({
|
||||
model,
|
||||
messages,
|
||||
tools: { ...staticTools, ...loadedTools },
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Challenge 2: ESM Dynamic Import in Browser vs Node.js
|
||||
|
||||
**Problem:** Dynamic `import()` works differently in browser vs server environments.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
**Server-Side (Recommended):**
|
||||
```typescript
|
||||
// Use Node.js dynamic import
|
||||
// Works with esm.sh CDN
|
||||
const tool = await import(`https://esm.sh/${pkg}@${version}`);
|
||||
```
|
||||
|
||||
**Client-Side (Avoid):**
|
||||
```typescript
|
||||
// Browser import() has CORS and CSP restrictions
|
||||
// Would require:
|
||||
// 1. CDN supports CORS
|
||||
// 2. CSP allows script-src from CDN
|
||||
// 3. Tools are browser-compatible (no Node.js APIs)
|
||||
```
|
||||
|
||||
**Hybrid Approach:**
|
||||
```typescript
|
||||
// Load tools server-side, serialize to client
|
||||
// Client displays available tools
|
||||
// Server executes tool calls
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Challenge 3: Tool Dependencies & Environment Variables
|
||||
|
||||
**Problem:** Dynamically loaded tools may require:
|
||||
- Environment variables (API keys)
|
||||
- npm dependencies not in bundle
|
||||
- Node.js-specific APIs
|
||||
|
||||
**Solutions:**
|
||||
|
||||
**Option A: Require Pre-Configuration**
|
||||
```typescript
|
||||
// Before loading, check if tool requirements are met
|
||||
async function canLoadTool(toolMeta: ToolMetadata): Promise<boolean> {
|
||||
// Check required env vars
|
||||
for (const env of toolMeta.package.env || []) {
|
||||
if (env.required && !process.env[env.name]) {
|
||||
console.warn(`Missing required env: ${env.name}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Graceful Degradation**
|
||||
```typescript
|
||||
// Load tool, catch errors, inform agent
|
||||
try {
|
||||
const tool = await loadTool(packageName, exportName);
|
||||
return tool;
|
||||
} catch (error) {
|
||||
return createStubTool(packageName, exportName, error);
|
||||
}
|
||||
|
||||
function createStubTool(pkg: string, exp: string, error: Error) {
|
||||
return tool({
|
||||
description: `[UNAVAILABLE] ${exp} from ${pkg}: ${error.message}`,
|
||||
parameters: z.object({}),
|
||||
execute: async () => {
|
||||
throw new Error(`Cannot execute ${exp}: ${error.message}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Option C: Proxy Through Server**
|
||||
```typescript
|
||||
// All tools execute server-side where env vars exist
|
||||
// Client just displays tool calls, server handles execution
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Challenge 4: Security & Sandboxing
|
||||
|
||||
**Problem:** Dynamically importing arbitrary npm packages is a security risk.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
**Option A: Allowlist Only**
|
||||
```typescript
|
||||
// Only load tools from TPMJS registry (already vetted)
|
||||
const allowedPackages = await prisma.package.findMany({
|
||||
select: { npmPackageName: true }
|
||||
});
|
||||
|
||||
if (!allowedPackages.includes(packageName)) {
|
||||
throw new Error('Package not in TPMJS registry');
|
||||
}
|
||||
```
|
||||
|
||||
**Option B: Version Pinning**
|
||||
```typescript
|
||||
// Only load specific versions from registry
|
||||
// Don't use 'latest' to avoid supply chain attacks
|
||||
const version = toolMeta.package.npmVersion; // e.g., "1.2.0"
|
||||
const url = `https://esm.sh/${pkg}@${version}`;
|
||||
```
|
||||
|
||||
**Option C: VM Sandbox (Advanced)**
|
||||
```typescript
|
||||
// Execute tools in isolated VM context
|
||||
import { VM } from 'vm2';
|
||||
|
||||
const vm = new VM({
|
||||
timeout: 5000,
|
||||
sandbox: {
|
||||
fetch: safeFetch, // Wrapped fetch with rate limits
|
||||
console: safeConsole,
|
||||
},
|
||||
});
|
||||
|
||||
const tool = vm.run(toolCode);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Challenge 5: Performance & Bundle Size
|
||||
|
||||
**Problem:** Loading many tools dynamically could be slow.
|
||||
|
||||
**Solutions:**
|
||||
|
||||
**Option A: Lazy Loading**
|
||||
```typescript
|
||||
// Only load tools when agent decides to use them
|
||||
// Not when they're discovered
|
||||
```
|
||||
|
||||
**Option B: Parallel Loading**
|
||||
```typescript
|
||||
// Load multiple tools concurrently
|
||||
const toolPromises = searchResults.map(result =>
|
||||
loadToolWithCache(result.package.npmPackageName, result.exportName, result.package.npmVersion)
|
||||
);
|
||||
|
||||
const tools = await Promise.all(toolPromises);
|
||||
```
|
||||
|
||||
**Option C: CDN Caching**
|
||||
```typescript
|
||||
// Use CDN with aggressive caching
|
||||
// esm.sh has built-in caching
|
||||
const url = `https://esm.sh/${pkg}@${version}?target=es2022&bundle`;
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
### Phase 1: MVP (Week 1-2)
|
||||
|
||||
**Goal:** Prove dynamic loading works with simple prototype
|
||||
|
||||
1. **Create `@tpmjs/search-registry` package**
|
||||
- Implement `searchTpmjsToolsTool`
|
||||
- Publish to npm
|
||||
- Add to manual-tools registry
|
||||
|
||||
2. **Build `/api/tools/search` endpoint**
|
||||
- Start with simple PostgreSQL `LIKE` search
|
||||
- Return tool metadata with package info
|
||||
- Test with curl
|
||||
|
||||
3. **Implement basic dynamic loader**
|
||||
- Use esm.sh CDN for imports
|
||||
- Load tools server-side only
|
||||
- Cache in memory
|
||||
|
||||
4. **Playground integration - Two-Turn Pattern**
|
||||
- User asks question
|
||||
- Agent calls `searchTpmjsToolsTool`
|
||||
- Backend loads tools
|
||||
- Agent uses tools in next turn
|
||||
|
||||
**Success Criteria:**
|
||||
- Agent can search registry
|
||||
- Agent can use dynamically loaded tools
|
||||
- End-to-end flow works for 1-2 example tools
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: BM25 Search (Week 3)
|
||||
|
||||
**Goal:** Improve search relevance with BM25
|
||||
|
||||
1. **Research BM25 implementation options**
|
||||
- Test PostgreSQL full-text search
|
||||
- Test JavaScript BM25 libraries
|
||||
- Benchmark performance
|
||||
|
||||
2. **Implement chosen approach**
|
||||
- Add search vector column if using PostgreSQL
|
||||
- Create search index
|
||||
- Update search endpoint
|
||||
|
||||
3. **Test search quality**
|
||||
- Create test queries
|
||||
- Measure precision/recall
|
||||
- Compare to baseline `LIKE` search
|
||||
|
||||
**Success Criteria:**
|
||||
- BM25 search returns more relevant results than LIKE
|
||||
- Search latency < 100ms for 95th percentile
|
||||
- Agent can find tools for diverse queries
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Production Hardening (Week 4)
|
||||
|
||||
**Goal:** Make system production-ready
|
||||
|
||||
1. **Error Handling**
|
||||
- Handle import failures gracefully
|
||||
- Validate tool schemas
|
||||
- Return helpful error messages to agent
|
||||
|
||||
2. **Security**
|
||||
- Implement package allowlist
|
||||
- Pin versions from registry
|
||||
- Add rate limiting to search API
|
||||
|
||||
3. **Performance**
|
||||
- Implement Redis caching for search results
|
||||
- Add CDN caching headers
|
||||
- Optimize tool loading parallelism
|
||||
|
||||
4. **Monitoring**
|
||||
- Log all dynamic tool loads
|
||||
- Track search queries and results
|
||||
- Monitor import success/failure rates
|
||||
|
||||
**Success Criteria:**
|
||||
- System handles errors without crashing
|
||||
- Security review passes
|
||||
- Latency and reliability SLOs met
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: Advanced Features (Week 5+)
|
||||
|
||||
**Goal:** Enhance UX and capabilities
|
||||
|
||||
1. **Pre-flight Search**
|
||||
- Analyze user message for intent
|
||||
- Proactively load tools before agent call
|
||||
- Reduce total turns needed
|
||||
|
||||
2. **Tool Recommendations**
|
||||
- "You might also need..." suggestions
|
||||
- Based on tool co-occurrence data
|
||||
- Help agent discover related tools
|
||||
|
||||
3. **Client-Side Tool Display**
|
||||
- Show which tools are available
|
||||
- Indicate dynamically loaded tools
|
||||
- Allow user to manually load tools
|
||||
|
||||
4. **Tool Versioning**
|
||||
- Support multiple versions of same tool
|
||||
- Let agent choose version
|
||||
- Handle breaking changes gracefully
|
||||
|
||||
---
|
||||
|
||||
## Success Metrics
|
||||
|
||||
### Technical Metrics
|
||||
|
||||
1. **Search Quality**
|
||||
- Precision@10 > 0.8 (80% of top 10 results are relevant)
|
||||
- Mean Reciprocal Rank (MRR) > 0.7
|
||||
- Search latency p95 < 100ms
|
||||
|
||||
2. **Tool Loading**
|
||||
- Import success rate > 95%
|
||||
- Tool load time p95 < 2 seconds
|
||||
- Cache hit rate > 70% after warmup
|
||||
|
||||
3. **End-to-End Performance**
|
||||
- Total conversation latency < 5 seconds (including tool search + load + execution)
|
||||
- Agent uses correct tools > 90% of time
|
||||
|
||||
### User Metrics
|
||||
|
||||
1. **Adoption**
|
||||
- % of playground sessions using dynamic tools > 30%
|
||||
- Number of unique tools loaded dynamically per week > 50
|
||||
|
||||
2. **Tool Coverage**
|
||||
- % of user queries satisfied with available tools > 80%
|
||||
- Tool search leading to successful task completion > 70%
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
### 1. CDN Choice for ESM Imports
|
||||
|
||||
**Options:**
|
||||
- **esm.sh** - Purpose-built for ESM imports, fast, reliable
|
||||
- **unpkg** - Popular, simple, but slower
|
||||
- **jsdelivr** - Fast CDN, good for production
|
||||
- **Custom bundler** - Pre-bundle tools, serve from our CDN
|
||||
|
||||
**Recommendation:** Start with esm.sh for MVP, evaluate custom bundler for production.
|
||||
|
||||
---
|
||||
|
||||
### 2. When to Load Tools?
|
||||
|
||||
**Options:**
|
||||
- **On-demand**: Load when agent calls search tool (current plan)
|
||||
- **Pre-flight**: Analyze user message, load proactively
|
||||
- **Lazy**: Load when agent tries to use tool (not when discovered)
|
||||
- **Eager**: Load all tools from search results immediately
|
||||
|
||||
**Recommendation:** Start with on-demand (Phase 1), add pre-flight in Phase 4.
|
||||
|
||||
---
|
||||
|
||||
### 3. How to Handle Environment Variables?
|
||||
|
||||
**Problem:** Dynamically loaded tools may need API keys (e.g., OpenWeather API).
|
||||
|
||||
**Options:**
|
||||
- **User provides**: UI for users to enter API keys (like playground settings)
|
||||
- **Server-managed**: Admin pre-configures keys in .env
|
||||
- **Graceful fail**: Load tool, but execution fails if env missing
|
||||
- **Hybrid**: Some tools work without keys (free tier), others require keys
|
||||
|
||||
**Recommendation:** Start with graceful fail (Phase 1), add user-provided keys (Phase 4).
|
||||
|
||||
---
|
||||
|
||||
### 4. Should Tools Load Client-Side or Server-Side?
|
||||
|
||||
**Client-Side Pros:**
|
||||
- Reduces server load
|
||||
- Faster for subsequent uses
|
||||
- Better for browser-compatible tools
|
||||
|
||||
**Client-Side Cons:**
|
||||
- Requires CORS-enabled CDN
|
||||
- CSP restrictions
|
||||
- Many tools need Node.js APIs
|
||||
- Exposing API keys in browser is insecure
|
||||
|
||||
**Server-Side Pros:**
|
||||
- Access to Node.js APIs
|
||||
- Secure environment variable access
|
||||
- No CORS issues
|
||||
- Easier to implement
|
||||
|
||||
**Server-Side Cons:**
|
||||
- Requires server memory for caching
|
||||
- Increases server load
|
||||
- Cold starts for new tools
|
||||
|
||||
**Recommendation:** Server-side for MVP (Phase 1), evaluate client-side for browser-compatible tools (Phase 4+).
|
||||
|
||||
---
|
||||
|
||||
### 5. How to Handle Tool Dependencies?
|
||||
|
||||
**Problem:** Some tools depend on other npm packages (e.g., `axios`, `cheerio`).
|
||||
|
||||
**Options:**
|
||||
- **Bundled**: CDN bundles dependencies (esm.sh does this)
|
||||
- **Peer deps**: Require dependencies in playground package.json
|
||||
- **Dynamic install**: npm install on-the-fly (slow, risky)
|
||||
- **Pre-vetted**: Only allow tools with no/minimal dependencies
|
||||
|
||||
**Recommendation:** Use esm.sh bundling (Phase 1), bundle size limits if issues arise.
|
||||
|
||||
---
|
||||
|
||||
## Risk Assessment
|
||||
|
||||
### High Risk
|
||||
|
||||
1. **Security Vulnerability**
|
||||
- **Risk**: Malicious package in registry executes code
|
||||
- **Mitigation**: Allowlist registry packages, version pinning, VM sandboxing
|
||||
- **Owner**: Security team
|
||||
|
||||
2. **Performance Degradation**
|
||||
- **Risk**: Loading many tools causes timeout/slow response
|
||||
- **Mitigation**: Parallel loading, caching, lazy loading, timeouts
|
||||
- **Owner**: Backend team
|
||||
|
||||
### Medium Risk
|
||||
|
||||
3. **Import Failures**
|
||||
- **Risk**: CDN down, package incompatible, missing dependencies
|
||||
- **Mitigation**: Fallback CDNs, error handling, stub tools
|
||||
- **Owner**: Frontend team
|
||||
|
||||
4. **AI SDK Limitations**
|
||||
- **Risk**: Can't dynamically update tools mid-stream
|
||||
- **Mitigation**: Two-turn pattern, pre-flight search
|
||||
- **Owner**: AI team
|
||||
|
||||
### Low Risk
|
||||
|
||||
5. **Search Quality**
|
||||
- **Risk**: BM25 doesn't return relevant tools
|
||||
- **Mitigation**: A/B test search algorithms, collect feedback
|
||||
- **Owner**: Search team
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 1. Tool Composition
|
||||
- Agent can combine multiple tools
|
||||
- Example: `searchTool` + `summarizeTool` = search and summarize
|
||||
|
||||
### 2. Tool Learning
|
||||
- Track which tools are used together
|
||||
- Recommend tool combinations
|
||||
- "Users who used X also used Y"
|
||||
|
||||
### 3. Custom Tool Registry
|
||||
- Users can add private tools
|
||||
- Organization-specific tool registry
|
||||
- Access control and permissions
|
||||
|
||||
### 4. Tool Marketplace
|
||||
- Developers promote their tools
|
||||
- Usage analytics and ratings
|
||||
- Paid/premium tools
|
||||
|
||||
### 5. Agent Templates
|
||||
- Pre-configured agents with tool sets
|
||||
- "Research Agent" has search + summarize tools
|
||||
- "Code Agent" has code generation tools
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
This dynamic tool loading system represents a paradigm shift in how AI agents discover and use tools. By making the TPMJS registry itself searchable, we enable infinite extensibility without the limitations of static bundling.
|
||||
|
||||
**Key Innovation:** Self-referential tool discovery - a tool that searches for tools.
|
||||
|
||||
**Next Steps:**
|
||||
1. Review this PRD with team
|
||||
2. Validate technical feasibility with ChatGPT/Claude
|
||||
3. Spike on BM25 search implementation
|
||||
4. Spike on dynamic ESM import
|
||||
5. Begin Phase 1 implementation
|
||||
|
||||
**Success Looks Like:**
|
||||
- User: "Search Wikipedia for quantum computing"
|
||||
- Agent: *searches registry, finds Wikipedia tool, loads it, uses it*
|
||||
- User: Gets Wikipedia results without any manual tool configuration
|
||||
|
||||
This is a novel approach that could define how AI agents discover and use tools. Let's build it. 🚀
|
||||
86
ENV_VAR_TRANSPORT_ISSUE.md
Normal file
86
ENV_VAR_TRANSPORT_ISSUE.md
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
# Environment Variables Not Sent to API - Frontend Transport Issue
|
||||
|
||||
## Problem
|
||||
|
||||
Environment variables saved in localStorage are NOT being sent to `/api/chat` endpoint.
|
||||
|
||||
**Evidence from logs:**
|
||||
```
|
||||
📥 Request body: {
|
||||
"conversationId": "7xur5hf1GDSOQMgFYF-l7",
|
||||
"env": {}, // ❌ EMPTY - should have FIRECRAWL_API_KEY
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
The issue is in `apps/playground/src/hooks/useChat.ts`:
|
||||
|
||||
```typescript
|
||||
export function useChat() {
|
||||
const [conversationId] = useState(() => nanoid());
|
||||
const envVars = useEnvVars(); // ❌ Empty on first render (useEffect loads async)
|
||||
|
||||
const envObject = envVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
const chat = useAISDKChat({
|
||||
transport: new DefaultChatTransport({ // ❌ Created ONCE with empty envObject
|
||||
api: '/api/chat',
|
||||
body: {
|
||||
conversationId,
|
||||
env: envObject, // ❌ This is {} on first render, never updates
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return { ...chat, conversationId };
|
||||
}
|
||||
```
|
||||
|
||||
**Why it fails:**
|
||||
|
||||
1. `useEnvVars()` loads from localStorage inside a `useEffect` (async)
|
||||
2. On first render, `envVars = []`, so `envObject = {}`
|
||||
3. `DefaultChatTransport` is created with `body: { env: {} }`
|
||||
4. Even when `envVars` updates later, the transport is already created and doesn't re-create
|
||||
|
||||
## Attempted Solutions That Don't Work
|
||||
|
||||
❌ **Just updating state** - Transport is created once and cached
|
||||
❌ **Using useEffect** - Transport is already created before effect runs
|
||||
|
||||
## What We Need
|
||||
|
||||
The `body` field in `DefaultChatTransport` needs to be **dynamic** and read the latest env vars on each request, not just once during component mount.
|
||||
|
||||
## Questions for ChatGPT
|
||||
|
||||
1. **How do we make `DefaultChatTransport` body dynamic?** Can we pass a function instead of an object?
|
||||
|
||||
2. **Does AI SDK have a way to update transport body between messages?** The env vars might change while the chat is open.
|
||||
|
||||
3. **Should we use a custom transport instead?** Can we implement our own transport that reads env vars fresh on each request?
|
||||
|
||||
4. **Alternative: Can we manually add env to each message?** Is there a way to inject extra data per-request instead of per-transport?
|
||||
|
||||
## Current Code Files
|
||||
|
||||
- `apps/playground/src/hooks/useChat.ts` - The broken hook
|
||||
- `apps/playground/src/components/sidebar/SettingsSidebar.tsx` - Where env vars are stored (works fine)
|
||||
- `apps/playground/src/app/api/chat/route.ts` - Server expects `body.env` but receives `{}`
|
||||
|
||||
## What We Know Works
|
||||
|
||||
✅ Saving env vars to localStorage - working
|
||||
✅ Reading env vars from localStorage - working
|
||||
✅ Server accepting and using env vars - working
|
||||
❌ **Sending env vars from client to server - BROKEN**
|
||||
|
||||
The ONLY broken part is the transport not sending the latest env object.
|
||||
424
HOW_TO_PUBLISH_A_TOOL.md
Normal file
424
HOW_TO_PUBLISH_A_TOOL.md
Normal file
|
|
@ -0,0 +1,424 @@
|
|||
# How to Publish a TPMJS Tool
|
||||
|
||||
This guide shows you how to create and publish an AI tool that will be automatically discovered and listed on tpmjs.com.
|
||||
|
||||
## Quick Start
|
||||
|
||||
1. Create a new NPM package
|
||||
2. Add `"tpmjs-tool"` to the `keywords` array in package.json
|
||||
3. Add a `tpmjs` field with your tool's metadata
|
||||
4. Publish to NPM
|
||||
5. Your tool will automatically appear on tpmjs.com within 15 minutes
|
||||
|
||||
## Step-by-Step Guide
|
||||
|
||||
### 1. Create Your NPM Package
|
||||
|
||||
Create a standard NPM package with your tool implementation:
|
||||
|
||||
```bash
|
||||
mkdir my-awesome-tool
|
||||
cd my-awesome-tool
|
||||
npm init -y
|
||||
```
|
||||
|
||||
### 2. Add the Required Keyword
|
||||
|
||||
In your `package.json`, add `"tpmjs-tool"` to the keywords array:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@yourname/my-awesome-tool",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["tpmjs-tool", "ai", "other-keywords"],
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Important:** The `"tpmjs-tool"` keyword is REQUIRED for automatic discovery!
|
||||
|
||||
### 3. Add TPMJS Metadata
|
||||
|
||||
Add a `tpmjs` field to your `package.json` with your tool's metadata. There are three tiers:
|
||||
|
||||
#### Tier 1: Minimal (Required Fields Only)
|
||||
|
||||
The bare minimum to get listed:
|
||||
|
||||
```json
|
||||
{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "A concise description of what your tool does"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Required fields:**
|
||||
- `category` - One of: `text-analysis`, `code-generation`, `data-processing`, `image-generation`, `audio-processing`, `search`, `integration`, `other`
|
||||
- `description` - Clear description of what the tool does (1-3 sentences)
|
||||
|
||||
#### Tier 2: Basic (Recommended)
|
||||
|
||||
Add parameter and return type information:
|
||||
|
||||
```json
|
||||
{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Analyzes sentiment in text and returns a score",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en', 'es')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object containing score (-1 to 1) and label (positive/negative/neutral)"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Tier 3: Rich (Full Documentation)
|
||||
|
||||
Complete metadata for maximum visibility:
|
||||
|
||||
```json
|
||||
{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
},
|
||||
{
|
||||
"name": "includeEmotions",
|
||||
"type": "boolean",
|
||||
"description": "Whether to include emotion breakdown",
|
||||
"required": false,
|
||||
"default": false
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score, label, and optional emotions array"
|
||||
},
|
||||
"env": [
|
||||
{
|
||||
"name": "SENTIMENT_API_KEY",
|
||||
"description": "API key for sentiment analysis service",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"aiAgent": {
|
||||
"useCase": "Use this tool when users need to analyze sentiment in text, detect emotions, or understand the tone of customer feedback, reviews, or social media posts.",
|
||||
"limitations": "Only supports English and Spanish. Maximum 10,000 characters per request.",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in user feedback",
|
||||
"Monitor social media sentiment"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Implement Your Tool
|
||||
|
||||
Write your tool's implementation. Here's the example from `@tpmjs/createblogpost`:
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
export interface BlogPostOptions {
|
||||
title: string;
|
||||
author: string;
|
||||
content: string;
|
||||
tags?: string[];
|
||||
format?: 'markdown' | 'mdx';
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
export interface BlogPost {
|
||||
frontmatter: {
|
||||
title: string;
|
||||
author: string;
|
||||
date: string;
|
||||
tags: string[];
|
||||
excerpt?: string;
|
||||
slug: string;
|
||||
wordCount: number;
|
||||
readingTime: number;
|
||||
};
|
||||
content: string;
|
||||
formattedOutput: string;
|
||||
}
|
||||
|
||||
export async function createBlogPost(options: BlogPostOptions): Promise<BlogPost> {
|
||||
// Your implementation here
|
||||
const { title, author, content, tags = [], format = 'markdown', excerpt } = options;
|
||||
|
||||
// Validate inputs
|
||||
if (!title || !author || !content) {
|
||||
throw new Error('Title, author, and content are required');
|
||||
}
|
||||
|
||||
// Process and return result
|
||||
return {
|
||||
frontmatter: { /* ... */ },
|
||||
content,
|
||||
formattedOutput: '...'
|
||||
};
|
||||
}
|
||||
|
||||
export default createBlogPost;
|
||||
```
|
||||
|
||||
### 5. Build and Publish
|
||||
|
||||
Build your package and publish to NPM:
|
||||
|
||||
```bash
|
||||
# Build your package
|
||||
npm run build
|
||||
|
||||
# Publish to NPM
|
||||
npm publish --access public
|
||||
```
|
||||
|
||||
### 6. Verification
|
||||
|
||||
Your tool will be automatically discovered through:
|
||||
|
||||
1. **Keyword Search** - Runs every 15 minutes, searches NPM for `"tpmjs-tool"`
|
||||
2. **Changes Feed** - Monitors NPM publishes in real-time (every 2 minutes)
|
||||
|
||||
After publishing, your tool should appear on https://tpmjs.com within 15 minutes!
|
||||
|
||||
You can verify by searching: https://tpmjs.com/api/tools?q=yourpackagename
|
||||
|
||||
## Real Example: @tpmjs/createblogpost
|
||||
|
||||
Here's the complete `package.json` from the published example:
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@tpmjs/createblogpost",
|
||||
"version": "0.2.0",
|
||||
"description": "A tool for creating structured blog posts with AI-generated content",
|
||||
"type": "module",
|
||||
"keywords": ["tpmjs-tool", "blog", "content", "ai", "writing"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/ajaxdavis/tpmjs.git",
|
||||
"directory": "packages/tools/createBlogPost"
|
||||
},
|
||||
"homepage": "https://tpmjs.com",
|
||||
"license": "MIT",
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Creates structured blog posts with customizable frontmatter, content sections, and SEO metadata. Supports multiple output formats including Markdown and MDX.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"description": "The title of the blog post",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "author",
|
||||
"type": "string",
|
||||
"description": "The author of the blog post",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "content",
|
||||
"type": "string",
|
||||
"description": "The main content of the blog post",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"type": "string[]",
|
||||
"description": "Array of tags for categorization",
|
||||
"required": false,
|
||||
"default": []
|
||||
},
|
||||
{
|
||||
"name": "format",
|
||||
"type": "'markdown' | 'mdx'",
|
||||
"description": "Output format for the blog post",
|
||||
"required": false,
|
||||
"default": "markdown"
|
||||
},
|
||||
{
|
||||
"name": "excerpt",
|
||||
"type": "string",
|
||||
"description": "Short excerpt or summary of the post",
|
||||
"required": false
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "BlogPost",
|
||||
"description": "A structured blog post object with frontmatter, content, and metadata including slug, wordCount, readingTime, and formattedOutput"
|
||||
},
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"aiAgent": {
|
||||
"useCase": "Use this tool when users need to generate blog posts, articles, or structured content with proper frontmatter and metadata. Ideal for content management systems, static site generators, and documentation sites.",
|
||||
"limitations": "Does not include AI content generation - you must provide the content. Only formats and structures existing content.",
|
||||
"examples": [
|
||||
"Create a blog post about TypeScript best practices",
|
||||
"Generate a tutorial post with code examples",
|
||||
"Format an article with SEO metadata"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Field Reference
|
||||
|
||||
### Required Fields (Tier 1 - Minimal)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `category` | string | Tool category (see categories below) |
|
||||
| `description` | string | Clear description (1-3 sentences) |
|
||||
|
||||
### Optional Fields (Tier 2 - Basic)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `parameters` | array | Array of parameter objects |
|
||||
| `returns` | object | Return type information |
|
||||
|
||||
### Optional Fields (Tier 3 - Rich)
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `env` | array | Required environment variables |
|
||||
| `frameworks` | array | Compatible frameworks |
|
||||
| `aiAgent` | object | AI agent integration info |
|
||||
|
||||
### Categories
|
||||
|
||||
Choose one of these for the `category` field:
|
||||
|
||||
- `text-analysis` - NLP, sentiment, summarization
|
||||
- `code-generation` - Code generation and transformation
|
||||
- `data-processing` - Data manipulation and transformation
|
||||
- `image-generation` - Image creation and editing
|
||||
- `audio-processing` - Audio/speech processing
|
||||
- `search` - Search and retrieval
|
||||
- `integration` - Third-party integrations
|
||||
- `other` - Anything else
|
||||
|
||||
### Environment Variables
|
||||
|
||||
If your tool requires environment variables:
|
||||
|
||||
```json
|
||||
"env": [
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"description": "API key for OpenAI services",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "API_ENDPOINT",
|
||||
"description": "Custom API endpoint URL",
|
||||
"required": false,
|
||||
"default": "https://api.example.com"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## Quality Score
|
||||
|
||||
Your tool gets a quality score based on:
|
||||
|
||||
- **Tier**: Rich (1.0) > Basic (0.5) > Minimal (0.25)
|
||||
- **Downloads**: Logarithmic scale based on monthly NPM downloads
|
||||
- **GitHub Stars**: Logarithmic scale based on repository stars
|
||||
|
||||
Higher scores = better visibility on tpmjs.com!
|
||||
|
||||
## Tips for Success
|
||||
|
||||
1. **Use descriptive names** - Make your package name clear and searchable
|
||||
2. **Complete metadata** - Tier 3 (Rich) tools get 4x the base score
|
||||
3. **Good documentation** - Add documentation URL to package.json homepage or repository fields
|
||||
4. **Active maintenance** - Regular updates boost download counts
|
||||
5. **AI-friendly descriptions** - Write the `aiAgent.useCase` field as guidance for AI agents
|
||||
|
||||
## Testing Locally
|
||||
|
||||
Before publishing, you can validate your `tpmjs` field using the validation schema:
|
||||
|
||||
```bash
|
||||
# In the tpmjs monorepo
|
||||
pnpm --filter=@tpmjs/types test
|
||||
```
|
||||
|
||||
Or manually check the structure matches the examples above.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Tool not appearing after 15 minutes?**
|
||||
- Check that you added `"tpmjs-tool"` to keywords
|
||||
- Verify your `tpmjs` field has required fields (category, description)
|
||||
- Check the NPM package is public: `npm view yourpackage`
|
||||
|
||||
**Tool showing as "minimal" tier?**
|
||||
- Add `parameters` and `returns` fields for Basic tier
|
||||
- Add all Rich tier fields for maximum visibility
|
||||
|
||||
**Want to force a sync?**
|
||||
You can manually trigger a sync (requires auth):
|
||||
```bash
|
||||
curl -X POST "https://tpmjs.com/api/sync/keyword" \
|
||||
-H "Authorization: Bearer YOUR_CRON_SECRET"
|
||||
```
|
||||
|
||||
## Support
|
||||
|
||||
Questions or issues?
|
||||
- File an issue: https://github.com/ajaxdavis/tpmjs/issues
|
||||
- Check the API: https://tpmjs.com/api/tools
|
||||
220
IMPLEMENTATION_STATUS.md
Normal file
220
IMPLEMENTATION_STATUS.md
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
# Dynamic Tool Loading - Implementation Status
|
||||
|
||||
## ✅ Completed
|
||||
|
||||
### 1. Search Tool Package (`@tpmjs/search-registry`)
|
||||
- ✅ Created package with AI SDK v6 JSON Schema format
|
||||
- ✅ Connects to search API endpoint
|
||||
- ✅ Returns tool metadata (packageName, exportName, version, importUrl)
|
||||
- ✅ Fixed schema format (was using Zod, now uses jsonSchema)
|
||||
- ✅ Location: `packages/tools/search-registry/`
|
||||
|
||||
### 2. Search API Endpoint (`/api/tools/search`)
|
||||
- ✅ Implemented simple text-based search (BM25 had dependency issues)
|
||||
- ✅ Searches by keywords in description, package name, keywords
|
||||
- ✅ Returns tools with import URLs for esm.sh
|
||||
- ✅ Location: `apps/web/src/app/api/tools/search/route.ts`
|
||||
|
||||
### 3. Pre-flight Tool Loading in Playground
|
||||
- ✅ Automatic search on every user message
|
||||
- ✅ Extracts user query from last message
|
||||
- ✅ Calls searchTpmjsTools automatically
|
||||
- ✅ Attempts to load discovered tools dynamically
|
||||
- ✅ Location: `apps/playground/src/app/api/chat/route.ts`
|
||||
|
||||
### 4. Dynamic Tool Loader (Railway Service Approach)
|
||||
- ✅ Updated to call Railway service instead of local imports
|
||||
- ✅ Calls `/load-and-describe` endpoint to get tool schema
|
||||
- ✅ Wraps tool with remote execution via `/execute-tool` endpoint
|
||||
- ✅ Caches tool wrappers locally
|
||||
- ✅ Location: `apps/playground/src/lib/dynamic-tool-loader.ts`
|
||||
|
||||
### 5. Documentation
|
||||
- ✅ DYNAMIC_IMPORT_ISSUE.md - Comprehensive problem analysis
|
||||
- ✅ RAILWAY_DYNAMIC_TOOL_LOADER.md - Railway implementation guide
|
||||
- ✅ This file - Implementation status
|
||||
|
||||
## 🚧 Pending (Railway Service Implementation)
|
||||
|
||||
### Railway Service Endpoints Needed
|
||||
|
||||
You need to add these two endpoints to your existing Railway service:
|
||||
|
||||
#### 1. `POST /load-and-describe`
|
||||
|
||||
**Purpose**: Load a tool from esm.sh and return its schema
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2",
|
||||
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"tool": {
|
||||
"exportName": "webSearchTool",
|
||||
"description": "Search the web using Firecrawl",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Reference**: See `RAILWAY_DYNAMIC_TOOL_LOADER.md` for full code
|
||||
|
||||
#### 2. `POST /execute-tool`
|
||||
|
||||
**Purpose**: Execute a dynamically loaded tool with parameters
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2",
|
||||
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2",
|
||||
"params": {
|
||||
"query": "latest AI news"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"output": { "results": [...] },
|
||||
"executionTimeMs": 1234
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation Reference**: See `RAILWAY_DYNAMIC_TOOL_LOADER.md` for full code
|
||||
|
||||
### Deployment Requirements
|
||||
|
||||
1. **Railway Service**:
|
||||
- Must run with `--experimental-network-imports` flag
|
||||
- Add to start command: `node --experimental-network-imports server.js`
|
||||
|
||||
2. **Environment Variables** (Vercel):
|
||||
```bash
|
||||
RAILWAY_SERVICE_URL=https://your-railway-service.up.railway.app
|
||||
# or reuse existing:
|
||||
SANDBOX_EXECUTOR_URL=https://your-railway-service.up.railway.app
|
||||
```
|
||||
|
||||
3. **Local Testing** (Railway service on port 3001):
|
||||
```bash
|
||||
RAILWAY_SERVICE_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## 🎯 Testing Checklist
|
||||
|
||||
Once Railway endpoints are deployed:
|
||||
|
||||
- [ ] Test `/load-and-describe` endpoint directly with curl
|
||||
- [ ] Test `/execute-tool` endpoint directly with curl
|
||||
- [ ] Test full flow in playground:
|
||||
- [ ] Ask: "search the web for latest AI news"
|
||||
- [ ] Verify pre-flight search finds tools
|
||||
- [ ] Verify tools load via Railway
|
||||
- [ ] Verify tool execution works
|
||||
- [ ] Check console logs for debugging info
|
||||
|
||||
## 📊 Current Flow
|
||||
|
||||
```
|
||||
User: "search the web for latest AI news"
|
||||
↓
|
||||
Chat API extracts query
|
||||
↓
|
||||
Automatically calls searchTpmjsTools
|
||||
↓
|
||||
Search API returns matching tools
|
||||
(packageName, exportName, version)
|
||||
↓
|
||||
loadToolsBatch() called for each tool
|
||||
↓
|
||||
For each tool:
|
||||
1. Check local cache
|
||||
2. If not cached:
|
||||
→ POST to Railway: /load-and-describe
|
||||
← Get back: description + inputSchema
|
||||
3. Create wrapper tool with:
|
||||
- description from Railway
|
||||
- inputSchema from Railway
|
||||
- execute() → calls Railway /execute-tool
|
||||
4. Cache wrapper locally
|
||||
↓
|
||||
All tools available to agent
|
||||
↓
|
||||
Agent calls tool (wrapper)
|
||||
↓
|
||||
Wrapper → POST to Railway: /execute-tool
|
||||
↓
|
||||
Railway imports from esm.sh and executes
|
||||
↓
|
||||
Result returned to agent
|
||||
↓
|
||||
Agent uses result to answer user
|
||||
```
|
||||
|
||||
## 🔍 Debugging
|
||||
|
||||
Check console logs for:
|
||||
- `📦 Loading from Railway` - Tool loading initiated
|
||||
- `✅ Tool loaded from Railway` - Tool schema received
|
||||
- `🚀 Executing ... remotely` - Tool execution initiated
|
||||
- `✅ Tool executed successfully` - Tool execution complete
|
||||
- `❌ Railway service error` - Connection failed
|
||||
- `❌ Failed to load tool` - Import failed
|
||||
|
||||
## 📁 Files Modified
|
||||
|
||||
1. `packages/tools/search-registry/src/index.ts` - Search tool
|
||||
2. `packages/tools/search-registry/package.json` - AI SDK version
|
||||
3. `apps/web/src/app/api/tools/search/route.ts` - Search endpoint
|
||||
4. `apps/playground/src/app/api/chat/route.ts` - Pre-flight search
|
||||
5. `apps/playground/src/lib/dynamic-tool-loader.ts` - Railway integration
|
||||
6. `apps/playground/next.config.ts` - Added urlImports (unused)
|
||||
7. `apps/playground/src/lib/tool-loader.ts` - Removed firecrawl
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Deploy Railway endpoints** using code from `RAILWAY_DYNAMIC_TOOL_LOADER.md`
|
||||
2. **Set environment variables** in Vercel
|
||||
3. **Test locally** with Railway service running on localhost:3001
|
||||
4. **Deploy to production** and test with real tools
|
||||
5. **Monitor logs** for any issues
|
||||
|
||||
## 💡 Key Insights
|
||||
|
||||
- **Next.js Limitation**: Cannot do dynamic HTTP imports due to bundler
|
||||
- **Railway Solution**: Plain Node.js with `--experimental-network-imports`
|
||||
- **Caching Strategy**: Two-level cache (local wrapper + Railway module)
|
||||
- **Execution Model**: Remote execution in Railway, not Next.js
|
||||
- **Security**: Tools execute in Railway sandbox, not Vercel
|
||||
- **Performance**: First load ~1-2s (import), cached loads <10ms
|
||||
|
||||
## 📚 Related Documentation
|
||||
|
||||
- `DYNAMIC_IMPORT_ISSUE.md` - Problem analysis and ChatGPT response
|
||||
- `RAILWAY_DYNAMIC_TOOL_LOADER.md` - Full Railway implementation guide
|
||||
- Plan file: `~/.claude/plans/jiggly-inventing-dragon.md`
|
||||
|
||||
---
|
||||
|
||||
**Status**: Ready for Railway deployment
|
||||
**Blocker**: Railway `/load-and-describe` and `/execute-tool` endpoints need implementation
|
||||
**ETA**: 30-60 minutes to implement Railway endpoints + test
|
||||
293
MANUAL_TOOLS.md
Normal file
293
MANUAL_TOOLS.md
Normal file
|
|
@ -0,0 +1,293 @@
|
|||
# Manual Tools Registry
|
||||
|
||||
## Overview
|
||||
|
||||
This system allows TPMJS to include high-quality tools that don't follow the standard `tpmjs` field specification in their package.json. These tools are manually curated and synced to the database.
|
||||
|
||||
## Why Manual Tools?
|
||||
|
||||
Some excellent tools (like Vercel's code execution, Exa search, Firecrawl, etc.) don't include the `tpmjs` field in their package.json. Rather than wait for these package maintainers to adopt the spec, we manually curate metadata for these tools.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Files
|
||||
|
||||
1. **`manual-tools.ts`** - The registry of manually curated tools
|
||||
2. **`sync-manual-tools.ts`** - Script to sync manual tools to database
|
||||
3. **`MANUAL_TOOLS.md`** - This documentation
|
||||
|
||||
### How It Works
|
||||
|
||||
1. **Manual Tool Registry** (`manual-tools.ts`)
|
||||
- Exports a `manualTools` array with metadata for each tool
|
||||
- Each entry includes npm package name, export name, category, description, parameters, etc.
|
||||
- Follows the same schema as the standard `tpmjs` field
|
||||
|
||||
2. **Sync Script** (`sync-manual-tools.ts`)
|
||||
- Fetches latest package metadata from npm
|
||||
- Combines npm metadata with manual metadata
|
||||
- Upserts Package + Tool records to database
|
||||
- Marks tools with `discoveryMethod: 'manual'`
|
||||
|
||||
3. **Database Storage**
|
||||
- Manual tools stored in same `packages` and `tools` tables as auto-discovered tools
|
||||
- No special handling needed in API or frontend
|
||||
- `discoveryMethod: 'manual'` field distinguishes them
|
||||
|
||||
## Adding a New Manual Tool
|
||||
|
||||
### Step 1: Add to Registry
|
||||
|
||||
Edit `manual-tools.ts` and add a new entry:
|
||||
|
||||
```typescript
|
||||
{
|
||||
npmPackageName: 'example-package',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'exampleTool',
|
||||
description: 'A clear, concise description of what this tool does',
|
||||
|
||||
// Optional but recommended for 'rich' tier
|
||||
parameters: [
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'The search query',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
|
||||
returns: {
|
||||
type: 'array',
|
||||
description: 'Array of search results',
|
||||
},
|
||||
|
||||
aiAgent: {
|
||||
useCase: 'Use when you need to search for X',
|
||||
limitations: 'Rate limits apply',
|
||||
examples: [
|
||||
'Search for current news',
|
||||
'Find specific information',
|
||||
],
|
||||
},
|
||||
|
||||
// Environment variables
|
||||
env: [
|
||||
{
|
||||
name: 'EXAMPLE_API_KEY',
|
||||
description: 'API key for the service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
|
||||
// Additional metadata
|
||||
tags: ['search', 'web'],
|
||||
docsUrl: 'https://example.com/docs',
|
||||
apiKeyUrl: 'https://example.com/api-keys',
|
||||
websiteUrl: 'https://example.com',
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: Run Sync Script
|
||||
|
||||
```bash
|
||||
# From repository root
|
||||
pnpm tsx sync-manual-tools.ts
|
||||
```
|
||||
|
||||
This will:
|
||||
1. Fetch the package from npm
|
||||
2. Create/update Package record
|
||||
3. Create/update Tool record(s)
|
||||
4. Set `discoveryMethod: 'manual'`
|
||||
|
||||
### Step 3: Verify
|
||||
|
||||
Check that the tool appears on tpmjs.com:
|
||||
|
||||
```bash
|
||||
# Start dev server
|
||||
pnpm dev --filter=@tpmjs/web
|
||||
|
||||
# Visit http://localhost:3000/tool/tool-search
|
||||
# Search for your package name
|
||||
```
|
||||
|
||||
## Multi-Tool Packages
|
||||
|
||||
If a package exports multiple tools, add multiple entries with the same `npmPackageName` but different `exportName`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
npmPackageName: 'firecrawl-aisdk',
|
||||
exportName: 'scrapeTool',
|
||||
description: 'Scrape websites...',
|
||||
// ...
|
||||
},
|
||||
{
|
||||
npmPackageName: 'firecrawl-aisdk',
|
||||
exportName: 'searchTool',
|
||||
description: 'Search the web...',
|
||||
// ...
|
||||
},
|
||||
{
|
||||
npmPackageName: 'firecrawl-aisdk',
|
||||
exportName: 'crawlTool',
|
||||
description: 'Crawl entire websites...',
|
||||
// ...
|
||||
},
|
||||
```
|
||||
|
||||
## Tier Calculation
|
||||
|
||||
Tools are automatically assigned a tier:
|
||||
|
||||
- **Rich tier**: Has `parameters` OR `returns` OR `aiAgent` fields
|
||||
- **Minimal tier**: Only has basic metadata
|
||||
|
||||
Rich tier tools get 4x quality score multiplier, so add detailed metadata when possible.
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating Manual Tools
|
||||
|
||||
1. Edit the entry in `manual-tools.ts`
|
||||
2. Run `pnpm tsx sync-manual-tools.ts`
|
||||
3. The upsert will update existing records
|
||||
|
||||
### Removing Manual Tools
|
||||
|
||||
1. Remove the entry from `manual-tools.ts`
|
||||
2. Manually delete from database OR wait for metrics sync to mark as stale
|
||||
|
||||
### Version Updates
|
||||
|
||||
The sync script automatically fetches the latest version from npm unless you specify `npmVersion` in the manual tool entry.
|
||||
|
||||
## Production Deployment
|
||||
|
||||
### Option 1: Manual Sync on Deploy
|
||||
|
||||
Add to your deployment workflow:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/deploy.yml
|
||||
- name: Sync manual tools
|
||||
run: pnpm tsx sync-manual-tools.ts
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
```
|
||||
|
||||
### Option 2: Scheduled Sync
|
||||
|
||||
Create a cron job or GitHub Action to sync periodically:
|
||||
|
||||
```yaml
|
||||
# .github/workflows/sync-manual.yml
|
||||
name: Sync Manual Tools
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0' # Weekly on Sunday
|
||||
workflow_dispatch: # Manual trigger
|
||||
|
||||
jobs:
|
||||
sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: pnpm/action-setup@v2
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '20'
|
||||
cache: 'pnpm'
|
||||
- run: pnpm install
|
||||
- run: pnpm tsx sync-manual-tools.ts
|
||||
env:
|
||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
||||
```
|
||||
|
||||
### Option 3: API Endpoint
|
||||
|
||||
Create a sync endpoint (similar to keyword/changes sync):
|
||||
|
||||
```typescript
|
||||
// apps/web/src/app/api/sync/manual/route.ts
|
||||
import { manualTools } from '@/manual-tools';
|
||||
// ... sync logic
|
||||
|
||||
export async function POST(request: Request) {
|
||||
// Verify CRON_SECRET
|
||||
// Run manual sync
|
||||
// Return results
|
||||
}
|
||||
```
|
||||
|
||||
## Currently Included Manual Tools
|
||||
|
||||
As of this documentation:
|
||||
|
||||
- **ai-sdk-tool-code-execution** - Vercel Sandbox code execution
|
||||
- **@exalabs/ai-sdk** - Exa web search
|
||||
- **@parallel-web/ai-sdk-tools** - Parallel search and extraction (2 tools)
|
||||
- **ctx-zip** - MCP + Vercel Sandbox integration
|
||||
- **@perplexity-ai/ai-sdk** - Perplexity search
|
||||
- **@tavily/ai-sdk** - Tavily web research
|
||||
- **firecrawl-aisdk** - Firecrawl scraping, search, crawling (3 tools)
|
||||
- **bedrock-agentcore** - AWS Bedrock code interpreter and browser (2 tools)
|
||||
- **@superagent-ai/ai-sdk** - Superagent security tools (3 tools)
|
||||
- **@valyu/ai-sdk** - Valyu domain-specific search tools (8 tools)
|
||||
|
||||
**Total: 24 manually curated tools across 10 packages**
|
||||
|
||||
## FAQ
|
||||
|
||||
### Why not just ask package maintainers to add the tpmjs field?
|
||||
|
||||
We should! But:
|
||||
1. Some packages are from large companies (Vercel, AWS, etc.) with slow adoption cycles
|
||||
2. We want these tools available on TPMJS now
|
||||
3. Manual curation lets us provide better metadata than package authors might
|
||||
|
||||
### Will manual tools be replaced by auto-discovered ones?
|
||||
|
||||
Yes! If a package adds a proper `tpmjs` field, the auto-discovery sync will update it with `discoveryMethod: 'keyword'` or `'changes-feed'`. Manual entries can then be removed from `manual-tools.ts`.
|
||||
|
||||
### Can I mix manual and auto-discovered tools from the same package?
|
||||
|
||||
Yes. If a package has some tools in the `tpmjs` field but is missing others, you can manually add the missing ones. The sync scripts will coexist peacefully.
|
||||
|
||||
### How do I know if a tool is manually curated?
|
||||
|
||||
Check the `discoveryMethod` field in the database:
|
||||
- `'manual'` = Manually curated
|
||||
- `'keyword'` = Auto-discovered via keyword search
|
||||
- `'changes-feed'` = Auto-discovered via npm changes feed
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Complete Metadata** - Provide as much metadata as possible for rich tier
|
||||
2. **Accurate Descriptions** - Tool descriptions should be clear and specific
|
||||
3. **AI-Friendly** - Write `aiAgent.useCase` as guidance for LLMs
|
||||
4. **Keep Updated** - Periodically check if packages have added native `tpmjs` support
|
||||
5. **Link to Docs** - Always include `docsUrl` when available
|
||||
6. **API Key URLs** - Include `apiKeyUrl` for tools requiring authentication
|
||||
|
||||
## Contributing
|
||||
|
||||
To contribute new manual tools:
|
||||
|
||||
1. Fork the repository
|
||||
2. Add your tool to `manual-tools.ts`
|
||||
3. Test with `pnpm tsx sync-manual-tools.ts`
|
||||
4. Open a pull request with:
|
||||
- Why this tool should be included
|
||||
- Link to the npm package
|
||||
- Screenshot of it working in TPMJS
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [HOW_TO_PUBLISH_A_TOOL.md](./HOW_TO_PUBLISH_A_TOOL.md) - Standard tpmjs field spec
|
||||
- [CLAUDE.md](./CLAUDE.md) - General project documentation
|
||||
- [packages/types/src/tpmjs.ts](./packages/types/src/tpmjs.ts) - TypeScript schema definitions
|
||||
522
OPENAI_SCHEMA_ERROR.md
Normal file
522
OPENAI_SCHEMA_ERROR.md
Normal file
|
|
@ -0,0 +1,522 @@
|
|||
# OpenAI Schema Validation Error - AI SDK v6
|
||||
|
||||
## ✅ RESOLVED
|
||||
|
||||
**Solution:** Use `tool()` and `jsonSchema()` from AI SDK instead of Zod for tool definitions.
|
||||
|
||||
## Error Message
|
||||
|
||||
```
|
||||
Error [AI_APICallError]: Invalid schema for function 'helloWorld': schema must be a JSON Schema of 'type: "object"', got 'type: "None"'.
|
||||
```
|
||||
|
||||
## Context
|
||||
|
||||
Building a Next.js playground app to test AI SDK v6 tool execution with OpenAI's GPT-4o-mini model. The error occurs when OpenAI validates the tool schema sent in the API request.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Zod 4.0.0 generates JSON Schema with `allOf` + `$ref` at the root level instead of a direct `type: "object"`. OpenAI's API requires a JSON Schema with `type: "object"` at the root, so it rejects Zod 4 schemas with `type: "None"` error.
|
||||
|
||||
## Environment
|
||||
|
||||
- **AI SDK Version**: `ai@6.0.0-beta.124`
|
||||
- **OpenAI Provider**: `@ai-sdk/openai@3.0.0-beta.74`
|
||||
- **OpenAI Library**: `openai@^6.9.1`
|
||||
- **Zod Version**: `zod@^4.0.0`
|
||||
- **Next.js Version**: `next@^16.0.4`
|
||||
- **Node.js**: Latest
|
||||
- **TypeScript**: Strict mode enabled
|
||||
|
||||
## Tool Definition
|
||||
|
||||
Located at: `packages/tools/hello/src/index.ts`
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Hello World Tool
|
||||
* Returns a simple "Hello, World!" greeting
|
||||
*
|
||||
* This is a proper AI SDK v6 tool that can be used with streamText()
|
||||
*/
|
||||
export const helloWorldTool = {
|
||||
description: 'Returns a simple "Hello, World!" greeting message',
|
||||
parameters: z.object({
|
||||
// OpenAI requires at least one optional parameter, can't be completely empty
|
||||
includeTimestamp: z.boolean().optional().describe('Whether to include a timestamp in the response'),
|
||||
}),
|
||||
execute: async ({ includeTimestamp = true }: { includeTimestamp?: boolean }) => {
|
||||
const response: any = {
|
||||
message: 'Hello, World!',
|
||||
};
|
||||
|
||||
if (includeTimestamp) {
|
||||
response.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Hello Name Tool
|
||||
* Returns a personalized greeting with the provided name
|
||||
*
|
||||
* This is a proper AI SDK v6 tool that can be used with streamText()
|
||||
*/
|
||||
export const helloNameTool = {
|
||||
description: 'Returns a personalized greeting with the provided name',
|
||||
parameters: z.object({
|
||||
name: z.string().describe('The name of the person to greet'),
|
||||
}),
|
||||
execute: async ({ name }: { name: string }) => {
|
||||
return {
|
||||
message: `Hello, ${name}!`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Tool Loading
|
||||
|
||||
Located at: `apps/playground/src/lib/tool-loader.ts`
|
||||
|
||||
```typescript
|
||||
// Static imports for tools (required for Next.js/webpack)
|
||||
import { helloWorldTool, helloNameTool } from '@tpmjs/hello';
|
||||
import { scrapeTool, crawlTool, searchTool } from 'firecrawl-aisdk';
|
||||
|
||||
/**
|
||||
* Load a specific TPMJS tool by package name
|
||||
*/
|
||||
export async function loadTpmjsTool(packageName: string): Promise<any> {
|
||||
try {
|
||||
// Map package names to their tool functions
|
||||
switch (packageName) {
|
||||
case '@tpmjs/hello':
|
||||
// Hello has multiple tools, return all of them
|
||||
return {
|
||||
helloWorld: helloWorldTool,
|
||||
helloName: helloNameTool,
|
||||
};
|
||||
|
||||
case 'firecrawl-aisdk':
|
||||
// Firecrawl has multiple tools, return all of them
|
||||
return {
|
||||
scrapeTool,
|
||||
crawlTool,
|
||||
searchTool,
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool package: ${packageName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw new Error(`Failed to load tool from package ${packageName}: ${error.message}`);
|
||||
}
|
||||
throw new Error(`Failed to load tool from package ${packageName}: Unknown error`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all installed TPMJS tools
|
||||
*/
|
||||
export async function loadAllTools(): Promise<Record<string, any>> {
|
||||
const installedTools = ['@tpmjs/hello', 'firecrawl-aisdk'];
|
||||
|
||||
const tools: Record<string, any> = {};
|
||||
|
||||
for (const packageName of installedTools) {
|
||||
try {
|
||||
const tool = await loadTpmjsTool(packageName);
|
||||
|
||||
// If the tool returns an object with multiple tools (like firecrawl), spread them
|
||||
if (tool && typeof tool === 'object' && !tool.description) {
|
||||
Object.assign(tools, tool);
|
||||
} else {
|
||||
// Single tool - use a cleaned name (remove hyphens, camelCase)
|
||||
const toolName = packageName.replace(/-([a-z])/g, (_match, letter) => letter.toUpperCase()).replace(/-/g, '');
|
||||
tools[toolName] = tool;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load tool ${packageName}:`, error);
|
||||
// Continue loading other tools even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
```
|
||||
|
||||
## API Route
|
||||
|
||||
Located at: `apps/playground/src/app/api/chat/route.ts`
|
||||
|
||||
```typescript
|
||||
import { loadAllTools } from '~/lib/tool-loader';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { streamText } from 'ai';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { messages } = body;
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return new Response(JSON.stringify({ error: 'Invalid request: messages array required' }), {
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
// Load all available tools
|
||||
const tools = await loadAllTools();
|
||||
|
||||
console.log('Loaded tools:', Object.keys(tools));
|
||||
|
||||
// Create system message
|
||||
const systemMessage = {
|
||||
role: 'system' as const,
|
||||
content: `You are a helpful AI assistant that can use TPMJS tools to help users.
|
||||
|
||||
Available tools:
|
||||
${Object.entries(tools)
|
||||
.map(([name, tool]) => `- ${name}: ${tool.description}`)
|
||||
.join('\n')}
|
||||
|
||||
Call tools as needed to answer user questions. Execute tools directly.`,
|
||||
};
|
||||
|
||||
// Stream the AI response with tools
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
messages: [systemMessage, ...messages],
|
||||
tools,
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
return result.toTextStreamResponse();
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Package Configuration
|
||||
|
||||
Located at: `packages/tools/hello/package.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@tpmjs/hello",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"description": "Example TPMJS tools - Hello World and Hello Name",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"dev": "tsc --watch",
|
||||
"clean": "rm -rf dist",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"keywords": [
|
||||
"tpmjs-tool",
|
||||
"ai-sdk",
|
||||
"hello",
|
||||
"example"
|
||||
],
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"description": "Simple greeting tools - Hello World and personalized Hello Name greetings"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai": "6.0.0-beta.124",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tpmjs/tsconfig": "workspace:*",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## TypeScript Configuration
|
||||
|
||||
Located at: `packages/tools/hello/tsconfig.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2020"],
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"moduleResolution": "node",
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
## Compiled Output
|
||||
|
||||
Located at: `packages/tools/hello/dist/index.js`
|
||||
|
||||
```javascript
|
||||
"use strict";
|
||||
Object.defineProperty(exports, "__esModule", { value: true });
|
||||
exports.helloNameTool = exports.helloWorldTool = void 0;
|
||||
const zod_1 = require("zod");
|
||||
/**
|
||||
* Hello World Tool
|
||||
* Returns a simple "Hello, World!" greeting
|
||||
*
|
||||
* This is a proper AI SDK v6 tool that can be used with streamText()
|
||||
*/
|
||||
exports.helloWorldTool = {
|
||||
description: 'Returns a simple "Hello, World!" greeting message',
|
||||
parameters: zod_1.z.object({
|
||||
// OpenAI requires at least one optional parameter, can't be completely empty
|
||||
includeTimestamp: zod_1.z.boolean().optional().describe('Whether to include a timestamp in the response'),
|
||||
}),
|
||||
execute: async ({ includeTimestamp = true }) => {
|
||||
const response = {
|
||||
message: 'Hello, World!',
|
||||
};
|
||||
if (includeTimestamp) {
|
||||
response.timestamp = new Date().toISOString();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
};
|
||||
/**
|
||||
* Hello Name Tool
|
||||
* Returns a personalized greeting with the provided name
|
||||
*
|
||||
* This is a proper AI SDK v6 tool that can be used with streamTime()
|
||||
*/
|
||||
exports.helloNameTool = {
|
||||
description: 'Returns a personalized greeting with the provided name',
|
||||
parameters: zod_1.z.object({
|
||||
name: zod_1.z.string().describe('The name of the person to greet'),
|
||||
}),
|
||||
execute: async ({ name }) => {
|
||||
return {
|
||||
message: `Hello, ${name}!`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## Full Error Response from OpenAI
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"message": "Invalid schema for function 'helloWorld': schema must be a JSON Schema of 'type: \"object\"', got 'type: \"None\"'.",
|
||||
"type": "invalid_request_error",
|
||||
"param": "tools[0].parameters",
|
||||
"code": "invalid_function_parameters"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
API endpoint: `https://api.openai.com/v1/responses`
|
||||
Status code: 400
|
||||
|
||||
## Problem Analysis
|
||||
|
||||
1. **OpenAI expects JSON Schema format** - The `tools[0].parameters` field must be a valid JSON Schema object with `type: "object"`
|
||||
|
||||
2. **AI SDK v6 should convert Zod to JSON Schema** - The AI SDK is supposed to automatically convert Zod schemas to JSON Schema when sending to OpenAI, but it's producing `type: "None"` instead
|
||||
|
||||
3. **Potential causes**:
|
||||
- Zod 4.0.0 compatibility issue with AI SDK v6 beta
|
||||
- AI SDK not properly converting the Zod schema
|
||||
- Issue with how the tool object is structured
|
||||
- Problem with how tools are passed to `streamText()`
|
||||
|
||||
4. **Already tried**:
|
||||
- Added at least one parameter (even optional) to helloWorldTool
|
||||
- Used proper Zod schema with `.describe()` for descriptions
|
||||
- Followed AI SDK v6 tool definition format exactly
|
||||
- Built the package successfully (dist folder exists)
|
||||
|
||||
## AI SDK v6 Tool Format Reference
|
||||
|
||||
According to AI SDK v6 documentation, a tool should be defined as:
|
||||
|
||||
```typescript
|
||||
{
|
||||
description: string,
|
||||
parameters: ZodSchema,
|
||||
execute: async (args) => Promise<any>
|
||||
}
|
||||
```
|
||||
|
||||
This matches our implementation exactly.
|
||||
|
||||
## Questions for ChatGPT
|
||||
|
||||
1. Is there a known compatibility issue between AI SDK v6 Beta (6.0.0-beta.124) and Zod 4.0.0?
|
||||
|
||||
2. Does the AI SDK v6 require a specific tool registration format when passing to `streamText()`?
|
||||
|
||||
3. Should tools be wrapped in a different structure (e.g., using `tool()` helper function)?
|
||||
|
||||
4. Is there a way to manually convert Zod schema to JSON Schema that OpenAI accepts?
|
||||
|
||||
5. Are there any known issues with using workspace packages (`@tpmjs/hello`) in Next.js API routes with dynamic imports?
|
||||
|
||||
6. Should we downgrade to Zod 3.x instead of Zod 4.0.0?
|
||||
|
||||
7. Is there a debug mode to see what JSON Schema is being sent to OpenAI?
|
||||
|
||||
## Additional Context
|
||||
|
||||
- The `firecrawl-aisdk` package works correctly with the same setup
|
||||
- Build process completes successfully with no TypeScript errors
|
||||
- The tool is being loaded and passed to `streamText()` correctly
|
||||
- Error only occurs when OpenAI validates the tool schema
|
||||
- This is a monorepo using pnpm workspaces and Turborepo
|
||||
|
||||
## Related Files
|
||||
|
||||
- Tool definition: `packages/tools/hello/src/index.ts`
|
||||
- Tool loader: `apps/playground/src/lib/tool-loader.ts`
|
||||
- API route: `apps/playground/src/app/api/chat/route.ts`
|
||||
- Package config: `packages/tools/hello/package.json`
|
||||
- Compiled output: `packages/tools/hello/dist/index.js`
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
Tools should be automatically converted from Zod schema to JSON Schema by AI SDK v6 and accepted by OpenAI's API.
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
OpenAI rejects the tool schema with error: `got 'type: "None"'` instead of a valid JSON Schema object.
|
||||
|
||||
---
|
||||
|
||||
## ✅ SOLUTION IMPLEMENTED
|
||||
|
||||
### What We Changed
|
||||
|
||||
Instead of using Zod schemas with `parameters`, we now use AI SDK's `tool()` helper with `jsonSchema()` for the input schema. This bypasses Zod's JSON Schema conversion entirely.
|
||||
|
||||
### Before (Broken with Zod 4)
|
||||
|
||||
```typescript
|
||||
import { z } from 'zod';
|
||||
|
||||
export const helloWorldTool = {
|
||||
description: 'Returns a simple "Hello, World!" greeting message',
|
||||
parameters: z.object({
|
||||
includeTimestamp: z.boolean().optional().describe('Whether to include a timestamp'),
|
||||
}),
|
||||
execute: async ({ includeTimestamp = true }) => {
|
||||
// ...
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
### After (Working with jsonSchema)
|
||||
|
||||
```typescript
|
||||
import { jsonSchema, tool } from 'ai';
|
||||
|
||||
type HelloWorldInput = {
|
||||
includeTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export const helloWorldTool = tool({
|
||||
description: 'Returns a simple "Hello, World!" greeting message',
|
||||
inputSchema: jsonSchema<HelloWorldInput>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeTimestamp: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a timestamp in the response',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
async execute({ includeTimestamp = true }) {
|
||||
const response: any = {
|
||||
message: 'Hello, World!',
|
||||
};
|
||||
if (includeTimestamp) {
|
||||
response.timestamp = new Date().toISOString();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Key Changes
|
||||
|
||||
1. **Import from `ai`**: Added `jsonSchema` and `tool` imports
|
||||
2. **Define TypeScript types**: Created `HelloWorldInput` type for type safety
|
||||
3. **Use `tool()` wrapper**: Wraps the entire tool definition
|
||||
4. **Use `jsonSchema()` for schema**: Provides explicit JSON Schema with `type: "object"` at root
|
||||
5. **Removed Zod dependency**: No longer need `zod` in package.json
|
||||
|
||||
### Benefits
|
||||
|
||||
- ✅ Works with OpenAI's strict schema validation
|
||||
- ✅ Explicit control over JSON Schema structure
|
||||
- ✅ Full TypeScript type safety with generic types
|
||||
- ✅ No dependency on Zod (one less package to maintain)
|
||||
- ✅ Follows AI SDK v6 best practices
|
||||
- ✅ Guaranteed `type: "object"` at root level
|
||||
|
||||
### Updated Package Dependencies
|
||||
|
||||
```json
|
||||
{
|
||||
"dependencies": {
|
||||
"ai": "6.0.0-beta.124"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Zod is no longer needed in tool packages that use `jsonSchema()`.
|
||||
|
||||
### References
|
||||
|
||||
- [AI SDK Core: tool](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool)
|
||||
- [AI SDK Core: jsonSchema](https://ai-sdk.dev/docs/reference/ai-sdk-core/json-schema)
|
||||
- [GitHub Issue: Zod 4 JSON Schema compatibility](https://github.com/vercel/ai/issues/10240)
|
||||
98
RAILWAY_DEPLOYMENT_NOTE.md
Normal file
98
RAILWAY_DEPLOYMENT_NOTE.md
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
# Railway Executor - Deployment Status
|
||||
|
||||
## Issue Discovered
|
||||
|
||||
Node.js does not support HTTP(S) imports by default, even with `--experimental-network-imports` flag (that flag doesn't exist in current Node versions).
|
||||
|
||||
## Solutions Considered
|
||||
|
||||
1. **Custom ESM Loader** - Complex, requires Node.js 18.19+ with `--loader` flag
|
||||
2. **fetch + eval** - Security concerns, doesn't handle ES modules properly
|
||||
3. **Bundler approach** - Would defeat the purpose of dynamic imports
|
||||
4. **Deno** - Supports HTTP imports natively, but different ecosystem
|
||||
|
||||
## Recommended Solution
|
||||
|
||||
Since the core issue is that we need truly dynamic runtime imports from HTTP URLs, and Node.js doesn't support this, we have **two viable paths**:
|
||||
|
||||
### Option A: Use Deno on Railway (RECOMMENDED)
|
||||
|
||||
Deno supports HTTP imports natively:
|
||||
|
||||
```typescript
|
||||
// server.ts (Deno)
|
||||
import { serve } from "https://deno.land/std@0.208.0/http/server.ts";
|
||||
|
||||
const moduleCache = new Map();
|
||||
|
||||
async function loadTool(url: string, exportName: string) {
|
||||
if (moduleCache.has(url)) {
|
||||
return moduleCache.get(url);
|
||||
}
|
||||
|
||||
// Deno supports this natively!
|
||||
const module = await import(url);
|
||||
const tool = module[exportName];
|
||||
moduleCache.set(url, tool);
|
||||
return tool;
|
||||
}
|
||||
|
||||
serve(async (req) => {
|
||||
// ... handle requests
|
||||
}, { port: 3002 });
|
||||
```
|
||||
|
||||
**Deploy to Railway:**
|
||||
```bash
|
||||
# In Railway dashboard:
|
||||
# - Set Start Command: deno run --allow-net --allow-env server.ts
|
||||
# - Or use railway.json with deno runtime
|
||||
```
|
||||
|
||||
### Option B: Pre-build Bundle Approach
|
||||
|
||||
Instead of truly dynamic imports, pre-fetch and cache tools:
|
||||
|
||||
1. Playground searches for tools
|
||||
2. Backend fetches tool code once and caches it
|
||||
3. Use `vm2` or similar to execute in sandbox
|
||||
4. Not truly "dynamic" but works with Node.js
|
||||
|
||||
## Current Status
|
||||
|
||||
The Railway executor service is **created** but **not deployed** because Node.js doesn't support the required HTTP imports.
|
||||
|
||||
**Files created:**
|
||||
- `apps/railway-executor/package.json`
|
||||
- `apps/railway-executor/server.js` (incomplete - needs Deno or vm2 approach)
|
||||
- `apps/railway-executor/README.md`
|
||||
|
||||
## Next Steps
|
||||
|
||||
**If using Deno (recommended):**
|
||||
1. Rewrite server.js as server.ts for Deno
|
||||
2. Deploy to Railway with Deno runtime
|
||||
3. Test HTTP imports work
|
||||
4. Update playground to use Railway URL
|
||||
|
||||
**If sticking with Node.js:**
|
||||
1. Install `vm2` package for sandboxed execution
|
||||
2. Implement fetch + vm2 approach
|
||||
3. Deploy to Railway
|
||||
4. Accept limitations (less dynamic, more complex)
|
||||
|
||||
## Alternative: Skip Railway, Use Different Architecture
|
||||
|
||||
Since the original issue is Next.js bundler limitations, consider:
|
||||
|
||||
**Web Workers in Browser** - Load tools client-side using native `import()`
|
||||
- Pros: No server needed, truly dynamic
|
||||
- Cons: Exposes API keys, security concerns
|
||||
|
||||
**Serverless Functions with Pre-installed Tools** - Deploy each tool as separate function
|
||||
- Pros: Works with Vercel/Next.js
|
||||
- Cons: Not truly dynamic, requires redeployment for new tools
|
||||
|
||||
---
|
||||
|
||||
**Recommendation**: Use Deno on Railway. It's designed for exactly this use case.
|
||||
376
RAILWAY_DYNAMIC_TOOL_LOADER.md
Normal file
376
RAILWAY_DYNAMIC_TOOL_LOADER.md
Normal file
|
|
@ -0,0 +1,376 @@
|
|||
# Railway Service - Dynamic Tool Loader Implementation
|
||||
|
||||
## Overview
|
||||
|
||||
This document describes the Railway service implementation needed to support dynamic tool loading from esm.sh in the TPMJS playground.
|
||||
|
||||
## Why Railway Service?
|
||||
|
||||
Next.js/Turbopack intercepts all `import()` calls and tries to resolve them through its module graph. HTTP URLs like `https://esm.sh/...` are not supported.
|
||||
|
||||
**Solution**: Use a plain Node.js service on Railway that:
|
||||
- Runs with `--experimental-network-imports` flag
|
||||
- Can dynamically import from HTTP URLs (esm.sh)
|
||||
- Executes tool functions and returns results
|
||||
- Is already set up for existing ToolPlayground
|
||||
|
||||
## New Endpoint Required
|
||||
|
||||
### `POST /load-and-describe`
|
||||
|
||||
**Purpose**: Dynamically import a tool package and return its AI SDK tool definition (description, schema) without executing it.
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2",
|
||||
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2"
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"tool": {
|
||||
"exportName": "webSearchTool",
|
||||
"description": "Search the web using Firecrawl",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": { "type": "string", "description": "Search query" }
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation** (pseudo-code for Railway service):
|
||||
|
||||
```javascript
|
||||
// server.js (Railway service)
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
|
||||
// Cache for imported modules
|
||||
const moduleCache = new Map();
|
||||
|
||||
app.post('/load-and-describe', async (req, res) => {
|
||||
const { packageName, exportName, version, importUrl } = req.body;
|
||||
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
|
||||
try {
|
||||
let toolModule;
|
||||
|
||||
// Check cache first
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Cache hit: ${cacheKey}`);
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
// Dynamic import from esm.sh
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
console.log(`📦 Importing: ${url}`);
|
||||
|
||||
const module = await import(url);
|
||||
toolModule = module[exportName];
|
||||
|
||||
if (!toolModule) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Export "${exportName}" not found in module`
|
||||
});
|
||||
}
|
||||
|
||||
// Validate it's an AI SDK tool
|
||||
if (!toolModule.description || !toolModule.execute) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: `Invalid AI SDK tool structure`
|
||||
});
|
||||
}
|
||||
|
||||
// Cache it
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
}
|
||||
|
||||
// Extract tool definition (description + schema)
|
||||
// AI SDK v6 tools have: description, inputSchema, execute
|
||||
res.json({
|
||||
success: true,
|
||||
tool: {
|
||||
exportName,
|
||||
description: toolModule.description,
|
||||
inputSchema: toolModule.inputSchema || toolModule.parameters?.shape || {},
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to load tool:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Start server
|
||||
const PORT = process.env.PORT || 3000;
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Railway tool loader running on port ${PORT}`);
|
||||
});
|
||||
```
|
||||
|
||||
**Railway Deployment**:
|
||||
```bash
|
||||
# Start command in Railway settings:
|
||||
node --experimental-network-imports server.js
|
||||
|
||||
# Or in package.json:
|
||||
{
|
||||
"scripts": {
|
||||
"start": "node --experimental-network-imports server.js"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Modified Endpoint: `POST /execute-tool`
|
||||
|
||||
**Purpose**: Execute a dynamically loaded tool with parameters.
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2",
|
||||
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2",
|
||||
"params": {
|
||||
"query": "latest AI news"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"output": {
|
||||
"results": [...]
|
||||
},
|
||||
"executionTimeMs": 1234
|
||||
}
|
||||
```
|
||||
|
||||
**Implementation** (pseudo-code):
|
||||
|
||||
```javascript
|
||||
app.post('/execute-tool', async (req, res) => {
|
||||
const { packageName, exportName, version, importUrl, params } = req.body;
|
||||
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let toolModule;
|
||||
|
||||
// Check cache or import
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
const module = await import(url);
|
||||
toolModule = module[exportName];
|
||||
|
||||
if (!toolModule || !toolModule.execute) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Tool not found or invalid'
|
||||
});
|
||||
}
|
||||
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
const result = await toolModule.execute(params);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: result,
|
||||
executionTimeMs: Date.now() - startTime
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
executionTimeMs: Date.now() - startTime
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Integration with Playground
|
||||
|
||||
### 1. Update `dynamic-tool-loader.ts`
|
||||
|
||||
Replace local dynamic imports with Railway service calls:
|
||||
|
||||
```typescript
|
||||
// apps/playground/src/lib/dynamic-tool-loader.ts
|
||||
|
||||
const RAILWAY_SERVICE_URL = process.env.RAILWAY_SERVICE_URL || 'http://localhost:3001';
|
||||
|
||||
export async function loadToolDynamically(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
version: string,
|
||||
importUrl?: string
|
||||
): Promise<any | null> {
|
||||
const cacheKey = getCacheKey(packageName, exportName);
|
||||
|
||||
// Check local cache first
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Cache hit: ${cacheKey}`);
|
||||
return moduleCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📦 Loading from Railway: ${packageName}/${exportName}`);
|
||||
|
||||
// Call Railway service to load and describe tool
|
||||
const response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName,
|
||||
exportName,
|
||||
version,
|
||||
importUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`❌ Railway service error: ${response.status}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
console.error(`❌ Failed to load tool: ${data.error}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Create a tool wrapper that executes remotely
|
||||
const tool = {
|
||||
description: data.tool.description,
|
||||
inputSchema: data.tool.inputSchema,
|
||||
execute: async (params: any) => {
|
||||
console.log(`🚀 Executing ${packageName}/${exportName} remotely`);
|
||||
|
||||
const execResponse = await fetch(`${RAILWAY_SERVICE_URL}/execute-tool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName,
|
||||
exportName,
|
||||
version,
|
||||
importUrl,
|
||||
params,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await execResponse.json();
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Tool execution failed');
|
||||
}
|
||||
|
||||
return result.output;
|
||||
},
|
||||
};
|
||||
|
||||
// Cache the wrapper
|
||||
moduleCache.set(cacheKey, tool);
|
||||
console.log(`✅ Loaded and cached: ${cacheKey}`);
|
||||
|
||||
return tool;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load ${packageName}#${exportName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Environment Variables
|
||||
|
||||
Add to `.env.local`:
|
||||
```bash
|
||||
RAILWAY_SERVICE_URL=https://your-railway-service.up.railway.app
|
||||
```
|
||||
|
||||
Or for local testing with Railway running locally:
|
||||
```bash
|
||||
RAILWAY_SERVICE_URL=http://localhost:3001
|
||||
```
|
||||
|
||||
## Testing Locally
|
||||
|
||||
### Terminal 1: Run Railway service locally
|
||||
```bash
|
||||
cd railway-service
|
||||
node --experimental-network-imports server.js
|
||||
```
|
||||
|
||||
### Terminal 2: Run playground
|
||||
```bash
|
||||
cd tpmjs
|
||||
pnpm dev --filter=@tpmjs/playground
|
||||
```
|
||||
|
||||
### Test the flow:
|
||||
```bash
|
||||
# Test Railway service directly
|
||||
curl -X POST http://localhost:3001/load-and-describe \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2"
|
||||
}'
|
||||
|
||||
# Then test via playground UI
|
||||
# Navigate to http://localhost:3000/playground
|
||||
# Ask: "search the web for latest AI news"
|
||||
```
|
||||
|
||||
## Deployment Checklist
|
||||
|
||||
- [ ] Create Railway service with Node.js
|
||||
- [ ] Add `--experimental-network-imports` flag to start command
|
||||
- [ ] Deploy `/load-and-describe` endpoint
|
||||
- [ ] Deploy `/execute-tool` endpoint (or modify existing `/execute`)
|
||||
- [ ] Set `RAILWAY_SERVICE_URL` in Vercel environment variables
|
||||
- [ ] Test with real tools from TPMJS registry
|
||||
- [ ] Monitor Railway logs for import errors
|
||||
|
||||
## Benefits
|
||||
|
||||
1. ✅ **Works around Next.js limitations** - Imports happen in plain Node
|
||||
2. ✅ **Reuses existing Railway infrastructure** - No new service needed
|
||||
3. ✅ **Caching on both sides** - Local cache + Railway cache
|
||||
4. ✅ **Security** - Tools execute in Railway sandbox, not Next.js
|
||||
5. ✅ **Scalability** - Railway handles the heavy lifting
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. Implement Railway service endpoints
|
||||
2. Update `dynamic-tool-loader.ts` to use Railway
|
||||
3. Test locally
|
||||
4. Deploy to Railway + Vercel
|
||||
5. Celebrate dynamic tool loading! 🎉
|
||||
363
STREAMING_EMPTY_RESPONSE.md
Normal file
363
STREAMING_EMPTY_RESPONSE.md
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
# AI SDK v6 Streaming Empty Response Issue
|
||||
|
||||
## Problem
|
||||
|
||||
Using AI SDK v6 Beta with OpenAI and `streamText()`, the API route returns a 200 OK response, but the streamed response body is **completely empty** when tools are involved.
|
||||
|
||||
- **Normal chat** (without tool calls): Works fine, streams text back
|
||||
- **Tool calls** (when user asks "say hello world"): Returns empty response body, no error messages
|
||||
|
||||
## Environment
|
||||
|
||||
- **AI SDK Version**: `ai@6.0.0-beta.124`
|
||||
- **OpenAI Provider**: `@ai-sdk/openai@3.0.0-beta.74`
|
||||
- **OpenAI Library**: `openai@^6.9.1`
|
||||
- **Next.js Version**: `next@^16.0.4` (App Router)
|
||||
- **Runtime**: Node.js (`runtime = 'nodejs'`)
|
||||
- **Model**: `gpt-4o-mini`
|
||||
|
||||
## API Route Implementation
|
||||
|
||||
Located at: `apps/playground/src/app/api/chat/route.ts`
|
||||
|
||||
```typescript
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { streamText, type CoreMessage, tool, jsonSchema } from 'ai';
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { env } from '~/env';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Initialize OpenAI provider
|
||||
const openai = createOpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
// Request schema
|
||||
const RequestSchema = z.object({
|
||||
messages: z.array(
|
||||
z.object({
|
||||
role: z.enum(['user', 'assistant', 'system']),
|
||||
content: z.string(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
// Simple inline test tool to verify streaming works
|
||||
const testHelloTool = tool({
|
||||
description: 'Returns a simple hello world greeting',
|
||||
inputSchema: jsonSchema<{ includeTimestamp?: boolean }>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeTimestamp: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a timestamp',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
async execute({ includeTimestamp = true }) {
|
||||
const response: any = { message: 'Hello, World!' };
|
||||
if (includeTimestamp) {
|
||||
response.timestamp = new Date().toISOString();
|
||||
}
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Chat with AI agent that can execute TPMJS tools
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { messages } = RequestSchema.parse(body);
|
||||
|
||||
// Use simple inline tool for testing
|
||||
const tools = {
|
||||
testHello: testHelloTool,
|
||||
};
|
||||
|
||||
// Create system prompt listing available tools
|
||||
const toolsList = Object.keys(tools)
|
||||
.map((name) => `- ${name}: ${tools[name]?.description}`)
|
||||
.join('\n');
|
||||
|
||||
const systemMessage: CoreMessage = {
|
||||
role: 'system',
|
||||
content: `You are a helpful AI assistant that can use TPMJS tools to help users.
|
||||
|
||||
Available tools:
|
||||
${toolsList}
|
||||
|
||||
Call tools as needed to answer user questions. When a user asks to say hello world or for a greeting, use the testHello tool.`,
|
||||
};
|
||||
|
||||
// Stream the response
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
messages: [systemMessage, ...messages],
|
||||
tools,
|
||||
});
|
||||
|
||||
// Return the stream as SSE
|
||||
return result.toTextStreamResponse();
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
|
||||
if (error instanceof z.ZodError) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: 'Invalid request format',
|
||||
details: error.issues,
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Client-Side Hook
|
||||
|
||||
Located at: `apps/playground/src/hooks/useChat.ts`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export function useChat() {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
// Add user message immediately
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, userMessage]);
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Create assistant message placeholder
|
||||
const assistantMessageId = crypto.randomUUID();
|
||||
const assistantMessage: ChatMessage = {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
};
|
||||
|
||||
setMessages((prev) => [...prev, assistantMessage]);
|
||||
|
||||
// Send request to API
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: [...messages, userMessage].map((m) => ({
|
||||
role: m.role,
|
||||
content: m.content,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
|
||||
// Read the streaming response
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('Response body is null');
|
||||
}
|
||||
|
||||
let accumulatedContent = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Decode the chunk
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
accumulatedContent += chunk;
|
||||
|
||||
// Update the assistant message with accumulated content
|
||||
setMessages((prev) =>
|
||||
prev.map((m) =>
|
||||
m.id === assistantMessageId
|
||||
? { ...m, content: accumulatedContent }
|
||||
: m
|
||||
)
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error sending message:', err);
|
||||
setError(err instanceof Error ? err.message : 'Failed to send message');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const clearChat = useCallback(() => {
|
||||
setMessages([]);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
error,
|
||||
sendMessage,
|
||||
clearChat,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Observed Behavior
|
||||
|
||||
### Working Case (Normal Chat)
|
||||
- User types: "hi"
|
||||
- API response: 200 OK
|
||||
- Response body: Streams text chunks successfully
|
||||
- UI shows: "Hi! How can I help you today?"
|
||||
|
||||
### Broken Case (Tool Call)
|
||||
- User types: "say hello world"
|
||||
- API response: 200 OK ✅
|
||||
- Response body: **EMPTY** ❌ (no chunks, no data, nothing)
|
||||
- UI shows: Empty message bubble
|
||||
- Console: No errors logged
|
||||
|
||||
## HTTP Response Details
|
||||
|
||||
```
|
||||
Request Method: POST
|
||||
Status Code: 200 OK
|
||||
URL: http://localhost:3001/api/chat
|
||||
Content-Type: text/plain; charset=utf-8
|
||||
Transfer-Encoding: chunked
|
||||
```
|
||||
|
||||
The response headers look correct for a streaming response, but the body is completely empty.
|
||||
|
||||
## What We've Tried
|
||||
|
||||
1. ✅ Fixed OpenAI schema validation error (was `type: "None"`, now uses proper JSON Schema)
|
||||
2. ✅ Using `tool()` and `jsonSchema()` from AI SDK
|
||||
3. ✅ Simplified to a single inline test tool
|
||||
4. ✅ Tool executes without errors (no schema validation issues)
|
||||
5. ✅ Normal chat works fine (proves streaming infrastructure is correct)
|
||||
|
||||
## Questions
|
||||
|
||||
1. **Is `toTextStreamResponse()` the correct method for streaming with tools in AI SDK v6?**
|
||||
- Should we use a different method like `toDataStreamResponse()` for tool calls?
|
||||
|
||||
2. **Are we constructing the messages array correctly?**
|
||||
- We're sending `{ role: 'user' | 'assistant' | 'system', content: string }[]`
|
||||
- Do we need to include tool call messages or tool result messages?
|
||||
|
||||
3. **Does AI SDK v6 require a specific message format for tool calls?**
|
||||
- Should we be including `toolInvocations` or `tool_calls` in the message history?
|
||||
- Are we missing required fields in the `CoreMessage` type?
|
||||
|
||||
4. **Is the client-side streaming reader correct?**
|
||||
- We're reading chunks with `response.body.getReader()`
|
||||
- Should we be parsing SSE events differently for tool calls?
|
||||
|
||||
5. **Does `streamText()` with tools require `maxSteps` parameter?**
|
||||
- Do we need to set `maxSteps: 5` to allow multi-step reasoning?
|
||||
|
||||
6. **Are we handling the conversation history correctly?**
|
||||
- We're sending all previous messages on each request
|
||||
- Should we be including assistant messages with tool call results?
|
||||
|
||||
## AI SDK v6 Documentation References
|
||||
|
||||
We're following these patterns from the official docs:
|
||||
|
||||
- [streamText() API](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text)
|
||||
- [tool() API](https://ai-sdk.dev/docs/reference/ai-sdk-core/tool)
|
||||
- [Tool Calling Guide](https://ai-sdk.dev/docs/ai-sdk-core/tools-and-tool-calling)
|
||||
|
||||
But we might be missing something specific about:
|
||||
- How to handle streaming when tools are executed
|
||||
- What response format tool calls produce
|
||||
- How to parse the stream when tools are involved
|
||||
|
||||
## Suspected Issue
|
||||
|
||||
**The message format might be wrong.** We're sending:
|
||||
|
||||
```typescript
|
||||
const systemMessage: CoreMessage = {
|
||||
role: 'system',
|
||||
content: `You are a helpful AI assistant...`,
|
||||
};
|
||||
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
messages: [systemMessage, ...messages],
|
||||
tools,
|
||||
});
|
||||
```
|
||||
|
||||
But `CoreMessage` might need additional fields when tools are involved, or we might need to handle tool call results differently in the conversation history.
|
||||
|
||||
## What We Need
|
||||
|
||||
1. Correct message format for `streamText()` with tools
|
||||
2. How to properly stream responses that include tool calls
|
||||
3. Whether we need different client-side parsing for tool call streams
|
||||
4. Example of a working Next.js API route using AI SDK v6 with `streamText()` and tools
|
||||
|
||||
## Repo Context
|
||||
|
||||
- Monorepo using Turborepo + pnpm workspaces
|
||||
- Next.js 16 App Router with Turbopack
|
||||
- TypeScript strict mode
|
||||
- All UI components from internal `@tpmjs/ui` package
|
||||
- Tools are imported from workspace package `@tpmjs/hello`
|
||||
400
USECHAT_INPUT_UNDEFINED.md
Normal file
400
USECHAT_INPUT_UNDEFINED.md
Normal file
|
|
@ -0,0 +1,400 @@
|
|||
# useChat Hook Returns Undefined Input Property
|
||||
|
||||
## Problem
|
||||
|
||||
Using `@ai-sdk/react`'s `useChat` hook, the `input` property is returning `undefined`, causing the application to crash when trying to call `.trim()` on it.
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
TypeError: Cannot read properties of undefined (reading 'trim')
|
||||
|
||||
at ChatInput (src/components/chat/ChatInput.tsx:41:48)
|
||||
```
|
||||
|
||||
**Code that fails:**
|
||||
```typescript
|
||||
<Button type="submit" disabled={!input.trim() || isLoading} loading={isLoading} size="lg">
|
||||
```
|
||||
|
||||
## Environment
|
||||
|
||||
- **AI SDK Version**: `ai@6.0.0-beta.124`
|
||||
- **AI SDK React**: `@ai-sdk/react` (latest version installed via pnpm)
|
||||
- **OpenAI Provider**: `@ai-sdk/openai@3.0.0-beta.74`
|
||||
- **Next.js**: `16.0.4` (App Router with Turbopack)
|
||||
- **React**: `19.0.0`
|
||||
- **Zod**: `4.0.0` (required, not downgrading)
|
||||
- **TypeScript**: `5.9.3`
|
||||
|
||||
## Current Implementation
|
||||
|
||||
### Custom useChat Hook Wrapper
|
||||
|
||||
Located at: `apps/playground/src/hooks/useChat.ts`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useChat as useAISDKChat } from '@ai-sdk/react';
|
||||
|
||||
/**
|
||||
* Custom chat hook that wraps the official @ai-sdk/react useChat
|
||||
* Handles SSE streaming with tool calls and UI message protocol
|
||||
*/
|
||||
export function useChat() {
|
||||
const chat = useAISDKChat({
|
||||
api: '/api/chat',
|
||||
});
|
||||
|
||||
return {
|
||||
messages: chat.messages,
|
||||
input: chat.input,
|
||||
isLoading: chat.isLoading,
|
||||
error: chat.error,
|
||||
handleInputChange: chat.handleInputChange,
|
||||
handleSubmit: chat.handleSubmit,
|
||||
setInput: chat.setInput,
|
||||
reload: chat.reload,
|
||||
stop: chat.stop,
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### API Route
|
||||
|
||||
Located at: `apps/playground/src/app/api/chat/route.ts`
|
||||
|
||||
```typescript
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { streamText, convertToModelMessages, stepCountIs, type UIMessage } from 'ai';
|
||||
import { type NextRequest } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import { loadAllTools } from '~/lib/tool-loader';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Initialize OpenAI provider
|
||||
const openai = createOpenAI({
|
||||
apiKey: env.OPENAI_API_KEY,
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Chat with AI agent that can execute TPMJS tools
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { messages }: { messages: UIMessage[] } = await request.json();
|
||||
|
||||
// Load all available TPMJS tools
|
||||
const tools = await loadAllTools();
|
||||
|
||||
// Create system prompt listing available tools
|
||||
const toolsList = Object.keys(tools)
|
||||
.map((name) => `- ${name}: ${tools[name]?.description}`)
|
||||
.join('\n');
|
||||
|
||||
const system = `You are a helpful AI assistant that can use TPMJS tools to help users.
|
||||
|
||||
Available tools:
|
||||
${toolsList}
|
||||
|
||||
When you use a tool, you MUST always follow up with a natural language answer to the user summarizing the result.`;
|
||||
|
||||
// Stream the response with multi-step tool usage enabled
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
system,
|
||||
messages: convertToModelMessages(messages),
|
||||
tools,
|
||||
stopWhen: stepCountIs(5), // Allow model to call tools AND generate text response
|
||||
});
|
||||
|
||||
// Return UI message stream with tool calls and text
|
||||
return result.toUIMessageStreamResponse();
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Component Using the Hook
|
||||
|
||||
Located at: `apps/playground/src/components/chat/ChatInterface.tsx`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useChat } from '~/hooks/useChat';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
|
||||
export function ChatInterface(): React.ReactElement {
|
||||
const { messages, input, isLoading, handleInputChange, handleSubmit, setInput } = useChat();
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<ChatMessages messages={messages} />
|
||||
<ChatInput
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleSubmit}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### ChatInput Component
|
||||
|
||||
Located at: `apps/playground/src/components/chat/ChatInput.tsx`
|
||||
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string;
|
||||
isLoading: boolean;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
setInput: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ChatInput({ input, isLoading, onInputChange, onSubmit }: ChatInputProps): React.ReactElement {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !isLoading) {
|
||||
// Trigger form submission
|
||||
const form = e.currentTarget.form;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="border-t border-border bg-background p-4">
|
||||
<div className="mx-auto flex max-w-4xl gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me to tell you a fish joke..."
|
||||
className="min-h-[60px] flex-1 resize-none"
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
/>
|
||||
<Button type="submit" disabled={!input.trim() || isLoading} loading={isLoading} size="lg">
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Tool Definition (Working)
|
||||
|
||||
The tools are defined using `tool()` and `jsonSchema()` from AI SDK to avoid Zod 4 compatibility issues:
|
||||
|
||||
Located at: `packages/tools/hello/src/index.ts`
|
||||
|
||||
```typescript
|
||||
import { jsonSchema, tool } from 'ai';
|
||||
|
||||
type HelloWorldInput = {
|
||||
includeTimestamp?: boolean;
|
||||
};
|
||||
|
||||
export const helloWorldTool = tool({
|
||||
description: 'Returns a simple "Hello, World!" greeting message',
|
||||
inputSchema: jsonSchema<HelloWorldInput>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
includeTimestamp: {
|
||||
type: 'boolean',
|
||||
description: 'Whether to include a timestamp in the response',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
}),
|
||||
async execute({ includeTimestamp = true }) {
|
||||
const response: any = {
|
||||
message: 'Hello, World!',
|
||||
};
|
||||
|
||||
if (includeTimestamp) {
|
||||
response.timestamp = new Date().toISOString();
|
||||
}
|
||||
|
||||
return response;
|
||||
},
|
||||
});
|
||||
|
||||
type HelloNameInput = {
|
||||
name: string;
|
||||
};
|
||||
|
||||
export const helloNameTool = tool({
|
||||
description: 'Returns a personalized greeting with the provided name',
|
||||
inputSchema: jsonSchema<HelloNameInput>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string',
|
||||
description: 'The name of the person to greet',
|
||||
},
|
||||
},
|
||||
required: ['name'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
async execute({ name }) {
|
||||
return {
|
||||
message: `Hello, ${name}!`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## What's Working
|
||||
|
||||
1. ✅ API route receives requests correctly
|
||||
2. ✅ Tools are loaded and registered successfully
|
||||
3. ✅ `streamText()` with `stopWhen: stepCountIs(5)` configured
|
||||
4. ✅ `toUIMessageStreamResponse()` returns proper SSE stream
|
||||
5. ✅ curl test shows tools are called correctly with proper JSON Schema
|
||||
6. ✅ Stream format includes `tool-input-start`, `tool-output-available`, `text-delta` events
|
||||
|
||||
## What's NOT Working
|
||||
|
||||
1. ❌ `input` property from `useChat` is `undefined`
|
||||
2. ❌ Application crashes when trying to access `input.trim()`
|
||||
3. ❌ Can't type in the chat input field
|
||||
|
||||
## curl Test (Successful)
|
||||
|
||||
```bash
|
||||
curl -N http://localhost:3001/api/chat \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"messages":[{"role":"user","content":"say hello thomas"}]}'
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```
|
||||
data: {"type":"start"}
|
||||
data: {"type":"start-step"}
|
||||
data: {"type":"tool-input-start","toolCallId":"call_...","toolName":"helloName"}
|
||||
data: {"type":"tool-input-delta","toolCallId":"call_...","inputTextDelta":"{\""}
|
||||
data: {"type":"tool-input-delta","toolCallId":"call_...","inputTextDelta":"name"}
|
||||
data: {"type":"tool-input-delta","toolCallId":"call_...","inputTextDelta":"\":\""}
|
||||
data: {"type":"tool-input-delta","toolCallId":"call_...","inputTextDelta":"Thomas"}
|
||||
data: {"type":"tool-input-delta","toolCallId":"call_...","inputTextDelta":"\"}"}
|
||||
data: {"type":"tool-input-available","toolCallId":"call_...","toolName":"helloName","input":{"name":"Thomas"},"providerMetadata":{...}}
|
||||
data: {"type":"tool-output-available","toolCallId":"call_...","output":{"message":"Hello, Thomas!","timestamp":"2025-12-03T16:07:31.366Z"}}
|
||||
data: {"type":"finish-step"}
|
||||
data: {"type":"start-step"}
|
||||
data: {"type":"text-start","id":"msg_...","providerMetadata":{...}}
|
||||
data: {"type":"text-delta","id":"msg_...","delta":"Hello"}
|
||||
data: {"type":"text-delta","id":"msg_...","delta":","}
|
||||
data: {"type":"text-delta","id":"msg_...","delta":" Thomas"}
|
||||
data: {"type":"text-delta","id":"msg_...","delta":"!"}
|
||||
data: {"type":"text-end","id":"msg_...","providerMetadata":{...}}
|
||||
data: {"type":"finish-step"}
|
||||
data: {"type":"finish","finishReason":"stop"}
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
The API works perfectly - tools are called, results are returned, text is generated. The issue is purely on the React client side.
|
||||
|
||||
## Questions
|
||||
|
||||
1. **Is `@ai-sdk/react`'s `useChat` compatible with AI SDK v6 Beta (6.0.0-beta.124)?**
|
||||
- Should we be using a different version of `@ai-sdk/react`?
|
||||
- Are there known compatibility issues with AI SDK v6 Beta?
|
||||
|
||||
2. **Why is `input` undefined?**
|
||||
- Does `useChat` require specific initialization options?
|
||||
- Do we need to provide `initialMessages` or `initialInput`?
|
||||
- Is there a required prop we're missing?
|
||||
|
||||
3. **Is the API route format correct for `@ai-sdk/react`'s `useChat`?**
|
||||
- Should the API accept a different request format?
|
||||
- Is `UIMessage[]` the correct type for messages?
|
||||
- Should we use `toDataStreamResponse()` instead of `toUIMessageStreamResponse()`?
|
||||
|
||||
4. **Do we need to handle client-side state differently?**
|
||||
- Should we initialize `input` with a default value?
|
||||
- Is there a provider or context missing?
|
||||
- Do we need to wrap the component tree with any providers?
|
||||
|
||||
5. **Is there a version mismatch between packages?**
|
||||
- `ai@6.0.0-beta.124`
|
||||
- `@ai-sdk/openai@3.0.0-beta.74`
|
||||
- `@ai-sdk/react@?` (unknown version)
|
||||
|
||||
6. **Does Zod 4 affect the client-side hook?**
|
||||
- We fixed the server-side tool schemas using `jsonSchema()`
|
||||
- Could there be client-side Zod 4 issues affecting `useChat`?
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
The `useChat` hook should return:
|
||||
- `input: string` - Current input value (should be empty string initially)
|
||||
- `handleInputChange: (e) => void` - Update input value
|
||||
- `handleSubmit: (e) => void` - Submit form and send message
|
||||
- `messages: Message[]` - Array of messages
|
||||
- `isLoading: boolean` - Loading state
|
||||
|
||||
## Actual Behavior
|
||||
|
||||
- `input: undefined` ❌
|
||||
- Everything else appears to be defined
|
||||
- Crash on first render when trying to access `input.trim()`
|
||||
|
||||
## Monorepo Context
|
||||
|
||||
- Turborepo monorepo with pnpm workspaces
|
||||
- Next.js 16 App Router with Turbopack
|
||||
- TypeScript strict mode
|
||||
- `@tpmjs/ui` package for UI components
|
||||
- `@tpmjs/hello` package for tools
|
||||
- Using workspace protocol (`workspace:*`) for internal dependencies
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [AI SDK Core: streamText](https://ai-sdk.dev/docs/reference/ai-sdk-core/stream-text)
|
||||
- [AI SDK React: useChat](https://ai-sdk.dev/docs/reference/ai-sdk-ui/use-chat)
|
||||
- [AI SDK UI: Stream Protocol](https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol)
|
||||
|
||||
## What We Need
|
||||
|
||||
1. Correct version compatibility information for AI SDK v6 Beta + @ai-sdk/react
|
||||
2. Why `input` is undefined and how to fix it
|
||||
3. Whether our API route format is correct for the React hook
|
||||
4. Any missing initialization or configuration for `useChat`
|
||||
5. Whether there are alternative approaches (custom SSE parsing, different hook, etc.)
|
||||
|
||||
We must keep Zod 4 and cannot downgrade. The server-side tools are working correctly with `jsonSchema()` workaround.
|
||||
404
ZOD_SCHEMA_SERIALIZATION_PROBLEM.md
Normal file
404
ZOD_SCHEMA_SERIALIZATION_PROBLEM.md
Normal file
|
|
@ -0,0 +1,404 @@
|
|||
# Zod Schema Serialization Problem - Dynamic Tool Loading System
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
We have a dynamic tool loading system with the following architecture:
|
||||
|
||||
```
|
||||
Next.js Playground (Vercel) → Railway Deno Service → esm.sh CDN → npm packages
|
||||
↓
|
||||
AI SDK (OpenAI)
|
||||
```
|
||||
|
||||
### Components:
|
||||
|
||||
1. **Next.js Playground** (`apps/playground`) - Runs on Vercel, hosts the AI chat interface
|
||||
2. **Railway Deno Service** (`apps/railway-executor/server.ts`) - Runs on Railway, uses Deno's native HTTP import support
|
||||
3. **Dynamic Tool Loader** (`apps/playground/src/lib/dynamic-tool-loader.ts`) - Client-side code that calls Railway
|
||||
|
||||
### Why We Need This Architecture:
|
||||
|
||||
- Next.js/Turbopack intercepts all `import()` calls and cannot import from HTTP URLs (like `https://esm.sh/package@version`)
|
||||
- Deno natively supports HTTP imports via `import('https://...')`
|
||||
- Tools are published to npm and loaded dynamically at runtime via esm.sh CDN
|
||||
- We cannot bundle tools at build time - they must be discovered and loaded dynamically
|
||||
|
||||
## The Problem
|
||||
|
||||
AI SDK tools require a specific format with `description` and `inputSchema`:
|
||||
|
||||
```typescript
|
||||
// AI SDK v6 Tool Format
|
||||
const tool = {
|
||||
description: "Search the web for current information...",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Search query"),
|
||||
numResults: z.number().optional(),
|
||||
}),
|
||||
execute: async (params) => {
|
||||
// ... execution logic
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**The Challenge:** We need to send tool definitions from Railway (Deno) to the Playground (Next.js) over HTTP, but Zod schemas cannot be JSON serialized.
|
||||
|
||||
### Error 1: `def.shape is not a function`
|
||||
|
||||
When we tried to send the Zod schema directly:
|
||||
|
||||
```typescript
|
||||
// Railway server.ts - DOESN'T WORK
|
||||
return Response.json({
|
||||
success: true,
|
||||
tool: {
|
||||
exportName: "searchTool",
|
||||
description: "...",
|
||||
inputSchema: toolModule.inputSchema, // ❌ Zod schema object
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The Zod schema object loses all its methods during JSON serialization, causing:
|
||||
```
|
||||
TypeError: def.shape is not a function
|
||||
```
|
||||
|
||||
### Error 2: OpenAI Invalid Schema Error
|
||||
|
||||
When we removed `inputSchema` entirely:
|
||||
|
||||
```typescript
|
||||
// dynamic-tool-loader.ts - DOESN'T WORK
|
||||
const tool = {
|
||||
description: data.tool.description,
|
||||
// No inputSchema
|
||||
execute: async (params) => { ... }
|
||||
};
|
||||
```
|
||||
|
||||
OpenAI API rejects the tool:
|
||||
```
|
||||
Error [AI_APICallError]: Invalid schema for function 'firecrawl-aisdk-searchTool':
|
||||
schema must be a JSON Schema of 'type: "object"', got 'type: "None"'.
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
1. ✅ **Must work with AI SDK v6** - Tools must have `description` + `inputSchema` format
|
||||
2. ✅ **Must serialize over HTTP** - Railway → Playground communication is via fetch/HTTP
|
||||
3. ✅ **Must support any Zod schema** - Tools use various Zod types (objects, arrays, unions, etc.)
|
||||
4. ✅ **Must preserve validation** - The schema needs to work for OpenAI's function calling
|
||||
5. ✅ **Must be efficient** - Schemas should be cached, not re-serialized on every request
|
||||
|
||||
## Current Code
|
||||
|
||||
### Railway Server (`apps/railway-executor/server.ts`)
|
||||
|
||||
```typescript
|
||||
async function loadAndDescribe(req: Request): Promise<Response> {
|
||||
const { packageName, exportName, version, importUrl } = await req.json();
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
|
||||
let toolModule;
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
const module = await import(url); // Deno supports HTTP imports!
|
||||
toolModule = module[exportName];
|
||||
|
||||
if (!toolModule.description || !toolModule.execute) {
|
||||
return Response.json({
|
||||
success: false,
|
||||
error: 'Invalid AI SDK tool structure'
|
||||
}, { status: 400 });
|
||||
}
|
||||
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
}
|
||||
|
||||
// ❌ PROBLEM: How to serialize inputSchema?
|
||||
return Response.json({
|
||||
success: true,
|
||||
tool: {
|
||||
exportName,
|
||||
description: toolModule.description,
|
||||
// Need to send inputSchema here somehow
|
||||
hasInputSchema: !!toolModule.inputSchema,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function executeTool(req: Request): Promise<Response> {
|
||||
const { packageName, exportName, version, importUrl, params } = await req.json();
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
|
||||
let toolModule;
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
const module = await import(url);
|
||||
toolModule = module[exportName];
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
}
|
||||
|
||||
const result = await toolModule.execute(params || {});
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
output: result,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Dynamic Tool Loader (`apps/playground/src/lib/dynamic-tool-loader.ts`)
|
||||
|
||||
```typescript
|
||||
export async function loadToolDynamically(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
version: string,
|
||||
importUrl?: string
|
||||
): Promise<any | null> {
|
||||
const cacheKey = getCacheKey(packageName, exportName);
|
||||
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
return moduleCache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Call Railway to load and describe tool
|
||||
const response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName,
|
||||
exportName,
|
||||
version,
|
||||
importUrl: importUrl || `https://esm.sh/${packageName}@${version}`,
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// ❌ PROBLEM: How to reconstruct inputSchema?
|
||||
const tool = {
|
||||
description: data.tool.description,
|
||||
// Need inputSchema here for OpenAI API
|
||||
execute: async (params: any) => {
|
||||
const execResponse = await fetch(`${RAILWAY_SERVICE_URL}/execute-tool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ packageName, exportName, version, importUrl, params }),
|
||||
});
|
||||
const result = await execResponse.json();
|
||||
return result.output;
|
||||
},
|
||||
};
|
||||
|
||||
moduleCache.set(cacheKey, tool);
|
||||
return tool;
|
||||
}
|
||||
```
|
||||
|
||||
## Example Tool Schema
|
||||
|
||||
Here's an example of what we need to serialize:
|
||||
|
||||
```typescript
|
||||
import { tool, jsonSchema } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
// AI SDK v6 format
|
||||
export const searchTool = tool({
|
||||
description: 'Search the web for current information...',
|
||||
inputSchema: jsonSchema<{
|
||||
query: string;
|
||||
numResults?: number;
|
||||
category?: string;
|
||||
}>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: {
|
||||
type: 'string',
|
||||
description: 'Search query'
|
||||
},
|
||||
numResults: {
|
||||
type: 'number',
|
||||
description: 'Number of results',
|
||||
minimum: 1,
|
||||
maximum: 20,
|
||||
default: 10
|
||||
},
|
||||
category: {
|
||||
type: 'string',
|
||||
enum: ['search', 'web-scraping', 'data-extraction'],
|
||||
description: 'Filter by category'
|
||||
}
|
||||
},
|
||||
required: ['query']
|
||||
}),
|
||||
execute: async ({ query, numResults = 10, category }) => {
|
||||
// ... implementation
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
The `inputSchema` is created via `jsonSchema<Type>()` which wraps a JSON Schema object.
|
||||
|
||||
## Potential Approaches to Consider
|
||||
|
||||
### Option 1: Serialize Zod Schema to JSON Schema
|
||||
|
||||
Use `zod-to-json-schema` library to convert Zod schemas to JSON Schema format:
|
||||
|
||||
```typescript
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema';
|
||||
|
||||
// In Railway server
|
||||
const jsonSchema = zodToJsonSchema(toolModule.inputSchema);
|
||||
return Response.json({ inputSchema: jsonSchema });
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- Standard approach
|
||||
- Widely used library
|
||||
- Preserves validation rules
|
||||
|
||||
**Cons:**
|
||||
- Adds dependency to Railway service
|
||||
- May not work with `jsonSchema()` wrapper from AI SDK
|
||||
- Need to ensure it works with AI SDK v6 format
|
||||
|
||||
### Option 2: Extract JSON Schema from AI SDK's jsonSchema()
|
||||
|
||||
The AI SDK's `jsonSchema()` function wraps a plain JSON Schema object. Maybe we can extract it:
|
||||
|
||||
```typescript
|
||||
// Investigate the structure of toolModule.inputSchema
|
||||
console.log(JSON.stringify(toolModule.inputSchema, null, 2));
|
||||
|
||||
// Potentially:
|
||||
const plainSchema = toolModule.inputSchema._def?.schema || toolModule.inputSchema;
|
||||
```
|
||||
|
||||
**Pros:**
|
||||
- No additional dependencies
|
||||
- Uses the schema exactly as AI SDK expects it
|
||||
|
||||
**Cons:**
|
||||
- Relies on internal structure (fragile)
|
||||
- May break with AI SDK updates
|
||||
|
||||
### Option 3: Store Schema Separately and Reconstruct
|
||||
|
||||
Store the raw JSON Schema definition separately in the tool package:
|
||||
|
||||
```typescript
|
||||
// In tool package
|
||||
export const searchToolSchema = {
|
||||
type: 'object',
|
||||
properties: { ... }
|
||||
};
|
||||
|
||||
export const searchTool = tool({
|
||||
description: '...',
|
||||
inputSchema: jsonSchema(searchToolSchema),
|
||||
execute: async (params) => { ... }
|
||||
});
|
||||
```
|
||||
|
||||
Then send `searchToolSchema` over HTTP and reconstruct.
|
||||
|
||||
**Pros:**
|
||||
- Clean separation
|
||||
- Easy to serialize
|
||||
|
||||
**Cons:**
|
||||
- Requires tool authors to export schema separately
|
||||
- Duplication of schema definition
|
||||
- Breaks existing tools
|
||||
|
||||
### Option 4: Import Tool Directly in Playground (If Possible)
|
||||
|
||||
Try to make HTTP imports work in Next.js somehow:
|
||||
|
||||
- Webpack configuration hacks?
|
||||
- Dynamic imports with custom loader?
|
||||
- Build-time bundling of tools?
|
||||
|
||||
**Pros:**
|
||||
- No serialization needed
|
||||
- Direct access to tool objects
|
||||
|
||||
**Cons:**
|
||||
- Next.js/Turbopack limitations are fundamental
|
||||
- Defeats the purpose of dynamic loading
|
||||
- May not be technically possible
|
||||
|
||||
### Option 5: Two-Way Communication - Send Schema Back
|
||||
|
||||
Instead of Railway → Playground, have Playground → Railway for schema:
|
||||
|
||||
1. Playground asks Railway: "Does this tool exist?"
|
||||
2. Railway responds: "Yes, here's the description"
|
||||
3. Playground asks Railway: "Execute this tool with these params"
|
||||
4. Railway validates params against schema and executes
|
||||
|
||||
The schema stays on Railway side, never serialized.
|
||||
|
||||
**Pros:**
|
||||
- Schema never leaves Railway
|
||||
- No serialization issues
|
||||
|
||||
**Cons:**
|
||||
- OpenAI API requires schema upfront for function calling
|
||||
- Can't defer schema to execution time
|
||||
- Doesn't solve the core problem
|
||||
|
||||
## Questions for Consideration
|
||||
|
||||
1. **Can we use `zod-to-json-schema` with AI SDK v6's `jsonSchema()` wrapper?**
|
||||
- How does `jsonSchema()` work internally?
|
||||
- Does it already store a plain JSON Schema somewhere?
|
||||
|
||||
2. **Does the AI SDK provide any serialization utilities?**
|
||||
- Is there a built-in way to serialize tool definitions?
|
||||
- Does Vercel have examples of this pattern?
|
||||
|
||||
3. **Can we modify the tool format to make serialization easier?**
|
||||
- Would it break compatibility with existing tools?
|
||||
- Is there a standardized way tools should export schemas?
|
||||
|
||||
4. **Is there a way to inspect the AI SDK's jsonSchema() structure?**
|
||||
- What properties does it have?
|
||||
- Can we extract the underlying JSON Schema object safely?
|
||||
|
||||
5. **Should we contribute back to the ecosystem?**
|
||||
- Is this a common problem?
|
||||
- Should there be a standard for serializable AI SDK tools?
|
||||
|
||||
## Current Status
|
||||
|
||||
- ✅ Railway service successfully imports tools from esm.sh via HTTP
|
||||
- ✅ Railway can execute tools and return results
|
||||
- ✅ Module caching works on Railway side
|
||||
- ✅ Tool wrapper caching works on Playground side
|
||||
- ❌ Cannot serialize Zod schemas over HTTP (this blocker)
|
||||
- ❌ OpenAI API rejects tools without proper inputSchema
|
||||
|
||||
## Files to Reference
|
||||
|
||||
- `apps/railway-executor/server.ts` - Railway Deno service
|
||||
- `apps/playground/src/lib/dynamic-tool-loader.ts` - Tool loader client
|
||||
- `packages/tools/search-registry/src/index.ts` - Example tool using AI SDK v6
|
||||
|
||||
## Success Criteria
|
||||
|
||||
We need a solution that:
|
||||
1. Sends complete tool definitions (description + inputSchema + execute capability) from Railway to Playground
|
||||
2. Works with OpenAI's function calling API (requires valid JSON Schema)
|
||||
3. Supports all Zod schema types used by AI SDK tools
|
||||
4. Is maintainable and doesn't break with AI SDK updates
|
||||
5. Doesn't require changes to existing published tool packages (if possible)
|
||||
31867
ai-sdk-v6.md
Normal file
31867
ai-sdk-v6.md
Normal file
File diff suppressed because it is too large
Load diff
2
apps/playground/.env.local.example
Normal file
2
apps/playground/.env.local.example
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
# Required: OpenAI API key
|
||||
OPENAI_API_KEY=sk-...
|
||||
16
apps/playground/eslint.config.mjs
Normal file
16
apps/playground/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import reactConfig from '@tpmjs/eslint-config/react.js';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: [
|
||||
'.next/**',
|
||||
'.turbo/**',
|
||||
'node_modules/**',
|
||||
'*.config.js',
|
||||
'*.config.ts',
|
||||
'next-env.d.ts',
|
||||
'eslint.config.mjs',
|
||||
],
|
||||
},
|
||||
...reactConfig,
|
||||
];
|
||||
6
apps/playground/next-env.d.ts
vendored
Normal file
6
apps/playground/next-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.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.
|
||||
11
apps/playground/next.config.ts
Normal file
11
apps/playground/next.config.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
import type { NextConfig } from 'next';
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@tpmjs/ui', '@tpmjs/utils', '@tpmjs/types', '@tpmjs/env'],
|
||||
reactStrictMode: true,
|
||||
experimental: {
|
||||
urlImports: ['https://esm.sh/', 'https://cdn.jsdelivr.net/npm/'],
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
49
apps/playground/package.json
Normal file
49
apps/playground/package.json
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
{
|
||||
"name": "@tpmjs/playground",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3001",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf .next .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "3.0.0-beta.74",
|
||||
"@ai-sdk/react": "3.0.0-beta.131",
|
||||
"@tpmjs/db": "workspace:*",
|
||||
"@tpmjs/env": "workspace:*",
|
||||
"@tpmjs/hello": "workspace:*",
|
||||
"@tpmjs/search-registry": "workspace:*",
|
||||
"@tpmjs/types": "workspace:*",
|
||||
"@tpmjs/ui": "workspace:*",
|
||||
"@tpmjs/utils": "workspace:*",
|
||||
"ai": "6.0.0-beta.124",
|
||||
"firecrawl-aisdk": "^0.7.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next": "^16.0.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.9.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"streamdown": "^1.6.9",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tpmjs/eslint-config": "workspace:*",
|
||||
"@tpmjs/tailwind-config": "workspace:*",
|
||||
"@tpmjs/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-config-next": "^16.0.4",
|
||||
"postcss": "^8.5.1",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
8
apps/playground/postcss.config.mjs
Normal file
8
apps/playground/postcss.config.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
const config = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
229
apps/playground/src/app/api/chat/route.ts
Normal file
229
apps/playground/src/app/api/chat/route.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { searchTpmjsToolsTool } from '@tpmjs/search-registry';
|
||||
import { type UIMessage, convertToModelMessages, stepCountIs, streamText } from 'ai';
|
||||
import type { NextRequest } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import {
|
||||
addConversationTools,
|
||||
loadToolsBatch,
|
||||
setConversationEnv,
|
||||
} from '~/lib/dynamic-tool-loader';
|
||||
import { loadAllTools, sanitizeToolName } from '~/lib/tool-loader';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
// Add conversation state tracking (in-memory for MVP)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex
|
||||
const conversationStates = new Map<string, { loadedTools: Record<string, any> }>();
|
||||
|
||||
/**
|
||||
* POST /api/chat
|
||||
* Chat with AI agent that can execute TPMJS tools
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
console.log('📥 Request body:', JSON.stringify(body, null, 2));
|
||||
|
||||
const messages: UIMessage[] = body.messages || [];
|
||||
const conversationId: string = body.conversationId || 'default';
|
||||
const clientEnv: Record<string, string> = body.env || {};
|
||||
|
||||
console.log(`🔑 Conversation ID: ${conversationId}`);
|
||||
console.log(
|
||||
`🔐 Client env vars: ${Object.keys(clientEnv).length} keys`,
|
||||
Object.keys(clientEnv)
|
||||
);
|
||||
|
||||
// Store env vars for this conversation (so cached tools can access them)
|
||||
setConversationEnv(conversationId, clientEnv);
|
||||
|
||||
// Initialize OpenAI with client-provided or server API key
|
||||
const apiKey = clientEnv.OPENAI_API_KEY || env.OPENAI_API_KEY;
|
||||
if (!apiKey) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: 'OPENAI_API_KEY is required. Please add it in the Settings sidebar.',
|
||||
}),
|
||||
{
|
||||
status: 400,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const openai = createOpenAI({
|
||||
apiKey,
|
||||
});
|
||||
|
||||
// Get or create conversation state
|
||||
if (!conversationStates.has(conversationId)) {
|
||||
console.log('✨ Creating new conversation state');
|
||||
conversationStates.set(conversationId, { loadedTools: {} });
|
||||
}
|
||||
// biome-ignore lint/style/noNonNullAssertion: We just ensured the value exists above
|
||||
const state = conversationStates.get(conversationId)!;
|
||||
console.log(
|
||||
`📊 Current loaded tools in conversation: ${Object.keys(state.loadedTools).length}`
|
||||
);
|
||||
|
||||
// 1. Load static tools + search tool
|
||||
const staticTools = await loadAllTools();
|
||||
console.log(`🔧 Loaded ${Object.keys(staticTools).length} static tools`);
|
||||
|
||||
staticTools.searchTpmjsTools = searchTpmjsToolsTool;
|
||||
console.log('✅ Added searchTpmjsTools to static tools');
|
||||
|
||||
// Debug: Check the search tool structure
|
||||
console.log('🔍 Search tool structure:', {
|
||||
description: searchTpmjsToolsTool.description,
|
||||
inputSchema: typeof searchTpmjsToolsTool.inputSchema,
|
||||
execute: typeof searchTpmjsToolsTool.execute,
|
||||
});
|
||||
|
||||
// 2. Extract user query and last 3 user messages for tool search
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
let userQuery = '';
|
||||
if (lastMessage?.role === 'user') {
|
||||
// Extract text from message parts
|
||||
const parts = (lastMessage as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
userQuery = part.text;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get last 3 user messages for context
|
||||
const recentUserMessages = messages
|
||||
.filter((msg) => msg.role === 'user')
|
||||
.slice(-3)
|
||||
.map((msg) => {
|
||||
// Extract text from parts
|
||||
const parts = (msg as any).parts || [];
|
||||
for (const part of parts) {
|
||||
if (part.type === 'text') {
|
||||
return part.text;
|
||||
}
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
console.log(`💬 User query: "${userQuery}"`);
|
||||
console.log(`📝 Recent messages: ${recentUserMessages.length}`);
|
||||
|
||||
// 3. Automatically search for relevant tools based on the user's message
|
||||
if (userQuery && userQuery.trim().length > 0) {
|
||||
console.log('🔎 Searching for relevant tools...');
|
||||
|
||||
try {
|
||||
// biome-ignore lint/style/noNonNullAssertion: Tool created with tool() always has execute
|
||||
const result = await searchTpmjsToolsTool.execute!(
|
||||
{
|
||||
query: userQuery,
|
||||
limit: 5, // Get top 5 relevant tools
|
||||
recentMessages: recentUserMessages,
|
||||
},
|
||||
{} as any
|
||||
);
|
||||
|
||||
// Type assertion: searchTpmjsToolsTool returns direct result, not AsyncIterable
|
||||
const searchResult = result as {
|
||||
query: string;
|
||||
matchCount: number;
|
||||
tools: any[];
|
||||
};
|
||||
|
||||
console.log(`📦 Found ${searchResult.matchCount} matching tools`);
|
||||
|
||||
if (searchResult.tools && searchResult.tools.length > 0) {
|
||||
console.log(
|
||||
'🔧 Tools found:',
|
||||
searchResult.tools.map((t: any) => `${t.packageName}/${t.exportName}`)
|
||||
);
|
||||
|
||||
// Dynamically load tools from esm.sh
|
||||
console.log(`📥 Loading ${searchResult.tools.length} tools dynamically...`);
|
||||
|
||||
const toolsToLoad = searchResult.tools.map((meta: any) => ({
|
||||
packageName: meta.packageName,
|
||||
exportName: meta.exportName,
|
||||
version: meta.version,
|
||||
importUrl: meta.importUrl,
|
||||
}));
|
||||
|
||||
try {
|
||||
const loadedTools = await loadToolsBatch(toolsToLoad, conversationId, clientEnv);
|
||||
console.log(`✅ Successfully loaded ${Object.keys(loadedTools).length} tools`);
|
||||
|
||||
// Add sanitized tools to conversation state
|
||||
for (const [key, tool] of Object.entries(loadedTools)) {
|
||||
const [pkg, exp] = key.split('::');
|
||||
const sanitizedKey = sanitizeToolName(`${pkg}-${exp}`);
|
||||
state.loadedTools[sanitizedKey] = tool;
|
||||
console.log(`✅ Added to conversation: ${sanitizedKey}`);
|
||||
}
|
||||
|
||||
// Track for this conversation
|
||||
addConversationTools(conversationId, Object.keys(state.loadedTools));
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading tools:', error);
|
||||
}
|
||||
} else {
|
||||
console.log('ℹ️ No matching tools found for this query');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error searching for tools:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Merge with conversation's dynamically loaded tools
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex
|
||||
const allTools: Record<string, any> = { ...staticTools, ...state.loadedTools };
|
||||
|
||||
// 5. Build system prompt with available tools
|
||||
const toolsList = Object.keys(allTools)
|
||||
.map((name) => {
|
||||
const tool = allTools[name] as { description?: string } | undefined;
|
||||
return `- ${name}: ${tool?.description || 'No description'}`;
|
||||
})
|
||||
.join('\n');
|
||||
|
||||
const system = `You are a helpful AI assistant that can use TPMJS tools to help users.
|
||||
|
||||
Available tools:
|
||||
${toolsList}
|
||||
|
||||
When you use a tool, you MUST always follow up with a natural language answer to the user summarizing the result.`;
|
||||
|
||||
// 6. Stream response with all available tools
|
||||
const result = streamText({
|
||||
model: openai('gpt-4o-mini'),
|
||||
system,
|
||||
messages: convertToModelMessages(messages),
|
||||
tools: allTools,
|
||||
stopWhen: stepCountIs(5), // Allow model to call tools AND generate text response
|
||||
});
|
||||
|
||||
// Return UI message stream with tool calls and text
|
||||
return result.toUIMessageStreamResponse();
|
||||
} catch (error) {
|
||||
console.error('Chat API error:', error);
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
37
apps/playground/src/app/api/tools/route.ts
Normal file
37
apps/playground/src/app/api/tools/route.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { searchTpmjsToolsTool } from '@tpmjs/search-registry';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Search for all tools (empty query returns all)
|
||||
// biome-ignore lint/style/noNonNullAssertion: Tool created with tool() always has execute
|
||||
const result = await searchTpmjsToolsTool.execute!(
|
||||
{
|
||||
query: '',
|
||||
limit: 100,
|
||||
},
|
||||
{} as any
|
||||
);
|
||||
|
||||
// Type assertion: searchTpmjsToolsTool returns direct result, not AsyncIterable
|
||||
const searchResult = result as { query: string; matchCount: number; tools: any[] };
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tools: searchResult.tools,
|
||||
total: searchResult.matchCount,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tools:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to fetch tools',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
30
apps/playground/src/app/globals.css
Normal file
30
apps/playground/src/app/globals.css
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
/* Light mode (default) */
|
||||
:root {
|
||||
/* Status Colors */
|
||||
--error: 0 65% 51%; /* Red */
|
||||
--error-foreground: 0 0% 100%; /* White text */
|
||||
--warning: 36 100% 50%; /* Amber */
|
||||
--warning-foreground: 0 0% 100%;
|
||||
--success: 152 57% 45%; /* Green */
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 210 100% 56%; /* Blue */
|
||||
--info-foreground: 0 0% 100%;
|
||||
}
|
||||
|
||||
/* Dark mode */
|
||||
.dark {
|
||||
--error: 0 65% 58%; /* Brighter red for dark mode */
|
||||
--error-foreground: 0 0% 100%;
|
||||
--warning: 36 100% 55%;
|
||||
--warning-foreground: 0 0% 100%;
|
||||
--success: 152 57% 50%;
|
||||
--success-foreground: 0 0% 100%;
|
||||
--info: 210 100% 60%;
|
||||
--info-foreground: 0 0% 100%;
|
||||
}
|
||||
}
|
||||
42
apps/playground/src/app/layout.tsx
Normal file
42
apps/playground/src/app/layout.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { ThemeProvider } from 'next-themes';
|
||||
import { Space_Grotesk, Space_Mono } from 'next/font/google';
|
||||
import './globals.css';
|
||||
|
||||
const spaceGrotesk = Space_Grotesk({
|
||||
subsets: ['latin'],
|
||||
variable: '--font-sans',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
const spaceMono = Space_Mono({
|
||||
subsets: ['latin'],
|
||||
weight: ['400', '700'],
|
||||
variable: '--font-mono',
|
||||
display: 'swap',
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'TPMJS Playground - Test AI Tools',
|
||||
description: 'Interactive playground for testing TPMJS tools with AI agents',
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}): React.ReactElement {
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
suppressHydrationWarning
|
||||
className={`${spaceGrotesk.variable} ${spaceMono.variable}`}
|
||||
>
|
||||
<body>
|
||||
<ThemeProvider attribute="class" defaultTheme="light" enableSystem={false}>
|
||||
{children}
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
24
apps/playground/src/app/page.tsx
Normal file
24
apps/playground/src/app/page.tsx
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
'use client';
|
||||
|
||||
import { ChatHeader } from '~/components/chat/ChatHeader';
|
||||
import { ChatInterface } from '~/components/chat/ChatInterface';
|
||||
import { SettingsSidebar } from '~/components/sidebar/SettingsSidebar';
|
||||
import { ToolsSidebar } from '~/components/sidebar/ToolsSidebar';
|
||||
|
||||
export default function PlaygroundPage(): React.ReactElement {
|
||||
const handleClearChat = () => {
|
||||
// Refresh the page to clear chat
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-screen flex-col bg-background">
|
||||
<ChatHeader onClear={handleClearChat} />
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<ToolsSidebar />
|
||||
<ChatInterface />
|
||||
<SettingsSidebar />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/playground/src/components/chat/ChatHeader.tsx
Normal file
52
apps/playground/src/components/chat/ChatHeader.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { useTheme } from 'next-themes';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface ChatHeaderProps {
|
||||
onClear: () => void;
|
||||
}
|
||||
|
||||
export function ChatHeader({ onClear }: ChatHeaderProps): React.ReactElement {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Avoid hydration mismatch
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
const toggleTheme = () => {
|
||||
setTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="border-b border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<h1 className="text-xl font-bold">TPMJS Playground</h1>
|
||||
<Link
|
||||
href="https://tpmjs.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-sm text-foreground-secondary hover:text-foreground"
|
||||
>
|
||||
View Registry →
|
||||
</Link>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{mounted && (
|
||||
<Button variant="ghost" onClick={toggleTheme} size="md">
|
||||
{theme === 'dark' ? '☀️' : '🌙'}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={onClear} size="md">
|
||||
Clear Chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
52
apps/playground/src/components/chat/ChatInput.tsx
Normal file
52
apps/playground/src/components/chat/ChatInput.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import type { FormEvent } from 'react';
|
||||
|
||||
interface ChatInputProps {
|
||||
input: string;
|
||||
isLoading: boolean;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onSubmit: (e: FormEvent<HTMLFormElement>) => void;
|
||||
setInput: (value: string) => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
input,
|
||||
isLoading,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
}: ChatInputProps): React.ReactElement {
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
if (input.trim() && !isLoading) {
|
||||
// Trigger form submission
|
||||
const form = e.currentTarget.form;
|
||||
if (form) {
|
||||
form.requestSubmit();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="border-t border-border bg-background p-4">
|
||||
<div className="mx-auto flex max-w-4xl gap-2">
|
||||
<Textarea
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask me to tell you a fish joke..."
|
||||
className="min-h-[60px] flex-1 resize-none"
|
||||
disabled={isLoading}
|
||||
rows={3}
|
||||
/>
|
||||
<Button type="submit" disabled={!input.trim() || isLoading} loading={isLoading} size="lg">
|
||||
Send
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
38
apps/playground/src/components/chat/ChatInterface.tsx
Normal file
38
apps/playground/src/components/chat/ChatInterface.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useChat } from '~/hooks/useChat';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { ChatMessages } from './ChatMessages';
|
||||
|
||||
export function ChatInterface(): React.ReactElement {
|
||||
const { messages, sendMessage, status } = useChat();
|
||||
const [input, setInput] = useState('');
|
||||
|
||||
const isLoading = status !== 'ready';
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
setInput(e.target.value);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
e.preventDefault();
|
||||
if (input.trim() && status === 'ready') {
|
||||
sendMessage({ text: input });
|
||||
setInput('');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col">
|
||||
<ChatMessages messages={messages} isStreaming={isLoading} />
|
||||
<ChatInput
|
||||
input={input}
|
||||
isLoading={isLoading}
|
||||
onInputChange={handleInputChange}
|
||||
onSubmit={handleSubmit}
|
||||
setInput={setInput}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
51
apps/playground/src/components/chat/ChatMessages.tsx
Normal file
51
apps/playground/src/components/chat/ChatMessages.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import type { UIMessage } from 'ai';
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { MessageBubble } from './MessageBubble';
|
||||
|
||||
interface ChatMessagesProps {
|
||||
messages: UIMessage[];
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function ChatMessages({
|
||||
messages,
|
||||
isStreaming = false,
|
||||
}: ChatMessagesProps): React.ReactElement {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Auto-scroll to bottom when new messages arrive
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
});
|
||||
|
||||
if (messages.length === 0) {
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center">
|
||||
<div className="text-center text-foreground-secondary">
|
||||
<div className="mb-2 text-4xl">🐟</div>
|
||||
<p className="text-lg">Start chatting to test TPMJS tools</p>
|
||||
<p className="mt-2 text-sm">Try asking: “Tell me a fish joke”</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 space-y-4 overflow-y-auto p-4">
|
||||
{messages.map((message, idx) => {
|
||||
// Only animate the last message if it's streaming
|
||||
const isLastMessage = idx === messages.length - 1;
|
||||
return (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isStreaming={isStreaming && isLastMessage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
124
apps/playground/src/components/chat/MessageBubble.tsx
Normal file
124
apps/playground/src/components/chat/MessageBubble.tsx
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Card, CardContent } from '@tpmjs/ui/Card/Card';
|
||||
import type { UIMessage } from 'ai';
|
||||
import { Streamdown } from 'streamdown';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: UIMessage;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
isStreaming = false,
|
||||
}: MessageBubbleProps): React.ReactElement {
|
||||
const isUser = message.role === 'user';
|
||||
|
||||
return (
|
||||
<div className={`flex ${isUser ? 'justify-end' : 'justify-start'}`}>
|
||||
<Card variant={isUser ? 'elevated' : 'outline'} className="w-full max-w-2xl">
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
<Badge variant={isUser ? 'default' : 'secondary'} size="sm">
|
||||
{isUser ? 'You' : 'AI'}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
{new Date().toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Render message parts */}
|
||||
{message.parts && message.parts.length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{message.parts.map((part, idx) => {
|
||||
// Skip step-start markers
|
||||
if (part.type === 'step-start') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Render text parts with markdown support
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={`text-${message.id}-${idx}`} className="text-sm">
|
||||
<Streamdown isAnimating={isStreaming && !isUser}>
|
||||
{part.text || ''}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Render tool calls (type starts with 'tool-')
|
||||
if (part.type.startsWith('tool-')) {
|
||||
const toolName = part.type.replace('tool-', '');
|
||||
// Type assertion for tool parts
|
||||
const toolPart = part as any;
|
||||
return (
|
||||
<div
|
||||
key={toolPart.toolCallId || idx}
|
||||
className="rounded border border-amber-500/20 bg-amber-500/5 p-3"
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 border-b border-amber-500/20 pb-2">
|
||||
<span className="text-base">🔧</span>
|
||||
<strong className="font-mono text-sm text-foreground">{toolName}</strong>
|
||||
{toolPart.state && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{toolPart.state}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tool Input */}
|
||||
{toolPart.input && (
|
||||
<div className="mb-3">
|
||||
<div className="mb-1 text-xs font-semibold text-foreground-secondary">
|
||||
Input:
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-surface p-2 text-xs text-foreground-secondary">
|
||||
{JSON.stringify(toolPart.input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Output */}
|
||||
{toolPart.output && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-foreground-secondary">
|
||||
Output:
|
||||
</div>
|
||||
<pre className="overflow-x-auto rounded bg-surface p-2 text-xs text-foreground-secondary">
|
||||
{JSON.stringify(toolPart.output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool Error */}
|
||||
{(toolPart.errorText || toolPart.error) && (
|
||||
<div>
|
||||
<div className="mb-1 text-xs font-semibold text-red-500">Error:</div>
|
||||
<pre className="overflow-x-auto rounded bg-red-500/10 p-2 text-xs text-red-400">
|
||||
{typeof (toolPart.errorText || toolPart.error) === 'string'
|
||||
? toolPart.errorText || toolPart.error
|
||||
: JSON.stringify(toolPart.errorText || toolPart.error, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
// Fallback if no parts
|
||||
<div className="text-sm">
|
||||
<Streamdown isAnimating={isStreaming && !isUser}>...</Streamdown>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
185
apps/playground/src/components/sidebar/SettingsSidebar.tsx
Normal file
185
apps/playground/src/components/sidebar/SettingsSidebar.tsx
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface EnvVar {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
const ENV_STORAGE_KEY = 'tpmjs-playground-env-vars';
|
||||
|
||||
export function SettingsSidebar(): React.ReactElement {
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
|
||||
const [newKey, setNewKey] = useState('');
|
||||
const [newValue, setNewValue] = useState('');
|
||||
|
||||
// Load env vars from localStorage on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(ENV_STORAGE_KEY);
|
||||
if (stored) {
|
||||
setEnvVars(JSON.parse(stored));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load env vars from localStorage:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Save env vars to localStorage whenever they change
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(ENV_STORAGE_KEY, JSON.stringify(envVars));
|
||||
// Dispatch custom event so other components can react to changes
|
||||
window.dispatchEvent(new CustomEvent('env-vars-updated', { detail: envVars }));
|
||||
} catch (error) {
|
||||
console.error('Failed to save env vars to localStorage:', error);
|
||||
}
|
||||
}, [envVars]);
|
||||
|
||||
const handleAddEnvVar = () => {
|
||||
if (!newKey.trim()) return;
|
||||
|
||||
// Check if key already exists
|
||||
const exists = envVars.some((env) => env.key === newKey);
|
||||
if (exists) {
|
||||
// Update existing
|
||||
setEnvVars(
|
||||
envVars.map((env) => (env.key === newKey ? { key: newKey, value: newValue } : env))
|
||||
);
|
||||
} else {
|
||||
// Add new
|
||||
setEnvVars([...envVars, { key: newKey, value: newValue }]);
|
||||
}
|
||||
|
||||
setNewKey('');
|
||||
setNewValue('');
|
||||
};
|
||||
|
||||
const handleRemoveEnvVar = (key: string) => {
|
||||
setEnvVars(envVars.filter((env) => env.key !== key));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside className="hidden w-80 border-l border-border bg-surface md:block">
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<h2 className="mb-4 text-lg font-bold">Settings</h2>
|
||||
|
||||
{/* Environment Variables Section */}
|
||||
<Card variant="outline" className="mb-4">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">
|
||||
Environment Variables{' '}
|
||||
<Badge variant="secondary" size="sm">
|
||||
{envVars.length}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="mb-4 text-xs text-foreground-secondary">
|
||||
Add API keys and other environment variables. They will be forwarded to tool
|
||||
executions.
|
||||
</p>
|
||||
|
||||
{/* Add new env var form */}
|
||||
<div className="mb-4 space-y-2">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Key (e.g., FIRECRAWL_API_KEY)"
|
||||
value={newKey}
|
||||
onChange={(e) => setNewKey(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="Value"
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
className="font-mono text-xs"
|
||||
/>
|
||||
<Button onClick={handleAddEnvVar} size="sm" variant="default" className="w-full">
|
||||
Add Variable
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* List of env vars */}
|
||||
<div className="space-y-2">
|
||||
{envVars.length === 0 ? (
|
||||
<p className="text-xs text-foreground-tertiary">No environment variables set</p>
|
||||
) : (
|
||||
envVars.map((env) => (
|
||||
<div
|
||||
key={env.key}
|
||||
className="flex items-center justify-between rounded border border-border bg-background p-2"
|
||||
>
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="truncate font-mono text-xs font-semibold">{env.key}</p>
|
||||
<p className="truncate font-mono text-xs text-foreground-tertiary">
|
||||
{env.value ? '•'.repeat(Math.min(env.value.length, 20)) : '(empty)'}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => handleRemoveEnvVar(env.key)}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="ml-2"
|
||||
>
|
||||
×
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Info Section */}
|
||||
<div className="mt-auto rounded border border-border bg-background p-3">
|
||||
<p className="text-xs text-foreground-secondary">
|
||||
<strong>Note:</strong> Environment variables are stored locally in your browser and sent
|
||||
with each tool execution request.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to get current env vars from localStorage
|
||||
* Can be used in other components to access env vars
|
||||
*/
|
||||
export function useEnvVars(): EnvVar[] {
|
||||
const [envVars, setEnvVars] = useState<EnvVar[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
// Load initially
|
||||
const loadEnvVars = () => {
|
||||
try {
|
||||
const stored = localStorage.getItem(ENV_STORAGE_KEY);
|
||||
if (stored) {
|
||||
setEnvVars(JSON.parse(stored));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load env vars:', error);
|
||||
}
|
||||
};
|
||||
|
||||
loadEnvVars();
|
||||
|
||||
// Listen for updates
|
||||
const handleUpdate = (event: Event) => {
|
||||
const customEvent = event as CustomEvent<EnvVar[]>;
|
||||
setEnvVars(customEvent.detail);
|
||||
};
|
||||
|
||||
window.addEventListener('env-vars-updated', handleUpdate);
|
||||
return () => window.removeEventListener('env-vars-updated', handleUpdate);
|
||||
}, []);
|
||||
|
||||
return envVars;
|
||||
}
|
||||
262
apps/playground/src/components/sidebar/ToolsSidebar.tsx
Normal file
262
apps/playground/src/components/sidebar/ToolsSidebar.tsx
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { ToolHealthBadge } from '@tpmjs/ui/ToolHealthBadge/ToolHealthBadge';
|
||||
import { ToolHealthBanner } from '@tpmjs/ui/ToolHealthBanner/ToolHealthBanner';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface Tool {
|
||||
toolId?: string;
|
||||
packageName: string;
|
||||
exportName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
version: string;
|
||||
qualityScore?: number;
|
||||
frameworks?: string[];
|
||||
env?: Array<{ name: string; description: string; required?: boolean; default?: string }>;
|
||||
importUrl?: string;
|
||||
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
healthCheckError?: string | null;
|
||||
lastHealthCheck?: string | null;
|
||||
}
|
||||
|
||||
export function ToolsSidebar(): React.ReactElement {
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [filter, setFilter] = useState('');
|
||||
const [selectedTool, setSelectedTool] = useState<Tool | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchTools() {
|
||||
try {
|
||||
const response = await fetch('/api/tools');
|
||||
const data = await response.json();
|
||||
console.log('🔍 [ToolsSidebar] Fetched tools:', data.tools?.length, 'tools');
|
||||
console.log('🔍 [ToolsSidebar] First tool sample:', data.tools?.[0]);
|
||||
console.log('🔍 [ToolsSidebar] Health fields check:', {
|
||||
hasImportHealth: 'importHealth' in (data.tools?.[0] || {}),
|
||||
hasExecutionHealth: 'executionHealth' in (data.tools?.[0] || {}),
|
||||
firstToolHealth: {
|
||||
importHealth: data.tools?.[0]?.importHealth,
|
||||
executionHealth: data.tools?.[0]?.executionHealth,
|
||||
},
|
||||
});
|
||||
if (data.success) {
|
||||
setTools(data.tools);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tools:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
fetchTools();
|
||||
}, []);
|
||||
|
||||
const filteredTools = tools.filter(
|
||||
(tool) =>
|
||||
tool.packageName?.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
tool.exportName?.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
tool.description?.toLowerCase().includes(filter.toLowerCase()) ||
|
||||
tool.category?.toLowerCase().includes(filter.toLowerCase())
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden w-64 border-r border-border bg-surface md:block">
|
||||
<div className="flex h-full flex-col p-4">
|
||||
<h2 className="mb-4 text-lg font-bold">
|
||||
Available Tools <Badge variant="secondary">{filteredTools.length}</Badge>
|
||||
</h2>
|
||||
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Filter tools..."
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value)}
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
<div className="flex-1 space-y-2 overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-sm text-foreground-secondary">Loading tools...</p>
|
||||
) : filteredTools.length === 0 ? (
|
||||
<p className="text-sm text-foreground-secondary">No tools found</p>
|
||||
) : (
|
||||
filteredTools.map((tool) => (
|
||||
<Card
|
||||
key={`${tool.packageName}-${tool.exportName}`}
|
||||
variant="outline"
|
||||
className="cursor-pointer transition-all hover:bg-background hover:shadow-md"
|
||||
onClick={() => setSelectedTool(tool)}
|
||||
>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-sm">{tool.exportName}</CardTitle>
|
||||
<p className="text-xs text-foreground-tertiary mt-0.5">{tool.packageName}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-xs text-foreground-secondary line-clamp-2">
|
||||
{tool.description}
|
||||
</p>
|
||||
<div className="mt-2 flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">v{tool.version}</span>
|
||||
<ToolHealthBadge
|
||||
importHealth={tool.importHealth}
|
||||
executionHealth={tool.executionHealth}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Tool Details Modal */}
|
||||
{selectedTool && (
|
||||
// biome-ignore lint/a11y/useSemanticElements: Modal backdrop - standard pattern for modal overlays with role=button
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm"
|
||||
onClick={(e) => {
|
||||
// Only close if clicking the backdrop itself, not the content
|
||||
if (e.target === e.currentTarget) setSelectedTool(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Escape') setSelectedTool(null);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<div className="relative mx-4 max-h-[90vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-border bg-white dark:bg-gray-900 p-6 shadow-xl">
|
||||
{/* Close button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelectedTool(null)}
|
||||
className="absolute right-4 top-4 text-foreground-tertiary hover:text-foreground"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-6 w-6"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<title>Close</title>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M6 18L18 6M6 6l12 12"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Tool header */}
|
||||
<div className="mb-6 border-b border-border pb-4">
|
||||
<h2 className="mb-2 text-2xl font-bold text-foreground">{selectedTool.exportName}</h2>
|
||||
<p className="mb-2 text-sm text-foreground-secondary">{selectedTool.packageName}</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{selectedTool.category}</Badge>
|
||||
<span className="text-sm text-foreground-tertiary">v{selectedTool.version}</span>
|
||||
{selectedTool.qualityScore && (
|
||||
<Badge variant="info">
|
||||
Score: {(selectedTool.qualityScore * 100).toFixed(0)}%
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health warning banner */}
|
||||
<ToolHealthBanner
|
||||
importHealth={selectedTool.importHealth}
|
||||
executionHealth={selectedTool.executionHealth}
|
||||
healthCheckError={selectedTool.healthCheckError}
|
||||
lastHealthCheck={selectedTool.lastHealthCheck}
|
||||
className="mb-6"
|
||||
/>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Description</h3>
|
||||
<p className="text-sm text-foreground-secondary">{selectedTool.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Frameworks */}
|
||||
{selectedTool.frameworks && selectedTool.frameworks.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Frameworks</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTool.frameworks.map((framework) => (
|
||||
<Badge key={framework} variant="outline" size="sm">
|
||||
{framework}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Environment Variables */}
|
||||
{selectedTool.env && selectedTool.env.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">
|
||||
Environment Variables
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedTool.env.map((envVar) => (
|
||||
<div key={envVar.name} className="rounded border border-border bg-surface p-3">
|
||||
<div className="mb-1 flex items-center gap-2">
|
||||
<code className="text-sm font-mono text-foreground">{envVar.name}</code>
|
||||
{envVar.required && (
|
||||
<Badge variant="error" size="sm">
|
||||
Required
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-foreground-secondary">{envVar.description}</p>
|
||||
{envVar.default && (
|
||||
<p className="mt-1 text-xs text-foreground-tertiary">
|
||||
Default: <code className="font-mono">{envVar.default}</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import URL */}
|
||||
{selectedTool.importUrl && (
|
||||
<div className="mb-6">
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Import URL</h3>
|
||||
<code className="block rounded bg-surface p-3 text-xs font-mono text-foreground-secondary break-all">
|
||||
{selectedTool.importUrl}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tool ID */}
|
||||
{selectedTool.toolId && (
|
||||
<div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-foreground">Tool ID</h3>
|
||||
<code className="block rounded bg-surface p-3 text-xs font-mono text-foreground-secondary">
|
||||
{selectedTool.toolId}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
7
apps/playground/src/env.ts
Normal file
7
apps/playground/src/env.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { createEnv } from '@tpmjs/env';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const env = createEnv({
|
||||
// Server-only (optional for playground - can be provided by client UI)
|
||||
OPENAI_API_KEY: z.string().min(1).optional(),
|
||||
});
|
||||
59
apps/playground/src/hooks/useChat.ts
Normal file
59
apps/playground/src/hooks/useChat.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
'use client';
|
||||
|
||||
import { useChat as useAISDKChat } from '@ai-sdk/react';
|
||||
import { DefaultChatTransport } from 'ai';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { useState } from 'react';
|
||||
|
||||
/**
|
||||
* Custom chat hook that wraps the official @ai-sdk/react useChat
|
||||
* Handles SSE streaming with tool calls and UI message protocol
|
||||
* Includes conversation ID tracking for dynamic tool loading
|
||||
*/
|
||||
const ENV_STORAGE_KEY = 'tpmjs-playground-env-vars';
|
||||
|
||||
export function useChat(): ReturnType<typeof useAISDKChat> & { conversationId: string } {
|
||||
// Generate stable conversation ID for session
|
||||
const [conversationId] = useState(() => nanoid());
|
||||
|
||||
const chat = useAISDKChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/chat',
|
||||
// Use function body that reads FRESH from localStorage on each request
|
||||
// This avoids React closure issues where envVars would be stale
|
||||
body: () => {
|
||||
// Read env vars directly from localStorage (not from React state)
|
||||
let envVars: Array<{ key: string; value: string }> = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(ENV_STORAGE_KEY);
|
||||
if (stored) {
|
||||
envVars = JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to read env vars from localStorage:', error);
|
||||
}
|
||||
|
||||
// Convert to object format
|
||||
const env = envVars.reduce(
|
||||
(acc, { key, value }) => {
|
||||
acc[key] = value;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
);
|
||||
|
||||
console.log('🔑 [useChat] Reading env vars from localStorage:', Object.keys(env));
|
||||
|
||||
return {
|
||||
conversationId,
|
||||
env,
|
||||
};
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return {
|
||||
...chat,
|
||||
conversationId,
|
||||
};
|
||||
}
|
||||
46
apps/playground/src/hooks/useToolUsage.ts
Normal file
46
apps/playground/src/hooks/useToolUsage.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useState } from 'react';
|
||||
import type { ToolUsageStats } from '~/lib/types';
|
||||
|
||||
export function useToolUsage() {
|
||||
const [toolUsage, setToolUsage] = useState<Map<string, ToolUsageStats>>(new Map());
|
||||
|
||||
const trackTool = useCallback((packageName: string) => {
|
||||
setToolUsage((prev) => {
|
||||
const newMap = new Map(prev);
|
||||
const existing = newMap.get(packageName);
|
||||
|
||||
if (existing) {
|
||||
newMap.set(packageName, {
|
||||
...existing,
|
||||
callCount: existing.callCount + 1,
|
||||
lastCalledAt: new Date(),
|
||||
});
|
||||
} else {
|
||||
newMap.set(packageName, {
|
||||
packageName,
|
||||
callCount: 1,
|
||||
lastCalledAt: new Date(),
|
||||
});
|
||||
}
|
||||
|
||||
return newMap;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const clearUsage = useCallback(() => {
|
||||
setToolUsage(new Map());
|
||||
}, []);
|
||||
|
||||
// Convert Map to array sorted by most recent first
|
||||
const toolUsageArray = Array.from(toolUsage.values()).sort(
|
||||
(a, b) => b.lastCalledAt.getTime() - a.lastCalledAt.getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
toolUsage: toolUsageArray,
|
||||
trackTool,
|
||||
clearUsage,
|
||||
};
|
||||
}
|
||||
325
apps/playground/src/lib/dynamic-tool-loader.ts
Normal file
325
apps/playground/src/lib/dynamic-tool-loader.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { jsonSchema, tool } from 'ai';
|
||||
|
||||
// Cache for tool wrappers (process-level)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex
|
||||
const moduleCache = new Map<string, any>();
|
||||
|
||||
// Cache for per-conversation active tools
|
||||
const conversationTools = new Map<string, Set<string>>();
|
||||
|
||||
// Cache for per-conversation env vars (updated on each request)
|
||||
const conversationEnv = new Map<string, Record<string, string>>();
|
||||
|
||||
// Railway service URL
|
||||
const RAILWAY_SERVICE_URL =
|
||||
process.env.RAILWAY_SERVICE_URL || process.env.SANDBOX_EXECUTOR_URL || 'http://localhost:3001';
|
||||
|
||||
/**
|
||||
* Generate cache key for a tool
|
||||
*/
|
||||
function getCacheKey(packageName: string, exportName: string): string {
|
||||
return `${packageName}::${exportName}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set environment variables for a conversation
|
||||
* This allows tools to access the latest env vars even when cached
|
||||
*/
|
||||
export function setConversationEnv(conversationId: string, env: Record<string, string>): void {
|
||||
console.log(`🔑 Setting env for conversation ${conversationId}:`, Object.keys(env));
|
||||
conversationEnv.set(conversationId, env);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get environment variables for a conversation
|
||||
*/
|
||||
function getConversationEnv(conversationId: string): Record<string, string> {
|
||||
return conversationEnv.get(conversationId) || {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Report tool failure and trigger async health check
|
||||
* Non-blocking - logs error and triggers health check in background
|
||||
*/
|
||||
async function reportToolFailure(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
error: string,
|
||||
phase: 'import' | 'execution'
|
||||
): Promise<void> {
|
||||
try {
|
||||
// Find the tool in the database
|
||||
const tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
exportName,
|
||||
package: {
|
||||
npmPackageName: packageName,
|
||||
},
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
console.warn(`⚠️ Tool not found in database for health check: ${packageName}/${exportName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🏥 Triggering health check for ${packageName}/${exportName} (${tool.id})`);
|
||||
|
||||
// Update health status immediately (optimistic)
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
[phase === 'import' ? 'importHealth' : 'executionHealth']: 'BROKEN',
|
||||
healthCheckError: error,
|
||||
lastHealthCheck: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Health status updated for ${packageName}/${exportName}`);
|
||||
} catch (err) {
|
||||
console.error('❌ Failed to report tool failure:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically load a tool via Railway service
|
||||
* Railway service runs with --experimental-network-imports and can import from esm.sh
|
||||
*/
|
||||
export async function loadToolDynamically(
|
||||
packageName: string,
|
||||
exportName: string,
|
||||
version: string,
|
||||
conversationId: string,
|
||||
importUrl?: string,
|
||||
env?: Record<string, string>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex
|
||||
): Promise<any | null> {
|
||||
const cacheKey = getCacheKey(packageName, exportName);
|
||||
|
||||
// Check cache first
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Cache hit: ${cacheKey}`);
|
||||
return moduleCache.get(cacheKey);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`📦 Loading from Railway: ${packageName}/${exportName}`);
|
||||
console.log(`🔗 Railway URL: ${RAILWAY_SERVICE_URL}`);
|
||||
|
||||
// Call Railway service to load and describe tool
|
||||
const response = await fetch(`${RAILWAY_SERVICE_URL}/load-and-describe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName,
|
||||
exportName,
|
||||
version,
|
||||
importUrl: importUrl || `https://esm.sh/${packageName}@${version}`,
|
||||
env: env || {},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`❌ Railway service error (${response.status}): ${errorText}`);
|
||||
|
||||
// Trigger health check update in background (non-blocking)
|
||||
reportToolFailure(
|
||||
packageName,
|
||||
exportName,
|
||||
`Railway service error (${response.status}): ${errorText}`,
|
||||
'import'
|
||||
).catch((err) =>
|
||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.success) {
|
||||
console.error(`❌ Failed to load tool: ${data.error}`);
|
||||
|
||||
// Trigger health check update in background (non-blocking)
|
||||
reportToolFailure(packageName, exportName, data.error || 'Unknown error', 'import').catch(
|
||||
(err) =>
|
||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
||||
);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(`✅ Tool loaded from Railway: ${cacheKey}`);
|
||||
console.log(`📋 Description: ${data.tool.description}`);
|
||||
|
||||
// Create a proper AI SDK tool wrapper that executes remotely
|
||||
// Railway returns plain JSON Schema - wrap it with jsonSchema() for AI SDK
|
||||
const toolWrapper = tool({
|
||||
description: data.tool.description,
|
||||
inputSchema: data.tool.inputSchema
|
||||
? jsonSchema(data.tool.inputSchema)
|
||||
: jsonSchema({ type: 'object', properties: {}, additionalProperties: false }),
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool params are dynamic
|
||||
execute: async (params: any) => {
|
||||
console.log(`🚀 Executing ${packageName}/${exportName} remotely with params:`, params);
|
||||
|
||||
// Get the latest env vars for this conversation (not from closure!)
|
||||
const currentEnv = getConversationEnv(conversationId);
|
||||
console.log(
|
||||
`🔐 Using env vars for conversation ${conversationId}:`,
|
||||
Object.keys(currentEnv)
|
||||
);
|
||||
|
||||
const execResponse = await fetch(`${RAILWAY_SERVICE_URL}/execute-tool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName,
|
||||
exportName,
|
||||
version,
|
||||
importUrl: importUrl || `https://esm.sh/${packageName}@${version}`,
|
||||
params,
|
||||
env: currentEnv,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await execResponse.json();
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`❌ Tool execution failed: ${result.error}`);
|
||||
|
||||
// Trigger health check update in background (non-blocking)
|
||||
reportToolFailure(
|
||||
packageName,
|
||||
exportName,
|
||||
result.error || 'Tool execution failed',
|
||||
'execution'
|
||||
).catch((err) =>
|
||||
console.error(`Failed to report tool failure for ${packageName}/${exportName}:`, err)
|
||||
);
|
||||
|
||||
throw new Error(result.error || 'Tool execution failed');
|
||||
}
|
||||
|
||||
console.log(`✅ Tool executed successfully in ${result.executionTimeMs}ms`);
|
||||
return result.output;
|
||||
},
|
||||
});
|
||||
|
||||
// Cache the wrapper
|
||||
moduleCache.set(cacheKey, toolWrapper);
|
||||
console.log(`✅ Cached tool wrapper: ${cacheKey}`);
|
||||
|
||||
return toolWrapper;
|
||||
} catch (error) {
|
||||
console.error(`❌ Failed to load ${packageName}#${exportName}:`, error);
|
||||
console.error(' Stack:', error instanceof Error ? error.stack : 'No stack trace');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load multiple tools in parallel with collated error reporting
|
||||
*/
|
||||
export async function loadToolsBatch(
|
||||
toolMetadata: Array<{
|
||||
packageName: string;
|
||||
exportName: string;
|
||||
version: string;
|
||||
importUrl?: string;
|
||||
}>,
|
||||
conversationId: string,
|
||||
env?: Record<string, string>
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex
|
||||
): Promise<Record<string, any>> {
|
||||
console.log(`📦 Loading ${toolMetadata.length} tools for conversation ${conversationId}`);
|
||||
console.log('🔑 Env vars being passed:', Object.keys(env || {}));
|
||||
|
||||
const promises = toolMetadata.map((meta) =>
|
||||
loadToolDynamically(
|
||||
meta.packageName,
|
||||
meta.exportName,
|
||||
meta.version,
|
||||
conversationId,
|
||||
meta.importUrl,
|
||||
env
|
||||
).then((tool) => ({
|
||||
packageName: meta.packageName,
|
||||
exportName: meta.exportName,
|
||||
key: getCacheKey(meta.packageName, meta.exportName),
|
||||
tool,
|
||||
success: tool !== null,
|
||||
}))
|
||||
);
|
||||
|
||||
const results = await Promise.all(promises);
|
||||
|
||||
// Separate successful and failed tools
|
||||
const successful = results.filter((r) => r.success);
|
||||
const failed = results.filter((r) => !r.success);
|
||||
|
||||
// Build tools object
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex
|
||||
const tools: Record<string, any> = {};
|
||||
for (const result of successful) {
|
||||
tools[result.key] = result.tool;
|
||||
}
|
||||
|
||||
// Log collated error summary
|
||||
console.log('\n📊 Batch Load Summary:');
|
||||
console.log(` ✅ Successful: ${successful.length}/${toolMetadata.length}`);
|
||||
console.log(` ❌ Failed: ${failed.length}/${toolMetadata.length}`);
|
||||
|
||||
if (failed.length > 0) {
|
||||
console.log('\n❌ Failed Tools:');
|
||||
for (const result of failed) {
|
||||
console.log(` - ${result.packageName}/${result.exportName}`);
|
||||
console.log(' (Health check triggered automatically)');
|
||||
}
|
||||
console.log('\n💡 Note: Failed tools have been marked as broken in the database.');
|
||||
console.log(' Check individual error logs above for detailed failure reasons.');
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Track tools for a conversation
|
||||
*/
|
||||
export function addConversationTools(conversationId: string, toolKeys: string[]): void {
|
||||
if (!conversationTools.has(conversationId)) {
|
||||
conversationTools.set(conversationId, new Set());
|
||||
}
|
||||
const tools = conversationTools.get(conversationId);
|
||||
if (!tools) return; // Should never happen after the check above
|
||||
for (const key of toolKeys) {
|
||||
tools.add(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all tools for a conversation
|
||||
*/
|
||||
export function getConversationTools(conversationId: string): string[] {
|
||||
return Array.from(conversationTools.get(conversationId) || []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear conversation tools (on session end)
|
||||
*/
|
||||
export function clearConversationTools(conversationId: string): void {
|
||||
conversationTools.delete(conversationId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
export function getCacheStats() {
|
||||
return {
|
||||
moduleCacheSize: moduleCache.size,
|
||||
conversationCount: conversationTools.size,
|
||||
};
|
||||
}
|
||||
94
apps/playground/src/lib/tool-loader.ts
Normal file
94
apps/playground/src/lib/tool-loader.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
// Static imports for tools (required for Next.js/webpack)
|
||||
import { helloNameTool, helloWorldTool } from '@tpmjs/hello';
|
||||
|
||||
/**
|
||||
* Tool registry mapping package names + export names to actual tool functions
|
||||
* This is a static mapping required for Next.js/webpack bundling
|
||||
*/
|
||||
const TOOL_REGISTRY: Record<string, Record<string, any>> = {
|
||||
'@tpmjs/hello': {
|
||||
helloWorldTool,
|
||||
helloNameTool,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Load a specific TPMJS tool by package name and export name
|
||||
* Uses static imports to work with Next.js/webpack bundling
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex and using any is appropriate here
|
||||
export async function loadTpmjsTool(packageName: string, exportName: string): Promise<any | null> {
|
||||
try {
|
||||
// Look up the package in the registry
|
||||
const packageTools = TOOL_REGISTRY[packageName];
|
||||
if (!packageTools) {
|
||||
console.warn(`Package not found in registry: ${packageName}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
// Look up the specific tool export
|
||||
const tool = packageTools[exportName];
|
||||
if (!tool) {
|
||||
console.warn(
|
||||
`Export '${exportName}' not found in package ${packageName}. Available exports:`,
|
||||
Object.keys(packageTools)
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
return tool;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load tool ${packageName}/${exportName}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize tool name to match OpenAI's requirements
|
||||
* Pattern: ^[a-zA-Z0-9_-]+$ (only letters, numbers, underscores, hyphens)
|
||||
*/
|
||||
export function sanitizeToolName(name: string): string {
|
||||
return name
|
||||
.replace(/@/g, '') // Remove @ symbols
|
||||
.replace(/\//g, '_') // Replace / with _
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_'); // Replace any other invalid chars with _
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all installed TPMJS tools
|
||||
* Returns a flat object with all tools keyed by sanitized packageName-exportName
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex and using any is appropriate here
|
||||
export async function loadAllTools(): Promise<Record<string, any>> {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types from AI SDK are complex and using any is appropriate here
|
||||
const tools: Record<string, any> = {};
|
||||
|
||||
// Iterate through all registered packages
|
||||
for (const [packageName, packageTools] of Object.entries(TOOL_REGISTRY)) {
|
||||
for (const [exportName, tool] of Object.entries(packageTools)) {
|
||||
// Create a unique, sanitized key for this tool
|
||||
const toolKey = sanitizeToolName(`${packageName}-${exportName}`);
|
||||
tools[toolKey] = tool;
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all available package names
|
||||
*/
|
||||
export function getAvailablePackages(): string[] {
|
||||
return Object.keys(TOOL_REGISTRY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all export names for a given package
|
||||
*/
|
||||
export function getPackageExports(packageName: string): string[] {
|
||||
const packageTools = TOOL_REGISTRY[packageName];
|
||||
if (!packageTools) {
|
||||
return [];
|
||||
}
|
||||
return Object.keys(packageTools);
|
||||
}
|
||||
37
apps/playground/src/lib/types.ts
Normal file
37
apps/playground/src/lib/types.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
export type MessageRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: MessageRole;
|
||||
content: string;
|
||||
timestamp: Date;
|
||||
toolCalls?: ToolCallInfo[];
|
||||
}
|
||||
|
||||
export interface ToolCallInfo {
|
||||
id: string;
|
||||
toolName: string;
|
||||
parameters: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
status?: 'pending' | 'success' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface TokenUsage {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
export interface ToolUsageStats {
|
||||
packageName: string;
|
||||
callCount: number;
|
||||
lastCalledAt: Date;
|
||||
}
|
||||
|
||||
export type SSEEvent =
|
||||
| { type: 'chunk'; data: { text: string } }
|
||||
| { type: 'tool-call'; data: ToolCallInfo }
|
||||
| { type: 'complete'; data: { tokenUsage?: TokenUsage } }
|
||||
| { type: 'error'; data: { message: string } };
|
||||
12
apps/playground/tailwind.config.ts
Normal file
12
apps/playground/tailwind.config.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import baseConfig from '@tpmjs/tailwind-config/base';
|
||||
import type { Config } from 'tailwindcss';
|
||||
|
||||
export default {
|
||||
...baseConfig,
|
||||
content: [
|
||||
'./src/app/**/*.{ts,tsx}',
|
||||
'./src/components/**/*.{ts,tsx}',
|
||||
'../../packages/ui/src/**/*.ts',
|
||||
],
|
||||
plugins: [...(baseConfig.plugins || []), require('@tailwindcss/typography')],
|
||||
} satisfies Config;
|
||||
12
apps/playground/tsconfig.json
Normal file
12
apps/playground/tsconfig.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"extends": "@tpmjs/tsconfig/nextjs.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"~/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
3
apps/railway-executor/.gitignore
vendored
Normal file
3
apps/railway-executor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
node_modules/
|
||||
.env
|
||||
.env.local
|
||||
14
apps/railway-executor/Dockerfile
Normal file
14
apps/railway-executor/Dockerfile
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Use official Deno image
|
||||
FROM denoland/deno:1.39.0
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
||||
# Copy the server file
|
||||
COPY server.ts .
|
||||
|
||||
# Expose port (Railway will set PORT env var)
|
||||
EXPOSE 3002
|
||||
|
||||
# Run the Deno server
|
||||
CMD ["deno", "run", "--allow-net", "--allow-env", "server.ts"]
|
||||
160
apps/railway-executor/README.md
Normal file
160
apps/railway-executor/README.md
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
# Railway Dynamic Tool Executor
|
||||
|
||||
Dynamic tool executor service that runs with `--experimental-network-imports` to support loading npm packages from esm.sh at runtime.
|
||||
|
||||
## Features
|
||||
|
||||
- 🔥 **Dynamic imports** from esm.sh
|
||||
- 💾 **Module caching** for fast repeated loads
|
||||
- 🛡️ **Validation** of AI SDK tool structure
|
||||
- 🚀 **Remote execution** of tools with parameters
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /health`
|
||||
Health check and cache statistics
|
||||
|
||||
### `POST /load-and-describe`
|
||||
Load a tool from esm.sh and return its schema
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2",
|
||||
"importUrl": "https://esm.sh/firecrawl-aisdk@0.7.2"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"tool": {
|
||||
"exportName": "webSearchTool",
|
||||
"description": "Search the web using Firecrawl",
|
||||
"inputSchema": { ... }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /execute-tool`
|
||||
Execute a tool with parameters
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"packageName": "firecrawl-aisdk",
|
||||
"exportName": "webSearchTool",
|
||||
"version": "0.7.2",
|
||||
"params": {
|
||||
"query": "latest AI news"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"output": { ... },
|
||||
"executionTimeMs": 1234
|
||||
}
|
||||
```
|
||||
|
||||
### `POST /cache/clear`
|
||||
Clear the module cache
|
||||
|
||||
### `GET /cache/stats`
|
||||
Get cache statistics
|
||||
|
||||
## Local Development
|
||||
|
||||
```bash
|
||||
# Install dependencies
|
||||
npm install
|
||||
|
||||
# Start server
|
||||
npm start
|
||||
|
||||
# Server runs on http://localhost:3001
|
||||
```
|
||||
|
||||
## Railway Deployment
|
||||
|
||||
This service is designed to run on Railway.
|
||||
|
||||
### Deploy Steps
|
||||
|
||||
1. Initialize Railway in this directory:
|
||||
```bash
|
||||
cd apps/railway-executor
|
||||
railway init
|
||||
```
|
||||
|
||||
2. Link to your Railway project:
|
||||
```bash
|
||||
railway link
|
||||
```
|
||||
|
||||
3. Deploy:
|
||||
```bash
|
||||
railway up
|
||||
```
|
||||
|
||||
4. Railway will automatically:
|
||||
- Detect Node.js
|
||||
- Run `npm install`
|
||||
- Execute `npm start` (which includes `--experimental-network-imports`)
|
||||
|
||||
### Environment Variables
|
||||
|
||||
No environment variables required for basic operation. Optional:
|
||||
|
||||
- `PORT` - Server port (Railway sets this automatically)
|
||||
- `NODE_ENV` - Set to `production` in Railway
|
||||
|
||||
## Testing
|
||||
|
||||
### Test health endpoint:
|
||||
```bash
|
||||
curl http://localhost:3001/health
|
||||
```
|
||||
|
||||
### Test tool loading:
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/load-and-describe \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"exportName": "helloWorldTool",
|
||||
"version": "0.1.0"
|
||||
}'
|
||||
```
|
||||
|
||||
### Test tool execution:
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/execute-tool \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"exportName": "helloWorldTool",
|
||||
"version": "0.1.0",
|
||||
"params": {}
|
||||
}'
|
||||
```
|
||||
|
||||
## Integration with Playground
|
||||
|
||||
The playground app calls this service to load and execute tools dynamically.
|
||||
|
||||
Set in playground environment:
|
||||
```bash
|
||||
RAILWAY_SERVICE_URL=https://your-service.up.railway.app
|
||||
```
|
||||
|
||||
Or use existing:
|
||||
```bash
|
||||
SANDBOX_EXECUTOR_URL=https://your-service.up.railway.app
|
||||
```
|
||||
939
apps/railway-executor/package-lock.json
generated
Normal file
939
apps/railway-executor/package-lock.json
generated
Normal file
|
|
@ -0,0 +1,939 @@
|
|||
{
|
||||
"name": "railway-executor",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "railway-executor",
|
||||
"version": "1.0.0",
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
"express": "^4.18.2"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "1.3.8",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
|
||||
"integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-types": "~2.1.34",
|
||||
"negotiator": "0.6.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/array-flatten": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
|
||||
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/body-parser": {
|
||||
"version": "1.20.4",
|
||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
|
||||
"integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"content-type": "~1.0.5",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "~1.2.0",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"on-finished": "~2.4.1",
|
||||
"qs": "~6.14.0",
|
||||
"raw-body": "~2.5.3",
|
||||
"type-is": "~1.6.18",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/bytes": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bind-apply-helpers": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/call-bound": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"get-intrinsic": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
"version": "0.5.4",
|
||||
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
|
||||
"integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "5.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/content-type": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
|
||||
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-signature": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cors": {
|
||||
"version": "2.8.5",
|
||||
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
|
||||
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"object-assign": "^4",
|
||||
"vary": "^1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ms": "2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/destroy": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
|
||||
"integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8",
|
||||
"npm": "1.2.8000 || >= 1.4.16"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"gopd": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/ee-first": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
|
||||
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/encodeurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/es-define-property": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-errors": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/escape-html": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
|
||||
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/etag": {
|
||||
"version": "1.8.1",
|
||||
"resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
|
||||
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "4.22.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
|
||||
"integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"accepts": "~1.3.8",
|
||||
"array-flatten": "1.1.1",
|
||||
"body-parser": "~1.20.3",
|
||||
"content-disposition": "~0.5.4",
|
||||
"content-type": "~1.0.4",
|
||||
"cookie": "~0.7.1",
|
||||
"cookie-signature": "~1.0.6",
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"finalhandler": "~1.3.1",
|
||||
"fresh": "~0.5.2",
|
||||
"http-errors": "~2.0.0",
|
||||
"merge-descriptors": "1.0.3",
|
||||
"methods": "~1.1.2",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"path-to-regexp": "~0.1.12",
|
||||
"proxy-addr": "~2.0.7",
|
||||
"qs": "~6.14.0",
|
||||
"range-parser": "~1.2.1",
|
||||
"safe-buffer": "5.2.1",
|
||||
"send": "~0.19.0",
|
||||
"serve-static": "~1.16.2",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "~2.0.1",
|
||||
"type-is": "~1.6.18",
|
||||
"utils-merge": "1.0.1",
|
||||
"vary": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/finalhandler": {
|
||||
"version": "1.3.2",
|
||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||
"integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"on-finished": "~2.4.1",
|
||||
"parseurl": "~1.3.3",
|
||||
"statuses": "~2.0.2",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/forwarded": {
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
|
||||
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/fresh": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
|
||||
"integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/function-bind": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-intrinsic": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bind-apply-helpers": "^1.0.2",
|
||||
"es-define-property": "^1.0.1",
|
||||
"es-errors": "^1.3.0",
|
||||
"es-object-atoms": "^1.1.1",
|
||||
"function-bind": "^1.1.2",
|
||||
"get-proto": "^1.0.1",
|
||||
"gopd": "^1.2.0",
|
||||
"has-symbols": "^1.1.0",
|
||||
"hasown": "^2.0.2",
|
||||
"math-intrinsics": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/get-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dunder-proto": "^1.0.1",
|
||||
"es-object-atoms": "^1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/gopd": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/has-symbols": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/hasown": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"function-bind": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/http-errors": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
|
||||
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"inherits": "~2.0.4",
|
||||
"setprototypeof": "~1.2.0",
|
||||
"statuses": "~2.0.2",
|
||||
"toidentifier": "~1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.4.24",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
|
||||
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/math-intrinsics": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/media-typer": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
|
||||
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/merge-descriptors": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||
"integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/methods": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
|
||||
"integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
|
||||
"integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"mime": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-db": {
|
||||
"version": "1.52.0",
|
||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mime-types": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"mime-db": "1.52.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/negotiator": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
|
||||
"integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/object-inspect": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
|
||||
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ee-first": "1.1.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/parseurl": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
|
||||
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/path-to-regexp": {
|
||||
"version": "0.1.12",
|
||||
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
|
||||
"integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/proxy-addr": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"forwarded": "0.2.0",
|
||||
"ipaddr.js": "1.9.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.14.0",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
|
||||
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"side-channel": "^1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/range-parser": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
|
||||
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/raw-body": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
|
||||
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bytes": "~3.1.2",
|
||||
"http-errors": "~2.0.1",
|
||||
"iconv-lite": "~0.4.24",
|
||||
"unpipe": "~1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/feross"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://www.patreon.com/feross"
|
||||
},
|
||||
{
|
||||
"type": "consulting",
|
||||
"url": "https://feross.org/support"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.1.tgz",
|
||||
"integrity": "sha512-p4rRk4f23ynFEfcD9LA0xRYngj+IyGiEYyqqOak8kaN0TvNmuxC2dcVeBn62GpCeR2CpWqyHCNScTP91QbAVFg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/send/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/send/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static": {
|
||||
"version": "1.16.2",
|
||||
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz",
|
||||
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"encodeurl": "~2.0.0",
|
||||
"escape-html": "~1.0.3",
|
||||
"parseurl": "~1.3.3",
|
||||
"send": "0.19.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/http-errors": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz",
|
||||
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"depd": "2.0.0",
|
||||
"inherits": "2.0.4",
|
||||
"setprototypeof": "1.2.0",
|
||||
"statuses": "2.0.1",
|
||||
"toidentifier": "1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/serve-static/node_modules/send": {
|
||||
"version": "0.19.0",
|
||||
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz",
|
||||
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"debug": "2.6.9",
|
||||
"depd": "2.0.0",
|
||||
"destroy": "1.2.0",
|
||||
"encodeurl": "~1.0.2",
|
||||
"escape-html": "~1.0.3",
|
||||
"etag": "~1.8.1",
|
||||
"fresh": "0.5.2",
|
||||
"http-errors": "2.0.0",
|
||||
"mime": "1.6.0",
|
||||
"ms": "2.1.3",
|
||||
"on-finished": "2.4.1",
|
||||
"range-parser": "~1.2.1",
|
||||
"statuses": "2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/send/node_modules/encodeurl": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz",
|
||||
"integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/serve-static/node_modules/statuses": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
|
||||
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/setprototypeof": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-list": "^1.0.0",
|
||||
"side-channel-map": "^1.0.1",
|
||||
"side-channel-weakmap": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-list": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz",
|
||||
"integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"es-errors": "^1.3.0",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-map": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
|
||||
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/side-channel-weakmap": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
|
||||
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"call-bound": "^1.0.2",
|
||||
"es-errors": "^1.3.0",
|
||||
"get-intrinsic": "^1.2.5",
|
||||
"object-inspect": "^1.13.3",
|
||||
"side-channel-map": "^1.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.4"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/toidentifier": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
|
||||
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/type-is": {
|
||||
"version": "1.6.18",
|
||||
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
|
||||
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"media-typer": "0.3.0",
|
||||
"mime-types": "~2.1.24"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/unpipe": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
|
||||
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/utils-merge": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
|
||||
"integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vary": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
|
||||
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
15
apps/railway-executor/package.json
Normal file
15
apps/railway-executor/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "railway-executor",
|
||||
"version": "1.0.0",
|
||||
"description": "Dynamic tool executor for TPMJS with esm.sh imports",
|
||||
"type": "module",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"cors": "^2.8.5"
|
||||
}
|
||||
}
|
||||
229
apps/railway-executor/server.js
Normal file
229
apps/railway-executor/server.js
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
/**
|
||||
* Railway Dynamic Tool Executor
|
||||
* Runs with --experimental-network-imports to support esm.sh imports
|
||||
*/
|
||||
|
||||
import cors from 'cors';
|
||||
import express from 'express';
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3001;
|
||||
|
||||
// Middleware
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// Cache for imported tool modules
|
||||
const moduleCache = new Map();
|
||||
|
||||
/**
|
||||
* Health check endpoint
|
||||
*/
|
||||
app.get('/health', (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
cacheSize: moduleCache.size,
|
||||
nodeVersion: process.version,
|
||||
experimentalNetworkImports: true,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Load and describe a tool from esm.sh
|
||||
* Returns tool metadata (description, schema) without executing
|
||||
*/
|
||||
app.post('/load-and-describe', async (req, res) => {
|
||||
const { packageName, exportName, version, importUrl } = req.body;
|
||||
|
||||
if (!packageName || !exportName || !version) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: packageName, exportName, version',
|
||||
});
|
||||
}
|
||||
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
|
||||
try {
|
||||
let toolModule;
|
||||
|
||||
// Check cache first
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Cache hit: ${cacheKey}`);
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
// Dynamic import from esm.sh using fetch + eval
|
||||
// Note: import() doesn't support HTTPS URLs without custom loader in Node.js
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
console.log(`📦 Importing: ${url}`);
|
||||
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch module: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const moduleCode = await response.text();
|
||||
|
||||
// Create a module wrapper that captures exports
|
||||
const moduleExports = {};
|
||||
const moduleWrapper = new Function('exports', 'module', 'require', moduleCode);
|
||||
moduleWrapper(moduleExports, { exports: moduleExports }, require);
|
||||
|
||||
// esm.sh returns ES modules, try to get default or named export
|
||||
const module = moduleExports.default || moduleExports;
|
||||
toolModule = module[exportName] || module;
|
||||
|
||||
if (!toolModule) {
|
||||
console.error(`❌ Export "${exportName}" not found. Available:`, Object.keys(module));
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: `Export "${exportName}" not found in module`,
|
||||
availableExports: Object.keys(module),
|
||||
});
|
||||
}
|
||||
|
||||
// Validate it's an AI SDK tool
|
||||
if (!toolModule.description || !toolModule.execute) {
|
||||
console.error(`❌ Invalid AI SDK tool structure:`, {
|
||||
hasDescription: !!toolModule.description,
|
||||
hasExecute: !!toolModule.execute,
|
||||
hasInputSchema: !!toolModule.inputSchema,
|
||||
keys: Object.keys(toolModule),
|
||||
});
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Invalid AI SDK tool structure (missing description or execute)',
|
||||
toolKeys: Object.keys(toolModule),
|
||||
});
|
||||
}
|
||||
|
||||
// Cache it
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
console.log(`✅ Cached: ${cacheKey}`);
|
||||
}
|
||||
|
||||
// Extract tool definition (description + schema)
|
||||
// AI SDK v6 tools have: description, inputSchema, execute
|
||||
res.json({
|
||||
success: true,
|
||||
tool: {
|
||||
exportName,
|
||||
description: toolModule.description,
|
||||
inputSchema: toolModule.inputSchema || toolModule.parameters?.shape || {},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load tool:', error);
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Execute a dynamically loaded tool with parameters
|
||||
*/
|
||||
app.post('/execute-tool', async (req, res) => {
|
||||
const { packageName, exportName, version, importUrl, params } = req.body;
|
||||
|
||||
if (!packageName || !exportName || !version) {
|
||||
return res.status(400).json({
|
||||
success: false,
|
||||
error: 'Missing required fields: packageName, exportName, version',
|
||||
});
|
||||
}
|
||||
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
let toolModule;
|
||||
|
||||
// Check cache or import
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Using cached tool: ${cacheKey}`);
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
console.log(`📦 Importing for execution: ${url}`);
|
||||
|
||||
const module = await import(url);
|
||||
toolModule = module[exportName];
|
||||
|
||||
if (!toolModule || !toolModule.execute) {
|
||||
return res.status(404).json({
|
||||
success: false,
|
||||
error: 'Tool not found or invalid',
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
});
|
||||
}
|
||||
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
console.log(`🚀 Executing ${cacheKey} with params:`, params);
|
||||
const result = await toolModule.execute(params || {});
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
console.log(`✅ Execution complete in ${executionTimeMs}ms`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
output: result,
|
||||
executionTimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
console.error('❌ Tool execution failed:', error);
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
error: error.message,
|
||||
executionTimeMs,
|
||||
stack: process.env.NODE_ENV === 'development' ? error.stack : undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Clear module cache (for debugging)
|
||||
*/
|
||||
app.post('/cache/clear', (req, res) => {
|
||||
const size = moduleCache.size;
|
||||
moduleCache.clear();
|
||||
console.log(`🗑️ Cleared cache (${size} entries)`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
message: `Cleared ${size} cached modules`,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Get cache statistics
|
||||
*/
|
||||
app.get('/cache/stats', (req, res) => {
|
||||
const entries = Array.from(moduleCache.keys());
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
cacheSize: moduleCache.size,
|
||||
cachedTools: entries,
|
||||
});
|
||||
});
|
||||
|
||||
// Start server
|
||||
app.listen(PORT, () => {
|
||||
console.log(`🚀 Railway Tool Executor running on port ${PORT}`);
|
||||
console.log(`📦 Experimental network imports: ENABLED`);
|
||||
console.log(`🔗 Health check: http://localhost:${PORT}/health`);
|
||||
console.log(`🛠️ Endpoints:`);
|
||||
console.log(` POST /load-and-describe - Load tool and get schema`);
|
||||
console.log(` POST /execute-tool - Execute a tool with params`);
|
||||
console.log(` POST /cache/clear - Clear module cache`);
|
||||
console.log(` GET /cache/stats - Get cache statistics`);
|
||||
});
|
||||
665
apps/railway-executor/server.ts
Normal file
665
apps/railway-executor/server.ts
Normal file
|
|
@ -0,0 +1,665 @@
|
|||
/**
|
||||
* Railway Dynamic Tool Executor (Deno)
|
||||
* Uses Deno's native HTTP import support
|
||||
*/
|
||||
|
||||
// Import zod-to-json-schema for Zod v3 support
|
||||
import { zodToJsonSchema } from 'https://esm.sh/zod-to-json-schema@3.25.0';
|
||||
|
||||
// Cache for imported tool modules
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
|
||||
const moduleCache = new Map<string, any>();
|
||||
|
||||
/**
|
||||
* Sanitize JSON Schema to fix common issues
|
||||
* - Replaces invalid type "None" with "object"
|
||||
* - Ensures type is always set
|
||||
* - Ensures object schemas have properties
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: JSON Schema can have any structure
|
||||
function sanitizeJsonSchema(schema: any): any {
|
||||
if (!schema || typeof schema !== 'object') {
|
||||
console.warn('⚠️ Invalid schema (not an object), returning default object schema');
|
||||
return { type: 'object', properties: {}, additionalProperties: false };
|
||||
}
|
||||
|
||||
// Clone the schema to avoid mutating the original
|
||||
const sanitized = { ...schema };
|
||||
|
||||
// Fix invalid type "None" (common in Python-based tools)
|
||||
if (sanitized.type === 'None' || sanitized.type === 'none' || sanitized.type === null) {
|
||||
console.warn(`⚠️ Invalid schema type "${sanitized.type}", replacing with "object"`);
|
||||
sanitized.type = 'object';
|
||||
if (!sanitized.properties) {
|
||||
sanitized.properties = {};
|
||||
}
|
||||
if (sanitized.additionalProperties === undefined) {
|
||||
sanitized.additionalProperties = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure type is set
|
||||
if (!sanitized.type) {
|
||||
console.warn('⚠️ Schema missing type, defaulting to "object"');
|
||||
sanitized.type = 'object';
|
||||
if (!sanitized.properties) {
|
||||
sanitized.properties = {};
|
||||
}
|
||||
if (sanitized.additionalProperties === undefined) {
|
||||
sanitized.additionalProperties = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Recursively sanitize nested schemas
|
||||
if (sanitized.properties && typeof sanitized.properties === 'object') {
|
||||
for (const [key, value] of Object.entries(sanitized.properties)) {
|
||||
if (value && typeof value === 'object') {
|
||||
sanitized.properties[key] = sanitizeJsonSchema(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sanitize array items
|
||||
if (sanitized.items && typeof sanitized.items === 'object') {
|
||||
sanitized.items = sanitizeJsonSchema(sanitized.items);
|
||||
}
|
||||
|
||||
// Sanitize anyOf/oneOf/allOf
|
||||
for (const key of ['anyOf', 'oneOf', 'allOf']) {
|
||||
if (Array.isArray(sanitized[key])) {
|
||||
sanitized[key] = sanitized[key].map((s: any) => sanitizeJsonSchema(s));
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load and describe a tool from esm.sh
|
||||
*/
|
||||
async function loadAndDescribe(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { packageName, exportName, version, importUrl, env } = body;
|
||||
|
||||
if (!packageName || !exportName || !version) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: packageName, exportName, version',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
|
||||
// biome-ignore lint/suspicious/noImplicitAnyLet: Tool type is determined dynamically after import
|
||||
let toolModule;
|
||||
|
||||
// Check cache first
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Cache hit: ${cacheKey}`);
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
// Dynamic import from esm.sh (Deno supports this natively!)
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
console.log(`📦 Importing: ${url}`);
|
||||
|
||||
const module = await import(url);
|
||||
let rawExport = module[exportName];
|
||||
|
||||
if (!rawExport) {
|
||||
console.error(`❌ Export "${exportName}" not found. Available:`, Object.keys(module));
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Export "${exportName}" not found in module`,
|
||||
availableExports: Object.keys(module),
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a factory function (not a direct tool)
|
||||
if (typeof rawExport === 'function' && !rawExport.description && !rawExport.execute) {
|
||||
console.log(`🏭 Detected factory function for ${cacheKey}, attempting to call...`);
|
||||
|
||||
let factoryResult = null;
|
||||
|
||||
// Strategy 1: Try calling with no arguments
|
||||
try {
|
||||
console.log(` Trying: ${exportName}()`);
|
||||
factoryResult = rawExport();
|
||||
if (factoryResult?.description && factoryResult?.execute) {
|
||||
console.log(' ✅ Success with no-args factory');
|
||||
rawExport = factoryResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ❌ No-args failed:', error.message);
|
||||
}
|
||||
|
||||
// Strategy 2: Try calling with env vars as config object
|
||||
if (!factoryResult && env && typeof env === 'object') {
|
||||
// Build multiple config variations to try
|
||||
const configVariations = [];
|
||||
|
||||
// Variation 1: Raw env vars (e.g., { VALYU_API_KEY: 'xxx' })
|
||||
configVariations.push({ ...env });
|
||||
|
||||
// Variation 2: Normalized to camelCase apiKey (e.g., { apiKey: 'xxx' })
|
||||
const apiKeyValue = Object.entries(env).find(([key]) =>
|
||||
key.toUpperCase().includes('API_KEY')
|
||||
)?.[1];
|
||||
if (apiKeyValue) {
|
||||
configVariations.push({ apiKey: apiKeyValue });
|
||||
}
|
||||
|
||||
// Variation 3: Normalized to key (e.g., { key: 'xxx' })
|
||||
if (apiKeyValue) {
|
||||
configVariations.push({ key: apiKeyValue });
|
||||
}
|
||||
|
||||
// Try each config variation
|
||||
for (const config of configVariations) {
|
||||
try {
|
||||
console.log(` Trying: ${exportName}(`, Object.keys(config), ')');
|
||||
factoryResult = rawExport(config);
|
||||
if (factoryResult?.description && factoryResult?.execute) {
|
||||
console.log(' ✅ Success with config:', Object.keys(config));
|
||||
rawExport = factoryResult;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ❌ Config', Object.keys(config), 'failed:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try calling with first env var value (single-arg pattern)
|
||||
if (!factoryResult && env && typeof env === 'object') {
|
||||
try {
|
||||
const firstValue = Object.values(env)[0];
|
||||
if (firstValue) {
|
||||
console.log(` Trying: ${exportName}(firstEnvValue)`);
|
||||
factoryResult = rawExport(firstValue);
|
||||
if (factoryResult?.description && factoryResult?.execute) {
|
||||
console.log(' ✅ Success with single-arg factory');
|
||||
rawExport = factoryResult;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ❌ Single-arg failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// If all factory strategies failed, return error
|
||||
if (!factoryResult) {
|
||||
console.error('❌ Factory function detected but all call strategies failed');
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Tool "${exportName}" is a factory function but couldn't be initialized. Tried: no-args, config object, and single-arg patterns.`,
|
||||
hint: 'This tool may require specific configuration. Check package documentation.',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toolModule = rawExport;
|
||||
|
||||
// Validate it's an AI SDK tool
|
||||
if (!toolModule.description || !toolModule.execute) {
|
||||
console.error('❌ Invalid AI SDK tool structure:', {
|
||||
hasDescription: !!toolModule.description,
|
||||
hasExecute: !!toolModule.execute,
|
||||
hasInputSchema: !!toolModule.inputSchema,
|
||||
keys: Object.keys(toolModule),
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Invalid AI SDK tool structure (missing description or execute)',
|
||||
toolKeys: Object.keys(toolModule),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Cache it
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
console.log(`✅ Cached: ${cacheKey}`);
|
||||
}
|
||||
|
||||
// Extract tool definition - try multiple schema formats
|
||||
let rawJsonSchema = null;
|
||||
|
||||
if (toolModule.inputSchema) {
|
||||
// Strategy 1: Try Zod v4 native JSON Schema export
|
||||
if (typeof toolModule.inputSchema.toJSONSchema === 'function') {
|
||||
console.log(`📋 Using Zod v4 toJSONSchema() for ${cacheKey}`);
|
||||
try {
|
||||
rawJsonSchema = toolModule.inputSchema.toJSONSchema();
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Zod toJSONSchema() failed for ${cacheKey}:`, error);
|
||||
}
|
||||
} else if (typeof toolModule.inputSchema.jsonSchema === 'function') {
|
||||
console.log(`📋 Using Zod v4 jsonSchema() for ${cacheKey}`);
|
||||
try {
|
||||
rawJsonSchema = toolModule.inputSchema.jsonSchema();
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Zod jsonSchema() failed for ${cacheKey}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 2: Try AI SDK v6 jsonSchema() wrapper (has .schema property)
|
||||
if (!rawJsonSchema && toolModule.inputSchema.schema) {
|
||||
console.log(`📋 Using AI SDK jsonSchema.schema for ${cacheKey}`);
|
||||
rawJsonSchema = toolModule.inputSchema.schema;
|
||||
}
|
||||
|
||||
// Strategy 2.5: Try AI SDK jsonSchema() wrapper (has .jsonSchema property)
|
||||
// Note: Some versions use .jsonSchema instead of .schema
|
||||
if (
|
||||
!rawJsonSchema &&
|
||||
toolModule.inputSchema.jsonSchema &&
|
||||
typeof toolModule.inputSchema.jsonSchema === 'object'
|
||||
) {
|
||||
console.log(`📋 Using AI SDK jsonSchema.jsonSchema for ${cacheKey}`);
|
||||
rawJsonSchema = toolModule.inputSchema.jsonSchema;
|
||||
}
|
||||
|
||||
// Strategy 3: Try Zod v3 schema (detect via _def property and convert)
|
||||
if (!rawJsonSchema && toolModule.inputSchema._def) {
|
||||
console.log(
|
||||
`📋 Detected Zod schema (v3), converting with zod-to-json-schema for ${cacheKey}`
|
||||
);
|
||||
try {
|
||||
rawJsonSchema = zodToJsonSchema(toolModule.inputSchema);
|
||||
console.log(`✅ Successfully converted Zod schema for ${cacheKey}`);
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ zod-to-json-schema conversion failed for ${cacheKey}:`, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no schema found, fail with helpful error
|
||||
if (!rawJsonSchema) {
|
||||
console.error(`❌ No valid schema found for ${cacheKey}`, {
|
||||
hasInputSchema: !!toolModule.inputSchema,
|
||||
inputSchemaType: typeof toolModule.inputSchema,
|
||||
hasToJSONSchema: typeof toolModule.inputSchema?.toJSONSchema === 'function',
|
||||
hasJsonSchemaFunction: typeof toolModule.inputSchema?.jsonSchema === 'function',
|
||||
hasJsonSchemaProperty:
|
||||
!!toolModule.inputSchema?.jsonSchema &&
|
||||
typeof toolModule.inputSchema?.jsonSchema === 'object',
|
||||
hasSchema: !!toolModule.inputSchema?.schema,
|
||||
keys: toolModule.inputSchema ? Object.keys(toolModule.inputSchema) : [],
|
||||
});
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Tool "${exportName}" has no valid inputSchema. Tools must use AI SDK jsonSchema(), Zod v4 toJSONSchema(), or Zod v3 schemas.`,
|
||||
debug: {
|
||||
hasInputSchema: !!toolModule.inputSchema,
|
||||
availableMethods: toolModule.inputSchema ? Object.keys(toolModule.inputSchema) : [],
|
||||
hasZodDef: !!toolModule.inputSchema?._def,
|
||||
},
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.log(`✅ Extracted schema for ${cacheKey}`);
|
||||
|
||||
// Sanitize schema - fix common issues with invalid schemas
|
||||
const sanitizedSchema = sanitizeJsonSchema(rawJsonSchema);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
tool: {
|
||||
exportName,
|
||||
description: toolModule.description,
|
||||
inputSchema: sanitizedSchema, // Plain JSON Schema - fully serializable
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to load tool:', error);
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool with parameters
|
||||
*/
|
||||
async function executeTool(req: Request): Promise<Response> {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { packageName, exportName, version, importUrl, params, env } = body;
|
||||
|
||||
console.log('📥 Execute request:', {
|
||||
packageName,
|
||||
exportName,
|
||||
version,
|
||||
envKeys: env ? Object.keys(env) : [],
|
||||
envValues: env || {},
|
||||
});
|
||||
|
||||
if (!packageName || !exportName || !version) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing required fields: packageName, exportName, version',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const cacheKey = `${packageName}::${exportName}`;
|
||||
const startTime = Date.now();
|
||||
|
||||
// biome-ignore lint/suspicious/noImplicitAnyLet: Tool type is determined dynamically after import
|
||||
let toolModule;
|
||||
|
||||
// Check cache or import
|
||||
if (moduleCache.has(cacheKey)) {
|
||||
console.log(`✅ Using cached tool: ${cacheKey}`);
|
||||
toolModule = moduleCache.get(cacheKey);
|
||||
} else {
|
||||
const url = importUrl || `https://esm.sh/${packageName}@${version}`;
|
||||
console.log(`📦 Importing for execution: ${url}`);
|
||||
|
||||
const module = await import(url);
|
||||
let rawExport = module[exportName];
|
||||
|
||||
if (!rawExport) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check if it's a factory function (not a direct tool)
|
||||
if (typeof rawExport === 'function' && !rawExport.description && !rawExport.execute) {
|
||||
console.log(`🏭 Detected factory function for ${cacheKey}, attempting to call...`);
|
||||
|
||||
let factoryResult = null;
|
||||
|
||||
// Strategy 1: Try calling with no arguments
|
||||
try {
|
||||
console.log(` Trying: ${exportName}()`);
|
||||
factoryResult = rawExport();
|
||||
if (factoryResult?.execute) {
|
||||
console.log(' ✅ Success with no-args factory');
|
||||
rawExport = factoryResult;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ❌ No-args failed:', error.message);
|
||||
}
|
||||
|
||||
// Strategy 2: Try calling with env vars as config object
|
||||
if (!factoryResult && env && typeof env === 'object') {
|
||||
// Build multiple config variations to try
|
||||
const configVariations = [];
|
||||
|
||||
// Variation 1: Raw env vars (e.g., { VALYU_API_KEY: 'xxx' })
|
||||
configVariations.push({ ...env });
|
||||
|
||||
// Variation 2: Normalized to camelCase apiKey (e.g., { apiKey: 'xxx' })
|
||||
const apiKeyValue = Object.entries(env).find(([key]) =>
|
||||
key.toUpperCase().includes('API_KEY')
|
||||
)?.[1];
|
||||
if (apiKeyValue) {
|
||||
configVariations.push({ apiKey: apiKeyValue });
|
||||
}
|
||||
|
||||
// Variation 3: Normalized to key (e.g., { key: 'xxx' })
|
||||
if (apiKeyValue) {
|
||||
configVariations.push({ key: apiKeyValue });
|
||||
}
|
||||
|
||||
// Try each config variation
|
||||
for (const config of configVariations) {
|
||||
try {
|
||||
console.log(` Trying: ${exportName}(`, Object.keys(config), ')');
|
||||
factoryResult = rawExport(config);
|
||||
if (factoryResult?.execute) {
|
||||
console.log(' ✅ Success with config:', Object.keys(config));
|
||||
rawExport = factoryResult;
|
||||
break;
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ❌ Config', Object.keys(config), 'failed:', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strategy 3: Try calling with first env var value (single-arg pattern)
|
||||
if (!factoryResult && env && typeof env === 'object') {
|
||||
try {
|
||||
const firstValue = Object.values(env)[0];
|
||||
if (firstValue) {
|
||||
console.log(` Trying: ${exportName}(firstEnvValue)`);
|
||||
factoryResult = rawExport(firstValue);
|
||||
if (factoryResult?.execute) {
|
||||
console.log(' ✅ Success with single-arg factory');
|
||||
rawExport = factoryResult;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(' ❌ Single-arg failed:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
if (!factoryResult) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Tool "${exportName}" is a factory function but couldn't be initialized`,
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
toolModule = rawExport;
|
||||
|
||||
if (!toolModule.execute) {
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool missing execute function',
|
||||
executionTimeMs: Date.now() - startTime,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
moduleCache.set(cacheKey, toolModule);
|
||||
}
|
||||
|
||||
// Inject environment variables from client
|
||||
if (env && typeof env === 'object') {
|
||||
const envKeys = Object.keys(env);
|
||||
if (envKeys.length > 0) {
|
||||
console.log(`🔐 Injecting ${envKeys.length} environment variables:`, envKeys);
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
const stringValue = String(value);
|
||||
|
||||
// Set in Deno environment (for esm.sh imports)
|
||||
Deno.env.set(key, stringValue);
|
||||
|
||||
// ALSO set in Node.js process.env (for npm: imports)
|
||||
// @ts-ignore - process is available in Node.js compatibility mode
|
||||
if (typeof globalThis.process !== 'undefined' && globalThis.process.env) {
|
||||
// @ts-ignore - process.env exists in Node compat mode
|
||||
globalThis.process.env[key] = stringValue;
|
||||
}
|
||||
|
||||
console.log(` ✅ Set ${key} = ${stringValue.substring(0, 10)}...`);
|
||||
}
|
||||
// Verify they're set in both places
|
||||
console.log(
|
||||
'🔍 Verification - Deno.env has:',
|
||||
envKeys.map((k) => `${k}=${Deno.env.get(k)?.substring(0, 10)}...`)
|
||||
);
|
||||
// @ts-ignore - process is available in Node.js compatibility mode
|
||||
if (typeof globalThis.process !== 'undefined' && globalThis.process.env) {
|
||||
console.log(
|
||||
'🔍 Verification - process.env has:',
|
||||
// @ts-ignore - process.env exists in Node compat mode
|
||||
envKeys.map((k) => `${k}=${globalThis.process.env[k]?.substring(0, 10)}...`)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No env vars provided in request');
|
||||
}
|
||||
} else {
|
||||
console.log('⚠️ No env object in request body');
|
||||
}
|
||||
|
||||
// Execute the tool
|
||||
console.log(`🚀 Executing ${cacheKey} with params:`, params);
|
||||
const result = await toolModule.execute(params || {});
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
console.log(`✅ Execution complete in ${executionTimeMs}ms`);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
output: result,
|
||||
executionTimeMs,
|
||||
});
|
||||
} catch (error) {
|
||||
const executionTimeMs = Date.now() - Date.now();
|
||||
console.error('❌ Tool execution failed:', error);
|
||||
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
executionTimeMs,
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
function health(): Response {
|
||||
return Response.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
cacheSize: moduleCache.size,
|
||||
denoVersion: Deno.version.deno,
|
||||
v8Version: Deno.version.v8,
|
||||
httpImports: true,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache stats
|
||||
*/
|
||||
function cacheStats(): Response {
|
||||
const entries = Array.from(moduleCache.keys());
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
cacheSize: moduleCache.size,
|
||||
cachedTools: entries,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear cache
|
||||
*/
|
||||
function clearCache(): Response {
|
||||
const size = moduleCache.size;
|
||||
moduleCache.clear();
|
||||
console.log(`🗑️ Cleared cache (${size} entries)`);
|
||||
|
||||
return Response.json({
|
||||
success: true,
|
||||
message: `Cleared ${size} cached modules`,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Main request handler
|
||||
*/
|
||||
async function handler(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Add CORS headers
|
||||
const headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
};
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response(null, { headers });
|
||||
}
|
||||
|
||||
try {
|
||||
let response: Response;
|
||||
|
||||
if (url.pathname === '/health' && req.method === 'GET') {
|
||||
response = health();
|
||||
} else if (url.pathname === '/load-and-describe' && req.method === 'POST') {
|
||||
response = await loadAndDescribe(req);
|
||||
} else if (url.pathname === '/execute-tool' && req.method === 'POST') {
|
||||
response = await executeTool(req);
|
||||
} else if (url.pathname === '/cache/stats' && req.method === 'GET') {
|
||||
response = cacheStats();
|
||||
} else if (url.pathname === '/cache/clear' && req.method === 'POST') {
|
||||
response = clearCache();
|
||||
} else {
|
||||
response = Response.json({ error: 'Not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Add CORS headers to response
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
response.headers.set(key, value);
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
console.error('Request handler error:', error);
|
||||
return Response.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
},
|
||||
{ status: 500, headers }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Start server
|
||||
const port = Number.parseInt(Deno.env.get('PORT') || '3002');
|
||||
|
||||
console.log(`🚀 Railway Tool Executor (Deno) running on port ${port}`);
|
||||
console.log('📦 HTTP imports: ENABLED');
|
||||
console.log(`🔗 Health check: http://localhost:${port}/health`);
|
||||
console.log('🛠️ Endpoints:');
|
||||
console.log(' POST /load-and-describe - Load tool and get schema');
|
||||
console.log(' POST /execute-tool - Execute a tool with params');
|
||||
console.log(' POST /cache/clear - Clear module cache');
|
||||
console.log(' GET /cache/stats - Get cache statistics');
|
||||
|
||||
Deno.serve({ port }, handler);
|
||||
1
apps/web/.gitignore
vendored
Normal file
1
apps/web/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.vercel
|
||||
|
|
@ -3,6 +3,7 @@ import type { NextConfig } from 'next';
|
|||
const nextConfig: NextConfig = {
|
||||
transpilePackages: ['@tpmjs/ui', '@tpmjs/utils', '@tpmjs/db', '@tpmjs/types', '@tpmjs/env'],
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: ['@tpmjs/package-executor'],
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
|
|
|||
|
|
@ -11,19 +11,31 @@
|
|||
"clean": "rm -rf .next .turbo"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/openai": "3.0.0-beta.74",
|
||||
"@tpmjs/db": "workspace:*",
|
||||
"@tpmjs/env": "workspace:*",
|
||||
"@tpmjs/npm-client": "workspace:*",
|
||||
"@tpmjs/package-executor": "workspace:*",
|
||||
"@tpmjs/types": "workspace:*",
|
||||
"@tpmjs/ui": "workspace:*",
|
||||
"@tpmjs/utils": "workspace:*",
|
||||
"@types/react-syntax-highlighter": "^15.5.13",
|
||||
"ai": "6.0.0-beta.124",
|
||||
"bm25": "^0.1.1",
|
||||
"next": "^16.0.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"openai": "^6.9.1",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^3.24.1"
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-syntax-highlighter": "^16.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@tpmjs/eslint-config": "workspace:*",
|
||||
"@tpmjs/tailwind-config": "workspace:*",
|
||||
"@tpmjs/tsconfig": "workspace:*",
|
||||
|
|
|
|||
|
|
@ -18,25 +18,14 @@ export const dynamic = 'force-dynamic';
|
|||
export async function GET() {
|
||||
try {
|
||||
// Run all aggregations in parallel
|
||||
const [totalTools, officialTools, categoryStats, recentCount, downloadSum] = await Promise.all([
|
||||
const [totalTools, officialTools, recentCount, packages] = await Promise.all([
|
||||
// Total tools count
|
||||
prisma.tool.count(),
|
||||
|
||||
// Official tools count
|
||||
// Official tools count (isOfficial is at package level)
|
||||
prisma.tool.count({
|
||||
where: { isOfficial: true },
|
||||
}),
|
||||
|
||||
// Group by category
|
||||
prisma.tool.groupBy({
|
||||
by: ['category'],
|
||||
_count: {
|
||||
id: true,
|
||||
},
|
||||
orderBy: {
|
||||
_count: {
|
||||
id: 'desc',
|
||||
},
|
||||
where: {
|
||||
package: { isOfficial: true },
|
||||
},
|
||||
}),
|
||||
|
||||
|
|
@ -49,21 +38,31 @@ export async function GET() {
|
|||
},
|
||||
}),
|
||||
|
||||
// Sum of all downloads
|
||||
prisma.tool.aggregate({
|
||||
_sum: {
|
||||
// Get all packages with their tool counts and download stats
|
||||
prisma.package.findMany({
|
||||
select: {
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
_count: {
|
||||
select: { tools: true },
|
||||
},
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
// Format category stats
|
||||
const categories = categoryStats.reduce<Record<string, number>>((acc, stat) => {
|
||||
if (stat.category) {
|
||||
acc[stat.category] = stat._count.id;
|
||||
// Calculate stats from packages
|
||||
const categories: Record<string, number> = {};
|
||||
let totalDownloads = 0;
|
||||
|
||||
for (const pkg of packages) {
|
||||
// Count tools by category
|
||||
if (pkg.category) {
|
||||
categories[pkg.category] = (categories[pkg.category] || 0) + pkg._count.tools;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
// Sum downloads
|
||||
totalDownloads += pkg.npmDownloadsLastMonth || 0;
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
@ -72,7 +71,7 @@ export async function GET() {
|
|||
officialTools,
|
||||
categories,
|
||||
recentTools: recentCount,
|
||||
totalDownloads: downloadSum._sum.npmDownloadsLastMonth || 0,
|
||||
totalDownloads,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { fetchChanges, fetchLatestPackageVersion } from '@tpmjs/npm-client';
|
||||
import { fetchChanges, fetchLatestPackageWithMetadata } from '@tpmjs/npm-client';
|
||||
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import { performHealthCheck } from '~/lib/health-check/health-check-service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -51,8 +52,8 @@ export async function POST(request: NextRequest) {
|
|||
// Process each change
|
||||
for (const change of changesResult.results) {
|
||||
try {
|
||||
// Fetch full package metadata
|
||||
const pkg = await fetchLatestPackageVersion(change.id);
|
||||
// Fetch full package metadata with README
|
||||
const pkg = await fetchLatestPackageWithMetadata(change.id);
|
||||
|
||||
// Skip if package not found
|
||||
if (!pkg) {
|
||||
|
|
@ -66,70 +67,125 @@ export async function POST(request: NextRequest) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Validate tpmjs field
|
||||
// Validate tpmjs field (supports both new multi-tool and legacy formats)
|
||||
const validation = validateTpmjsField(pkg.tpmjs);
|
||||
if (!validation.valid || !validation.data) {
|
||||
if (!validation.valid || !validation.packageData || !validation.tools) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log auto-migration from legacy format
|
||||
if (validation.wasLegacyFormat) {
|
||||
console.log(`Auto-migrated legacy package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
// Extract repository URL and GitHub stars
|
||||
const githubStars: number | null = null;
|
||||
|
||||
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
|
||||
const tpmjsData = validation.data as {
|
||||
category: string;
|
||||
description: string;
|
||||
example: string;
|
||||
parameters?: unknown;
|
||||
returns?: unknown;
|
||||
authentication?: unknown;
|
||||
pricing?: unknown;
|
||||
frameworks?: string[];
|
||||
links?: unknown;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
aiAgent?: unknown;
|
||||
};
|
||||
|
||||
// Prepare data for upsert
|
||||
const toolData = {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
category: tpmjsData.category,
|
||||
description: tpmjsData.description,
|
||||
example: tpmjsData.example,
|
||||
parameters: tpmjsData.parameters ?? undefined,
|
||||
returns: tpmjsData.returns ?? undefined,
|
||||
authentication: tpmjsData.authentication ?? undefined,
|
||||
pricing: tpmjsData.pricing ?? undefined,
|
||||
frameworks: tpmjsData.frameworks || [],
|
||||
links: tpmjsData.links ?? undefined,
|
||||
tags: tpmjsData.tags || [],
|
||||
status: tpmjsData.status ?? undefined,
|
||||
aiAgent: tpmjsData.aiAgent ?? undefined,
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
tier: validation.tier || 'minimal',
|
||||
};
|
||||
|
||||
// Upsert tool to database
|
||||
await prisma.tool.upsert({
|
||||
// Upsert Package record
|
||||
const packageRecord = await prisma.package.upsert({
|
||||
where: { npmPackageName: pkg.name },
|
||||
create: {
|
||||
npmPackageName: pkg.name,
|
||||
...toolData,
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
discoveryMethod: 'changes-feed',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
||||
githubStars: githubStars,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: toolData,
|
||||
update: {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
},
|
||||
});
|
||||
|
||||
// Get existing tools for this package
|
||||
const existingTools = await prisma.tool.findMany({
|
||||
where: { packageId: packageRecord.id },
|
||||
});
|
||||
|
||||
// Upsert each tool in the tools array
|
||||
for (const toolDef of validation.tools) {
|
||||
const upsertedTool = await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_exportName: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: {
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger immediate health check (non-blocking)
|
||||
performHealthCheck(upsertedTool.id, 'sync').catch((err) => {
|
||||
console.error(
|
||||
`Health check failed for ${pkg.name}/${toolDef.exportName} (${upsertedTool.id}):`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned tools (tools removed from package.json)
|
||||
const orphanedTools = existingTools.filter(
|
||||
(existingTool) =>
|
||||
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
|
||||
);
|
||||
|
||||
if (orphanedTools.length > 0) {
|
||||
await prisma.tool.deleteMany({
|
||||
where: {
|
||||
id: { in: orphanedTools.map((t) => t.id) },
|
||||
},
|
||||
});
|
||||
console.log(`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
|
|
|
|||
89
apps/web/src/app/api/sync/health-check/route.ts
Normal file
89
apps/web/src/app/api/sync/health-check/route.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import { performBatchHealthCheck } from '~/lib/health-check/health-check-service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes
|
||||
|
||||
/**
|
||||
* POST /api/sync/health-check
|
||||
* Daily health check for all tools
|
||||
*
|
||||
* This endpoint is called by Vercel Cron (daily at 2am UTC)
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
if (env.CRON_SECRET && token !== env.CRON_SECRET) {
|
||||
return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
console.log('🏥 Daily health check cron job starting...');
|
||||
|
||||
// Get all tools
|
||||
const tools = await prisma.tool.findMany({
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
console.log(`📊 Found ${tools.length} tools to check`);
|
||||
|
||||
const toolIds = tools.map((t) => t.id);
|
||||
|
||||
// Perform batch health checks
|
||||
const result = await performBatchHealthCheck(toolIds, 'daily-cron', 5);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
// Log sync operation
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'health-check',
|
||||
status: result.errors > 0 ? 'partial' : 'success',
|
||||
processed: result.healthy + result.broken + result.unknown,
|
||||
skipped: 0,
|
||||
errors: result.errors,
|
||||
message: `Checked ${result.total} tools: ${result.healthy} healthy, ${result.broken} broken, ${result.unknown} unknown`,
|
||||
metadata: {
|
||||
durationMs,
|
||||
...result,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`✅ Daily health check complete in ${durationMs}ms`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
...result,
|
||||
durationMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Health check cron failed:', error);
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'health-check',
|
||||
status: 'error',
|
||||
processed: 0,
|
||||
skipped: 0,
|
||||
errors: 1,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
metadata: { durationMs },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ success: false, error: 'Health check failed' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,9 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { fetchLatestPackageVersion, searchByKeyword } from '@tpmjs/npm-client';
|
||||
import { fetchLatestPackageWithMetadata, searchByKeyword } from '@tpmjs/npm-client';
|
||||
import { validateTpmjsField } from '@tpmjs/types/tpmjs';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import { performHealthCheck } from '~/lib/health-check/health-check-service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -30,6 +31,7 @@ export async function POST(request: NextRequest) {
|
|||
let skipped = 0;
|
||||
let errors = 0;
|
||||
const errorMessages: string[] = [];
|
||||
const skippedPackages: Array<{ name: string; author: string; reason: string }> = [];
|
||||
|
||||
try {
|
||||
// Search for packages with 'tpmjs-tool' keyword
|
||||
|
|
@ -41,85 +43,163 @@ export async function POST(request: NextRequest) {
|
|||
// Process each package
|
||||
for (const result of searchResults) {
|
||||
try {
|
||||
// Fetch full package metadata
|
||||
const pkg = await fetchLatestPackageVersion(result.package.name);
|
||||
// Fetch full package metadata with README
|
||||
const pkg = await fetchLatestPackageWithMetadata(result.package.name);
|
||||
|
||||
// Skip if package not found
|
||||
if (!pkg) {
|
||||
skipped++;
|
||||
skippedPackages.push({
|
||||
name: result.package.name,
|
||||
author: 'unknown',
|
||||
reason: 'package not found',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Extract author name
|
||||
const authorName =
|
||||
typeof pkg.author === 'string'
|
||||
? pkg.author
|
||||
: typeof pkg.author === 'object' && pkg.author?.name
|
||||
? pkg.author.name
|
||||
: 'unknown';
|
||||
|
||||
// Check if package has tpmjs field
|
||||
if (!pkg.tpmjs) {
|
||||
skipped++;
|
||||
skippedPackages.push({
|
||||
name: pkg.name,
|
||||
author: authorName,
|
||||
reason: 'missing tpmjs field',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate tpmjs field
|
||||
// Validate tpmjs field (supports both new multi-tool and legacy formats)
|
||||
const validation = validateTpmjsField(pkg.tpmjs);
|
||||
if (!validation.valid || !validation.data) {
|
||||
if (!validation.valid || !validation.packageData || !validation.tools) {
|
||||
skipped++;
|
||||
skippedPackages.push({
|
||||
name: pkg.name,
|
||||
author: authorName,
|
||||
reason: 'invalid tpmjs field',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Log auto-migration from legacy format
|
||||
if (validation.wasLegacyFormat) {
|
||||
console.log(`Auto-migrated legacy package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
// Extract repository URL and GitHub stars
|
||||
const githubStars: number | null = null;
|
||||
|
||||
// Cast to TpmjsRich to access optional fields (they'll be undefined if not present)
|
||||
const tpmjsData = validation.data as {
|
||||
category: string;
|
||||
description: string;
|
||||
example: string;
|
||||
parameters?: unknown;
|
||||
returns?: unknown;
|
||||
authentication?: unknown;
|
||||
pricing?: unknown;
|
||||
frameworks?: string[];
|
||||
links?: unknown;
|
||||
tags?: string[];
|
||||
status?: string;
|
||||
aiAgent?: unknown;
|
||||
};
|
||||
|
||||
// Prepare data for upsert
|
||||
const toolData = {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
category: tpmjsData.category,
|
||||
description: tpmjsData.description,
|
||||
example: tpmjsData.example,
|
||||
parameters: tpmjsData.parameters ?? undefined,
|
||||
returns: tpmjsData.returns ?? undefined,
|
||||
authentication: tpmjsData.authentication ?? undefined,
|
||||
pricing: tpmjsData.pricing ?? undefined,
|
||||
frameworks: tpmjsData.frameworks || [],
|
||||
links: tpmjsData.links ?? undefined,
|
||||
tags: tpmjsData.tags || [],
|
||||
status: tpmjsData.status ?? undefined,
|
||||
aiAgent: tpmjsData.aiAgent ?? undefined,
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
tier: validation.tier || 'minimal',
|
||||
};
|
||||
|
||||
// Upsert tool to database
|
||||
await prisma.tool.upsert({
|
||||
// Upsert Package record
|
||||
const packageRecord = await prisma.package.upsert({
|
||||
where: { npmPackageName: pkg.name },
|
||||
create: {
|
||||
npmPackageName: pkg.name,
|
||||
...toolData,
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
discoveryMethod: 'keyword',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
||||
githubStars: githubStars,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: toolData,
|
||||
update: {
|
||||
npmVersion: pkg.version,
|
||||
npmPublishedAt: pkg.publishedAt ? new Date(pkg.publishedAt) : new Date(),
|
||||
npmDescription: pkg.description ?? undefined,
|
||||
npmRepository: pkg.repository ?? undefined,
|
||||
npmHomepage: pkg.homepage ?? undefined,
|
||||
npmLicense: pkg.license ?? undefined,
|
||||
npmKeywords: pkg.topLevelKeywords || pkg.keywords || [],
|
||||
npmReadme: pkg.readme ?? undefined,
|
||||
npmAuthor: pkg.author ?? undefined,
|
||||
npmMaintainers: pkg.maintainers ?? undefined,
|
||||
category: validation.packageData.category,
|
||||
env: validation.packageData.env ?? undefined,
|
||||
frameworks: validation.packageData.frameworks || [],
|
||||
tier: validation.tier || 'minimal',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs-tool') || false,
|
||||
},
|
||||
});
|
||||
|
||||
// Get existing tools for this package
|
||||
const existingTools = await prisma.tool.findMany({
|
||||
where: { packageId: packageRecord.id },
|
||||
});
|
||||
|
||||
// Upsert each tool in the tools array
|
||||
for (const toolDef of validation.tools) {
|
||||
const upsertedTool = await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_exportName: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
packageId: packageRecord.id,
|
||||
exportName: toolDef.exportName,
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
qualityScore: null, // Will be calculated by metrics sync
|
||||
},
|
||||
update: {
|
||||
description: toolDef.description,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: toolDef.parameters ? (toolDef.parameters as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
returns: toolDef.returns ? (toolDef.returns as any) : undefined,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
aiAgent: toolDef.aiAgent ? (toolDef.aiAgent as any) : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
// Trigger immediate health check (non-blocking)
|
||||
performHealthCheck(upsertedTool.id, 'sync').catch((err) => {
|
||||
console.error(
|
||||
`Health check failed for ${pkg.name}/${toolDef.exportName} (${upsertedTool.id}):`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned tools (tools removed from package.json)
|
||||
const orphanedTools = existingTools.filter(
|
||||
(existingTool) =>
|
||||
!validation.tools?.some((toolDef) => toolDef.exportName === existingTool.exportName)
|
||||
);
|
||||
|
||||
if (orphanedTools.length > 0) {
|
||||
await prisma.tool.deleteMany({
|
||||
where: {
|
||||
id: { in: orphanedTools.map((t) => t.id) },
|
||||
},
|
||||
});
|
||||
console.log(`Deleted ${orphanedTools.length} orphaned tools from package: ${pkg.name}`);
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
|
|
@ -174,6 +254,8 @@ export async function POST(request: NextRequest) {
|
|||
errors,
|
||||
packagesFound: searchResults.length,
|
||||
durationMs: Date.now() - startTime,
|
||||
errorMessages: errorMessages.slice(0, 5), // Include first 5 error messages
|
||||
skippedPackages: skippedPackages, // Include all skipped package names
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ export const maxDuration = 300; // 5 minutes max for cron jobs
|
|||
|
||||
/**
|
||||
* POST /api/sync/metrics
|
||||
* Update download stats and quality scores for all tools
|
||||
* Update download stats and quality scores for all packages and tools
|
||||
*
|
||||
* This endpoint is called by Vercel Cron (every hour)
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
|
|
@ -31,43 +31,51 @@ export async function POST(request: NextRequest) {
|
|||
const errorMessages: string[] = [];
|
||||
|
||||
try {
|
||||
// Get all tools from database
|
||||
const tools = await prisma.tool.findMany({
|
||||
select: {
|
||||
id: true,
|
||||
npmPackageName: true,
|
||||
tier: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
githubStars: true,
|
||||
// Get all packages with their tools from database
|
||||
const packages = await prisma.package.findMany({
|
||||
include: {
|
||||
tools: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Process each tool
|
||||
for (const tool of tools) {
|
||||
// Process each package
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
// Fetch download stats from NPM
|
||||
const downloads = await fetchDownloadStats(tool.npmPackageName);
|
||||
// Fetch download stats from NPM (package-level metric)
|
||||
const downloads = await fetchDownloadStats(pkg.npmPackageName);
|
||||
|
||||
// Calculate quality score (0.00 to 1.00)
|
||||
const qualityScore = calculateQualityScore({
|
||||
tier: tool.tier,
|
||||
downloads,
|
||||
githubStars: tool.githubStars || 0,
|
||||
});
|
||||
|
||||
// Update tool metrics
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
// Update package metrics
|
||||
await prisma.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: {
|
||||
npmDownloadsLastMonth: downloads,
|
||||
qualityScore,
|
||||
// githubStars would be updated here if we had GitHub API integration
|
||||
},
|
||||
});
|
||||
|
||||
// Calculate and update quality score for each tool in this package
|
||||
for (const tool of pkg.tools) {
|
||||
const qualityScore = calculateQualityScore({
|
||||
tier: pkg.tier, // Tier is at package level
|
||||
downloads, // Package downloads
|
||||
githubStars: pkg.githubStars || 0, // Package stars
|
||||
hasParameters: !!tool.parameters,
|
||||
hasReturns: !!tool.returns,
|
||||
hasAiAgent: !!tool.aiAgent,
|
||||
});
|
||||
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
qualityScore,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
const errorMsg = `Failed to process ${tool.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
const errorMsg = `Failed to process ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errorMessages.push(errorMsg);
|
||||
console.error(errorMsg);
|
||||
}
|
||||
|
|
@ -80,13 +88,15 @@ export async function POST(request: NextRequest) {
|
|||
source: 'metrics',
|
||||
checkpoint: {
|
||||
lastRun: new Date().toISOString(),
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
},
|
||||
},
|
||||
update: {
|
||||
checkpoint: {
|
||||
lastRun: new Date().toISOString(),
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -102,10 +112,11 @@ export async function POST(request: NextRequest) {
|
|||
message:
|
||||
errors > 0
|
||||
? `Processed with errors: ${errorMessages.slice(0, 3).join('; ')}`
|
||||
: `Successfully updated metrics for ${processed} tools`,
|
||||
: `Successfully updated metrics for ${processed} packages`,
|
||||
metadata: {
|
||||
durationMs: Date.now() - startTime,
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -116,7 +127,8 @@ export async function POST(request: NextRequest) {
|
|||
processed,
|
||||
skipped,
|
||||
errors,
|
||||
totalTools: tools.length,
|
||||
totalPackages: packages.length,
|
||||
totalTools: packages.reduce((sum, pkg) => sum + pkg.tools.length, 0),
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
});
|
||||
|
|
@ -152,25 +164,40 @@ export async function POST(request: NextRequest) {
|
|||
/**
|
||||
* Calculate quality score based on multiple factors
|
||||
* Returns a value between 0.00 and 1.00
|
||||
*
|
||||
* Score components:
|
||||
* - Tier (0.4 minimal, 0.6 rich)
|
||||
* - Downloads (logarithmic, max 0.2)
|
||||
* - GitHub stars (logarithmic, max 0.1)
|
||||
* - Tool metadata richness (0.1 for each: parameters, returns, aiAgent)
|
||||
*/
|
||||
function calculateQualityScore(params: {
|
||||
tier: string;
|
||||
downloads: number;
|
||||
githubStars: number;
|
||||
hasParameters: boolean;
|
||||
hasReturns: boolean;
|
||||
hasAiAgent: boolean;
|
||||
}): number {
|
||||
const { tier, downloads, githubStars } = params;
|
||||
const { tier, downloads, githubStars, hasParameters, hasReturns, hasAiAgent } = params;
|
||||
|
||||
// Base score from tier
|
||||
const tierScore = tier === 'rich' ? 0.6 : 0.4;
|
||||
|
||||
// Downloads score (logarithmic scale, max 0.3)
|
||||
const downloadsScore = Math.min(0.3, Math.log10(downloads + 1) / 10);
|
||||
// Downloads score (logarithmic scale, max 0.2)
|
||||
const downloadsScore = Math.min(0.2, Math.log10(downloads + 1) / 15);
|
||||
|
||||
// GitHub stars score (logarithmic scale, max 0.1)
|
||||
const starsScore = Math.min(0.1, Math.log10(githubStars + 1) / 10);
|
||||
|
||||
// Tool metadata richness score (max 0.1)
|
||||
let richnessScore = 0;
|
||||
if (hasParameters) richnessScore += 0.04;
|
||||
if (hasReturns) richnessScore += 0.03;
|
||||
if (hasAiAgent) richnessScore += 0.03;
|
||||
|
||||
// Total score (capped at 1.00)
|
||||
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore);
|
||||
const totalScore = Math.min(1.0, tierScore + downloadsScore + starsScore + richnessScore);
|
||||
|
||||
// Round to 2 decimal places
|
||||
return Math.round(totalScore * 100) / 100;
|
||||
|
|
|
|||
208
apps/web/src/app/api/tools/[...slug]/route.ts
Normal file
208
apps/web/src/app/api/tools/[...slug]/route.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { performHealthCheck } from '~/lib/health-check/health-check-service';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* Parse tool slug to extract package name and export name
|
||||
*/
|
||||
function parseSlug(slug: string[]): { packageName: string; exportName: string | undefined } {
|
||||
let packageName: string;
|
||||
let exportName: string | undefined;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - package name without scope
|
||||
packageName = slug[0] || '';
|
||||
} else if (slug.length === 2) {
|
||||
// Could be: @scope/package OR package/exportName
|
||||
if (slug[0]?.startsWith('@')) {
|
||||
// @scope/package
|
||||
packageName = slug.join('/');
|
||||
} else {
|
||||
// package + exportName
|
||||
packageName = slug[0] || '';
|
||||
exportName = slug[1];
|
||||
}
|
||||
} else {
|
||||
// 3+ slugs: @scope/package/exportName
|
||||
packageName = slug.slice(0, slug[0]?.startsWith('@') ? 2 : 1).join('/');
|
||||
exportName = slug[slug.length - 1];
|
||||
}
|
||||
|
||||
return { packageName, exportName };
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tools/[...slug]
|
||||
*
|
||||
* Fetch a single tool by its NPM package name (slug)
|
||||
* Supports catch-all routing for scoped packages like @tpmjs/text-transformer
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
const { packageName, exportName } = parseSlug(slug);
|
||||
|
||||
if (exportName) {
|
||||
// Find specific tool by package name and export name
|
||||
const tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
include: { package: true },
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tool,
|
||||
});
|
||||
}
|
||||
// Find all tools for the package
|
||||
const pkg = await prisma.package.findUnique({
|
||||
where: { npmPackageName: packageName },
|
||||
include: { tools: true },
|
||||
});
|
||||
|
||||
if (!pkg) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Package not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
package: pkg,
|
||||
tools: pkg.tools,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching tool:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch tool',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tools/[...slug]
|
||||
*
|
||||
* Manually trigger a health check for a specific tool
|
||||
* Rate limit: 5-minute cooldown per tool
|
||||
*
|
||||
* Examples:
|
||||
* - POST /api/tools/@tpmjs/hello/hello
|
||||
* - POST /api/tools/my-package/myTool
|
||||
*/
|
||||
export async function POST(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
const { packageName, exportName } = parseSlug(slug);
|
||||
|
||||
// Health checks require export name
|
||||
if (!exportName) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Export name required for health check',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Find the tool
|
||||
const tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
lastHealthCheck: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limiting: Check if last health check was within 5 minutes
|
||||
const fiveMinutesAgo = new Date(Date.now() - 5 * 60 * 1000);
|
||||
if (tool.lastHealthCheck && tool.lastHealthCheck > fiveMinutesAgo) {
|
||||
const nextAvailable = new Date(tool.lastHealthCheck.getTime() + 5 * 60 * 1000);
|
||||
const secondsRemaining = Math.ceil((nextAvailable.getTime() - Date.now()) / 1000);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Rate limit exceeded. Try again in ${secondsRemaining} seconds.`,
|
||||
retryAfter: secondsRemaining,
|
||||
},
|
||||
{ status: 429 }
|
||||
);
|
||||
}
|
||||
|
||||
// Perform health check
|
||||
console.log(`🏥 Manual health check triggered for ${packageName}/${exportName}`);
|
||||
const result = await performHealthCheck(tool.id, 'manual');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
toolId: result.toolId,
|
||||
packageName: packageName,
|
||||
exportName: exportName,
|
||||
importStatus: result.importStatus,
|
||||
importError: result.importError,
|
||||
importTimeMs: result.importTimeMs,
|
||||
executionStatus: result.executionStatus,
|
||||
executionError: result.executionError,
|
||||
executionTimeMs: result.executionTimeMs,
|
||||
overallStatus: result.overallStatus,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error performing manual health check:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to perform health check',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* GET /api/tools/[id]
|
||||
* Get tool details by ID or package name
|
||||
*
|
||||
* Params:
|
||||
* - id: Tool ID (number) or NPM package name (string)
|
||||
*/
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
if (!id) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing ID parameter',
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Try to find by ID first (cuid), then by package name
|
||||
const tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
OR: [{ id }, { npmPackageName: id }],
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
message: `No tool found with ID or package name: ${id}`,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tool,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching tool details:', error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch tool details',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* GET /api/tools/[slug]
|
||||
*
|
||||
* Fetch a single tool by its NPM package name (slug)
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string }> }
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
// Find the tool by npmPackageName
|
||||
const tool = await prisma.tool.findUnique({
|
||||
where: {
|
||||
npmPackageName: decodeURIComponent(slug),
|
||||
},
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Tool not found',
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Return the tool data
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: tool,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching tool:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Failed to fetch tool',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
48
apps/web/src/app/api/tools/broken/route.ts
Normal file
48
apps/web/src/app/api/tools/broken/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* GET /api/tools/broken
|
||||
* List all tools with broken health status
|
||||
*
|
||||
* Returns tools where importHealth='BROKEN' OR executionHealth='BROKEN'
|
||||
* Includes package relation with npmPackageName and npmVersion
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const brokenTools = await prisma.tool.findMany({
|
||||
where: {
|
||||
OR: [{ importHealth: 'BROKEN' }, { executionHealth: 'BROKEN' }],
|
||||
},
|
||||
include: {
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
npmVersion: true,
|
||||
category: true,
|
||||
isOfficial: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
lastHealthCheck: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: brokenTools,
|
||||
count: brokenTools.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch broken tools:', error);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: 'Failed to fetch broken tools' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
216
apps/web/src/app/api/tools/execute/[...slug]/route.ts
Normal file
216
apps/web/src/app/api/tools/execute/[...slug]/route.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
/**
|
||||
* Tool execution endpoint with SSE streaming
|
||||
* Executes TPMJS tools with AI agents and streams real-time progress
|
||||
*/
|
||||
|
||||
import { checkRateLimit, getClientIP } from '@/lib/rate-limiter';
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
// Use Node.js runtime for SSE streaming
|
||||
export const runtime = 'nodejs';
|
||||
export const maxDuration = 60; // 60 seconds timeout
|
||||
export const dynamic = 'force-dynamic'; // Prevent static generation for AI SDK routes
|
||||
|
||||
interface ExecuteRequest {
|
||||
prompt: string;
|
||||
parameters?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /api/tools/execute/[...slug]
|
||||
* Executes a tool with an AI agent and streams the response via SSE
|
||||
*
|
||||
* Slug format: [toolId] or [packageName, exportName]
|
||||
* Examples:
|
||||
* /api/tools/execute/clx123abc (by tool ID)
|
||||
* /api/tools/execute/@tpmjs/hello/helloWorldTool (by package and export name)
|
||||
*/
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
try {
|
||||
// Parse request body
|
||||
const body = (await request.json()) as ExecuteRequest;
|
||||
const { prompt, parameters } = body;
|
||||
|
||||
if (!prompt || prompt.length === 0) {
|
||||
return NextResponse.json({ error: 'Prompt is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (prompt.length > 2000) {
|
||||
return NextResponse.json({ error: 'Prompt too long (max 2000 characters)' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Get client IP and check rate limit
|
||||
const ipAddress = getClientIP(request);
|
||||
const rateLimit = await checkRateLimit(ipAddress);
|
||||
|
||||
if (!rateLimit.allowed) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Rate limit exceeded',
|
||||
resetAt: rateLimit.resetAt,
|
||||
remaining: 0,
|
||||
},
|
||||
{
|
||||
status: 429,
|
||||
headers: {
|
||||
'X-RateLimit-Limit': '10',
|
||||
'X-RateLimit-Remaining': '0',
|
||||
'X-RateLimit-Reset': rateLimit.resetAt.toISOString(),
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch tool from database with package relation
|
||||
// Support both ID-based lookup and packageName/exportName lookup
|
||||
let tool;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - treat as tool ID
|
||||
tool = await prisma.tool.findUnique({
|
||||
where: { id: slug[0] || '' },
|
||||
include: { package: true },
|
||||
});
|
||||
} else {
|
||||
// Multiple slugs - treat as packageName/exportName
|
||||
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
|
||||
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
|
||||
|
||||
tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
include: { package: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Create simulation record
|
||||
const simulation = await prisma.simulation.create({
|
||||
data: {
|
||||
toolId: tool.id,
|
||||
userPrompt: prompt,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: parameters ? (parameters as any) : undefined,
|
||||
ipAddress,
|
||||
userAgent: request.headers.get('user-agent') || null,
|
||||
status: 'running',
|
||||
model: 'gpt-4-turbo',
|
||||
},
|
||||
});
|
||||
|
||||
// Create readable stream for SSE
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const sendEvent = (event: string, data: unknown) => {
|
||||
const message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
controller.enqueue(encoder.encode(message));
|
||||
};
|
||||
|
||||
try {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Dynamically import AI agent to avoid loading tiktoken at build time
|
||||
const { executeToolWithAgent } = await import('@/lib/ai-agent/tool-executor-agent');
|
||||
|
||||
// Execute tool with AI agent
|
||||
const result = await executeToolWithAgent(
|
||||
tool,
|
||||
prompt,
|
||||
(chunk) => {
|
||||
// Stream text chunks
|
||||
sendEvent('chunk', { text: chunk });
|
||||
},
|
||||
(tokens) => {
|
||||
// Stream token updates
|
||||
sendEvent('tokens', tokens);
|
||||
}
|
||||
);
|
||||
|
||||
const executionTimeMs = Date.now() - startTime;
|
||||
|
||||
// Update simulation with results
|
||||
await prisma.simulation.update({
|
||||
where: { id: simulation.id },
|
||||
data: {
|
||||
status: 'success',
|
||||
output: { result: result.output },
|
||||
agentSteps: result.agentSteps,
|
||||
executionTimeMs,
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Create token usage record
|
||||
await prisma.tokenUsage.create({
|
||||
data: {
|
||||
simulationId: simulation.id,
|
||||
inputTokens: result.tokenBreakdown.inputTokens,
|
||||
toolDescTokens: result.tokenBreakdown.toolDescTokens,
|
||||
schemaTokens: result.tokenBreakdown.schemaTokens,
|
||||
outputTokens: result.tokenBreakdown.outputTokens,
|
||||
totalTokens: result.tokenBreakdown.totalTokens,
|
||||
estimatedCost: result.tokenBreakdown.estimatedCost,
|
||||
},
|
||||
});
|
||||
|
||||
// Send completion event
|
||||
sendEvent('complete', {
|
||||
output: result.output,
|
||||
tokenBreakdown: result.tokenBreakdown,
|
||||
executionTimeMs,
|
||||
agentSteps: result.agentSteps,
|
||||
});
|
||||
} catch (error) {
|
||||
// Update simulation with error
|
||||
await prisma.simulation.update({
|
||||
where: { id: simulation.id },
|
||||
data: {
|
||||
status: 'error',
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
completedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
// Send error event
|
||||
sendEvent('error', {
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
});
|
||||
} finally {
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Return SSE stream
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
'X-RateLimit-Limit': '10',
|
||||
'X-RateLimit-Remaining': rateLimit.remaining.toString(),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Execute endpoint error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,97 @@ export const runtime = 'nodejs';
|
|||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* Build health filters from query parameters
|
||||
*/
|
||||
function buildHealthFilters(
|
||||
brokenParam: string | null,
|
||||
importHealth: string | null,
|
||||
executionHealth: string | null
|
||||
): Prisma.ToolWhereInput[] {
|
||||
const healthFilters: Prisma.ToolWhereInput[] = [];
|
||||
|
||||
if (brokenParam === 'true') {
|
||||
// Shorthand: at least one health check failed
|
||||
healthFilters.push({
|
||||
OR: [{ importHealth: 'BROKEN' }, { executionHealth: 'BROKEN' }],
|
||||
});
|
||||
} else {
|
||||
// Individual health status filters
|
||||
if (importHealth && ['HEALTHY', 'BROKEN', 'UNKNOWN'].includes(importHealth)) {
|
||||
healthFilters.push({ importHealth: importHealth as 'HEALTHY' | 'BROKEN' | 'UNKNOWN' });
|
||||
}
|
||||
if (executionHealth && ['HEALTHY', 'BROKEN', 'UNKNOWN'].includes(executionHealth)) {
|
||||
healthFilters.push({
|
||||
executionHealth: executionHealth as 'HEALTHY' | 'BROKEN' | 'UNKNOWN',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return healthFilters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build package filters from query parameters
|
||||
*/
|
||||
function buildPackageFilter(
|
||||
category: string | null,
|
||||
officialParam: string | null
|
||||
): Prisma.PackageWhereInput {
|
||||
const packageFilter: Prisma.PackageWhereInput = {};
|
||||
|
||||
if (category) {
|
||||
packageFilter.category = category;
|
||||
}
|
||||
|
||||
if (officialParam !== null) {
|
||||
packageFilter.isOfficial = officialParam === 'true';
|
||||
}
|
||||
|
||||
return packageFilter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build where clause for tool query
|
||||
*/
|
||||
function buildWhereClause(
|
||||
query: string | null,
|
||||
packageFilter: Prisma.PackageWhereInput,
|
||||
healthFilters: Prisma.ToolWhereInput[]
|
||||
): Prisma.ToolWhereInput {
|
||||
const where: Prisma.ToolWhereInput = {};
|
||||
|
||||
// Search filter (searches tool description and package name)
|
||||
if (query) {
|
||||
where.OR = [
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{ package: { npmPackageName: { contains: query, mode: 'insensitive' }, ...packageFilter } },
|
||||
];
|
||||
} else if (Object.keys(packageFilter).length > 0) {
|
||||
// Apply package filter if no search query
|
||||
where.package = packageFilter;
|
||||
}
|
||||
|
||||
// Apply health filters as AND conditions
|
||||
if (healthFilters.length > 0) {
|
||||
where.AND = healthFilters;
|
||||
}
|
||||
|
||||
return where;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /api/tools
|
||||
* Search and list tools with filtering, sorting, and pagination
|
||||
*
|
||||
* Query params:
|
||||
* - q: Search query (searches name, description, tags)
|
||||
* - q: Search query (searches package name, tool description)
|
||||
* - category: Filter by category
|
||||
* - official: Filter by official status (true/false)
|
||||
* - limit: Results per page (default: 20, max: 100)
|
||||
* - importHealth: Filter by import health (HEALTHY, BROKEN, UNKNOWN)
|
||||
* - executionHealth: Filter by execution health (HEALTHY, BROKEN, UNKNOWN)
|
||||
* - broken: Shorthand for "at least one health check failed" (true/false)
|
||||
* - limit: Results per page (default: 20, max: 50)
|
||||
* - offset: Pagination offset (default: 0)
|
||||
*/
|
||||
export async function GET(request: NextRequest) {
|
||||
|
|
@ -24,47 +106,33 @@ export async function GET(request: NextRequest) {
|
|||
const query = searchParams.get('q');
|
||||
const category = searchParams.get('category');
|
||||
const officialParam = searchParams.get('official');
|
||||
const importHealth = searchParams.get('importHealth');
|
||||
const executionHealth = searchParams.get('executionHealth');
|
||||
const brokenParam = searchParams.get('broken');
|
||||
const limitParam = searchParams.get('limit');
|
||||
const offsetParam = searchParams.get('offset');
|
||||
|
||||
// Validate and set defaults (reduced max from 100 to 50 for faster queries)
|
||||
const limit = Math.min(
|
||||
Number.parseInt(limitParam || '20', 10),
|
||||
50 // Reduced from 100 for better performance
|
||||
);
|
||||
// Validate and set defaults
|
||||
const limit = Math.min(Number.parseInt(limitParam || '20', 10), 50);
|
||||
const offset = Math.max(Number.parseInt(offsetParam || '0', 10), 0);
|
||||
|
||||
// Build where clause
|
||||
const where: Prisma.ToolWhereInput = {};
|
||||
// Build filters
|
||||
const packageFilter = buildPackageFilter(category, officialParam);
|
||||
const healthFilters = buildHealthFilters(brokenParam, importHealth, executionHealth);
|
||||
const where = buildWhereClause(query, packageFilter, healthFilters);
|
||||
|
||||
// Search filter (case-insensitive partial match)
|
||||
if (query) {
|
||||
where.OR = [
|
||||
{ npmPackageName: { contains: query, mode: 'insensitive' } },
|
||||
{ description: { contains: query, mode: 'insensitive' } },
|
||||
{
|
||||
tags: {
|
||||
hasSome: [query],
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// Category filter
|
||||
if (category) {
|
||||
where.category = category;
|
||||
}
|
||||
|
||||
// Official filter
|
||||
if (officialParam !== null) {
|
||||
where.isOfficial = officialParam === 'true';
|
||||
}
|
||||
|
||||
// Execute queries - run count separately only if needed for pagination
|
||||
// For first page, we can skip count if we don't need total pages
|
||||
// Execute query - fetch tools with package relation
|
||||
// We fetch limit+1 to check if there are more results (avoid expensive count)
|
||||
const tools = await prisma.tool.findMany({
|
||||
where,
|
||||
orderBy: [{ qualityScore: 'desc' }, { npmDownloadsLastMonth: 'desc' }, { createdAt: 'desc' }],
|
||||
include: {
|
||||
package: true, // Include package data for each tool
|
||||
},
|
||||
orderBy: [
|
||||
{ qualityScore: 'desc' }, // Tool quality score
|
||||
{ package: { npmDownloadsLastMonth: 'desc' } }, // Package downloads
|
||||
{ createdAt: 'desc' }, // Tool creation time
|
||||
],
|
||||
take: limit + 1, // Fetch one extra to check if there are more
|
||||
skip: offset,
|
||||
});
|
||||
|
|
@ -81,7 +149,6 @@ export async function GET(request: NextRequest) {
|
|||
offset,
|
||||
hasMore,
|
||||
// Note: total count omitted for performance (can be expensive)
|
||||
// Only return count if explicitly requested
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
|
|
|
|||
189
apps/web/src/app/api/tools/search/route.ts
Normal file
189
apps/web/src/app/api/tools/search/route.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
// BM25 parameters
|
||||
const k1 = 1.5; // term frequency saturation parameter
|
||||
const b = 0.75; // length normalization parameter
|
||||
|
||||
// Tokenize text into words
|
||||
function tokenize(text: string): string[] {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/[^\w\s]/g, ' ')
|
||||
.split(/\s+/)
|
||||
.filter((t) => t.length > 0);
|
||||
}
|
||||
|
||||
// Calculate term frequency
|
||||
function termFrequency(term: string, tokens: string[]): number {
|
||||
return tokens.filter((t) => t === term).length;
|
||||
}
|
||||
|
||||
// Calculate BM25 score
|
||||
function calculateBM25(
|
||||
query: string,
|
||||
document: string,
|
||||
avgDocLength: number,
|
||||
totalDocs: number,
|
||||
docFrequencies: Map<string, number>
|
||||
): number {
|
||||
const queryTokens = tokenize(query);
|
||||
const docTokens = tokenize(document);
|
||||
const docLength = docTokens.length;
|
||||
|
||||
let score = 0;
|
||||
|
||||
for (const term of queryTokens) {
|
||||
const tf = termFrequency(term, docTokens);
|
||||
if (tf === 0) continue;
|
||||
|
||||
// IDF calculation
|
||||
const docFreq = docFrequencies.get(term) || 0;
|
||||
const idf = Math.log((totalDocs - docFreq + 0.5) / (docFreq + 0.5) + 1);
|
||||
|
||||
// BM25 formula
|
||||
const numerator = tf * (k1 + 1);
|
||||
const denominator = tf + k1 * (1 - b + b * (docLength / avgDocLength));
|
||||
|
||||
score += idf * (numerator / denominator);
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
export async function GET(request: Request) {
|
||||
console.log('🔎 [SEARCH API] Request received');
|
||||
|
||||
try {
|
||||
const { searchParams } = new URL(request.url);
|
||||
const query = searchParams.get('q') || '';
|
||||
const category = searchParams.get('category');
|
||||
const limit = Math.min(Number.parseInt(searchParams.get('limit') || '10'), 50);
|
||||
|
||||
// Get recent messages for context (passed as JSON in 'messages' param)
|
||||
const messagesParam = searchParams.get('messages');
|
||||
const recentMessages = messagesParam ? JSON.parse(messagesParam) : [];
|
||||
|
||||
console.log(
|
||||
`🔎 [SEARCH API] Query: "${query}", Category: ${category}, Limit: ${limit}, Messages: ${recentMessages.length}`
|
||||
);
|
||||
|
||||
// Fetch all tools with package info
|
||||
const tools = await prisma.tool.findMany({
|
||||
include: { package: true },
|
||||
where: category
|
||||
? {
|
||||
package: { category },
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
|
||||
console.log(`📊 [SEARCH API] Found ${tools.length} tools in database`);
|
||||
|
||||
// Combine query with recent messages for better context
|
||||
const fullQuery = [query, ...recentMessages].filter(Boolean).join(' ');
|
||||
console.log(`🔍 [SEARCH API] Full search context: "${fullQuery.slice(0, 100)}..."`);
|
||||
|
||||
// Build all documents first
|
||||
const documents = tools.map((tool) => ({
|
||||
tool,
|
||||
text: [
|
||||
tool.description,
|
||||
tool.exportName,
|
||||
tool.package.npmPackageName,
|
||||
tool.package.npmDescription || '',
|
||||
...(tool.package.npmKeywords || []),
|
||||
].join(' '),
|
||||
}));
|
||||
|
||||
// Calculate document frequencies (IDF)
|
||||
const docFrequencies = new Map<string, number>();
|
||||
const queryTokens = tokenize(fullQuery);
|
||||
|
||||
for (const term of queryTokens) {
|
||||
let count = 0;
|
||||
for (const doc of documents) {
|
||||
const docTokens = tokenize(doc.text);
|
||||
if (docTokens.includes(term)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
docFrequencies.set(term, count);
|
||||
}
|
||||
|
||||
// Calculate average document length
|
||||
const totalTokens = documents.reduce((sum, doc) => sum + tokenize(doc.text).length, 0);
|
||||
const avgDocLength = totalTokens / documents.length;
|
||||
|
||||
// Calculate BM25 scores
|
||||
const scoredResults = documents.map(({ tool, text }) => {
|
||||
const bm25Score = calculateBM25(fullQuery, text, avgDocLength, tools.length, docFrequencies);
|
||||
const qualityBoost = Number(tool.qualityScore ?? 0) * 0.5;
|
||||
const downloadBoost = Math.log10((tool.package.npmDownloadsLastMonth || 0) + 1) * 0.1;
|
||||
const finalScore = bm25Score + qualityBoost + downloadBoost;
|
||||
|
||||
return { tool, score: finalScore };
|
||||
});
|
||||
|
||||
// Sort by score and take top N
|
||||
const topResults = scoredResults
|
||||
.filter(({ score }) => score > 0) // Only include results with matches
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit + 1);
|
||||
|
||||
const hasMore = topResults.length > limit;
|
||||
const results = hasMore ? topResults.slice(0, limit) : topResults;
|
||||
|
||||
console.log(`✅ [SEARCH API] Returning ${results.length} results (hasMore: ${hasMore})`);
|
||||
|
||||
// Format response to match existing /api/tools structure
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
query,
|
||||
filters: { category },
|
||||
results: {
|
||||
total: scoredResults.filter(({ score }) => score > 0).length,
|
||||
returned: results.length,
|
||||
tools: results.map(({ tool }) => ({
|
||||
id: tool.id,
|
||||
exportName: tool.exportName,
|
||||
description: tool.description,
|
||||
qualityScore: tool.qualityScore,
|
||||
importHealth: tool.importHealth,
|
||||
executionHealth: tool.executionHealth,
|
||||
healthCheckError: tool.healthCheckError,
|
||||
lastHealthCheck: tool.lastHealthCheck,
|
||||
package: {
|
||||
npmPackageName: tool.package.npmPackageName,
|
||||
npmVersion: tool.package.npmVersion,
|
||||
category: tool.package.category,
|
||||
frameworks: tool.package.frameworks,
|
||||
env: tool.package.env,
|
||||
npmRepository: tool.package.npmRepository,
|
||||
isOfficial: tool.package.isOfficial,
|
||||
npmDownloadsLastMonth: tool.package.npmDownloadsLastMonth,
|
||||
},
|
||||
importUrl: `https://esm.sh/${tool.package.npmPackageName}@${tool.package.npmVersion}`,
|
||||
cdnUrl: `https://cdn.jsdelivr.net/npm/${tool.package.npmPackageName}@${tool.package.npmVersion}/+esm`,
|
||||
})),
|
||||
},
|
||||
pagination: {
|
||||
limit,
|
||||
hasMore,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ [SEARCH API] Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Search failed',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
70
apps/web/src/app/api/tools/simulations/[...slug]/route.ts
Normal file
70
apps/web/src/app/api/tools/simulations/[...slug]/route.ts
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
/**
|
||||
* Simulation history endpoint
|
||||
* Returns recent simulations for a tool with token usage data
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* GET /api/tools/simulations/[...slug]
|
||||
* Returns the last 10 simulations for a tool
|
||||
*
|
||||
* Slug can be:
|
||||
* - Tool ID (single slug)
|
||||
* - Package name + export name (multiple slugs)
|
||||
*/
|
||||
export async function GET(
|
||||
_request: NextRequest,
|
||||
{ params }: { params: Promise<{ slug: string[] }> }
|
||||
) {
|
||||
const { slug } = await params;
|
||||
|
||||
try {
|
||||
let tool;
|
||||
|
||||
if (slug.length === 1) {
|
||||
// Single slug - treat as tool ID
|
||||
tool = await prisma.tool.findUnique({
|
||||
where: { id: slug[0] || '' },
|
||||
select: { id: true },
|
||||
});
|
||||
} else {
|
||||
// Multiple slugs - treat as packageName/exportName
|
||||
const packageName = decodeURIComponent(slug.slice(0, -1).join('/'));
|
||||
const exportName = decodeURIComponent(slug[slug.length - 1] || '');
|
||||
|
||||
tool = await prisma.tool.findFirst({
|
||||
where: {
|
||||
package: { npmPackageName: packageName },
|
||||
exportName: exportName,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
}
|
||||
|
||||
if (!tool) {
|
||||
return NextResponse.json({ error: 'Tool not found' }, { status: 404 });
|
||||
}
|
||||
|
||||
// Fetch recent simulations with token usage
|
||||
const simulations = await prisma.simulation.findMany({
|
||||
where: { toolId: tool.id },
|
||||
include: {
|
||||
tokenUsage: true,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
|
||||
return NextResponse.json({ simulations });
|
||||
} catch (error) {
|
||||
console.error('Simulations endpoint error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: error instanceof Error ? error.message : 'Internal server error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -5,46 +5,46 @@
|
|||
@layer base {
|
||||
/* Light mode (default) */
|
||||
:root {
|
||||
/* Backgrounds & Surfaces */
|
||||
--background: 0 0% 100%; /* Pure white */
|
||||
--surface: 210 20% 98%; /* Off-white */
|
||||
/* Backgrounds & Surfaces - DRAMATIC CONTRAST */
|
||||
--background: 220 15% 96%; /* Light blue-gray background */
|
||||
--surface: 0 0% 100%; /* Pure white - cards really pop! */
|
||||
--surface-elevated: 0 0% 100%; /* White (elevated) */
|
||||
--surface-overlay: 0 0% 98%; /* Light gray */
|
||||
--surface-overlay: 0 0% 100%; /* White overlays */
|
||||
|
||||
/* Foreground (Text) */
|
||||
--foreground: 222 47% 11%; /* Almost black */
|
||||
--foreground-secondary: 215 16% 47%; /* Medium gray */
|
||||
--foreground-tertiary: 215 16% 65%; /* Light gray */
|
||||
--foreground-muted: 215 16% 75%; /* Very light gray */
|
||||
--foreground-secondary: 215 25% 35%; /* Much darker for readability */
|
||||
--foreground-tertiary: 215 20% 50%; /* Medium gray */
|
||||
--foreground-muted: 215 16% 65%; /* Light gray */
|
||||
|
||||
/* Borders */
|
||||
--border: 214 32% 91%; /* Light gray */
|
||||
--border-strong: 214 32% 80%; /* Medium gray */
|
||||
--border-subtle: 214 20% 95%; /* Very light gray */
|
||||
/* Borders - MUCH MORE VISIBLE */
|
||||
--border: 214 25% 80%; /* Strong medium gray */
|
||||
--border-strong: 214 30% 60%; /* Dark gray for emphasis */
|
||||
--border-subtle: 214 20% 88%; /* Subtle but visible */
|
||||
|
||||
/* Interactive States */
|
||||
--primary: 222 47% 11%; /* Dark for light mode */
|
||||
--primary-foreground: 210 40% 98%; /* Light text */
|
||||
--secondary: 210 40% 96%; /* Light secondary */
|
||||
/* Interactive States - MODERN & REFINED */
|
||||
--primary: 221 83% 53%; /* Sophisticated blue */
|
||||
--primary-foreground: 0 0% 100%; /* White text */
|
||||
--secondary: 220 15% 90%; /* Subtle gray-blue bg */
|
||||
--secondary-foreground: 222 47% 11%; /* Dark text */
|
||||
--accent: 210 40% 96%; /* Accent bg */
|
||||
--accent-foreground: 222 47% 11%; /* Accent text */
|
||||
--muted: 210 40% 96%; /* Muted bg */
|
||||
--muted-foreground: 215 16% 47%; /* Muted text */
|
||||
--accent: 221 75% 95%; /* Soft blue tint */
|
||||
--accent-foreground: 221 70% 35%; /* Rich blue text */
|
||||
--muted: 220 15% 92%; /* Subtle muted bg */
|
||||
--muted-foreground: 215 25% 40%; /* Darker muted text */
|
||||
|
||||
/* Status Colors */
|
||||
--success: 142 71% 45%;
|
||||
--success-foreground: 142 76% 15%;
|
||||
--error: 0 72% 51%;
|
||||
--error-foreground: 0 86% 17%;
|
||||
--warning: 38 92% 50%;
|
||||
--warning-foreground: 48 96% 19%;
|
||||
--info: 217 91% 60%;
|
||||
--info-foreground: 214 95% 23%;
|
||||
/* Status Colors - Modern Editorial Palette */
|
||||
--success: 152 57% 45%; /* Refined emerald green */
|
||||
--success-foreground: 0 0% 100%; /* White text on success */
|
||||
--error: 0 65% 51%; /* Sophisticated red, less harsh */
|
||||
--error-foreground: 0 0% 100%; /* White text on error */
|
||||
--warning: 36 100% 50%; /* Warm sophisticated amber */
|
||||
--warning-foreground: 0 0% 100%; /* White text on warning */
|
||||
--info: 210 100% 56%; /* Cool modern blue */
|
||||
--info-foreground: 0 0% 100%; /* White text on info */
|
||||
|
||||
/* Destructive (legacy) */
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--destructive: 0 65% 51%; /* Match error color */
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Form Elements */
|
||||
--input: 214 32% 91%;
|
||||
|
|
@ -52,11 +52,11 @@
|
|||
--ring-offset: 0 0% 100%;
|
||||
|
||||
/* Grid/Blueprint Pattern */
|
||||
--grid-color: 214 32% 95%;
|
||||
--grid-color: 214 32% 90%;
|
||||
--grid-size: 24px; /* Grid cell size */
|
||||
|
||||
/* Card */
|
||||
--card: 0 0% 100%;
|
||||
--card: 0 0% 100%; /* Pure white - stands out dramatically on blue-gray background */
|
||||
--card-foreground: 222 47% 11%;
|
||||
|
||||
/* Border Radii */
|
||||
|
|
@ -93,9 +93,9 @@
|
|||
--tracking-wider: 0.05em;
|
||||
--tracking-widest: 0.1em;
|
||||
|
||||
/* Brutalist Accent Colors */
|
||||
--brutalist-accent: 221 83% 53%; /* #0066ff - Electric blue for light mode */
|
||||
--brutalist-accent-hover: 221 83% 43%;
|
||||
/* Brutalist Accent Colors - Modern Editorial */
|
||||
--brutalist-accent: 221 83% 53%; /* Sophisticated blue */
|
||||
--brutalist-accent-hover: 221 83% 45%; /* Slightly deeper on hover */
|
||||
}
|
||||
|
||||
/* Dark mode (opt-in) - Vercel/Cursor/Perplexity aesthetic */
|
||||
|
|
@ -127,19 +127,19 @@
|
|||
--muted: 210 10% 12%; /* Muted background */
|
||||
--muted-foreground: 210 8% 60%; /* Muted text */
|
||||
|
||||
/* Status Colors (desaturated for dark mode) */
|
||||
--success: 142 71% 45%; /* #10b981 - Green */
|
||||
--success-foreground: 142 76% 95%; /* Light green text */
|
||||
--error: 0 72% 51%; /* #ef4444 - Red */
|
||||
--error-foreground: 0 86% 97%; /* Light red text */
|
||||
--warning: 38 92% 50%; /* #f59e0b - Amber */
|
||||
--warning-foreground: 48 96% 89%; /* Light amber text */
|
||||
--info: 217 91% 60%; /* #3b82f6 - Blue */
|
||||
--info-foreground: 214 95% 93%; /* Light blue text */
|
||||
/* Status Colors - Modern Editorial (Dark Mode) */
|
||||
--success: 152 57% 50%; /* Refined emerald - slightly brighter for dark */
|
||||
--success-foreground: 0 0% 100%; /* White text */
|
||||
--error: 0 65% 58%; /* Sophisticated red - brighter for dark */
|
||||
--error-foreground: 0 0% 100%; /* White text */
|
||||
--warning: 36 100% 55%; /* Warm amber - brighter for dark */
|
||||
--warning-foreground: 0 0% 100%; /* White text */
|
||||
--info: 210 100% 60%; /* Cool blue - brighter for dark */
|
||||
--info-foreground: 0 0% 100%; /* White text */
|
||||
|
||||
/* Destructive (legacy support) */
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 86% 97%;
|
||||
--destructive: 0 65% 58%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
|
||||
/* Form Elements */
|
||||
--input: 210 10% 20%; /* Input border */
|
||||
|
|
@ -153,9 +153,9 @@
|
|||
--card: 210 10% 8%;
|
||||
--card-foreground: 210 10% 90%;
|
||||
|
||||
/* Brutalist Accent Colors */
|
||||
--brutalist-accent: 158 100% 50%; /* #00ff88 - Neon green for dark mode */
|
||||
--brutalist-accent-hover: 158 100% 40%;
|
||||
/* Brutalist Accent Colors - Modern Editorial */
|
||||
--brutalist-accent: 210 100% 60%; /* Cool sophisticated blue for dark mode */
|
||||
--brutalist-accent-hover: 210 100% 65%; /* Slightly brighter on hover */
|
||||
}
|
||||
|
||||
/* Base element styles */
|
||||
|
|
|
|||
698
apps/web/src/app/how-it-works/page.tsx
Normal file
698
apps/web/src/app/how-it-works/page.tsx
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata = {
|
||||
title: 'How It Works | TPMJS',
|
||||
description: 'Learn how TPMJS automatically discovers, indexes, and serves AI tools from npm',
|
||||
};
|
||||
|
||||
export default function HowItWorksPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 py-16">
|
||||
<Container size="lg" padding="lg">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4 text-foreground">How TPMJS Works</h1>
|
||||
<p className="text-xl text-foreground-secondary max-w-2xl mx-auto">
|
||||
The complete journey from npm package to AI-powered tool execution
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Overview */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">What is TPMJS?</h2>
|
||||
<div className="prose max-w-none text-foreground-secondary text-lg space-y-4">
|
||||
<p>
|
||||
TPMJS (Tool Package Manager for JavaScript) is a{' '}
|
||||
<span className="text-foreground font-semibold">
|
||||
registry and execution platform
|
||||
</span>{' '}
|
||||
that automatically discovers, catalogs, and runs AI tools from the npm ecosystem.
|
||||
</p>
|
||||
<p>
|
||||
It acts as a bridge between{' '}
|
||||
<span className="text-foreground font-semibold">AI agents</span> (powered by
|
||||
frameworks like Vercel AI SDK, LangChain, and LlamaIndex) and{' '}
|
||||
<span className="text-foreground font-semibold">reusable tool packages</span>{' '}
|
||||
published to npm.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-6 mt-8">
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="text-2xl mb-2">🔍</div>
|
||||
<h3 className="font-semibold mb-2 text-foreground">Automatic Discovery</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Tools appear on tpmjs.com within 2-15 minutes of publishing to npm
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="text-2xl mb-2">📊</div>
|
||||
<h3 className="font-semibold mb-2 text-foreground">Quality Scoring</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Automatic scoring based on documentation, downloads, and metadata completeness
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="text-2xl mb-2">⚡</div>
|
||||
<h3 className="font-semibold mb-2 text-foreground">Instant Execution</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
AI agents can discover and execute tools through a unified API
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* For Developers */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">For Tool Developers</h2>
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg text-foreground-secondary">
|
||||
Publishing a tool to TPMJS is as simple as publishing to npm with a standardized
|
||||
metadata field.
|
||||
</p>
|
||||
|
||||
{/* Step 1 */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold">
|
||||
1
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold text-foreground">
|
||||
Add metadata to package.json
|
||||
</h3>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"name": "@yourname/awesome-tool",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["tpmjs-tool"],
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [{
|
||||
"exportName": "analyzeSentiment",
|
||||
"description": "Analyze sentiment of text",
|
||||
"parameters": [{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "Text to analyze",
|
||||
"required": true
|
||||
}],
|
||||
"returns": {
|
||||
"type": "string",
|
||||
"description": "Sentiment score"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Step 2 */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="flex items-center justify-center w-8 h-8 rounded-full bg-primary text-primary-foreground font-bold">
|
||||
2
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold text-foreground">Publish to npm</h3>
|
||||
</div>
|
||||
<CodeBlock language="bash" code="npm publish --access public" />
|
||||
<p className="text-sm text-foreground-secondary mt-4">
|
||||
That's it! TPMJS will automatically discover your tool within 2-15 minutes.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-center">
|
||||
<Link href="/publish">
|
||||
<Button size="lg" variant="default">
|
||||
View Publishing Guide
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* For AI Agents */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">For AI Agents</h2>
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg text-foreground-secondary">
|
||||
AI agents can search, discover, and execute tools through the TPMJS API.
|
||||
</p>
|
||||
|
||||
{/* Search Tools */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-4 text-foreground">Search & Filter</h3>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`# Search tools by query
|
||||
GET /api/tools?q=sentiment&category=text-analysis
|
||||
|
||||
# Filter by health status
|
||||
GET /api/tools?importHealth=HEALTHY&executionHealth=HEALTHY
|
||||
|
||||
# Get official tools only
|
||||
GET /api/tools?official=true`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Execute Tools */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-4 text-foreground">Execute Tools</h3>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`import { streamText } from 'ai';
|
||||
import { analyzeSentiment } from '@yourname/awesome-tool';
|
||||
|
||||
const result = await streamText({
|
||||
model: openai('gpt-4'),
|
||||
prompt: 'Analyze the sentiment of: I love this product!',
|
||||
tools: {
|
||||
analyzeSentiment, // Just import and use alongside your other tools
|
||||
// ... your other tools
|
||||
},
|
||||
});`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Playground */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-4 text-foreground">Test in Playground</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Try tools interactively before integrating them into your AI agent.
|
||||
</p>
|
||||
<Link href="/playground">
|
||||
<Button variant="outline">Open Playground</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The Magic Behind the Scenes */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">The Magic Behind the Scenes</h2>
|
||||
<div className="space-y-8">
|
||||
{/* 1. Discovery */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-foreground">
|
||||
1. Automatic Discovery
|
||||
</h3>
|
||||
<p className="text-lg text-foreground-secondary mb-4">
|
||||
TPMJS uses three parallel mechanisms to discover tools from npm:
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4">
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold mb-2 text-foreground">Changes Feed</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Monitors npm's real-time changes stream
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-foreground-tertiary">Every 2 minutes</div>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold mb-2 text-foreground">Keyword Search</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Searches npm for "tpmjs-tool" keyword
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-foreground-tertiary">Every 15 minutes</div>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold mb-2 text-foreground">Manual Curation</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Curated list of high-quality tools
|
||||
</p>
|
||||
<div className="mt-2 text-xs text-foreground-tertiary">Updated regularly</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 2. Validation */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-foreground">2. Validation</h3>
|
||||
<p className="text-lg text-foreground-secondary mb-4">
|
||||
Every discovered package is validated against the TPMJS schema:
|
||||
</p>
|
||||
<ul className="space-y-2 text-foreground-secondary">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-success mt-1">✓</span>
|
||||
<span>Valid category from predefined list</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-success mt-1">✓</span>
|
||||
<span>Description between 20-500 characters</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-success mt-1">✓</span>
|
||||
<span>Parameters follow type schema (string, number, boolean, etc.)</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-success mt-1">✓</span>
|
||||
<span>Environment variables properly documented</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-success mt-1">✓</span>
|
||||
<span>Supports legacy single-tool and modern multi-tool formats</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* 3. Quality Scoring */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-foreground">3. Quality Scoring</h3>
|
||||
<p className="text-lg text-foreground-secondary mb-4">
|
||||
Every tool receives a quality score (0.00 to 1.00) based on:
|
||||
</p>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-foreground">Tier (Metadata Completeness)</span>
|
||||
<span className="font-mono text-sm text-foreground-secondary">40-60%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-foreground">Downloads (Popularity)</span>
|
||||
<span className="font-mono text-sm text-foreground-secondary">up to 20%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-foreground">GitHub Stars</span>
|
||||
<span className="font-mono text-sm text-foreground-secondary">up to 10%</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-foreground">AI-Friendly Metadata</span>
|
||||
<span className="font-mono text-sm text-foreground-secondary">up to 10%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t border-border">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Higher quality scores = better visibility in search results and featured
|
||||
sections
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 4. Health Checks */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-foreground">4. Health Checks</h3>
|
||||
<p className="text-lg text-foreground-secondary mb-4">
|
||||
Every tool is tested to ensure it works correctly:
|
||||
</p>
|
||||
<div className="grid md:grid-cols-2 gap-4">
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold mb-2 text-foreground">Import Health</h4>
|
||||
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||
<li>• Can the package be imported?</li>
|
||||
<li>• Does the export exist?</li>
|
||||
<li>• Is it in AI SDK format?</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded-lg bg-surface">
|
||||
<h4 className="font-semibold mb-2 text-foreground">Execution Health</h4>
|
||||
<ul className="text-sm text-foreground-secondary space-y-1">
|
||||
<li>• Can test parameters be generated?</li>
|
||||
<li>• Does the tool execute without errors?</li>
|
||||
<li>• Does it return valid results?</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 5. Indexing */}
|
||||
<div>
|
||||
<h3 className="text-2xl font-semibold mb-4 text-foreground">5. Indexing</h3>
|
||||
<p className="text-lg text-foreground-secondary mb-4">
|
||||
Tools are stored in a PostgreSQL database with rich metadata:
|
||||
</p>
|
||||
<ul className="space-y-2 text-foreground-secondary">
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-1">→</span>
|
||||
<span>
|
||||
<strong className="text-foreground">Package-level:</strong> Version, README,
|
||||
repository, category, downloads, stars
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-1">→</span>
|
||||
<span>
|
||||
<strong className="text-foreground">Tool-level:</strong> Export name,
|
||||
description, parameters, return type, AI guidance
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<span className="text-primary mt-1">→</span>
|
||||
<span>
|
||||
<strong className="text-foreground">Metrics:</strong> Quality score, health
|
||||
status, execution history
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Architecture Diagram */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">System Architecture</h2>
|
||||
<div className="p-8 border border-border rounded-lg bg-surface font-mono text-sm overflow-x-auto">
|
||||
<pre className="text-foreground-secondary whitespace-pre">
|
||||
{`┌─────────────────────────────────────────────────────────────────┐
|
||||
│ NPM Registry │
|
||||
└────────────┬────────────────────────┬───────────────────────────┘
|
||||
│ │
|
||||
┌────────▼────────┐ ┌────────▼────────┐ ┌──────────────┐
|
||||
│ Changes Feed │ │ Keyword Search │ │ Manual Tools │
|
||||
│ Every 2 min │ │ Every 15 min │ │ As needed │
|
||||
└────────┬────────┘ └────────┬────────┘ └──────┬───────┘
|
||||
│ │ │
|
||||
└────────────────────────┴───────────────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Validation │
|
||||
│ Schema Check │
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
│ │
|
||||
┌───────▼────────┐ ┌──────────▼──────────┐
|
||||
│ PostgreSQL │ │ Health Checks │
|
||||
│ Database │ │ Import + Execution │
|
||||
└───────┬────────┘ └──────────┬──────────┘
|
||||
│ │
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Metrics Sync │
|
||||
│ Quality Score │
|
||||
│ Every hour │
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌─────────────────┴─────────────────┐
|
||||
│ │
|
||||
┌───────▼────────┐ ┌──────────▼──────────┐
|
||||
│ Search API │ │ Execution API │
|
||||
│ /api/tools │ │ /api/tools/execute │
|
||||
└───────┬────────┘ └──────────┬──────────┘
|
||||
│ │
|
||||
└─────────────────┬─────────────────┘
|
||||
│
|
||||
┌───────▼────────┐
|
||||
│ Frontend UI │
|
||||
│ Search, Detail │
|
||||
│ Playground │
|
||||
└────────────────┘`}
|
||||
</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Data Flow */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">From Publish to Execution</h2>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start gap-4 p-4 border border-border rounded-lg bg-surface">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-bold">
|
||||
1
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-1">Developer publishes to npm</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Package with{' '}
|
||||
<code className="text-xs bg-surface px-1 py-0.5 rounded">tpmjs-tool</code>{' '}
|
||||
keyword
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-tertiary ml-auto">~1 second</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 border border-border rounded-lg bg-surface">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-bold">
|
||||
2
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-1">TPMJS discovers package</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Changes feed or keyword search picks it up
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-tertiary ml-auto">2-15 minutes</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 border border-border rounded-lg bg-surface">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-bold">
|
||||
3
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-1">Validation & indexing</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Schema validation, database insertion, health checks
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-tertiary ml-auto">~5 seconds</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 border border-border rounded-lg bg-surface">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-bold">
|
||||
4
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-1">Tool appears on tpmjs.com</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Searchable, browsable, and executable in playground
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-tertiary ml-auto">Instant</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 border border-border rounded-lg bg-surface">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-bold">
|
||||
5
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-1">Quality score calculated</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Based on tier, downloads, stars, and metadata
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-tertiary ml-auto">Within 1 hour</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-4 p-4 border border-border rounded-lg bg-surface">
|
||||
<span className="flex-shrink-0 flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary font-bold">
|
||||
6
|
||||
</span>
|
||||
<div>
|
||||
<h4 className="font-semibold text-foreground mb-1">
|
||||
AI agents can discover & execute
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Available via API for search and execution
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-xs text-foreground-tertiary ml-auto">Ongoing</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Beta: Dynamic Tool Loading */}
|
||||
<section className="mb-16">
|
||||
<div className="inline-flex items-center gap-2 mb-6">
|
||||
<span className="px-3 py-1 text-sm font-semibold bg-primary/10 text-primary rounded-full">
|
||||
🧪 Beta
|
||||
</span>
|
||||
<h2 className="text-3xl font-bold text-foreground">Dynamic Tool Loading</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<p className="text-lg text-foreground-secondary">
|
||||
Our playground demonstrates the future of AI agents: tools that discover and load
|
||||
themselves dynamically based on conversation context.
|
||||
</p>
|
||||
|
||||
{/* How It Works */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface space-y-6">
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">
|
||||
🔍 BM25 Search + Context Awareness
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
When you chat in the playground, your messages are analyzed using the{' '}
|
||||
<strong className="text-foreground">BM25 ranking algorithm</strong> to find the
|
||||
most relevant tools from the entire registry.
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`// The playground automatically searches for relevant tools
|
||||
const relevantTools = await searchTpmjsTools({
|
||||
query: userMessage,
|
||||
limit: 5,
|
||||
recentMessages: lastThreeMessages // Context matters!
|
||||
});
|
||||
|
||||
// Tools are ranked by:
|
||||
// - BM25 relevance score (keyword matching)
|
||||
// - Quality score (documentation, downloads)
|
||||
// - Download popularity (logarithmic boost)
|
||||
// Result: The right tools, at the right time`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">
|
||||
⚡ Zero-Config Dynamic Loading
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Found tools are loaded on-demand from esm.sh and executed in a sandboxed Deno
|
||||
environment on Railway.
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`// Traditional approach: Static tool imports
|
||||
import { weatherTool } from '@acme/weather';
|
||||
import { searchTool } from '@acme/search';
|
||||
// Problem: Must know tools ahead of time ❌
|
||||
|
||||
// TPMJS approach: Dynamic tool loading
|
||||
import { streamText } from 'ai';
|
||||
import { searchTpmjsToolsTool } from '@tpmjs/search-registry';
|
||||
|
||||
const result = await streamText({
|
||||
model: openai('gpt-4'),
|
||||
messages,
|
||||
tools: {
|
||||
// This meta-tool lets the AI discover its own tools!
|
||||
searchTpmjsTools: searchTpmjsToolsTool,
|
||||
},
|
||||
});
|
||||
|
||||
// Agent decides: "I need weather data"
|
||||
// → Searches registry → Finds @acme/weather
|
||||
// → Loads from esm.sh → Executes in Deno sandbox ✅`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">
|
||||
🏝️ Sandboxed Execution
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
All dynamically loaded tools execute in an isolated Deno runtime on Railway,
|
||||
ensuring security and reliability.
|
||||
</p>
|
||||
<div className="grid md:grid-cols-3 gap-4 mt-4">
|
||||
<div className="p-4 border border-border rounded bg-background">
|
||||
<h4 className="font-semibold mb-2 text-foreground text-sm">
|
||||
Network Imports
|
||||
</h4>
|
||||
<p className="text-xs text-foreground-secondary">
|
||||
Deno loads packages directly from esm.sh with{' '}
|
||||
<code className="text-xs">--experimental-network-imports</code>
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded bg-background">
|
||||
<h4 className="font-semibold mb-2 text-foreground text-sm">
|
||||
Automatic Health Checks
|
||||
</h4>
|
||||
<p className="text-xs text-foreground-secondary">
|
||||
Failed imports or executions trigger health status updates in the registry
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border border-border rounded bg-background">
|
||||
<h4 className="font-semibold mb-2 text-foreground text-sm">
|
||||
Process-Level Caching
|
||||
</h4>
|
||||
<p className="text-xs text-foreground-secondary">
|
||||
Tools are cached per conversation to avoid redundant network requests
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">
|
||||
🎯 Coming Soon: Collections
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Imagine pre-configured tool bundles (mini sub-agents) that you can reference by
|
||||
name:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`// Future API concept (not yet implemented)
|
||||
const result = await streamText({
|
||||
model: openai('gpt-4'),
|
||||
messages,
|
||||
tools: await tpmjs.loadToolsFor(messages, {
|
||||
collections: ['web-scraping', 'data-analysis'],
|
||||
// Loads curated tool sets optimized for specific tasks
|
||||
// Collections can be public (official) or private (your own)
|
||||
}),
|
||||
});
|
||||
|
||||
// Example collections:
|
||||
// - 'web-scraping': puppeteer, cheerio, readability tools
|
||||
// - 'data-analysis': pandas-like tools, plotting, statistics
|
||||
// - 'ecommerce': payment, inventory, shipping tools
|
||||
// - Or build your own custom collections!`}
|
||||
/>
|
||||
<div className="mt-4 p-4 bg-primary/5 border border-primary/20 rounded">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
<strong className="text-foreground">Why collections?</strong> They let you
|
||||
compose specialized sub-agents without manually curating tool lists. Think of
|
||||
them as "skill packs" for your AI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Try It */}
|
||||
<div className="p-6 border-2 border-primary/20 rounded-lg bg-primary/5">
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">
|
||||
Try It in the Playground
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Ask the playground agent to "search for tools about X" and watch it discover,
|
||||
load, and execute tools dynamically!
|
||||
</p>
|
||||
<Link href="/playground">
|
||||
<Button size="lg" variant="default">
|
||||
Open Playground
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="text-center py-12 border border-border rounded-lg bg-surface">
|
||||
<h2 className="text-3xl font-bold mb-4 text-foreground">Ready to Get Started?</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-8 max-w-2xl mx-auto">
|
||||
Whether you're building AI tools or integrating them into your agent, TPMJS makes
|
||||
it simple.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/publish">
|
||||
<Button size="lg" variant="default">
|
||||
Publish a Tool
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="outline">
|
||||
Browse Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/playground">
|
||||
<Button size="lg" variant="outline">
|
||||
Try Playground
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { Space_Grotesk, Space_Mono } from 'next/font/google';
|
||||
import { AppFooter } from '../components/AppFooter';
|
||||
import { ThemeProvider } from '../components/providers/ThemeProvider';
|
||||
import './globals.css';
|
||||
|
||||
|
|
@ -40,7 +41,10 @@ export default function RootLayout({
|
|||
enableSystem={true}
|
||||
disableTransitionOnChange={false}
|
||||
>
|
||||
{children}
|
||||
<div className="flex flex-col min-h-screen">
|
||||
<div className="flex-1">{children}</div>
|
||||
<AppFooter />
|
||||
</div>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -1,132 +1,210 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Header } from '@tpmjs/ui/Header/Header';
|
||||
import Link from 'next/link';
|
||||
import { ThemeToggle } from '../components/ThemeToggle';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
import { HeroSection } from '../components/home/HeroSection';
|
||||
|
||||
export default function HomePage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{/* Header */}
|
||||
<Header
|
||||
title={
|
||||
<Link
|
||||
href="/"
|
||||
className="text-foreground hover:text-foreground text-xl md:text-2xl font-bold uppercase tracking-tight"
|
||||
>
|
||||
TPMJS
|
||||
</Link>
|
||||
}
|
||||
size="md"
|
||||
sticky={true}
|
||||
actions={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/playground">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Playground
|
||||
</Button>
|
||||
</Link>
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Documentation
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
Sign In
|
||||
</Button>
|
||||
<Button size="sm">Sign Up</Button>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
async function getHomePageData() {
|
||||
try {
|
||||
// Fetch stats in parallel
|
||||
const [packageCount, toolCount, featuredTools, categoryStats] = await Promise.all([
|
||||
// Total package count
|
||||
prisma.package.count(),
|
||||
|
||||
<main className="flex-1">
|
||||
// Total tool count
|
||||
prisma.tool.count(),
|
||||
|
||||
// Top 6 featured tools by quality score
|
||||
prisma.tool.findMany({
|
||||
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
|
||||
take: 6,
|
||||
select: {
|
||||
id: true,
|
||||
exportName: true,
|
||||
description: true,
|
||||
qualityScore: true,
|
||||
package: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
isOfficial: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
// Category distribution for stats (group by package category)
|
||||
prisma.package.groupBy({
|
||||
by: ['category'],
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats: {
|
||||
packageCount,
|
||||
toolCount,
|
||||
categoryCount: categoryStats.length,
|
||||
},
|
||||
featuredTools,
|
||||
categories: categoryStats.slice(0, 5).map((c) => ({
|
||||
name: c.category,
|
||||
count: c._count._all,
|
||||
})),
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch homepage data:', error);
|
||||
return {
|
||||
stats: {
|
||||
packageCount: 0,
|
||||
toolCount: 0,
|
||||
categoryCount: 0,
|
||||
},
|
||||
featuredTools: [],
|
||||
categories: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default async function HomePage(): Promise<React.ReactElement> {
|
||||
const data = await getHomePageData();
|
||||
return (
|
||||
<>
|
||||
<AppHeader />
|
||||
|
||||
<main>
|
||||
{/* Hero Section - Dithered Design */}
|
||||
<HeroSection />
|
||||
<HeroSection stats={data.stats} />
|
||||
|
||||
{/* Featured Tools Section */}
|
||||
<section className="py-16 bg-background">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="text-center mb-12">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-foreground">
|
||||
Discover AI Tools
|
||||
Featured Tools
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto mb-8">
|
||||
Browse our collection of AI-ready tools. Search, filter, and integrate the perfect
|
||||
tools for your AI agents.
|
||||
Top-rated tools from our registry. Sorted by quality score and community adoption.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="default">
|
||||
Browse All Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="outline">
|
||||
Search Tools
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mt-12">
|
||||
{/* Feature cards */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="text-4xl mb-4">🔍</div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-foreground">Smart Search</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
Find tools by name, category, tags, or functionality. Advanced filters help you
|
||||
discover exactly what you need.
|
||||
</p>
|
||||
</div>
|
||||
{data.featuredTools.length > 0 ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
|
||||
{data.featuredTools.map((tool) => (
|
||||
<Link
|
||||
key={tool.id}
|
||||
href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}
|
||||
className="group"
|
||||
>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface hover:border-foreground transition-colors h-full flex flex-col">
|
||||
<div className="flex items-start justify-between mb-3">
|
||||
<h3 className="text-lg font-semibold text-foreground group-hover:text-brutalist-accent transition-colors">
|
||||
{tool.package.npmPackageName}
|
||||
<span className="text-xs text-foreground-tertiary ml-2">
|
||||
({tool.exportName})
|
||||
</span>
|
||||
</h3>
|
||||
{tool.package.isOfficial && (
|
||||
<Badge variant="default" size="sm">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="text-4xl mb-4">⚡</div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-foreground">Quality Metrics</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
Every tool includes quality scores, download stats, and community feedback to help
|
||||
you choose wisely.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary mb-4 flex-1 line-clamp-3">
|
||||
{tool.description}
|
||||
</p>
|
||||
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="text-4xl mb-4">🤖</div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-foreground">AI Agent Ready</h3>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="outline" size="sm">
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
|
||||
<span>
|
||||
Quality:{' '}
|
||||
{tool.qualityScore ? Number(tool.qualityScore).toFixed(2) : 'N/A'}
|
||||
</span>
|
||||
<span>
|
||||
{tool.package.npmDownloadsLastMonth?.toLocaleString() || '0'} downloads/mo
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-foreground-secondary">
|
||||
All tools include AI agent integration guides, parameter specs, and usage
|
||||
examples.
|
||||
No tools available yet. Check back soon!
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center items-center">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="default">
|
||||
Browse All {data.stats.toolCount} Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="outline">
|
||||
Search by Category
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
||||
{/* Publish Your Tool Section */}
|
||||
<section className="py-16 bg-surface">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="text-center max-w-3xl mx-auto">
|
||||
<h2 className="text-3xl md:text-4xl font-bold mb-4 text-foreground">
|
||||
Publish Your AI Tool
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-8">
|
||||
Share your tool with the AI community. Automatic discovery, quality scoring, and
|
||||
seamless integration with popular AI frameworks.
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
|
||||
<div className="p-4">
|
||||
<div className="text-3xl mb-2">🚀</div>
|
||||
<h3 className="font-semibold mb-1 text-foreground">Quick Setup</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Add one keyword to package.json and publish to NPM
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-3xl mb-2">⚡</div>
|
||||
<h3 className="font-semibold mb-1 text-foreground">Auto Discovery</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Your tool appears on tpmjs.com within 15 minutes
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<div className="text-3xl mb-2">📊</div>
|
||||
<h3 className="font-semibold mb-1 text-foreground">Quality Metrics</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Automatic scoring based on docs, downloads, and stars
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/publish">
|
||||
<Button size="lg" variant="default">
|
||||
Learn How to Publish
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="py-8 border-t border-border bg-surface">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-foreground-secondary">© 2025 TPMJS. All rights reserved.</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<button type="button" className="text-foreground-secondary hover:text-foreground">
|
||||
Privacy
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button type="button" className="text-foreground-secondary hover:text-foreground">
|
||||
Terms
|
||||
</button>
|
||||
<span className="text-border">·</span>
|
||||
<button type="button" className="text-foreground-secondary hover:text-foreground">
|
||||
Contact
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import { Checkbox } from '@tpmjs/ui/Checkbox/Checkbox';
|
|||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { FormField } from '@tpmjs/ui/FormField/FormField';
|
||||
import { Header } from '@tpmjs/ui/Header/Header';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { Label } from '@tpmjs/ui/Label/Label';
|
||||
|
|
@ -26,9 +25,8 @@ import { Slider } from '@tpmjs/ui/Slider/Slider';
|
|||
import { Switch } from '@tpmjs/ui/Switch/Switch';
|
||||
import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
|
||||
import { Textarea } from '@tpmjs/ui/Textarea/Textarea';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { ThemeToggle } from '../../components/ThemeToggle';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
// Disable static generation for this page due to context provider requirements
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -50,26 +48,7 @@ export default function PlaygroundPage() {
|
|||
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col dotted-grid-background">
|
||||
{/* Header */}
|
||||
<Header
|
||||
title={
|
||||
<Link href="/" className="text-foreground hover:text-foreground">
|
||||
TPMJS Playground
|
||||
</Link>
|
||||
}
|
||||
size="md"
|
||||
sticky={true}
|
||||
actions={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Home
|
||||
</Button>
|
||||
</Link>
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 py-12 relative">
|
||||
<div className="absolute inset-0 bg-background/95 -z-10" />
|
||||
|
|
|
|||
416
apps/web/src/app/publish/page.tsx
Normal file
416
apps/web/src/app/publish/page.tsx
Normal file
|
|
@ -0,0 +1,416 @@
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata = {
|
||||
title: 'Publish a Tool | TPMJS',
|
||||
description: 'Learn how to publish your AI tool to the TPMJS registry',
|
||||
};
|
||||
|
||||
export default function PublishPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 py-16">
|
||||
<Container size="lg" padding="lg">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4 text-foreground">
|
||||
Publish Your AI Tool
|
||||
</h1>
|
||||
<p className="text-xl text-foreground-secondary max-w-2xl mx-auto">
|
||||
Share your tool with the world. Automatic discovery, quality scoring, and AI agent
|
||||
integration.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Start */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Quick Start</h2>
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<ol className="space-y-4 text-foreground-secondary">
|
||||
<li className="text-lg">Create a new NPM package</li>
|
||||
<li className="text-lg">
|
||||
Add{' '}
|
||||
<code className="text-foreground bg-surface px-2 py-1 rounded">tpmjs-tool</code>{' '}
|
||||
to keywords
|
||||
</li>
|
||||
<li className="text-lg">
|
||||
Add a <code className="text-foreground bg-surface px-2 py-1 rounded">tpmjs</code>{' '}
|
||||
field with metadata
|
||||
</li>
|
||||
<li className="text-lg">Publish to NPM</li>
|
||||
<li className="text-lg">
|
||||
Your tool appears on{' '}
|
||||
<Link href="/tool/tool-search" className="text-primary hover:underline">
|
||||
tpmjs.com
|
||||
</Link>{' '}
|
||||
within 15 minutes!
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 1: Package.json Setup */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">
|
||||
Step 1: Add Required Keyword
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
Add the{' '}
|
||||
<code className="text-foreground bg-surface px-2 py-1 rounded">tpmjs-tool</code>{' '}
|
||||
keyword to your package.json. This is required for automatic discovery.
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"name": "@yourname/my-awesome-tool",
|
||||
"version": "1.0.0",
|
||||
"keywords": ["tpmjs-tool", "ai", "text"],
|
||||
...
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Step 2: Metadata Tiers */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Step 2: Add TPMJS Metadata</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
There are three tiers of metadata. Higher tiers get better visibility and quality
|
||||
scores.
|
||||
</p>
|
||||
|
||||
{/* Tier 1: Minimal */}
|
||||
<div className="mb-8 p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-3 py-1 bg-foreground/10 rounded text-sm font-medium text-foreground">
|
||||
Tier 1: Minimal
|
||||
</span>
|
||||
<span className="text-foreground-secondary">Required fields only</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "myTool",
|
||||
"description": "A concise description of what your tool does"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
<p className="mt-4 text-sm text-foreground-secondary">
|
||||
Quality Score: <strong className="text-foreground">1x base multiplier</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tier 2: Basic */}
|
||||
<div className="mb-8 p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-3 py-1 bg-primary/20 rounded text-sm font-medium text-foreground">
|
||||
Tier 2: Basic
|
||||
</span>
|
||||
<span className="text-foreground-secondary">Add parameter & return info</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Analyzes sentiment in text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score and label"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
<p className="mt-4 text-sm text-foreground-secondary">
|
||||
Quality Score: <strong className="text-foreground">2x base multiplier</strong>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Tier 3: Rich */}
|
||||
<div className="mb-8 p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<span className="px-3 py-1 bg-success/20 rounded text-sm font-medium text-foreground">
|
||||
Tier 3: Rich
|
||||
</span>
|
||||
<span className="text-foreground-secondary">Full documentation</span>
|
||||
</div>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"env": [
|
||||
{
|
||||
"name": "SENTIMENT_API_KEY",
|
||||
"description": "API key for sentiment analysis service",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [...],
|
||||
"returns": {...},
|
||||
"aiAgent": {
|
||||
"useCase": "Use when users need to analyze sentiment or detect emotions",
|
||||
"limitations": "English and Spanish only. Max 10,000 characters",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in feedback"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
<p className="mt-4 text-sm text-foreground-secondary">
|
||||
Quality Score: <strong className="text-foreground">4x base multiplier</strong> 🚀
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Categories */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Available Categories</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{[
|
||||
{ name: 'text-analysis', desc: 'NLP, sentiment, summarization' },
|
||||
{ name: 'code-generation', desc: 'Code generation and transformation' },
|
||||
{ name: 'data-processing', desc: 'Data manipulation and transformation' },
|
||||
{ name: 'image-generation', desc: 'Image creation and editing' },
|
||||
{ name: 'audio-processing', desc: 'Audio/speech processing' },
|
||||
{ name: 'search', desc: 'Search and retrieval' },
|
||||
{ name: 'integration', desc: 'Third-party integrations' },
|
||||
{ name: 'other', desc: 'Anything else' },
|
||||
].map((cat) => (
|
||||
<div key={cat.name} className="p-4 border border-border rounded-lg bg-surface">
|
||||
<code className="text-foreground font-medium">{cat.name}</code>
|
||||
<p className="text-sm text-foreground-secondary mt-1">{cat.desc}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Step 3: Publish */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Step 3: Publish to NPM</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
Build your package and publish it to NPM. Your tool will be automatically discovered
|
||||
within 15 minutes.
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`# Build your package
|
||||
npm run build
|
||||
|
||||
# Publish to NPM (use --access public for scoped packages)
|
||||
npm publish --access public
|
||||
|
||||
# That's it! Your tool will appear on tpmjs.com soon`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Quality Score */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Quality Score</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
Your tool gets a quality score based on three factors:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Tier</h3>
|
||||
<ul className="space-y-2 text-foreground-secondary">
|
||||
<li>Rich: 4x multiplier</li>
|
||||
<li>Basic: 2x multiplier</li>
|
||||
<li>Minimal: 1x multiplier</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">Downloads</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
Logarithmic scale based on monthly NPM downloads
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-3 text-foreground">GitHub Stars</h3>
|
||||
<p className="text-foreground-secondary">
|
||||
Logarithmic scale based on repository stars
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Real Example */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Real Example</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
Here is a complete example from{' '}
|
||||
<Link
|
||||
href="/tool/@tpmjs/createblogpost"
|
||||
className="text-primary hover:underline font-medium"
|
||||
>
|
||||
@tpmjs/createblogpost
|
||||
</Link>
|
||||
:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"name": "@tpmjs/createblogpost",
|
||||
"version": "0.2.0",
|
||||
"keywords": ["tpmjs-tool", "blog", "content"],
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "createBlogPostTool",
|
||||
"description": "Creates structured blog posts with frontmatter and SEO metadata",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "title",
|
||||
"type": "string",
|
||||
"description": "The title of the blog post",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "content",
|
||||
"type": "string",
|
||||
"description": "The main content",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "BlogPost",
|
||||
"description": "Structured blog post with frontmatter"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Tips */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Tips for Success</h2>
|
||||
<div className="space-y-4">
|
||||
{[
|
||||
{
|
||||
icon: '📝',
|
||||
title: 'Use descriptive names',
|
||||
desc: 'Make your package name clear and searchable',
|
||||
},
|
||||
{
|
||||
icon: '📊',
|
||||
title: 'Complete metadata',
|
||||
desc: 'Rich tier tools get 4x better visibility',
|
||||
},
|
||||
{
|
||||
icon: '📚',
|
||||
title: 'Good documentation',
|
||||
desc: 'Add documentation URL to package.json homepage or repository fields',
|
||||
},
|
||||
{
|
||||
icon: '🔄',
|
||||
title: 'Active maintenance',
|
||||
desc: 'Regular updates boost download counts',
|
||||
},
|
||||
{
|
||||
icon: '🤖',
|
||||
title: 'AI-friendly descriptions',
|
||||
desc: 'Write aiAgent.useCase as guidance for AI agents',
|
||||
},
|
||||
].map((tip) => (
|
||||
<div
|
||||
key={tip.title}
|
||||
className="flex gap-4 p-6 border border-border rounded-lg bg-surface"
|
||||
>
|
||||
<div className="text-4xl">{tip.icon}</div>
|
||||
<div>
|
||||
<h3 className="text-xl font-semibold mb-2 text-foreground">{tip.title}</h3>
|
||||
<p className="text-foreground-secondary">{tip.desc}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="text-center py-16 px-6 border border-border rounded-lg bg-surface">
|
||||
<h2 className="text-3xl font-bold mb-4 text-foreground">Ready to Publish?</h2>
|
||||
<p className="text-xl text-foreground-secondary mb-8 max-w-2xl mx-auto">
|
||||
Follow the steps above and your tool will be live on TPMJS within 15 minutes.
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="default">
|
||||
Browse Existing Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<a href="https://github.com/tpmjs/tpmjs" target="_blank" rel="noopener noreferrer">
|
||||
<Button size="lg" variant="outline">
|
||||
View on GitHub
|
||||
</Button>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border py-8">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="text-center text-foreground-secondary">
|
||||
<p>
|
||||
Questions?{' '}
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs/issues"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
File an issue on GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
824
apps/web/src/app/spec/page.tsx
Normal file
824
apps/web/src/app/spec/page.tsx
Normal file
|
|
@ -0,0 +1,824 @@
|
|||
import { TPMJS_CATEGORIES } from '@tpmjs/types/tpmjs';
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata = {
|
||||
title: 'TPMJS Specification | The Open Standard for AI Tool Discovery',
|
||||
description:
|
||||
'Complete technical reference for the TPMJS specification - field definitions, validation rules, and integration guide for AI tool developers.',
|
||||
};
|
||||
|
||||
export default function SpecPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1 py-16">
|
||||
<Container size="lg" padding="lg">
|
||||
{/* Hero */}
|
||||
<div className="text-center mb-16">
|
||||
<h1 className="text-4xl md:text-5xl font-bold mb-4 text-foreground">
|
||||
TPMJS Specification
|
||||
</h1>
|
||||
<p className="text-xl text-foreground-secondary max-w-2xl mx-auto">
|
||||
The open standard for AI tool discovery and integration
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* What is TPMJS */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">What is TPMJS?</h2>
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<p className="text-lg text-foreground-secondary mb-4">
|
||||
TPMJS (Tool Package Manager for JavaScript) is an open standard and registry for AI
|
||||
tool discovery and integration. It solves the problem of fragmented AI tool
|
||||
ecosystems by providing:
|
||||
</p>
|
||||
<ul className="space-y-2 text-foreground-secondary list-disc list-inside">
|
||||
<li>
|
||||
<strong className="text-foreground">Automatic Discovery</strong> - Tools are
|
||||
automatically indexed from NPM based on keywords
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Standardized Metadata</strong> - A unified
|
||||
specification for describing tool capabilities
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Quality Scoring</strong> - Algorithmic ranking
|
||||
based on documentation completeness and community adoption
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">AI Agent Integration</strong> - Structured
|
||||
metadata optimized for LLM tool selection
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How it Works */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">How it Works</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="text-3xl mb-2">📦</div>
|
||||
<CardTitle>1. Publish to NPM</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Add the{' '}
|
||||
<code className="text-foreground bg-surface px-1 rounded">tpmjs-tool</code>{' '}
|
||||
keyword and a{' '}
|
||||
<code className="text-foreground bg-surface px-1 rounded">tpmjs</code> metadata
|
||||
field to your package.json
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="text-3xl mb-2">🔍</div>
|
||||
<CardTitle>2. Automatic Discovery</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
TPMJS monitors NPM every 2 minutes for new tools and updates the registry
|
||||
automatically
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="text-3xl mb-2">⚡</div>
|
||||
<CardTitle>3. Instant Availability</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Your tool appears on tpmjs.com within 15 minutes, searchable by AI agents and
|
||||
developers
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* The Specification */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">The Specification</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-8">
|
||||
The TPMJS specification defines a{' '}
|
||||
<code className="text-foreground bg-surface px-2 py-1 rounded">tpmjs</code> field in
|
||||
package.json with three tiers of metadata. Higher tiers receive better visibility and
|
||||
quality scores.
|
||||
</p>
|
||||
|
||||
{/* Tier 1: Minimal */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Badge variant="outline" size="lg">
|
||||
Tier 1: Minimal
|
||||
</Badge>
|
||||
<span className="text-foreground-secondary">Required fields only</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>category</code> <span className="text-red-500">*</span>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary mb-3">
|
||||
Tool category for organization. Must be one of the following:
|
||||
</p>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-2">
|
||||
{TPMJS_CATEGORIES.map((cat) => (
|
||||
<Badge key={cat} variant="secondary" size="sm">
|
||||
{cat}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>description</code> <span className="text-red-500">*</span>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Clear description of what the tool does. Must be 20-500 characters. This
|
||||
appears in search results and tool listings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h5 className="text-sm font-semibold text-foreground mb-3">Example:</h5>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Analyzes sentiment in text and returns positive/negative/neutral classification"
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tier 2: Basic */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Badge variant="default" size="lg">
|
||||
Tier 2: Basic
|
||||
</Badge>
|
||||
<span className="text-foreground-secondary">
|
||||
+ Parameter and return type documentation
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>parameters</code>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary mb-3">
|
||||
Array of parameter objects describing function inputs. Each parameter has:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-foreground-secondary ml-4">
|
||||
<li>
|
||||
<code className="text-foreground">name</code> - Parameter name
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">type</code> - TypeScript type
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">description</code> - What it does
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">required</code> - Boolean
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">default</code> - Default value
|
||||
(optional)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>returns</code>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary mb-3">
|
||||
Object describing the return value:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-foreground-secondary ml-4">
|
||||
<li>
|
||||
<code className="text-foreground">type</code> - Return type
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">description</code> - What is returned
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h5 className="text-sm font-semibold text-foreground mb-3">Example:</h5>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Analyzes sentiment in text",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "text",
|
||||
"type": "string",
|
||||
"description": "The text to analyze",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "language",
|
||||
"type": "string",
|
||||
"description": "Language code (e.g., 'en', 'es')",
|
||||
"required": false,
|
||||
"default": "en"
|
||||
}
|
||||
],
|
||||
"returns": {
|
||||
"type": "SentimentResult",
|
||||
"description": "Object with score (-1 to 1) and label (positive/negative/neutral)"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Tier 3: Rich */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<Badge variant="success" size="lg">
|
||||
Tier 3: Rich
|
||||
</Badge>
|
||||
<span className="text-foreground-secondary">
|
||||
+ Complete metadata for maximum visibility
|
||||
</span>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>env</code>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Array of environment variables required by the tool. Each variable has:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-foreground-secondary ml-4">
|
||||
<li>
|
||||
<code className="text-foreground">name</code> - Environment variable name
|
||||
(e.g., "OPENAI_API_KEY")
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">description</code> - What the variable
|
||||
is used for
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">required</code> - Boolean (defaults to
|
||||
true)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">default</code> - Default value if not
|
||||
provided (optional)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>frameworks</code>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Array of compatible AI frameworks. Supported values:
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{[
|
||||
'vercel-ai',
|
||||
'langchain',
|
||||
'llamaindex',
|
||||
'haystack',
|
||||
'semantic-kernel',
|
||||
].map((fw) => (
|
||||
<Badge key={fw} variant="outline" size="sm">
|
||||
{fw}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-lg font-semibold text-foreground mb-2">
|
||||
<code>aiAgent</code>
|
||||
</h4>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
AI agent integration guidance. Helps LLMs understand when and how to use
|
||||
your tool:
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-sm text-foreground-secondary ml-4">
|
||||
<li>
|
||||
<code className="text-foreground">useCase</code> - When to use this tool
|
||||
(min 10 chars, required)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">limitations</code> - Known constraints
|
||||
(optional)
|
||||
</li>
|
||||
<li>
|
||||
<code className="text-foreground">examples</code> - Array of example use
|
||||
cases (optional)
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h5 className="text-sm font-semibold text-foreground mb-3">
|
||||
Complete Example:
|
||||
</h5>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"tpmjs": {
|
||||
"category": "text-analysis",
|
||||
"frameworks": ["vercel-ai", "langchain"],
|
||||
"env": [
|
||||
{
|
||||
"name": "SENTIMENT_API_KEY",
|
||||
"description": "API key for sentiment analysis service",
|
||||
"required": true
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"exportName": "sentimentAnalysisTool",
|
||||
"description": "Advanced sentiment analysis with emotion detection",
|
||||
"parameters": [...],
|
||||
"returns": {...},
|
||||
"aiAgent": {
|
||||
"useCase": "Use when users need to analyze sentiment or detect emotions in text",
|
||||
"limitations": "English and Spanish only. Max 10,000 characters per request.",
|
||||
"examples": [
|
||||
"Analyze customer review sentiment",
|
||||
"Detect emotions in user feedback"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Field Reference Table */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Field Reference</h2>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 px-4 text-foreground">Field</th>
|
||||
<th className="text-left py-3 px-4 text-foreground">Type</th>
|
||||
<th className="text-left py-3 px-4 text-foreground">Tier</th>
|
||||
<th className="text-left py-3 px-4 text-foreground">Required</th>
|
||||
<th className="text-left py-3 px-4 text-foreground">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">category</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">string</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="outline" size="sm">
|
||||
Minimal
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-red-500">Yes</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">Tool category from predefined list</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">description</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">string</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="outline" size="sm">
|
||||
Minimal
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-red-500">Yes</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">Tool description (20-500 chars)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">parameters</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">array</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="default" size="sm">
|
||||
Basic
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 px-4">Function parameter definitions</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">returns</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">object</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="default" size="sm">
|
||||
Basic
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 px-4">Return type definition</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">env</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">array</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="success" size="sm">
|
||||
Rich
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 px-4">Required environment variables</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">frameworks</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">array</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="success" size="sm">
|
||||
Rich
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 px-4">Compatible AI frameworks</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border">
|
||||
<td className="py-3 px-4">
|
||||
<code className="text-foreground">aiAgent</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">object</td>
|
||||
<td className="py-3 px-4">
|
||||
<Badge variant="success" size="sm">
|
||||
Rich
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 px-4">AI agent integration guidance</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Quality Score */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Quality Score</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
Tools are ranked by quality score, calculated from three factors:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6 mb-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tier Multiplier</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary">
|
||||
<li>
|
||||
<strong className="text-foreground">Rich:</strong> 4x multiplier
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Basic:</strong> 2x multiplier
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Minimal:</strong> 1x multiplier
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>NPM Downloads</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Logarithmic scale based on monthly downloads. More downloads = higher score.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>GitHub Stars</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Logarithmic scale based on repository stars. Community validation boosts
|
||||
visibility.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">Formula:</h4>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`function calculateQualityScore(params: {
|
||||
tier: 'minimal' | 'basic' | 'rich';
|
||||
downloads: number;
|
||||
githubStars: number;
|
||||
}): number {
|
||||
const tierScore = tier === 'rich' ? 0.6 : tier === 'basic' ? 0.4 : 0.2;
|
||||
const downloadsScore = Math.min(0.3, Math.log10(downloads + 1) / 10);
|
||||
const starsScore = Math.min(0.1, Math.log10(githubStars + 1) / 10);
|
||||
|
||||
return Math.min(1.0, tierScore + downloadsScore + starsScore);
|
||||
}`}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Discovery & Sync */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Discovery & Sync</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
TPMJS automatically discovers and updates tools using three strategies:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Changes Feed</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Monitors NPM's real-time changes feed every 2 minutes
|
||||
</p>
|
||||
<Badge variant="outline" size="sm">
|
||||
Real-time
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Keyword Search</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Searches for{' '}
|
||||
<code className="text-foreground bg-surface px-1 rounded">tpmjs-tool</code>{' '}
|
||||
keyword every 15 minutes
|
||||
</p>
|
||||
<Badge variant="outline" size="sm">
|
||||
Every 15 min
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Metrics Update</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Updates download stats and quality scores hourly
|
||||
</p>
|
||||
<Badge variant="outline" size="sm">
|
||||
Hourly
|
||||
</Badge>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Validation */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Validation</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
The TPMJS specification is validated using Zod schemas. The validation logic is
|
||||
available in the{' '}
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@tpmjs/types"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
@tpmjs/types
|
||||
</a>{' '}
|
||||
package.
|
||||
</p>
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<h4 className="text-sm font-semibold text-foreground mb-3">
|
||||
Common Validation Errors:
|
||||
</h4>
|
||||
<ul className="space-y-2 text-sm text-foreground-secondary list-disc list-inside">
|
||||
<li>
|
||||
<strong className="text-foreground">Invalid category:</strong> Category must be
|
||||
one of the 12 predefined values
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Description too short/long:</strong>{' '}
|
||||
Description must be 20-500 characters
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-foreground">Invalid env:</strong> Each environment
|
||||
variable must have a name and description
|
||||
</li>
|
||||
</ul>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
{/* Publishing Your Tool */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Publishing Your Tool</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
Publishing a tool to TPMJS is simple:
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-8">
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl mb-2">1️⃣</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Add <code className="text-foreground bg-surface px-1 rounded">tpmjs-tool</code>{' '}
|
||||
keyword
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl mb-2">2️⃣</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Add <code className="text-foreground bg-surface px-1 rounded">tpmjs</code> field
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl mb-2">3️⃣</div>
|
||||
<p className="text-sm text-foreground-secondary">Publish to NPM</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="pt-6 text-center">
|
||||
<div className="text-3xl mb-2">✅</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Appears on tpmjs.com in 15 min
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<Link href="/publish">
|
||||
<Button size="lg" variant="default">
|
||||
View Complete Publishing Guide →
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Support & Resources */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-3xl font-bold mb-6 text-foreground">Support & Resources</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Documentation</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Link
|
||||
href="/publish"
|
||||
className="block text-sm text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
→ Publishing Guide
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
→ GitHub Repository
|
||||
</a>
|
||||
<a
|
||||
href="https://www.npmjs.com/package/@tpmjs/types"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
→ TypeScript Types Package
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Examples</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<Link
|
||||
href="/tool/tool-search"
|
||||
className="block text-sm text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
→ Browse All Tools
|
||||
</Link>
|
||||
<Link
|
||||
href="/playground"
|
||||
className="block text-sm text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
→ Try the Playground
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs/issues"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-sm text-foreground-secondary hover:text-primary"
|
||||
>
|
||||
→ Report Issues or Ask Questions
|
||||
</a>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</section>
|
||||
</Container>
|
||||
</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t border-border py-8 bg-surface">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="text-center text-foreground-secondary">
|
||||
<p>
|
||||
TPMJS is an open standard. Contribute on{' '}
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs"
|
||||
className="text-primary hover:underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
580
apps/web/src/app/tool/[...slug]/page.tsx
Normal file
580
apps/web/src/app/tool/[...slug]/page.tsx
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { Markdown } from '~/components/Markdown';
|
||||
import { ToolPlayground } from '~/components/ToolPlayground';
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
qualityScore: string;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
npmDownloadsLastWeek: number;
|
||||
npmKeywords: string[];
|
||||
npmReadme: string | null;
|
||||
npmAuthor: { name: string; email?: string; url?: string } | string | null;
|
||||
npmMaintainers: Array<{ name: string; email?: string }> | null;
|
||||
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
healthCheckError?: string | null;
|
||||
lastHealthCheck?: string | null;
|
||||
tpmjsMetadata: {
|
||||
example?: string;
|
||||
parameters?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default?: unknown;
|
||||
}>;
|
||||
returns?: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
authentication?: {
|
||||
required: boolean;
|
||||
type?: string;
|
||||
};
|
||||
pricing?: {
|
||||
model: string;
|
||||
};
|
||||
frameworks?: string[];
|
||||
links?: {
|
||||
documentation?: string;
|
||||
repository?: string;
|
||||
homepage?: string;
|
||||
};
|
||||
aiAgent?: {
|
||||
useCase?: string;
|
||||
limitations?: string;
|
||||
examples?: string[];
|
||||
};
|
||||
} | null;
|
||||
githubStars: number | null;
|
||||
npmLicense: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex UI component with many conditional renders
|
||||
export default function ToolDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string[] }>;
|
||||
}): React.ReactElement {
|
||||
const [tool, setTool] = useState<Tool | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [slug, setSlug] = useState<string>('');
|
||||
const [recheckLoading, setRecheckLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Join slug array to reconstruct package name (e.g., ['@tpmjs', 'text-transformer'] -> '@tpmjs/text-transformer')
|
||||
params.then((p) => setSlug(p.slug.join('/')));
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
const fetchTool = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/tools/${slug}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTool(data.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch tool');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTool();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<Container size="xl" padding="md" className="py-12">
|
||||
<div className="text-center text-foreground-secondary">Loading tool...</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !tool) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
<Container size="xl" padding="md" className="py-12">
|
||||
<div className="text-center">
|
||||
<p className="text-red-500 text-lg mb-4">{error || 'Tool not found'}</p>
|
||||
<Link href="/tool/tool-search">
|
||||
<Button variant="default">Browse All Tools</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const authorName = typeof tool.npmAuthor === 'string' ? tool.npmAuthor : tool.npmAuthor?.name;
|
||||
|
||||
const recheckHealth = async () => {
|
||||
setRecheckLoading(true);
|
||||
try {
|
||||
const response = await fetch(`/api/tools/${slug}`, {
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json();
|
||||
alert(data.error || 'Recheck failed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Refresh page to show updated health
|
||||
window.location.reload();
|
||||
} catch {
|
||||
alert('Failed to recheck health');
|
||||
} finally {
|
||||
setRecheckLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
{/* Main content */}
|
||||
<Container size="xl" padding="md" className="py-8">
|
||||
{/* Breadcrumb */}
|
||||
<div className="flex items-center gap-2 text-sm text-foreground-secondary mb-6">
|
||||
<Link href="/" className="hover:text-foreground">
|
||||
Home
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<Link href="/tool/tool-search" className="hover:text-foreground">
|
||||
Tools
|
||||
</Link>
|
||||
<span>/</span>
|
||||
<span className="text-foreground">{tool.npmPackageName}</span>
|
||||
</div>
|
||||
|
||||
{/* Title section */}
|
||||
<div className="mb-8">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-4xl font-bold text-foreground mb-2">{tool.npmPackageName}</h1>
|
||||
<p className="text-lg text-foreground-secondary">{tool.description}</p>
|
||||
{authorName && (
|
||||
<p className="text-sm text-foreground-tertiary mt-2">
|
||||
by <span className="text-foreground-secondary">{authorName}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{tool.isOfficial && (
|
||||
<Badge variant="default" size="lg">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="secondary">{tool.category}</Badge>
|
||||
<Badge variant="outline">v{tool.npmVersion}</Badge>
|
||||
{tool.npmLicense && <Badge variant="outline">{tool.npmLicense}</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Health warning banner */}
|
||||
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
|
||||
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl mt-0.5">⚠️</span>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-1">
|
||||
This tool is currently broken
|
||||
</h3>
|
||||
<div className="space-y-1 text-sm text-red-700 dark:text-red-400">
|
||||
{tool.importHealth === 'BROKEN' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="error" size="sm">
|
||||
Import Failed
|
||||
</Badge>
|
||||
<span className="text-xs">Cannot load from Railway service</span>
|
||||
</div>
|
||||
)}
|
||||
{tool.executionHealth === 'BROKEN' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="error" size="sm">
|
||||
Execution Failed
|
||||
</Badge>
|
||||
<span className="text-xs">Runtime error with test parameters</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{tool.healthCheckError && (
|
||||
<pre className="mt-2 p-2 rounded bg-red-100 dark:bg-red-900/30 text-xs font-mono text-red-800 dark:text-red-300 overflow-x-auto whitespace-pre-wrap">
|
||||
{tool.healthCheckError}
|
||||
</pre>
|
||||
)}
|
||||
{tool.lastHealthCheck && (
|
||||
<p className="text-xs text-red-600 dark:text-red-500 mt-2">
|
||||
Last checked: {new Date(tool.lastHealthCheck).toLocaleString()}
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={recheckHealth}
|
||||
disabled={recheckLoading}
|
||||
className="mt-3 text-sm font-medium text-red-700 dark:text-red-400 hover:underline disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{recheckLoading ? 'Rechecking...' : 'Recheck health →'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Left column - Main content */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{/* Interactive Playground */}
|
||||
{/* biome-ignore lint/suspicious/noExplicitAny: Prisma Tool type compatibility with component props */}
|
||||
<ToolPlayground tool={tool as any} />
|
||||
|
||||
{/* Installation */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Installation</CardTitle>
|
||||
<CardDescription>
|
||||
Install this tool using your preferred package manager
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CodeBlock
|
||||
code={`npm install ${tool.npmPackageName}`}
|
||||
language="bash"
|
||||
showCopy={true}
|
||||
/>
|
||||
<CodeBlock
|
||||
code={`yarn add ${tool.npmPackageName}`}
|
||||
language="bash"
|
||||
showCopy={true}
|
||||
/>
|
||||
<CodeBlock
|
||||
code={`pnpm add ${tool.npmPackageName}`}
|
||||
language="bash"
|
||||
showCopy={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* README */}
|
||||
{tool.npmReadme && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>README</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Markdown content={tool.npmReadme} />
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Usage Example */}
|
||||
{tool.tpmjsMetadata?.example && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage Example</CardTitle>
|
||||
<CardDescription>Quick start example</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CodeBlock
|
||||
code={tool.tpmjsMetadata.example}
|
||||
language="typescript"
|
||||
showCopy={true}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* AI Agent Information */}
|
||||
{tool.tpmjsMetadata?.aiAgent && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>AI Agent Integration</CardTitle>
|
||||
<CardDescription>How AI agents can use this tool</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{tool.tpmjsMetadata.aiAgent.useCase && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">Use Case</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
{tool.tpmjsMetadata.aiAgent.useCase}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{tool.tpmjsMetadata.aiAgent.limitations && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">Limitations</h4>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
{tool.tpmjsMetadata.aiAgent.limitations}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{tool.tpmjsMetadata.aiAgent.examples &&
|
||||
tool.tpmjsMetadata.aiAgent.examples.length > 0 && (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-foreground mb-2">Examples</h4>
|
||||
<ul className="list-disc list-inside space-y-1">
|
||||
{tool.tpmjsMetadata.aiAgent.examples.map((example) => (
|
||||
<li key={example} className="text-sm text-foreground-secondary">
|
||||
{example}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Parameters */}
|
||||
{tool.tpmjsMetadata?.parameters && tool.tpmjsMetadata.parameters.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Parameters</CardTitle>
|
||||
<CardDescription>Available configuration options</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{tool.tpmjsMetadata.parameters.map((param) => (
|
||||
<div key={param.name} className="border-b border-border pb-4 last:border-0">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<code className="text-sm font-mono text-foreground">{param.name}</code>
|
||||
{param.required ? (
|
||||
<Badge variant="error" size="sm">
|
||||
Required
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="outline" size="sm">
|
||||
Optional
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-sm text-foreground-secondary mb-1">
|
||||
<span className="font-semibold">Type: </span>
|
||||
<code className="font-mono">{param.type}</code>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">{param.description}</p>
|
||||
{param.default !== undefined && (
|
||||
<div className="text-sm text-foreground-tertiary mt-1">
|
||||
<span className="font-semibold">Default: </span>
|
||||
<code className="font-mono">{JSON.stringify(param.default)}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right column - Sidebar */}
|
||||
<div className="space-y-6">
|
||||
{/* Stats */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Statistics</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm text-foreground-secondary mb-1">Downloads/month</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{tool.npmDownloadsLastMonth?.toLocaleString() || '0'}
|
||||
</p>
|
||||
</div>
|
||||
{tool.githubStars != null && (
|
||||
<div>
|
||||
<p className="text-sm text-foreground-secondary mb-1">GitHub Stars</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{tool.githubStars.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
|
||||
<ProgressBar
|
||||
value={(tool.qualityScore ? Number.parseFloat(tool.qualityScore) : 0) * 100}
|
||||
variant={
|
||||
tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.7
|
||||
? 'success'
|
||||
: tool.qualityScore && Number.parseFloat(tool.qualityScore) >= 0.5
|
||||
? 'primary'
|
||||
: 'warning'
|
||||
}
|
||||
size="md"
|
||||
showLabel={true}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* NPM Keywords */}
|
||||
{tool.npmKeywords && tool.npmKeywords.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>NPM Keywords</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.npmKeywords.map((keyword) => (
|
||||
<Badge key={keyword} variant="outline" size="sm">
|
||||
{keyword}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tags */}
|
||||
{tool.tags && tool.tags.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tags</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" size="sm">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Maintainers */}
|
||||
{tool.npmMaintainers && tool.npmMaintainers.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Maintainers</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-2">
|
||||
{tool.npmMaintainers.map((maintainer) => (
|
||||
<div key={maintainer.name} className="text-sm">
|
||||
<span className="text-foreground font-medium">{maintainer.name}</span>
|
||||
{maintainer.email && (
|
||||
<span className="text-foreground-tertiary ml-2">
|
||||
({maintainer.email})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Links */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Links</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-2">
|
||||
<a
|
||||
href={`https://www.npmjs.com/package/${tool.npmPackageName}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
|
||||
>
|
||||
<Icon icon="externalLink" size="sm" />
|
||||
<span>View on NPM</span>
|
||||
</a>
|
||||
{tool.tpmjsMetadata?.links?.documentation && (
|
||||
<a
|
||||
href={tool.tpmjsMetadata.links.documentation}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
|
||||
>
|
||||
<Icon icon="externalLink" size="sm" />
|
||||
<span>Documentation</span>
|
||||
</a>
|
||||
)}
|
||||
{tool.tpmjsMetadata?.links?.repository && (
|
||||
<a
|
||||
href={tool.tpmjsMetadata.links.repository}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
|
||||
>
|
||||
<Icon icon="github" size="sm" />
|
||||
<span>Repository</span>
|
||||
</a>
|
||||
)}
|
||||
{tool.tpmjsMetadata?.links?.homepage && (
|
||||
<a
|
||||
href={tool.tpmjsMetadata.links.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground"
|
||||
>
|
||||
<Icon icon="externalLink" size="sm" />
|
||||
<span>Homepage</span>
|
||||
</a>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Frameworks */}
|
||||
{tool.tpmjsMetadata?.frameworks && tool.tpmjsMetadata.frameworks.length > 0 && (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Frameworks</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.tpmjsMetadata.frameworks.map((framework) => (
|
||||
<Badge key={framework} variant="secondary" size="sm">
|
||||
{framework}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,676 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@tpmjs/ui/Card/Card';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Header } from '@tpmjs/ui/Header/Header';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||
import Link from 'next/link';
|
||||
import { createElement, useEffect, useState } from 'react';
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
qualityScore: string;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
npmDownloadsLastWeek: number;
|
||||
tpmjsMetadata: {
|
||||
example?: string;
|
||||
parameters?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default?: unknown;
|
||||
}>;
|
||||
returns?: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
authentication?: {
|
||||
required: boolean;
|
||||
type?: string;
|
||||
};
|
||||
pricing?: {
|
||||
model: string;
|
||||
};
|
||||
frameworks?: string[];
|
||||
links?: {
|
||||
documentation?: string;
|
||||
repository?: string;
|
||||
homepage?: string;
|
||||
};
|
||||
aiAgent?: {
|
||||
useCase?: string;
|
||||
limitations?: string;
|
||||
examples?: string[];
|
||||
};
|
||||
} | null;
|
||||
githubStars: number | null;
|
||||
npmLicense: string | null;
|
||||
npmKeywords: string[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export default function ToolDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>;
|
||||
}): React.ReactElement {
|
||||
const [tool, setTool] = useState<Tool | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [slug, setSlug] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
params.then((p) => setSlug(p.slug));
|
||||
}, [params]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) return;
|
||||
|
||||
const fetchTool = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch(`/api/tools/${encodeURIComponent(slug)}`);
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTool(data.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch tool');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTool();
|
||||
}, [slug]);
|
||||
|
||||
if (loading) {
|
||||
return createElement('div', { className: 'min-h-screen bg-background' }, [
|
||||
createElement(Header, {
|
||||
key: 'header',
|
||||
title: createElement('div', { className: 'flex items-center gap-2' }, [
|
||||
createElement(Link, { key: 'link', href: '/', className: 'flex items-center gap-2' }, [
|
||||
createElement('span', { key: 'title', className: 'text-2xl font-bold' }, 'TPMJS'),
|
||||
createElement(Badge, { key: 'badge', variant: 'outline', size: 'sm' }, 'Beta'),
|
||||
]),
|
||||
]),
|
||||
sticky: true,
|
||||
size: 'md',
|
||||
}),
|
||||
createElement(
|
||||
Container,
|
||||
{ key: 'container', size: 'xl', padding: 'md', className: 'py-12' },
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'text-center text-foreground-secondary' },
|
||||
'Loading tool...'
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
if (error || !tool) {
|
||||
return createElement('div', { className: 'min-h-screen bg-background' }, [
|
||||
createElement(Header, {
|
||||
key: 'header',
|
||||
title: createElement('div', { className: 'flex items-center gap-2' }, [
|
||||
createElement(Link, { key: 'link', href: '/', className: 'flex items-center gap-2' }, [
|
||||
createElement('span', { key: 'title', className: 'text-2xl font-bold' }, 'TPMJS'),
|
||||
createElement(Badge, { key: 'badge', variant: 'outline', size: 'sm' }, 'Beta'),
|
||||
]),
|
||||
]),
|
||||
sticky: true,
|
||||
size: 'md',
|
||||
}),
|
||||
createElement(
|
||||
Container,
|
||||
{ key: 'container', size: 'xl', padding: 'md', className: 'py-12' },
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'text-center' },
|
||||
createElement('p', { className: 'text-red-500 text-lg mb-4' }, error || 'Tool not found'),
|
||||
createElement(
|
||||
Link,
|
||||
{ href: '/tool/tool-search' },
|
||||
createElement(Button, { variant: 'default' }, 'Browse All Tools')
|
||||
)
|
||||
)
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
return createElement('div', { className: 'min-h-screen bg-background' }, [
|
||||
// Header
|
||||
createElement(Header, {
|
||||
key: 'header',
|
||||
title: createElement('div', { className: 'flex items-center gap-2' }, [
|
||||
createElement(Link, { key: 'link', href: '/', className: 'flex items-center gap-2' }, [
|
||||
createElement('span', { key: 'title', className: 'text-2xl font-bold' }, 'TPMJS'),
|
||||
createElement(Badge, { key: 'badge', variant: 'outline', size: 'sm' }, 'Beta'),
|
||||
]),
|
||||
]),
|
||||
actions: createElement('div', { className: 'flex items-center gap-3' }, [
|
||||
createElement(
|
||||
Link,
|
||||
{ key: 'browse', href: '/tool/tool-search' },
|
||||
createElement(Button, { variant: 'ghost', size: 'sm' }, 'Browse Tools')
|
||||
),
|
||||
tool.npmRepository
|
||||
? createElement(
|
||||
'a',
|
||||
{
|
||||
key: 'github',
|
||||
href: tool.npmRepository.url.replace('git+', '').replace('.git', ''),
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className: 'text-foreground-secondary hover:text-foreground transition-colors',
|
||||
},
|
||||
createElement(Icon, { icon: 'github', size: 'md' })
|
||||
)
|
||||
: null,
|
||||
]),
|
||||
sticky: true,
|
||||
size: 'md',
|
||||
}),
|
||||
|
||||
// Main content
|
||||
createElement(Container, { key: 'container', size: 'xl', padding: 'md', className: 'py-8' }, [
|
||||
// Breadcrumb
|
||||
createElement(
|
||||
'div',
|
||||
{
|
||||
key: 'breadcrumb',
|
||||
className: 'flex items-center gap-2 text-sm text-foreground-secondary mb-6',
|
||||
},
|
||||
[
|
||||
createElement(
|
||||
Link,
|
||||
{ key: 'home', href: '/', className: 'hover:text-foreground' },
|
||||
'Home'
|
||||
),
|
||||
createElement('span', { key: 'sep1' }, '/'),
|
||||
createElement(
|
||||
Link,
|
||||
{ key: 'tools', href: '/tool/tool-search', className: 'hover:text-foreground' },
|
||||
'Tools'
|
||||
),
|
||||
createElement('span', { key: 'sep2' }, '/'),
|
||||
createElement(
|
||||
'span',
|
||||
{ key: 'current', className: 'text-foreground' },
|
||||
tool.npmPackageName
|
||||
),
|
||||
]
|
||||
),
|
||||
|
||||
// Title section
|
||||
createElement('div', { key: 'title-section', className: 'mb-8' }, [
|
||||
createElement(
|
||||
'div',
|
||||
{ key: 'title-row', className: 'flex items-start justify-between mb-4' },
|
||||
[
|
||||
createElement('div', { key: 'title-content' }, [
|
||||
createElement(
|
||||
'h1',
|
||||
{ key: 'title', className: 'text-4xl font-bold text-foreground mb-2' },
|
||||
tool.npmPackageName
|
||||
),
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'description', className: 'text-lg text-foreground-secondary' },
|
||||
tool.description
|
||||
),
|
||||
]),
|
||||
tool.isOfficial
|
||||
? createElement(
|
||||
Badge,
|
||||
{ key: 'official', variant: 'default', size: 'lg' },
|
||||
'Official'
|
||||
)
|
||||
: null,
|
||||
]
|
||||
),
|
||||
createElement('div', { key: 'badges', className: 'flex flex-wrap gap-2' }, [
|
||||
createElement(Badge, { key: 'category', variant: 'secondary' }, tool.category),
|
||||
createElement(Badge, { key: 'version', variant: 'outline' }, `v${tool.npmVersion}`),
|
||||
tool.npmLicense
|
||||
? createElement(Badge, { key: 'license', variant: 'outline' }, tool.npmLicense)
|
||||
: null,
|
||||
]),
|
||||
]),
|
||||
|
||||
// Main grid
|
||||
createElement(
|
||||
'div',
|
||||
{ key: 'main-grid', className: 'grid grid-cols-1 lg:grid-cols-3 gap-6' },
|
||||
[
|
||||
// Left column - Main content
|
||||
createElement('div', { key: 'left-col', className: 'lg:col-span-2 space-y-6' }, [
|
||||
// Installation
|
||||
createElement(Card, { key: 'installation' }, [
|
||||
createElement(CardHeader, { key: 'header' }, [
|
||||
createElement(CardTitle, { key: 'title' }, 'Installation'),
|
||||
createElement(
|
||||
CardDescription,
|
||||
{ key: 'desc' },
|
||||
'Install this tool using your preferred package manager'
|
||||
),
|
||||
]),
|
||||
createElement(CardContent, { key: 'content', className: 'space-y-4' }, [
|
||||
createElement(CodeBlock, {
|
||||
key: 'npm',
|
||||
code: `npm install ${tool.npmPackageName}`,
|
||||
language: 'bash',
|
||||
showCopy: true,
|
||||
}),
|
||||
createElement(CodeBlock, {
|
||||
key: 'yarn',
|
||||
code: `yarn add ${tool.npmPackageName}`,
|
||||
language: 'bash',
|
||||
showCopy: true,
|
||||
}),
|
||||
createElement(CodeBlock, {
|
||||
key: 'pnpm',
|
||||
code: `pnpm add ${tool.npmPackageName}`,
|
||||
language: 'bash',
|
||||
showCopy: true,
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Usage Example
|
||||
tool.tpmjsMetadata?.example
|
||||
? createElement(Card, { key: 'example' }, [
|
||||
createElement(CardHeader, { key: 'header' }, [
|
||||
createElement(CardTitle, { key: 'title' }, 'Usage Example'),
|
||||
createElement(CardDescription, { key: 'desc' }, 'Quick start example'),
|
||||
]),
|
||||
createElement(
|
||||
CardContent,
|
||||
{ key: 'content' },
|
||||
createElement(CodeBlock, {
|
||||
code: tool.tpmjsMetadata.example,
|
||||
language: 'typescript',
|
||||
showCopy: true,
|
||||
})
|
||||
),
|
||||
])
|
||||
: null,
|
||||
|
||||
// AI Agent Information
|
||||
tool.tpmjsMetadata?.aiAgent
|
||||
? createElement(Card, { key: 'ai-agent' }, [
|
||||
createElement(CardHeader, { key: 'header' }, [
|
||||
createElement(CardTitle, { key: 'title' }, 'AI Agent Integration'),
|
||||
createElement(
|
||||
CardDescription,
|
||||
{ key: 'desc' },
|
||||
'How AI agents can use this tool'
|
||||
),
|
||||
]),
|
||||
createElement(CardContent, { key: 'content', className: 'space-y-4' }, [
|
||||
tool.tpmjsMetadata.aiAgent.useCase
|
||||
? createElement('div', { key: 'usecase' }, [
|
||||
createElement(
|
||||
'h4',
|
||||
{
|
||||
key: 'title',
|
||||
className: 'text-sm font-semibold text-foreground mb-2',
|
||||
},
|
||||
'Use Case'
|
||||
),
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'text', className: 'text-sm text-foreground-secondary' },
|
||||
tool.tpmjsMetadata.aiAgent.useCase
|
||||
),
|
||||
])
|
||||
: null,
|
||||
tool.tpmjsMetadata.aiAgent.limitations
|
||||
? createElement('div', { key: 'limitations' }, [
|
||||
createElement(
|
||||
'h4',
|
||||
{
|
||||
key: 'title',
|
||||
className: 'text-sm font-semibold text-foreground mb-2',
|
||||
},
|
||||
'Limitations'
|
||||
),
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'text', className: 'text-sm text-foreground-secondary' },
|
||||
tool.tpmjsMetadata.aiAgent.limitations
|
||||
),
|
||||
])
|
||||
: null,
|
||||
tool.tpmjsMetadata.aiAgent.examples &&
|
||||
tool.tpmjsMetadata.aiAgent.examples.length > 0
|
||||
? createElement('div', { key: 'examples' }, [
|
||||
createElement(
|
||||
'h4',
|
||||
{
|
||||
key: 'title',
|
||||
className: 'text-sm font-semibold text-foreground mb-2',
|
||||
},
|
||||
'Examples'
|
||||
),
|
||||
createElement(
|
||||
'ul',
|
||||
{ key: 'list', className: 'list-disc list-inside space-y-1' },
|
||||
tool.tpmjsMetadata.aiAgent.examples.map((example, i) =>
|
||||
createElement(
|
||||
'li',
|
||||
{ key: i, className: 'text-sm text-foreground-secondary' },
|
||||
example
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
: null,
|
||||
]),
|
||||
])
|
||||
: null,
|
||||
|
||||
// Parameters
|
||||
tool.tpmjsMetadata?.parameters && tool.tpmjsMetadata.parameters.length > 0
|
||||
? createElement(Card, { key: 'parameters' }, [
|
||||
createElement(CardHeader, { key: 'header' }, [
|
||||
createElement(CardTitle, { key: 'title' }, 'Parameters'),
|
||||
createElement(
|
||||
CardDescription,
|
||||
{ key: 'desc' },
|
||||
'Available configuration options'
|
||||
),
|
||||
]),
|
||||
createElement(
|
||||
CardContent,
|
||||
{ key: 'content' },
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'space-y-4' },
|
||||
tool.tpmjsMetadata.parameters.map((param) =>
|
||||
createElement(
|
||||
'div',
|
||||
{
|
||||
key: param.name,
|
||||
className: 'border-b border-border pb-4 last:border-0',
|
||||
},
|
||||
[
|
||||
createElement(
|
||||
'div',
|
||||
{ key: 'header', className: 'flex items-start justify-between mb-2' },
|
||||
[
|
||||
createElement(
|
||||
'code',
|
||||
{ key: 'name', className: 'text-sm font-mono text-foreground' },
|
||||
param.name
|
||||
),
|
||||
param.required
|
||||
? createElement(
|
||||
Badge,
|
||||
{ key: 'required', variant: 'error', size: 'sm' },
|
||||
'Required'
|
||||
)
|
||||
: createElement(
|
||||
Badge,
|
||||
{ key: 'optional', variant: 'outline', size: 'sm' },
|
||||
'Optional'
|
||||
),
|
||||
]
|
||||
),
|
||||
createElement(
|
||||
'div',
|
||||
{ key: 'type', className: 'text-sm text-foreground-secondary mb-1' },
|
||||
[
|
||||
createElement(
|
||||
'span',
|
||||
{ key: 'label', className: 'font-semibold' },
|
||||
'Type: '
|
||||
),
|
||||
createElement(
|
||||
'code',
|
||||
{ key: 'value', className: 'font-mono' },
|
||||
param.type
|
||||
),
|
||||
]
|
||||
),
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'desc', className: 'text-sm text-foreground-secondary' },
|
||||
param.description
|
||||
),
|
||||
param.default !== undefined
|
||||
? createElement(
|
||||
'div',
|
||||
{
|
||||
key: 'default',
|
||||
className: 'text-sm text-foreground-tertiary mt-1',
|
||||
},
|
||||
[
|
||||
createElement(
|
||||
'span',
|
||||
{ key: 'label', className: 'font-semibold' },
|
||||
'Default: '
|
||||
),
|
||||
createElement(
|
||||
'code',
|
||||
{ key: 'value', className: 'font-mono' },
|
||||
JSON.stringify(param.default)
|
||||
),
|
||||
]
|
||||
)
|
||||
: null,
|
||||
]
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
: null,
|
||||
]),
|
||||
|
||||
// Right column - Sidebar
|
||||
createElement('div', { key: 'right-col', className: 'space-y-6' }, [
|
||||
// Stats
|
||||
createElement(Card, { key: 'stats' }, [
|
||||
createElement(
|
||||
CardHeader,
|
||||
{ key: 'header' },
|
||||
createElement(CardTitle, { key: 'title' }, 'Statistics')
|
||||
),
|
||||
createElement(CardContent, { key: 'content', className: 'space-y-4' }, [
|
||||
createElement('div', { key: 'downloads' }, [
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'label', className: 'text-sm text-foreground-secondary mb-1' },
|
||||
'Downloads/month'
|
||||
),
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'value', className: 'text-2xl font-bold text-foreground' },
|
||||
tool.npmDownloadsLastMonth.toLocaleString()
|
||||
),
|
||||
]),
|
||||
tool.githubStars !== null
|
||||
? createElement('div', { key: 'stars' }, [
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'label', className: 'text-sm text-foreground-secondary mb-1' },
|
||||
'GitHub Stars'
|
||||
),
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'value', className: 'text-2xl font-bold text-foreground' },
|
||||
tool.githubStars.toLocaleString()
|
||||
),
|
||||
])
|
||||
: null,
|
||||
createElement('div', { key: 'quality' }, [
|
||||
createElement(
|
||||
'p',
|
||||
{ key: 'label', className: 'text-sm text-foreground-secondary mb-2' },
|
||||
'Quality Score'
|
||||
),
|
||||
createElement(ProgressBar, {
|
||||
key: 'bar',
|
||||
value: Number.parseFloat(tool.qualityScore) * 100,
|
||||
variant:
|
||||
Number.parseFloat(tool.qualityScore) >= 0.7
|
||||
? 'success'
|
||||
: Number.parseFloat(tool.qualityScore) >= 0.5
|
||||
? 'primary'
|
||||
: 'warning',
|
||||
size: 'md',
|
||||
showLabel: true,
|
||||
}),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
|
||||
// Tags
|
||||
tool.tags.length > 0
|
||||
? createElement(Card, { key: 'tags' }, [
|
||||
createElement(
|
||||
CardHeader,
|
||||
{ key: 'header' },
|
||||
createElement(CardTitle, { key: 'title' }, 'Tags')
|
||||
),
|
||||
createElement(
|
||||
CardContent,
|
||||
{ key: 'content' },
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'flex flex-wrap gap-2' },
|
||||
tool.tags.map((tag) =>
|
||||
createElement(Badge, { key: tag, variant: 'outline', size: 'sm' }, tag)
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
: null,
|
||||
|
||||
// Links
|
||||
createElement(Card, { key: 'links' }, [
|
||||
createElement(
|
||||
CardHeader,
|
||||
{ key: 'header' },
|
||||
createElement(CardTitle, { key: 'title' }, 'Links')
|
||||
),
|
||||
createElement(CardContent, { key: 'content', className: 'space-y-2' }, [
|
||||
createElement(
|
||||
'a',
|
||||
{
|
||||
key: 'npm',
|
||||
href: `https://www.npmjs.com/package/${tool.npmPackageName}`,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className:
|
||||
'flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground',
|
||||
},
|
||||
[
|
||||
createElement(Icon, { key: 'icon', icon: 'externalLink', size: 'sm' }),
|
||||
createElement('span', { key: 'text' }, 'View on NPM'),
|
||||
]
|
||||
),
|
||||
tool.tpmjsMetadata?.links?.documentation
|
||||
? createElement(
|
||||
'a',
|
||||
{
|
||||
key: 'docs',
|
||||
href: tool.tpmjsMetadata.links.documentation,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className:
|
||||
'flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground',
|
||||
},
|
||||
[
|
||||
createElement(Icon, { key: 'icon', icon: 'externalLink', size: 'sm' }),
|
||||
createElement('span', { key: 'text' }, 'Documentation'),
|
||||
]
|
||||
)
|
||||
: null,
|
||||
tool.tpmjsMetadata?.links?.repository
|
||||
? createElement(
|
||||
'a',
|
||||
{
|
||||
key: 'repo',
|
||||
href: tool.tpmjsMetadata.links.repository,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className:
|
||||
'flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground',
|
||||
},
|
||||
[
|
||||
createElement(Icon, { key: 'icon', icon: 'github', size: 'sm' }),
|
||||
createElement('span', { key: 'text' }, 'Repository'),
|
||||
]
|
||||
)
|
||||
: null,
|
||||
tool.tpmjsMetadata?.links?.homepage
|
||||
? createElement(
|
||||
'a',
|
||||
{
|
||||
key: 'home',
|
||||
href: tool.tpmjsMetadata.links.homepage,
|
||||
target: '_blank',
|
||||
rel: 'noopener noreferrer',
|
||||
className:
|
||||
'flex items-center gap-2 text-sm text-foreground-secondary hover:text-foreground',
|
||||
},
|
||||
[
|
||||
createElement(Icon, { key: 'icon', icon: 'externalLink', size: 'sm' }),
|
||||
createElement('span', { key: 'text' }, 'Homepage'),
|
||||
]
|
||||
)
|
||||
: null,
|
||||
]),
|
||||
]),
|
||||
|
||||
// Frameworks
|
||||
tool.tpmjsMetadata?.frameworks && tool.tpmjsMetadata.frameworks.length > 0
|
||||
? createElement(Card, { key: 'frameworks' }, [
|
||||
createElement(
|
||||
CardHeader,
|
||||
{ key: 'header' },
|
||||
createElement(CardTitle, { key: 'title' }, 'Frameworks')
|
||||
),
|
||||
createElement(
|
||||
CardContent,
|
||||
{ key: 'content' },
|
||||
createElement(
|
||||
'div',
|
||||
{ className: 'flex flex-wrap gap-2' },
|
||||
tool.tpmjsMetadata.frameworks.map((framework) =>
|
||||
createElement(
|
||||
Badge,
|
||||
{ key: framework, variant: 'secondary', size: 'sm' },
|
||||
framework
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
])
|
||||
: null,
|
||||
]),
|
||||
]
|
||||
),
|
||||
]),
|
||||
]);
|
||||
}
|
||||
265
apps/web/src/app/tool/broken/page.tsx
Normal file
265
apps/web/src/app/tool/broken/page.tsx
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@tpmjs/ui/Card/Card';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface BrokenTool {
|
||||
id: string;
|
||||
exportName: string;
|
||||
description: string;
|
||||
importHealth: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
executionHealth: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
healthCheckError: string | null;
|
||||
lastHealthCheck: string | null;
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
category: string;
|
||||
isOfficial: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Broken Tools Page
|
||||
*
|
||||
* Displays all tools with broken health status (importHealth='BROKEN' OR executionHealth='BROKEN')
|
||||
*/
|
||||
export default function BrokenToolsPage(): React.ReactElement {
|
||||
const [tools, setTools] = useState<BrokenTool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch broken tools from API
|
||||
useEffect(() => {
|
||||
const fetchBrokenTools = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/tools/broken');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setTools(data.data);
|
||||
setError(null);
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch broken tools');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchBrokenTools();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
{/* Main content */}
|
||||
<Container size="xl" padding="md" className="py-8">
|
||||
{/* Page header */}
|
||||
<div className="space-y-4 mb-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-3xl">⚠️</span>
|
||||
<h1 className="text-4xl font-bold text-foreground">Broken Tools</h1>
|
||||
</div>
|
||||
<p className="text-lg text-foreground-secondary">
|
||||
Tools that are currently failing health checks. These tools may not work correctly until
|
||||
the underlying issues are resolved.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && (
|
||||
<div className="text-center py-12 text-foreground-secondary">Loading broken tools...</div>
|
||||
)}
|
||||
|
||||
{/* Error state */}
|
||||
{error && <div className="text-center py-12 text-red-500">Error: {error}</div>}
|
||||
|
||||
{/* Empty state - All healthy! */}
|
||||
{!loading && !error && tools.length === 0 && (
|
||||
<Card className="text-center py-12">
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex justify-center">
|
||||
<Icon icon="check" size="lg" className="text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">All Tools are Healthy!</h2>
|
||||
<p className="text-foreground-secondary">
|
||||
No broken tools detected. All tools are passing health checks.
|
||||
</p>
|
||||
</div>
|
||||
<Link href="/tool/tool-search">
|
||||
<Button variant="default" size="md">
|
||||
Browse Tools
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Broken tools grid */}
|
||||
{!loading && !error && tools.length > 0 && (
|
||||
<>
|
||||
<div className="mb-6 p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
|
||||
<div className="flex items-start gap-3">
|
||||
<span className="text-xl mt-0.5">⚠️</span>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-red-800 dark:text-red-300 mb-1">
|
||||
{tools.length} {tools.length === 1 ? 'tool is' : 'tools are'} currently broken
|
||||
</h3>
|
||||
<p className="text-sm text-red-700 dark:text-red-400">
|
||||
These tools are experiencing import or execution failures. Click on a tool to
|
||||
see details and manually trigger a health recheck.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tools.map((tool) => {
|
||||
const toolUrl = `/tool/${tool.package.npmPackageName}/${tool.exportName}`;
|
||||
const lastCheckedDate = tool.lastHealthCheck
|
||||
? new Date(tool.lastHealthCheck)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Card key={tool.id} className="flex flex-col border-red-200 dark:border-red-900">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<CardTitle>
|
||||
{tool.exportName !== 'default'
|
||||
? tool.exportName
|
||||
: tool.package.npmPackageName}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-foreground-secondary mt-1">
|
||||
{tool.package.npmPackageName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<CardDescription>{tool.description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="flex-1 space-y-4">
|
||||
{/* Category badge and version */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
v{tool.package.npmVersion}
|
||||
</span>
|
||||
{tool.package.isOfficial && (
|
||||
<Badge variant="default" size="sm">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Health status badges */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground-secondary mb-2">
|
||||
Health Status:
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{/* Import health */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-foreground-tertiary w-20">Import:</span>
|
||||
{tool.importHealth === 'BROKEN' ? (
|
||||
<Badge variant="error" size="sm">
|
||||
<Icon icon="x" size="sm" className="mr-1" />
|
||||
Broken
|
||||
</Badge>
|
||||
) : tool.importHealth === 'HEALTHY' ? (
|
||||
<Badge variant="success" size="sm">
|
||||
<Icon icon="check" size="sm" className="mr-1" />
|
||||
Healthy
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm">
|
||||
Unknown
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Execution health */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-foreground-tertiary w-20">
|
||||
Execution:
|
||||
</span>
|
||||
{tool.executionHealth === 'BROKEN' ? (
|
||||
<Badge variant="error" size="sm">
|
||||
<Icon icon="x" size="sm" className="mr-1" />
|
||||
Broken
|
||||
</Badge>
|
||||
) : tool.executionHealth === 'HEALTHY' ? (
|
||||
<Badge variant="success" size="sm">
|
||||
<Icon icon="check" size="sm" className="mr-1" />
|
||||
Healthy
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" size="sm">
|
||||
Unknown
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{tool.healthCheckError && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium text-foreground-secondary">
|
||||
Error:
|
||||
</div>
|
||||
<CodeBlock
|
||||
code={tool.healthCheckError}
|
||||
language="text"
|
||||
className="text-xs"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Last checked timestamp */}
|
||||
{lastCheckedDate && (
|
||||
<div className="text-xs text-foreground-tertiary">
|
||||
Last checked: {lastCheckedDate.toLocaleString()}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Link href={toolUrl} className="w-full">
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
View Details & Recheck →
|
||||
</Button>
|
||||
</Link>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -12,7 +12,6 @@ import {
|
|||
} from '@tpmjs/ui/Card/Card';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Header } from '@tpmjs/ui/Header/Header';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { ProgressBar } from '@tpmjs/ui/ProgressBar/ProgressBar';
|
||||
|
|
@ -20,18 +19,23 @@ import { Select } from '@tpmjs/ui/Select/Select';
|
|||
import { Tabs } from '@tpmjs/ui/Tabs/Tabs';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
interface Tool {
|
||||
id: string;
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
exportName: string;
|
||||
description: string;
|
||||
category: string;
|
||||
tags: string[];
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
qualityScore: string;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
importHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
executionHealth?: 'HEALTHY' | 'BROKEN' | 'UNKNOWN';
|
||||
package: {
|
||||
npmPackageName: string;
|
||||
npmVersion: string;
|
||||
category: string;
|
||||
npmRepository: { url: string; type: string } | null;
|
||||
isOfficial: boolean;
|
||||
npmDownloadsLastMonth: number;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -43,12 +47,13 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
const [activeTab, setActiveTab] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [healthFilter, setHealthFilter] = useState('all');
|
||||
const [tools, setTools] = useState<Tool[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [availableCategories, setAvailableCategories] = useState<string[]>([]);
|
||||
const [availableTags, setAvailableTags] = useState<string[]>([]);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const [officialCount, setOfficialCount] = useState(0);
|
||||
|
||||
// Fetch tools from API
|
||||
useEffect(() => {
|
||||
|
|
@ -69,31 +74,51 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
params.set('category', categoryFilter);
|
||||
}
|
||||
|
||||
const response = await fetch(`/api/tools?${params.toString()}`);
|
||||
const data = await response.json();
|
||||
if (healthFilter === 'healthy') {
|
||||
params.set('importHealth', 'HEALTHY');
|
||||
params.set('executionHealth', 'HEALTHY');
|
||||
} else if (healthFilter === 'broken') {
|
||||
params.set('broken', 'true');
|
||||
}
|
||||
|
||||
if (data.success) {
|
||||
const fetchedTools = data.data;
|
||||
// Fetch tools and counts in parallel
|
||||
const [toolsResponse, allCountResponse, officialCountResponse] = await Promise.all([
|
||||
fetch(`/api/tools?${params.toString()}`),
|
||||
fetch('/api/tools'),
|
||||
fetch('/api/tools?official=true'),
|
||||
]);
|
||||
|
||||
const [toolsData, allCountData, officialCountData] = await Promise.all([
|
||||
toolsResponse.json(),
|
||||
allCountResponse.json(),
|
||||
officialCountResponse.json(),
|
||||
]);
|
||||
|
||||
if (toolsData.success) {
|
||||
const fetchedTools = toolsData.data;
|
||||
setTools(fetchedTools);
|
||||
setError(null);
|
||||
|
||||
// Extract unique categories and tags from all tools
|
||||
// Update counts
|
||||
if (allCountData.success) {
|
||||
setTotalCount(allCountData.data.length);
|
||||
}
|
||||
if (officialCountData.success) {
|
||||
setOfficialCount(officialCountData.data.length);
|
||||
}
|
||||
|
||||
// Extract unique categories from all tools
|
||||
const categories = new Set<string>();
|
||||
const tags = new Set<string>();
|
||||
|
||||
for (const tool of fetchedTools) {
|
||||
if (tool.category) {
|
||||
categories.add(tool.category);
|
||||
}
|
||||
for (const tag of tool.tags) {
|
||||
tags.add(tag);
|
||||
if (tool.package.category) {
|
||||
categories.add(tool.package.category);
|
||||
}
|
||||
}
|
||||
|
||||
setAvailableCategories(Array.from(categories).sort());
|
||||
setAvailableTags(Array.from(tags).sort());
|
||||
} else {
|
||||
setError(data.error || 'Failed to fetch tools');
|
||||
setError(toolsData.error || 'Failed to fetch tools');
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Unknown error');
|
||||
|
|
@ -103,47 +128,11 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
};
|
||||
|
||||
fetchTools();
|
||||
}, [searchQuery, activeTab, categoryFilter]);
|
||||
|
||||
// Filter tools by selected tags (client-side)
|
||||
const displayedTools =
|
||||
selectedTags.length > 0
|
||||
? tools.filter((tool) => selectedTags.some((tag) => tool.tags.includes(tag)))
|
||||
: tools;
|
||||
}, [searchQuery, activeTab, categoryFilter, healthFilter]);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
{/* Header */}
|
||||
<Header
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-2xl font-bold">TPMJS</span>
|
||||
<Badge variant="outline" size="sm">
|
||||
Beta
|
||||
</Badge>
|
||||
</div>
|
||||
}
|
||||
actions={
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="sm">
|
||||
Docs
|
||||
</Button>
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="github" size="md" />
|
||||
</a>
|
||||
<Button variant="default" size="sm">
|
||||
Publish Tool
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
sticky={true}
|
||||
size="md"
|
||||
/>
|
||||
<AppHeader />
|
||||
|
||||
{/* Main content */}
|
||||
<Container size="xl" padding="md" className="py-8">
|
||||
|
|
@ -183,54 +172,45 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Health filter */}
|
||||
<div className="flex items-center gap-2 min-w-[200px]">
|
||||
<span className="text-sm font-medium text-foreground-secondary">Health:</span>
|
||||
<Select
|
||||
value={healthFilter}
|
||||
onChange={(e) => setHealthFilter(e.target.value)}
|
||||
size="sm"
|
||||
options={[
|
||||
{ value: 'all', label: 'All Tools' },
|
||||
{ value: 'healthy', label: 'Healthy Only' },
|
||||
{ value: 'broken', label: 'Broken Only' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clear filters button */}
|
||||
{(categoryFilter !== 'all' || selectedTags.length > 0) && (
|
||||
{(categoryFilter !== 'all' || healthFilter !== 'all') && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCategoryFilter('all');
|
||||
setSelectedTags([]);
|
||||
setHealthFilter('all');
|
||||
}}
|
||||
>
|
||||
Clear Filters
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popular tags */}
|
||||
{availableTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<span className="text-sm font-medium text-foreground-secondary mr-2">
|
||||
Filter by tag:
|
||||
</span>
|
||||
{availableTags.slice(0, 10).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant={selectedTags.includes(tag) ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="cursor-pointer hover:bg-foreground/10 transition-colors"
|
||||
onClick={() => {
|
||||
setSelectedTags((prev) =>
|
||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
||||
);
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
tabs={[
|
||||
{ id: 'all', label: 'All Tools', count: tools.length },
|
||||
{ id: 'all', label: 'All Tools', count: totalCount },
|
||||
{
|
||||
id: 'featured',
|
||||
label: 'Official',
|
||||
count: tools.filter((t) => t.isOfficial).length,
|
||||
count: officialCount,
|
||||
},
|
||||
]}
|
||||
activeTab={activeTab}
|
||||
|
|
@ -250,22 +230,49 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
{/* Tool grid */}
|
||||
{!loading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{displayedTools.length > 0 ? (
|
||||
displayedTools.map((tool) => (
|
||||
{tools.length > 0 ? (
|
||||
tools.map((tool) => (
|
||||
<Card key={tool.id} className="flex flex-col">
|
||||
<CardHeader>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<CardTitle>{tool.npmPackageName}</CardTitle>
|
||||
{tool.npmRepository && (
|
||||
<a
|
||||
href={tool.npmRepository.url.replace('git+', '').replace('.git', '')}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="externalLink" size="sm" />
|
||||
</a>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<CardTitle>
|
||||
{tool.exportName !== 'default'
|
||||
? tool.exportName
|
||||
: tool.package.npmPackageName}
|
||||
</CardTitle>
|
||||
<div className="text-sm text-foreground-secondary mt-1">
|
||||
{tool.package.npmPackageName}
|
||||
</div>
|
||||
</div>
|
||||
{tool.package.npmRepository &&
|
||||
(() => {
|
||||
// Clean up repository URL
|
||||
let repoUrl = tool.package.npmRepository.url;
|
||||
|
||||
// Remove git+ prefix
|
||||
repoUrl = repoUrl.replace(/^git\+/, '');
|
||||
|
||||
// Remove .git suffix
|
||||
repoUrl = repoUrl.replace(/\.git$/, '');
|
||||
|
||||
// Convert git:// to https://
|
||||
repoUrl = repoUrl.replace(/^git:\/\//, 'https://');
|
||||
|
||||
// Convert ssh URLs to https
|
||||
repoUrl = repoUrl.replace(/^git@github\.com:/, 'https://github.com/');
|
||||
|
||||
return (
|
||||
<a
|
||||
href={repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="externalLink" size="sm" />
|
||||
</a>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
<CardDescription>{tool.description}</CardDescription>
|
||||
</CardHeader>
|
||||
|
|
@ -274,33 +281,30 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
{/* Category badge and version */}
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Badge variant="secondary" size="sm">
|
||||
{tool.category}
|
||||
{tool.package.category}
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">v{tool.npmVersion}</span>
|
||||
{tool.isOfficial && (
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
v{tool.package.npmVersion}
|
||||
</span>
|
||||
{tool.package.isOfficial && (
|
||||
<Badge variant="default" size="sm">
|
||||
Official
|
||||
</Badge>
|
||||
)}
|
||||
{(tool.importHealth === 'BROKEN' || tool.executionHealth === 'BROKEN') && (
|
||||
<Badge variant="error" size="sm">
|
||||
<Icon icon="x" size="sm" className="mr-1" />
|
||||
Broken
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{tool.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{tool.tags.slice(0, 5).map((tag) => (
|
||||
<Badge key={tag} variant="outline" size="sm">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quality score and downloads */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-foreground-secondary">Quality Score</span>
|
||||
<span className="text-foreground-tertiary">
|
||||
{tool.npmDownloadsLastMonth.toLocaleString()} downloads/mo
|
||||
{tool.package.npmDownloadsLastMonth.toLocaleString()} downloads/mo
|
||||
</span>
|
||||
</div>
|
||||
<ProgressBar
|
||||
|
|
@ -319,7 +323,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
|
||||
{/* Install command */}
|
||||
<CodeBlock
|
||||
code={`npm install ${tool.npmPackageName}`}
|
||||
code={`npm install ${tool.package.npmPackageName}`}
|
||||
language="bash"
|
||||
size="sm"
|
||||
showCopy={true}
|
||||
|
|
@ -327,7 +331,7 @@ export default function ToolSearchPage(): React.ReactElement {
|
|||
</CardContent>
|
||||
|
||||
<CardFooter>
|
||||
<Link href={`/tool/${encodeURIComponent(tool.npmPackageName)}`}>
|
||||
<Link href={`/tool/${tool.package.npmPackageName}/${tool.exportName}`}>
|
||||
<Button variant="outline" size="sm" className="w-full">
|
||||
View Details
|
||||
</Button>
|
||||
|
|
|
|||
41
apps/web/src/components/AppFooter.tsx
Normal file
41
apps/web/src/components/AppFooter.tsx
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
'use client';
|
||||
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
|
||||
export function AppFooter(): React.ReactElement {
|
||||
return (
|
||||
<footer className="py-8 border-t border-border bg-surface">
|
||||
<Container size="xl" padding="lg">
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-foreground-secondary">© 2025 TPMJS. All rights reserved.</p>
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<a
|
||||
href="mailto:thomasalwyndavis@gmail.com"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
Contact
|
||||
</a>
|
||||
<span className="text-border">·</span>
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<span className="text-border">·</span>
|
||||
<a
|
||||
href="https://twitter.com/tpmjs_registry"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
Twitter
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Container>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/AppHeader.tsx
Normal file
63
apps/web/src/components/AppHeader.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
'use client';
|
||||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Header } from '@tpmjs/ui/Header/Header';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
|
||||
/**
|
||||
* Shared application header used across all pages
|
||||
*/
|
||||
export function AppHeader(): React.ReactElement {
|
||||
return (
|
||||
<Header
|
||||
title={
|
||||
<Link
|
||||
href="/"
|
||||
className="text-foreground hover:text-foreground text-xl md:text-2xl font-bold uppercase tracking-tight"
|
||||
>
|
||||
TPMJS
|
||||
</Link>
|
||||
}
|
||||
size="md"
|
||||
sticky={true}
|
||||
actions={
|
||||
<div className="flex items-center gap-4">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Tools
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/how-it-works">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
How It Works
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/playground">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Playground
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/spec">
|
||||
<Button variant="ghost" size="sm" className="text-foreground hover:text-foreground">
|
||||
Spec
|
||||
</Button>
|
||||
</Link>
|
||||
<a
|
||||
href="https://github.com/tpmjs/tpmjs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="github" size="md" />
|
||||
</a>
|
||||
<Link href="/publish">
|
||||
<Button variant="default" size="sm">
|
||||
Publish Tool
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
163
apps/web/src/components/Markdown.tsx
Normal file
163
apps/web/src/components/Markdown.tsx
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
'use client';
|
||||
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
||||
// eslint-disable-next-line import/no-internal-modules
|
||||
import { solarizedlight } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
|
||||
interface MarkdownProps {
|
||||
content: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Markdown renderer component
|
||||
* Safely renders markdown content with GitHub Flavored Markdown support
|
||||
* Styled to match npm.com's beautiful README rendering
|
||||
*/
|
||||
export function Markdown({ content, className = '' }: MarkdownProps): React.ReactElement {
|
||||
return (
|
||||
<div
|
||||
className={`prose prose-slate dark:prose-invert max-w-none prose-lg
|
||||
prose-headings:font-semibold prose-headings:tracking-tight prose-headings:text-zinc-900 dark:prose-headings:text-zinc-100
|
||||
prose-h1:text-4xl prose-h1:mb-6 prose-h1:mt-8 prose-h1:pb-3 prose-h1:border-b prose-h1:border-zinc-200 dark:prose-h1:border-zinc-700
|
||||
prose-h2:text-3xl prose-h2:mb-5 prose-h2:mt-10 prose-h2:pb-2 prose-h2:border-b prose-h2:border-zinc-200 dark:prose-h2:border-zinc-700
|
||||
prose-h3:text-2xl prose-h3:mb-4 prose-h3:mt-8
|
||||
prose-h4:text-xl prose-h4:mb-3 prose-h4:mt-6
|
||||
prose-p:text-base prose-p:leading-relaxed prose-p:mb-4 prose-p:text-zinc-700 dark:prose-p:text-zinc-300
|
||||
prose-a:text-blue-600 dark:prose-a:text-blue-400 prose-a:no-underline hover:prose-a:underline prose-a:font-medium prose-a:transition-colors
|
||||
prose-code:text-sm prose-code:font-mono prose-code:before:content-none prose-code:after:content-none
|
||||
prose-pre:p-0 prose-pre:m-0 prose-pre:bg-transparent prose-pre:border-0
|
||||
prose-blockquote:border-l-4 prose-blockquote:border-blue-500 dark:prose-blockquote:border-blue-400 prose-blockquote:pl-5 prose-blockquote:py-2 prose-blockquote:italic prose-blockquote:text-zinc-700 dark:prose-blockquote:text-zinc-300 prose-blockquote:bg-blue-50 dark:prose-blockquote:bg-blue-950/20 prose-blockquote:my-6 prose-blockquote:rounded-r
|
||||
prose-ul:list-disc prose-ul:pl-6 prose-ul:my-5 prose-ul:space-y-2
|
||||
prose-ol:list-decimal prose-ol:pl-6 prose-ol:my-5 prose-ol:space-y-2
|
||||
prose-li:text-base prose-li:leading-relaxed prose-li:text-zinc-700 dark:prose-li:text-zinc-300
|
||||
prose-img:rounded-lg prose-img:shadow-lg prose-img:my-8 prose-img:border prose-img:border-zinc-200 dark:prose-img:border-zinc-700
|
||||
prose-hr:border-zinc-300 dark:prose-hr:border-zinc-700 prose-hr:my-10
|
||||
prose-strong:font-semibold prose-strong:text-zinc-900 dark:prose-strong:text-zinc-100
|
||||
${className}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
components={{
|
||||
// Custom component for code blocks with syntax highlighting
|
||||
code: ({ className, children, ...props }) => {
|
||||
const match = /language-(\w+)/.exec(className || '');
|
||||
const language = match ? match[1] : '';
|
||||
const isInline = !className;
|
||||
|
||||
if (isInline) {
|
||||
return (
|
||||
<code
|
||||
className="bg-pink-50 dark:bg-pink-950/30 text-pink-700 dark:text-pink-400 px-1.5 py-0.5 rounded text-sm font-mono font-semibold border border-pink-200 dark:border-pink-900"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SyntaxHighlighter
|
||||
language={language || 'text'}
|
||||
// @ts-expect-error - Type conflict between solarizedlight theme and SyntaxHighlighter props
|
||||
style={solarizedlight}
|
||||
customStyle={{
|
||||
margin: '0',
|
||||
borderRadius: '0.5rem',
|
||||
fontSize: '0.875rem',
|
||||
lineHeight: '1.5',
|
||||
padding: '1rem',
|
||||
}}
|
||||
wrapLongLines={true}
|
||||
{...props}
|
||||
>
|
||||
{String(children).replace(/\n$/, '')}
|
||||
</SyntaxHighlighter>
|
||||
);
|
||||
},
|
||||
// Pre wrapper for code blocks (SyntaxHighlighter handles its own styling)
|
||||
pre: ({ children, ...props }) => {
|
||||
return (
|
||||
<div className="my-6 overflow-hidden rounded-lg shadow-md border border-zinc-200 dark:border-zinc-700">
|
||||
<pre {...props}>{children}</pre>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Make links open in new tab with better styling
|
||||
a: ({ href, children, ...props }) => {
|
||||
const isExternal = href?.startsWith('http');
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target={isExternal ? '_blank' : undefined}
|
||||
rel={isExternal ? 'noopener noreferrer' : undefined}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline font-medium"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
// Better table styling with improved readability
|
||||
table: ({ children, ...props }) => {
|
||||
return (
|
||||
<div className="overflow-x-auto my-8 rounded-lg border border-zinc-300 dark:border-zinc-700 shadow-sm">
|
||||
<table
|
||||
className="min-w-full divide-y divide-zinc-300 dark:divide-zinc-700"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
// Table header with better styling
|
||||
thead: ({ children, ...props }) => {
|
||||
return (
|
||||
<thead className="bg-zinc-100 dark:bg-zinc-800" {...props}>
|
||||
{children}
|
||||
</thead>
|
||||
);
|
||||
},
|
||||
// Table header cells with better spacing
|
||||
th: ({ children, ...props }) => {
|
||||
return (
|
||||
<th
|
||||
className="px-6 py-3 text-left text-xs font-semibold text-zinc-700 dark:text-zinc-300 uppercase tracking-wider"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
},
|
||||
// Table data cells with better spacing
|
||||
td: ({ children, ...props }) => {
|
||||
return (
|
||||
<td className="px-6 py-4 text-sm text-zinc-900 dark:text-zinc-100" {...props}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
},
|
||||
// Table rows with hover effect
|
||||
tr: ({ children, ...props }) => {
|
||||
return (
|
||||
<tr
|
||||
className="border-b border-zinc-200 dark:border-zinc-800 last:border-b-0 hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</tr>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
93
apps/web/src/components/TokenBreakdown.tsx
Normal file
93
apps/web/src/components/TokenBreakdown.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* TokenBreakdown component
|
||||
* Visualizes token usage breakdown with horizontal bars
|
||||
*/
|
||||
|
||||
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
|
||||
|
||||
interface TokenBreakdownProps {
|
||||
tokens: TokenData;
|
||||
}
|
||||
|
||||
interface TokenBar {
|
||||
label: string;
|
||||
tokens: number;
|
||||
color: string;
|
||||
bgColor: string;
|
||||
}
|
||||
|
||||
export function TokenBreakdown({ tokens }: TokenBreakdownProps): React.ReactElement {
|
||||
const { inputTokens, toolDescTokens, schemaTokens, outputTokens, totalTokens, estimatedCost } =
|
||||
tokens;
|
||||
|
||||
const bars: TokenBar[] = [
|
||||
{
|
||||
label: 'Input',
|
||||
tokens: inputTokens,
|
||||
color: 'bg-blue-500',
|
||||
bgColor: 'bg-blue-100 dark:bg-blue-950',
|
||||
},
|
||||
{
|
||||
label: 'Tool Description',
|
||||
tokens: toolDescTokens,
|
||||
color: 'bg-purple-500',
|
||||
bgColor: 'bg-purple-100 dark:bg-purple-950',
|
||||
},
|
||||
{
|
||||
label: 'Schema',
|
||||
tokens: schemaTokens,
|
||||
color: 'bg-green-500',
|
||||
bgColor: 'bg-green-100 dark:bg-green-950',
|
||||
},
|
||||
{
|
||||
label: 'Output',
|
||||
tokens: outputTokens,
|
||||
color: 'bg-orange-500',
|
||||
bgColor: 'bg-orange-100 dark:bg-orange-950',
|
||||
},
|
||||
];
|
||||
|
||||
const getPercentage = (value: number): number => {
|
||||
if (totalTokens === 0) return 0;
|
||||
return (value / totalTokens) * 100;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{bars.map((bar) => {
|
||||
const percentage = getPercentage(bar.tokens);
|
||||
return (
|
||||
<div key={bar.label} className="space-y-1">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium text-foreground">{bar.label}</span>
|
||||
<span className="text-foreground-secondary">
|
||||
{bar.tokens.toLocaleString()} tokens ({percentage.toFixed(1)}%)
|
||||
</span>
|
||||
</div>
|
||||
<div className={`h-2 rounded-full ${bar.bgColor}`}>
|
||||
<div
|
||||
className={`h-full rounded-full ${bar.color} transition-all duration-500`}
|
||||
style={{ width: `${percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border pt-4 space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-semibold text-foreground">Total Tokens</span>
|
||||
<span className="font-semibold text-foreground">{totalTokens.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-foreground-secondary">Estimated Cost</span>
|
||||
<span className="text-foreground-secondary">${estimatedCost.toFixed(4)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
apps/web/src/components/ToolPlayground.tsx
Normal file
426
apps/web/src/components/ToolPlayground.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
'use client';
|
||||
|
||||
/**
|
||||
* ToolPlayground component
|
||||
* Interactive playground for executing TPMJS tools with AI agents
|
||||
*/
|
||||
|
||||
import type { TokenBreakdown as TokenData } from '@/lib/ai-agent/tool-executor-agent';
|
||||
import type { Package, Tool } from '@tpmjs/db';
|
||||
import { useState } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import { TokenBreakdown } from './TokenBreakdown';
|
||||
|
||||
interface ToolPlaygroundProps {
|
||||
tool: Tool & { package: Package };
|
||||
}
|
||||
|
||||
type Tab = 'input' | 'output' | 'logs' | 'tokens';
|
||||
|
||||
interface ExecutionLog {
|
||||
level: 'info' | 'warning' | 'error' | 'debug';
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export function ToolPlayground({ tool }: ToolPlaygroundProps): React.ReactElement {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('input');
|
||||
const [prompt, setPrompt] = useState('');
|
||||
const [isExecuting, setIsExecuting] = useState(false);
|
||||
const [output, setOutput] = useState('');
|
||||
const [logs, setLogs] = useState<ExecutionLog[]>([]);
|
||||
const [tokens, setTokens] = useState<TokenData | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [rateLimitInfo, setRateLimitInfo] = useState<{ remaining: number } | null>(null);
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: SSE stream handling requires sequential logic
|
||||
const handleExecute = async () => {
|
||||
if (!prompt.trim() || isExecuting) return;
|
||||
|
||||
setIsExecuting(true);
|
||||
setOutput('');
|
||||
setError(null);
|
||||
setLogs([]);
|
||||
setTokens(null);
|
||||
setActiveTab('output');
|
||||
|
||||
try {
|
||||
const response = await fetch(
|
||||
`/api/tools/execute/${encodeURIComponent(tool.package.npmPackageName)}/${encodeURIComponent(tool.exportName)}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ prompt }),
|
||||
}
|
||||
);
|
||||
|
||||
// Check rate limit headers
|
||||
const remaining = response.headers.get('X-RateLimit-Remaining');
|
||||
if (remaining) {
|
||||
setRateLimitInfo({ remaining: Number.parseInt(remaining, 10) });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.error || 'Execution failed');
|
||||
}
|
||||
|
||||
// Handle SSE stream
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
if (!reader) {
|
||||
throw new Error('No response body');
|
||||
}
|
||||
|
||||
let buffer = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event:')) {
|
||||
const event = line.slice(6).trim();
|
||||
const nextLine = lines.shift();
|
||||
|
||||
if (nextLine?.startsWith('data:')) {
|
||||
const data = JSON.parse(nextLine.slice(5).trim());
|
||||
|
||||
switch (event) {
|
||||
case 'chunk':
|
||||
setOutput((prev) => prev + data.text);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
level: 'info',
|
||||
message: `Streaming: ${data.text.slice(0, 50)}${data.text.length > 50 ? '...' : ''}`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'tokens':
|
||||
setTokens(data as TokenData);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
level: 'debug',
|
||||
message: `Token update: ${data.totalTokens || 0} total tokens`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'complete':
|
||||
// Don't replace output - keep the streamed text
|
||||
// setOutput(data.output); // Removed: this was overwriting streamed content
|
||||
setTokens(data.tokenBreakdown);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
level: 'info',
|
||||
message: `Execution completed in ${data.executionTimeMs}ms with ${data.agentSteps} agent steps`,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
setError(data.message);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
level: 'error',
|
||||
message: data.message,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error';
|
||||
setError(message);
|
||||
setLogs((prev) => [
|
||||
...prev,
|
||||
{
|
||||
level: 'error',
|
||||
message,
|
||||
timestamp: new Date(),
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setIsExecuting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const tabs: { id: Tab; label: string; badge?: string }[] = [
|
||||
{ id: 'input', label: 'Input' },
|
||||
{ id: 'output', label: 'Output', badge: output ? '✓' : undefined },
|
||||
{ id: 'logs', label: 'Logs', badge: logs.length > 0 ? logs.length.toString() : undefined },
|
||||
{ id: 'tokens', label: 'Token Usage', badge: tokens ? '✓' : undefined },
|
||||
];
|
||||
|
||||
const getLevelBadgeColor = (level: ExecutionLog['level']) => {
|
||||
switch (level) {
|
||||
case 'info':
|
||||
return 'bg-blue-100 text-blue-700 dark:bg-blue-950 dark:text-blue-400';
|
||||
case 'warning':
|
||||
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-950 dark:text-yellow-400';
|
||||
case 'error':
|
||||
return 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-400';
|
||||
case 'debug':
|
||||
return 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-border rounded-lg overflow-hidden bg-background">
|
||||
{/* Header */}
|
||||
<div className="border-b border-border bg-muted/30 px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-foreground">Interactive Playground</h2>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Test {tool.package.npmPackageName} ({tool.exportName}) with AI-powered execution
|
||||
</p>
|
||||
</div>
|
||||
{rateLimitInfo && (
|
||||
<div className="text-sm text-foreground-secondary">
|
||||
{rateLimitInfo.remaining} executions remaining
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-border bg-muted/10">
|
||||
<div className="flex space-x-1 px-6">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-4 py-3 text-sm font-medium transition-colors relative ${
|
||||
activeTab === tab.id
|
||||
? 'text-foreground border-b-2 border-primary'
|
||||
: 'text-foreground-secondary hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.badge && (
|
||||
<span className="ml-2 inline-flex items-center justify-center w-5 h-5 text-xs rounded-full bg-primary/10 text-primary">
|
||||
{tab.badge}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab Content */}
|
||||
<div className="p-6">
|
||||
{activeTab === 'input' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="prompt" className="block text-sm font-medium text-foreground mb-2">
|
||||
Prompt
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt"
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
placeholder="Enter your prompt here... (e.g., 'Create a blog post about TypeScript best practices')"
|
||||
className="w-full h-32 px-4 py-3 rounded-lg border border-input bg-background text-foreground placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-ring resize-none"
|
||||
disabled={isExecuting}
|
||||
/>
|
||||
<p className="text-xs text-foreground-tertiary mt-2">
|
||||
{prompt.length}/2000 characters
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleExecute}
|
||||
disabled={isExecuting || !prompt.trim()}
|
||||
className="px-6 py-3 bg-primary text-primary-foreground rounded-lg font-medium hover:bg-primary/90 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isExecuting ? (
|
||||
<span className="flex items-center">
|
||||
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative loading spinner */}
|
||||
<svg
|
||||
className="animate-spin -ml-1 mr-3 h-5 w-5"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
Executing...
|
||||
</span>
|
||||
) : (
|
||||
'Execute'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'output' && (
|
||||
<div className="space-y-4">
|
||||
{error ? (
|
||||
<div className="p-4 rounded-lg bg-red-50 dark:bg-red-950/30 border border-red-200 dark:border-red-900">
|
||||
<p className="text-sm text-red-700 dark:text-red-400 font-medium">Error</p>
|
||||
<p className="text-sm text-red-600 dark:text-red-500 mt-1">{error}</p>
|
||||
</div>
|
||||
) : output ? (
|
||||
<div className="space-y-4">
|
||||
{/* JSON Output */}
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Raw Output (JSON)</h3>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-4 overflow-x-auto">
|
||||
<pre className="text-xs text-foreground font-mono">{output}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Human-Readable Preview */}
|
||||
{(() => {
|
||||
try {
|
||||
const parsed = JSON.parse(output);
|
||||
return (
|
||||
<div>
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">
|
||||
Human-Readable Preview
|
||||
</h3>
|
||||
<div className="rounded-lg border border-border bg-muted/30 p-6 prose prose-sm dark:prose-invert max-w-none">
|
||||
{parsed.formattedOutput ? (
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>
|
||||
{parsed.formattedOutput}
|
||||
</ReactMarkdown>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{Object.entries(parsed).map(([key, value]) => (
|
||||
<div key={key}>
|
||||
<span className="font-semibold">{key}:</span>{' '}
|
||||
{typeof value === 'object'
|
||||
? JSON.stringify(value, null, 2)
|
||||
: String(value)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})()}
|
||||
</div>
|
||||
) : isExecuting ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center">
|
||||
{/* biome-ignore lint/a11y/noSvgWithoutTitle: decorative loading spinner */}
|
||||
<svg
|
||||
className="animate-spin h-8 w-8 text-primary mx-auto mb-4"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle
|
||||
className="opacity-25"
|
||||
cx="12"
|
||||
cy="12"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="4"
|
||||
/>
|
||||
<path
|
||||
className="opacity-75"
|
||||
fill="currentColor"
|
||||
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-foreground-secondary">Executing...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-foreground-secondary">
|
||||
No output yet. Execute a prompt to see results.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'logs' && (
|
||||
<div className="space-y-2">
|
||||
{logs.length > 0 ? (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{logs.map((log, index) => (
|
||||
<div key={index} className="flex items-start space-x-3 text-sm">
|
||||
<span
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium uppercase ${getLevelBadgeColor(log.level)}`}
|
||||
>
|
||||
{log.level}
|
||||
</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-foreground">{log.message}</p>
|
||||
<p className="text-xs text-foreground-tertiary mt-0.5">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-foreground-secondary">
|
||||
No logs yet. Execute a prompt to see logs.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'tokens' && (
|
||||
<div>
|
||||
{tokens ? (
|
||||
<TokenBreakdown tokens={tokens} />
|
||||
) : (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-foreground-secondary">
|
||||
No token data yet. Execute a prompt to see token usage.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -2,10 +2,44 @@
|
|||
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { Input } from '@tpmjs/ui/Input/Input';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useState } from 'react';
|
||||
|
||||
export function HeroSection(): React.ReactElement {
|
||||
interface HeroSectionProps {
|
||||
stats: {
|
||||
packageCount: number;
|
||||
toolCount: number;
|
||||
categoryCount: number;
|
||||
};
|
||||
}
|
||||
|
||||
function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return `${(num / 1000000).toFixed(1)}M`;
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return `${(num / 1000).toFixed(1)}K`;
|
||||
}
|
||||
return num.toString();
|
||||
}
|
||||
|
||||
export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const router = useRouter();
|
||||
|
||||
const handleSearch = () => {
|
||||
if (searchQuery.trim()) {
|
||||
router.push(`/tool/tool-search?q=${encodeURIComponent(searchQuery)}`);
|
||||
} else {
|
||||
router.push('/tool/tool-search');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="relative min-h-[90vh] overflow-hidden bg-background">
|
||||
|
|
@ -37,19 +71,14 @@ export function HeroSection(): React.ReactElement {
|
|||
{/* Live Metrics Strip */}
|
||||
<div className="mb-12 flex flex-wrap items-center gap-3 border-l-[6px] border-brutalist-accent pl-6 font-mono text-base md:text-lg font-bold uppercase tracking-wider">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">2,847</span>
|
||||
<span className="text-foreground">{formatNumber(stats.packageCount)}</span>
|
||||
<span className="text-foreground-secondary">PACKAGES</span>
|
||||
</div>
|
||||
<span className="text-foreground-tertiary">/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{formatNumber(stats.toolCount)}</span>
|
||||
<span className="text-foreground-secondary">TOOLS</span>
|
||||
</div>
|
||||
<span className="text-foreground-tertiary">/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">12M+</span>
|
||||
<span className="text-foreground-secondary">INVOCATIONS</span>
|
||||
</div>
|
||||
<span className="text-foreground-tertiary">/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">47ms</span>
|
||||
<span className="text-foreground-secondary">LATENCY</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subheading */}
|
||||
|
|
@ -71,6 +100,7 @@ export function HeroSection(): React.ReactElement {
|
|||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="search tools..."
|
||||
className="brutalist-border h-16 md:h-20 pl-14 pr-36 md:pr-40 text-lg md:text-xl font-mono placeholder:text-foreground-tertiary placeholder:uppercase focus:ring-4 focus:ring-brutalist-accent focus:ring-offset-0 bg-background"
|
||||
style={{ borderRadius: 0 }}
|
||||
|
|
@ -79,6 +109,7 @@ export function HeroSection(): React.ReactElement {
|
|||
{/* Search Button */}
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSearch}
|
||||
className="brutalist-border-thick absolute right-2 top-1/2 -translate-y-1/2 h-12 md:h-16 px-6 md:px-8 bg-brutalist-accent text-foreground hover:bg-brutalist-accent-hover font-bold uppercase tracking-wider shadow-lg"
|
||||
style={{ borderRadius: 0, borderColor: 'hsl(var(--foreground))' }}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,4 +5,8 @@ export const env = createEnv({
|
|||
NODE_ENV: z.enum(['development', 'production', 'test']).default('development'),
|
||||
NEXT_PUBLIC_API_URL: z.string().url().optional(),
|
||||
CRON_SECRET: z.string().min(32).optional(), // Required for Vercel Cron security
|
||||
RAILWAY_EXECUTOR_URL: z
|
||||
.string()
|
||||
.url()
|
||||
.default('https://endearing-commitment-production.up.railway.app'), // Railway service for health checks
|
||||
});
|
||||
|
|
|
|||
269
apps/web/src/lib/ai-agent/tool-executor-agent.ts
Normal file
269
apps/web/src/lib/ai-agent/tool-executor-agent.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
/**
|
||||
* AI Agent service for executing TPMJS tools
|
||||
* Converts TPMJS metadata to Zod schemas and executes with AI SDK v6
|
||||
*/
|
||||
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import type { Package, Tool } from '@tpmjs/db';
|
||||
import { executePackage } from '@tpmjs/package-executor';
|
||||
import { type CoreMessage, generateText } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* Parameter from TPMJS metadata
|
||||
*/
|
||||
interface TPMJSParameter {
|
||||
name: string;
|
||||
type: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
default?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Token usage breakdown
|
||||
*/
|
||||
export interface TokenBreakdown {
|
||||
inputTokens: number;
|
||||
toolDescTokens: number;
|
||||
schemaTokens: number;
|
||||
outputTokens: number;
|
||||
totalTokens: number;
|
||||
estimatedCost: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TPMJS parameter type to Zod schema
|
||||
*/
|
||||
function typeToZodSchema(type: string): z.ZodTypeAny {
|
||||
// Handle array types
|
||||
if (type.endsWith('[]')) {
|
||||
const baseType = type.slice(0, -2);
|
||||
return z.array(typeToZodSchema(baseType));
|
||||
}
|
||||
|
||||
// Handle union types (e.g., 'markdown' | 'mdx')
|
||||
if (type.includes('|')) {
|
||||
const types = type.split('|').map((t) => t.trim().replace(/'/g, ''));
|
||||
return z.enum(types as [string, ...string[]]);
|
||||
}
|
||||
|
||||
// Handle primitive types
|
||||
switch (type) {
|
||||
case 'string':
|
||||
return z.string();
|
||||
case 'number':
|
||||
return z.number();
|
||||
case 'boolean':
|
||||
return z.boolean();
|
||||
case 'object':
|
||||
return z.object({});
|
||||
default:
|
||||
// Default to string for unknown types
|
||||
return z.string();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert TPMJS parameters to Zod schema object
|
||||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Zod requires any for dynamic schema objects
|
||||
export function tpmjsParamsToZodSchema(parameters: TPMJSParameter[]): z.ZodObject<any> {
|
||||
const shape: Record<string, z.ZodTypeAny> = {};
|
||||
|
||||
for (const param of parameters) {
|
||||
let schema = typeToZodSchema(param.type);
|
||||
|
||||
// Add description
|
||||
schema = schema.describe(param.description);
|
||||
|
||||
// Make optional if not required
|
||||
if (!param.required) {
|
||||
schema = schema.optional();
|
||||
}
|
||||
|
||||
shape[param.name] = schema;
|
||||
}
|
||||
|
||||
return z.object(shape);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create AI SDK v6 tool definition from TPMJS Tool
|
||||
* Requires Tool with Package relation
|
||||
*/
|
||||
export function createToolDefinition(tool: Tool & { package: Package }) {
|
||||
const parameters = Array.isArray(tool.parameters)
|
||||
? (tool.parameters as unknown as TPMJSParameter[])
|
||||
: [];
|
||||
|
||||
console.log('[createToolDefinition] Tool:', tool.package.npmPackageName, '/', tool.exportName);
|
||||
console.log('[createToolDefinition] Parameters array:', JSON.stringify(parameters));
|
||||
console.log('[createToolDefinition] Parameters length:', parameters.length);
|
||||
|
||||
// Ensure we have a valid schema - if no parameters, use an empty object
|
||||
const inputSchema =
|
||||
parameters.length > 0
|
||||
? tpmjsParamsToZodSchema(parameters)
|
||||
: z.object({}).describe('No parameters required');
|
||||
|
||||
console.log('[createToolDefinition] Created Zod schema:', inputSchema);
|
||||
|
||||
const sanitizedName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.exportName}`);
|
||||
|
||||
// AI SDK v6 tool definition
|
||||
return {
|
||||
description: tool.description,
|
||||
inputSchema, // AI SDK v6 uses inputSchema
|
||||
execute: async (params: Record<string, unknown>) => {
|
||||
console.log('[Tool execute] Running:', sanitizedName, params);
|
||||
|
||||
// Execute the actual npm package in a sandbox
|
||||
// Use the actual export name from the Tool record
|
||||
const result = await executePackage(
|
||||
tool.package.npmPackageName,
|
||||
tool.exportName, // Use actual export name (e.g., "helloWorldTool", "default")
|
||||
params,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || 'Package execution failed');
|
||||
}
|
||||
|
||||
console.log('[Tool execute] Result:', result.output);
|
||||
return result.output;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Count tokens in text using character estimation
|
||||
* Uses rough estimation: ~4 characters per token
|
||||
* This is used instead of tiktoken to avoid WASM dependency issues in serverless
|
||||
*/
|
||||
function countTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate token breakdown for tool execution
|
||||
*/
|
||||
export function calculateTokenBreakdown(
|
||||
userPrompt: string,
|
||||
toolDescription: string,
|
||||
parameters: TPMJSParameter[],
|
||||
returns: unknown,
|
||||
output: string
|
||||
): TokenBreakdown {
|
||||
const inputTokens = countTokens(userPrompt);
|
||||
const toolDescTokens = countTokens(toolDescription);
|
||||
const schemaTokens = countTokens(JSON.stringify({ parameters, returns }));
|
||||
const outputTokens = countTokens(output);
|
||||
const totalTokens = inputTokens + toolDescTokens + schemaTokens + outputTokens;
|
||||
|
||||
// GPT-4 Turbo pricing (approximate)
|
||||
const inputCost = (inputTokens + toolDescTokens + schemaTokens) * (0.01 / 1000);
|
||||
const outputCost = outputTokens * (0.03 / 1000);
|
||||
const estimatedCost = inputCost + outputCost;
|
||||
|
||||
return {
|
||||
inputTokens,
|
||||
toolDescTokens,
|
||||
schemaTokens,
|
||||
outputTokens,
|
||||
totalTokens,
|
||||
estimatedCost,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize npm package name to valid OpenAI tool name
|
||||
* OpenAI tool names must match: ^[a-zA-Z0-9_-]+
|
||||
* Converts: @tpmjs/createblogpost -> tpmjs-createblogpost
|
||||
*/
|
||||
function sanitizeToolName(npmPackageName: string): string {
|
||||
return npmPackageName.replace(/[@/]/g, '-').replace(/^-+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tool with AI agent using AI SDK v6
|
||||
* Requires Tool with Package relation
|
||||
*/
|
||||
export async function executeToolWithAgent(
|
||||
tool: Tool & { package: Package },
|
||||
userPrompt: string,
|
||||
onChunk?: (chunk: string) => void,
|
||||
onTokenUpdate?: (tokens: Partial<TokenBreakdown>) => void
|
||||
) {
|
||||
const toolDef = createToolDefinition(tool);
|
||||
const sanitizedToolName = sanitizeToolName(`${tool.package.npmPackageName}-${tool.exportName}`);
|
||||
|
||||
console.log('[executeToolWithAgent] Tool name:', sanitizedToolName);
|
||||
|
||||
const messages: CoreMessage[] = [
|
||||
{
|
||||
role: 'user',
|
||||
content: userPrompt,
|
||||
},
|
||||
];
|
||||
|
||||
const toolsConfig = {
|
||||
[sanitizedToolName]: toolDef,
|
||||
};
|
||||
|
||||
console.log('[executeToolWithAgent] Calling generateText');
|
||||
|
||||
// Use generateText for tool execution
|
||||
const result = await generateText({
|
||||
model: openai('gpt-4-turbo'),
|
||||
messages,
|
||||
tools: toolsConfig,
|
||||
});
|
||||
|
||||
console.log('[executeToolWithAgent] Result:', JSON.stringify(result, null, 2));
|
||||
|
||||
// Extract tool results from the response
|
||||
let toolOutput: unknown = null;
|
||||
if (result.response?.messages) {
|
||||
for (const message of result.response.messages) {
|
||||
if (message.role === 'tool' && 'content' in message) {
|
||||
toolOutput = message.content;
|
||||
console.log('[executeToolWithAgent] Tool output found:', toolOutput);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Format the output as JSON
|
||||
const fullOutput = toolOutput
|
||||
? JSON.stringify(toolOutput, null, 2)
|
||||
: result.text || JSON.stringify(result, null, 2);
|
||||
|
||||
console.log('[executeToolWithAgent] Final output:', fullOutput);
|
||||
|
||||
// Stream the output (all at once since generateText is non-streaming)
|
||||
if (onChunk) {
|
||||
onChunk(fullOutput);
|
||||
}
|
||||
|
||||
// Calculate final token breakdown
|
||||
const parameters = Array.isArray(tool.parameters)
|
||||
? (tool.parameters as unknown as TPMJSParameter[])
|
||||
: [];
|
||||
const tokenBreakdown = calculateTokenBreakdown(
|
||||
userPrompt,
|
||||
tool.description,
|
||||
parameters,
|
||||
tool.returns,
|
||||
fullOutput
|
||||
);
|
||||
|
||||
onTokenUpdate?.(tokenBreakdown);
|
||||
|
||||
return {
|
||||
output: fullOutput,
|
||||
tokenBreakdown,
|
||||
agentSteps: result.steps?.length || 1,
|
||||
};
|
||||
}
|
||||
385
apps/web/src/lib/health-check/health-check-service.ts
Normal file
385
apps/web/src/lib/health-check/health-check-service.ts
Normal file
|
|
@ -0,0 +1,385 @@
|
|||
/**
|
||||
* Health Check Service
|
||||
* Checks tool import and execution health via Railway executor
|
||||
*/
|
||||
|
||||
import { type HealthStatus, type Package, type Prisma, type Tool, prisma } from '@tpmjs/db';
|
||||
import { env } from '~/env';
|
||||
|
||||
const RAILWAY_EXECUTOR_URL = env.RAILWAY_EXECUTOR_URL;
|
||||
|
||||
interface HealthCheckResult {
|
||||
toolId: string;
|
||||
importStatus: HealthStatus;
|
||||
importError: string | null;
|
||||
importTimeMs: number | null;
|
||||
executionStatus: HealthStatus;
|
||||
executionError: string | null;
|
||||
executionTimeMs: number | null;
|
||||
overallStatus: HealthStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool can be imported (load-and-describe)
|
||||
*/
|
||||
async function checkImportHealth(tool: Tool & { package: Package }): Promise<{
|
||||
status: HealthStatus;
|
||||
error: string | null;
|
||||
timeMs: number;
|
||||
}> {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(`${RAILWAY_EXECUTOR_URL}/load-and-describe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName: tool.package.npmPackageName,
|
||||
exportName: tool.exportName,
|
||||
version: tool.package.npmVersion,
|
||||
env: tool.package.env || {},
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
});
|
||||
|
||||
const timeMs = Date.now() - startTime;
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
const error = data.error || `HTTP ${response.status}`;
|
||||
|
||||
// If error is just missing env vars, tool is not broken
|
||||
if (isEnvironmentConfigError(error)) {
|
||||
return {
|
||||
status: 'HEALTHY',
|
||||
error: null,
|
||||
timeMs,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'BROKEN',
|
||||
error,
|
||||
timeMs,
|
||||
};
|
||||
}
|
||||
|
||||
// Verify tool has required fields
|
||||
if (!data.tool?.description || !data.tool?.inputSchema) {
|
||||
return {
|
||||
status: 'BROKEN',
|
||||
error: 'Missing required tool fields (description or inputSchema)',
|
||||
timeMs,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'HEALTHY', error: null, timeMs };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// If error is just missing env vars, tool is not broken
|
||||
if (isEnvironmentConfigError(errorMessage)) {
|
||||
return {
|
||||
status: 'HEALTHY',
|
||||
error: null,
|
||||
timeMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'BROKEN',
|
||||
error: errorMessage,
|
||||
timeMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a tool can execute with test parameters
|
||||
*/
|
||||
async function checkExecutionHealth(tool: Tool & { package: Package }): Promise<{
|
||||
status: HealthStatus;
|
||||
error: string | null;
|
||||
timeMs: number;
|
||||
testParams: Record<string, unknown>;
|
||||
}> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Generate test parameters based on tool schema
|
||||
const testParams = generateTestParameters(tool);
|
||||
|
||||
try {
|
||||
const response = await fetch(`${RAILWAY_EXECUTOR_URL}/execute-tool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName: tool.package.npmPackageName,
|
||||
exportName: tool.exportName,
|
||||
version: tool.package.npmVersion,
|
||||
params: testParams,
|
||||
env: tool.package.env || {},
|
||||
}),
|
||||
signal: AbortSignal.timeout(30000), // 30 second timeout
|
||||
});
|
||||
|
||||
const timeMs = Date.now() - startTime;
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || !data.success) {
|
||||
const error = data.error || `HTTP ${response.status}`;
|
||||
|
||||
// If error is just missing env vars, tool is not broken - just needs configuration
|
||||
if (isEnvironmentConfigError(error)) {
|
||||
return {
|
||||
status: 'HEALTHY',
|
||||
error: null, // Clear the error since it's just a config issue
|
||||
timeMs,
|
||||
testParams,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'BROKEN',
|
||||
error,
|
||||
timeMs,
|
||||
testParams,
|
||||
};
|
||||
}
|
||||
|
||||
return { status: 'HEALTHY', error: null, timeMs, testParams };
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
|
||||
// If error is just missing env vars, tool is not broken
|
||||
if (isEnvironmentConfigError(errorMessage)) {
|
||||
return {
|
||||
status: 'HEALTHY',
|
||||
error: null,
|
||||
timeMs: Date.now() - startTime,
|
||||
testParams,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
status: 'BROKEN',
|
||||
error: errorMessage,
|
||||
timeMs: Date.now() - startTime,
|
||||
testParams,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error is due to missing environment variables (configuration issue)
|
||||
* rather than a broken tool (code issue)
|
||||
*/
|
||||
function isEnvironmentConfigError(error: string | null): boolean {
|
||||
if (!error) return false;
|
||||
|
||||
const envErrorPatterns = [
|
||||
/is required/i,
|
||||
/is not set/i,
|
||||
/missing.*environment/i,
|
||||
/environment.*missing/i,
|
||||
/api key.*required/i,
|
||||
/api key.*not provided/i,
|
||||
/missing.*api key/i,
|
||||
/must be set/i,
|
||||
/not found.*environment/i,
|
||||
/please set/i,
|
||||
/please provide/i,
|
||||
/configure.*environment/i,
|
||||
];
|
||||
|
||||
return envErrorPatterns.some((pattern) => pattern.test(error));
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate minimal test parameters for a tool
|
||||
* Uses required parameters with sensible defaults
|
||||
*/
|
||||
function generateTestParameters(tool: Tool & { package: Package }): Record<string, unknown> {
|
||||
const parameters = Array.isArray(tool.parameters)
|
||||
? (tool.parameters as Array<{ name: string; type: string; required: boolean }>)
|
||||
: [];
|
||||
|
||||
const testParams: Record<string, unknown> = {};
|
||||
|
||||
for (const param of parameters) {
|
||||
if (param.required) {
|
||||
// Generate minimal test value based on type
|
||||
switch (param.type) {
|
||||
case 'string':
|
||||
testParams[param.name] = 'test';
|
||||
break;
|
||||
case 'number':
|
||||
testParams[param.name] = 1;
|
||||
break;
|
||||
case 'boolean':
|
||||
testParams[param.name] = true;
|
||||
break;
|
||||
case 'object':
|
||||
testParams[param.name] = {};
|
||||
break;
|
||||
case 'array':
|
||||
testParams[param.name] = [];
|
||||
break;
|
||||
default:
|
||||
testParams[param.name] = 'test';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return testParams;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform full health check on a tool (import + execution)
|
||||
*/
|
||||
export async function performHealthCheck(
|
||||
toolId: string,
|
||||
triggerSource = 'manual'
|
||||
): Promise<HealthCheckResult> {
|
||||
// Fetch tool with package relation
|
||||
const tool = await prisma.tool.findUnique({
|
||||
where: { id: toolId },
|
||||
include: { package: true },
|
||||
});
|
||||
|
||||
if (!tool) {
|
||||
throw new Error(`Tool not found: ${toolId}`);
|
||||
}
|
||||
|
||||
console.log(`🏥 Health check starting for ${tool.package.npmPackageName}/${tool.exportName}`);
|
||||
|
||||
// Check import health
|
||||
const importResult = await checkImportHealth(tool);
|
||||
console.log(
|
||||
` Import: ${importResult.status} ${importResult.error ? `(${importResult.error})` : ''}`
|
||||
);
|
||||
|
||||
// Only check execution if import succeeded
|
||||
let executionResult: Awaited<ReturnType<typeof checkExecutionHealth>>;
|
||||
if (importResult.status === 'HEALTHY') {
|
||||
executionResult = await checkExecutionHealth(tool);
|
||||
console.log(
|
||||
` Execution: ${executionResult.status} ${executionResult.error ? `(${executionResult.error})` : ''}`
|
||||
);
|
||||
} else {
|
||||
// Skip execution check if import failed
|
||||
executionResult = {
|
||||
status: 'UNKNOWN',
|
||||
error: 'Skipped due to import failure',
|
||||
timeMs: 0,
|
||||
testParams: {},
|
||||
};
|
||||
console.log(' Execution: UNKNOWN (skipped due to import failure)');
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
const overallStatus: HealthStatus =
|
||||
importResult.status === 'BROKEN' || executionResult.status === 'BROKEN'
|
||||
? 'BROKEN'
|
||||
: importResult.status === 'HEALTHY' && executionResult.status === 'HEALTHY'
|
||||
? 'HEALTHY'
|
||||
: 'UNKNOWN';
|
||||
|
||||
console.log(` Overall: ${overallStatus}`);
|
||||
|
||||
// Create HealthCheck record
|
||||
await prisma.healthCheck.create({
|
||||
data: {
|
||||
toolId: tool.id,
|
||||
checkType: 'FULL',
|
||||
triggerSource,
|
||||
importStatus: importResult.status,
|
||||
importError: importResult.error,
|
||||
importTimeMs: importResult.timeMs,
|
||||
executionStatus: executionResult.status,
|
||||
executionError: executionResult.error,
|
||||
executionTimeMs: executionResult.timeMs,
|
||||
testParameters: executionResult.testParams as Prisma.InputJsonValue,
|
||||
overallStatus,
|
||||
},
|
||||
});
|
||||
|
||||
// Update Tool record with latest health status
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
importHealth: importResult.status,
|
||||
executionHealth: executionResult.status,
|
||||
lastHealthCheck: new Date(),
|
||||
healthCheckError: importResult.error || executionResult.error,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
toolId: tool.id,
|
||||
importStatus: importResult.status,
|
||||
importError: importResult.error,
|
||||
importTimeMs: importResult.timeMs,
|
||||
executionStatus: executionResult.status,
|
||||
executionError: executionResult.error,
|
||||
executionTimeMs: executionResult.timeMs,
|
||||
overallStatus,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch health check for multiple tools
|
||||
* Processes in batches to avoid overwhelming Railway
|
||||
*/
|
||||
export async function performBatchHealthCheck(
|
||||
toolIds: string[],
|
||||
triggerSource = 'daily-cron',
|
||||
batchSize = 5
|
||||
): Promise<{
|
||||
total: number;
|
||||
healthy: number;
|
||||
broken: number;
|
||||
unknown: number;
|
||||
errors: number;
|
||||
}> {
|
||||
let healthy = 0;
|
||||
let broken = 0;
|
||||
let unknown = 0;
|
||||
let errors = 0;
|
||||
|
||||
console.log(
|
||||
`🏥 Batch health check starting for ${toolIds.length} tools (batch size: ${batchSize})`
|
||||
);
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < toolIds.length; i += batchSize) {
|
||||
const batch = toolIds.slice(i, i + batchSize);
|
||||
console.log(
|
||||
` Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(toolIds.length / batchSize)}`
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
batch.map(async (toolId) => {
|
||||
try {
|
||||
const result = await performHealthCheck(toolId, triggerSource);
|
||||
if (result.overallStatus === 'HEALTHY') healthy++;
|
||||
else if (result.overallStatus === 'BROKEN') broken++;
|
||||
else unknown++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
console.error(` ❌ Health check failed for tool ${toolId}:`, error);
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Brief delay between batches to avoid rate limiting
|
||||
if (i + batchSize < toolIds.length) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
`✅ Batch health check complete: ${healthy} healthy, ${broken} broken, ${unknown} unknown, ${errors} errors`
|
||||
);
|
||||
|
||||
return { total: toolIds.length, healthy, broken, unknown, errors };
|
||||
}
|
||||
68
apps/web/src/lib/rate-limiter.ts
Normal file
68
apps/web/src/lib/rate-limiter.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Rate limiter service for tool playground executions
|
||||
* Prevents abuse by limiting requests per IP address
|
||||
*/
|
||||
|
||||
import { prisma } from '@tpmjs/db';
|
||||
|
||||
const RATE_LIMIT_WINDOW_MS = 3600000; // 1 hour
|
||||
const RATE_LIMIT_MAX_REQUESTS = 10; // 10 executions per hour
|
||||
|
||||
export interface RateLimitResult {
|
||||
allowed: boolean;
|
||||
remaining: number;
|
||||
resetAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an IP address has exceeded the rate limit
|
||||
*/
|
||||
export async function checkRateLimit(ipAddress: string): Promise<RateLimitResult> {
|
||||
const oneHourAgo = new Date(Date.now() - RATE_LIMIT_WINDOW_MS);
|
||||
|
||||
// Count simulations from this IP in the last hour
|
||||
const count = await prisma.simulation.count({
|
||||
where: {
|
||||
ipAddress,
|
||||
createdAt: {
|
||||
gte: oneHourAgo,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const remaining = Math.max(0, RATE_LIMIT_MAX_REQUESTS - count);
|
||||
const allowed = count < RATE_LIMIT_MAX_REQUESTS;
|
||||
const resetAt = new Date(Date.now() + RATE_LIMIT_WINDOW_MS);
|
||||
|
||||
return {
|
||||
allowed,
|
||||
remaining,
|
||||
resetAt,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client IP address from request headers
|
||||
*/
|
||||
export function getClientIP(request: Request): string {
|
||||
// Try various headers for IP address (in order of priority)
|
||||
const headers = request.headers;
|
||||
|
||||
const forwardedFor = headers.get('x-forwarded-for');
|
||||
if (forwardedFor) {
|
||||
return forwardedFor.split(',')[0]?.trim() || 'unknown';
|
||||
}
|
||||
|
||||
const realIP = headers.get('x-real-ip');
|
||||
if (realIP) {
|
||||
return realIP;
|
||||
}
|
||||
|
||||
const cfConnectingIP = headers.get('cf-connecting-ip');
|
||||
if (cfConnectingIP) {
|
||||
return cfConnectingIP;
|
||||
}
|
||||
|
||||
// Fallback to unknown
|
||||
return 'unknown';
|
||||
}
|
||||
|
|
@ -8,4 +8,5 @@ export default {
|
|||
'./src/components/**/*.{ts,tsx}',
|
||||
'../../packages/ui/src/**/*.ts',
|
||||
],
|
||||
plugins: [...(baseConfig.plugins || []), require('@tailwindcss/typography')],
|
||||
} satisfies Config;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"$schema": "https://openapi.vercel.sh/vercel.json",
|
||||
"buildCommand": "cd ../.. && pnpm install && pnpm --filter=@tpmjs/web... build",
|
||||
"installCommand": "pnpm install"
|
||||
"installCommand": "cd ../.. && pnpm install"
|
||||
}
|
||||
|
|
|
|||
281
docs/vercel-registry-sync.md
Normal file
281
docs/vercel-registry-sync.md
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
# Vercel AI Registry Sync
|
||||
|
||||
Automated system that syncs tools from Vercel's AI SDK registry to our manual-tools.ts file.
|
||||
|
||||
## Overview
|
||||
|
||||
The Vercel AI SDK maintains an official registry of tools at:
|
||||
```
|
||||
https://github.com/vercel/ai/blob/main/content/tools-registry/registry.ts
|
||||
```
|
||||
|
||||
This automation:
|
||||
1. Fetches the latest registry every hour
|
||||
2. Identifies new tools not yet in our manual-tools.ts
|
||||
3. Uses OpenAI GPT-4 to intelligently convert tool metadata
|
||||
4. Appends new tools to manual-tools.ts
|
||||
5. Commits and pushes changes automatically
|
||||
6. Sends Discord notifications
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Script: `sync-vercel-registry.ts`
|
||||
|
||||
Located at the repository root, this TypeScript script:
|
||||
|
||||
**Step 1: Fetch Registry**
|
||||
- Downloads `registry.ts` from Vercel's GitHub
|
||||
- Parses TypeScript to extract tool definitions
|
||||
- Converts to JSON array
|
||||
|
||||
**Step 2: Find New Tools**
|
||||
- Compares against existing `manual-tools.ts`
|
||||
- Identifies tools by `npmPackageName`
|
||||
- Returns list of new tools to add
|
||||
|
||||
**Step 3: AI Conversion**
|
||||
- For each new tool, calls OpenAI GPT-4
|
||||
- Provides Vercel tool metadata + our ManualTool interface
|
||||
- AI extracts:
|
||||
- Export names (handles multiple exports per package)
|
||||
- Parameters from code examples
|
||||
- Environment variables
|
||||
- Categories and tags
|
||||
- Use cases and limitations
|
||||
|
||||
**Step 4: Append to File**
|
||||
- Generates properly formatted TypeScript code
|
||||
- Inserts before closing `];` of manualTools array
|
||||
- Preserves existing formatting
|
||||
|
||||
### 2. GitHub Action: `.github/workflows/sync-vercel-registry.yml`
|
||||
|
||||
**Triggers:**
|
||||
- **Schedule:** Every hour (`0 * * * *`)
|
||||
- **Manual:** Via workflow_dispatch
|
||||
- **Auto:** On push to main that modifies `sync-vercel-registry.ts`
|
||||
|
||||
**Steps:**
|
||||
1. Checkout repository with `GITHUB_TOKEN` for commits
|
||||
2. Setup Node.js 22 and pnpm 10.14.0
|
||||
3. Install dependencies
|
||||
4. Run sync script with `OPENAI_API_KEY`
|
||||
5. Capture output and extract statistics
|
||||
6. If changes detected:
|
||||
- Commit with detailed message
|
||||
- Push to main
|
||||
7. Send Discord notification with results
|
||||
|
||||
## Configuration
|
||||
|
||||
### Required GitHub Secrets
|
||||
|
||||
| Secret | Description | Where to Get |
|
||||
|--------|-------------|--------------|
|
||||
| `OPENAI_API_KEY` | OpenAI API key for GPT-4 | https://platform.openai.com/api-keys |
|
||||
| `GITHUB_TOKEN` | Auto-provided by GitHub | (automatic) |
|
||||
| `DISCORD_WEBHOOK` | Discord webhook URL | Discord Server Settings → Integrations → Webhooks |
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The script uses:
|
||||
- `OPENAI_API_KEY` - Required for AI conversion
|
||||
- `GITHUB_TOKEN` - Required for committing changes
|
||||
|
||||
## Monitoring
|
||||
|
||||
### GitHub Actions
|
||||
|
||||
View workflow runs:
|
||||
```bash
|
||||
gh run list --workflow=sync-vercel-registry.yml
|
||||
gh run view <run-id> --log
|
||||
```
|
||||
|
||||
### Discord Notifications
|
||||
|
||||
Each run sends a Discord embed with:
|
||||
- ✅/⚠️/❌ Status indicator
|
||||
- Total tools in registry
|
||||
- Number processed (new)
|
||||
- Number skipped (existing)
|
||||
- Number of errors
|
||||
- Whether changes were committed
|
||||
- Link to GitHub Actions run
|
||||
- Link to commit (if changes made)
|
||||
|
||||
### Logs
|
||||
|
||||
The workflow includes extensive logging:
|
||||
- Timestamps and API key masking
|
||||
- Tool-by-tool processing
|
||||
- OpenAI conversion details
|
||||
- Git diff preview
|
||||
- Statistics summary
|
||||
|
||||
## Manual Testing
|
||||
|
||||
Test the script locally:
|
||||
|
||||
```bash
|
||||
# Set your OpenAI API key
|
||||
export OPENAI_API_KEY="sk-..."
|
||||
|
||||
# Run the sync script
|
||||
pnpm tsx sync-vercel-registry.ts
|
||||
```
|
||||
|
||||
This will:
|
||||
- Fetch the Vercel registry
|
||||
- Find new tools
|
||||
- Convert with AI
|
||||
- Show what would be added (but won't commit)
|
||||
|
||||
## Workflow Output Example
|
||||
|
||||
```
|
||||
════════════════════════════════════════
|
||||
🚀 Starting Vercel AI Registry Sync
|
||||
════════════════════════════════════════
|
||||
|
||||
📅 Time: 2025-12-04 15:00:00 UTC
|
||||
🔑 OpenAI API Key: sk-proj-...
|
||||
|
||||
📥 Fetching Vercel AI registry...
|
||||
URL: https://raw.githubusercontent.com/vercel/ai/refs/heads/main/content/tools-registry/registry.ts
|
||||
|
||||
✅ Fetched registry (15234 bytes)
|
||||
|
||||
🔍 Parsing TypeScript registry...
|
||||
Found tools array (12456 chars)
|
||||
|
||||
🔧 Converting to JSON...
|
||||
✅ Parsed 12 tools from registry
|
||||
|
||||
🔍 Checking for new tools...
|
||||
Existing manual tools: 25
|
||||
Vercel registry tools: 12
|
||||
|
||||
✨ Found 2 new tools:
|
||||
|
||||
1. Example Tool (@example/sdk)
|
||||
2. Another Tool (@another/tool)
|
||||
|
||||
🤖 Converting new tools with OpenAI...
|
||||
|
||||
🤖 Using OpenAI to convert: Example Tool
|
||||
Package: @example/sdk
|
||||
✅ Received OpenAI response (1234 chars)
|
||||
✨ Converted to 2 ManualTool(s):
|
||||
1. searchTool - Search the web for current information...
|
||||
2. extractTool - Extract structured content from web pages...
|
||||
|
||||
📊 Conversion Summary:
|
||||
New Vercel tools: 2
|
||||
Converted ManualTools: 3
|
||||
Errors: 0
|
||||
|
||||
📝 Adding 3 new tools to manual-tools.ts...
|
||||
|
||||
✅ Successfully updated manual-tools.ts
|
||||
|
||||
════════════════════════════════════════
|
||||
✅ Vercel AI Registry Sync Complete!
|
||||
|
||||
📊 Final Results:
|
||||
Processed: 3
|
||||
Skipped: 10
|
||||
Errors: 0
|
||||
Total: 12
|
||||
```
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
When changes are committed, the message includes:
|
||||
|
||||
```
|
||||
chore: sync 3 new tools from Vercel AI registry
|
||||
|
||||
Added 3 tools from Vercel AI SDK registry:
|
||||
- Total tools in registry: 12
|
||||
- Already synced: 10
|
||||
- Newly added: 3
|
||||
- Errors: 0
|
||||
|
||||
🤖 Automated by GitHub Actions
|
||||
Run: https://github.com/org/repo/actions/runs/123456789
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Failed to parse JSON"
|
||||
|
||||
The TypeScript-to-JSON conversion may fail if Vercel changes their registry format.
|
||||
|
||||
**Fix:** Update the regex patterns in `fetchVercelRegistry()` function.
|
||||
|
||||
### "Empty response from OpenAI"
|
||||
|
||||
API key issue or rate limiting.
|
||||
|
||||
**Fix:**
|
||||
- Verify `OPENAI_API_KEY` is set correctly
|
||||
- Check OpenAI account balance
|
||||
- Check rate limits at https://platform.openai.com/usage
|
||||
|
||||
### "Could not find closing bracket"
|
||||
|
||||
The `manual-tools.ts` file structure changed.
|
||||
|
||||
**Fix:** Ensure the file ends with `];` on its own line.
|
||||
|
||||
### "Commit failed"
|
||||
|
||||
Git permissions issue.
|
||||
|
||||
**Fix:** Verify GitHub Actions has write permissions in repository settings:
|
||||
- Settings → Actions → General → Workflow permissions
|
||||
- Enable "Read and write permissions"
|
||||
|
||||
## Maintenance
|
||||
|
||||
### Updating the Conversion Prompt
|
||||
|
||||
The AI prompt is in `convertToolWithAI()` function. Key sections:
|
||||
|
||||
1. **Interface Definition** - Keep in sync with `ManualTool` interface
|
||||
2. **Examples** - Show the AI how to handle multi-export packages
|
||||
3. **Instructions** - Be explicit about response format
|
||||
|
||||
### Changing Sync Frequency
|
||||
|
||||
Edit `.github/workflows/sync-vercel-registry.yml`:
|
||||
|
||||
```yaml
|
||||
schedule:
|
||||
- cron: '0 */6 * * *' # Every 6 hours instead of hourly
|
||||
```
|
||||
|
||||
### Adding More Registries
|
||||
|
||||
To sync from additional registries:
|
||||
|
||||
1. Create new script: `sync-other-registry.ts`
|
||||
2. Copy workflow: `sync-other-registry.yml`
|
||||
3. Update fetch URL and parsing logic
|
||||
4. Add to documentation
|
||||
|
||||
## Statistics
|
||||
|
||||
As of December 2025:
|
||||
- Vercel registry: 12 tools
|
||||
- TPMJS manual tools: 25+ tools
|
||||
- Sync frequency: Every hour
|
||||
- Average execution time: ~30 seconds
|
||||
- OpenAI cost per run: ~$0.01
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Manual Tools](../manual-tools.ts) - The target file
|
||||
- [Manual Sync](../sync-manual-tools.ts) - Syncs manual tools to database
|
||||
- [GitHub Actions Overview](../CLAUDE.md#github-actions) - All workflows
|
||||
|
|
@ -7,10 +7,8 @@ pre-commit:
|
|||
stage_fixed: true
|
||||
|
||||
type-check:
|
||||
run: pnpm type-check
|
||||
|
||||
lint:
|
||||
run: pnpm lint
|
||||
# Only type-check packages with staged changes
|
||||
run: pnpm turbo type-check --filter='...[HEAD]'
|
||||
|
||||
pre-push:
|
||||
commands:
|
||||
|
|
|
|||
785
manual-tools.ts
Normal file
785
manual-tools.ts
Normal file
|
|
@ -0,0 +1,785 @@
|
|||
/**
|
||||
* Manual Tools Registry
|
||||
*
|
||||
* This file contains tools that should be included in TPMJS but don't follow
|
||||
* the standard tpmjs field specification in their package.json.
|
||||
*
|
||||
* These tools are manually curated and synced to the database via the
|
||||
* manual sync script.
|
||||
*/
|
||||
|
||||
export interface ManualTool {
|
||||
// Package metadata
|
||||
npmPackageName: string;
|
||||
npmVersion?: string; // Optional - will fetch latest if not specified
|
||||
category:
|
||||
| 'text-analysis'
|
||||
| 'code-generation'
|
||||
| 'data-processing'
|
||||
| 'image-generation'
|
||||
| 'audio-processing'
|
||||
| 'search'
|
||||
| 'integration'
|
||||
| 'other';
|
||||
frameworks: Array<'vercel-ai' | 'langchain' | 'llamaindex' | 'other'>;
|
||||
|
||||
// Tool definition
|
||||
exportName: string;
|
||||
description: string;
|
||||
|
||||
// Optional rich metadata
|
||||
parameters?: Array<{
|
||||
name: string;
|
||||
type: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
default?: string;
|
||||
}>;
|
||||
|
||||
returns?: {
|
||||
type: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
aiAgent?: {
|
||||
useCase: string;
|
||||
limitations?: string;
|
||||
examples?: string[];
|
||||
};
|
||||
|
||||
// Environment variables
|
||||
env?: Array<{
|
||||
name: string;
|
||||
description: string;
|
||||
required: boolean;
|
||||
}>;
|
||||
|
||||
// Additional metadata not in npm
|
||||
tags?: string[];
|
||||
docsUrl?: string;
|
||||
apiKeyUrl?: string;
|
||||
websiteUrl?: string;
|
||||
}
|
||||
|
||||
export const manualTools: ManualTool[] = [
|
||||
{
|
||||
npmPackageName: 'ai-sdk-tool-code-execution',
|
||||
category: 'code-generation',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'executeCode',
|
||||
description:
|
||||
'Execute Python code in a sandboxed environment using Vercel Sandbox. Run calculations, data processing, and other computational tasks safely in an isolated environment with Python 3.13.',
|
||||
tags: ['code-execution', 'sandbox'],
|
||||
env: [
|
||||
{
|
||||
name: 'VERCEL_OIDC_TOKEN',
|
||||
description: 'Vercel OIDC token for sandbox authentication',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'code',
|
||||
type: 'string',
|
||||
description: 'Python code to execute in the sandbox',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Execution result with stdout, stderr, and return value',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase:
|
||||
'Use when you need to perform calculations, data processing, or execute Python code safely',
|
||||
limitations: 'Python 3.13 only. No network access. 30 second execution timeout.',
|
||||
examples: [
|
||||
'Perform complex mathematical calculations',
|
||||
'Process data with pandas/numpy',
|
||||
'Generate plots with matplotlib',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://vercel.com/docs/vercel-sandbox',
|
||||
apiKeyUrl: 'https://vercel.com/docs/vercel-sandbox#authentication',
|
||||
websiteUrl: 'https://vercel.com/docs/vercel-sandbox',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@exalabs/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'webSearch',
|
||||
description:
|
||||
"Search the web for current information using Exa's AI-powered search API. Returns high-quality, relevant results optimized for LLM consumption.",
|
||||
tags: ['search', 'web', 'extraction'],
|
||||
env: [
|
||||
{
|
||||
name: 'EXA_API_KEY',
|
||||
description: 'API key for Exa search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'Search query',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'numResults',
|
||||
type: 'number',
|
||||
description: 'Number of results to return (1-10)',
|
||||
required: false,
|
||||
default: '5',
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'array',
|
||||
description: 'Array of search results with title, URL, and content',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use when you need current information, news, research papers, or web content',
|
||||
limitations: 'Requires API key. Rate limits apply based on plan.',
|
||||
examples: [
|
||||
'Find latest AI developments',
|
||||
'Search for technical documentation',
|
||||
'Research current events',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://docs.exa.ai/reference/vercel',
|
||||
apiKeyUrl: 'https://dashboard.exa.ai/api-keys',
|
||||
websiteUrl: 'https://exa.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@parallel-web/ai-sdk-tools',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'searchTool',
|
||||
description:
|
||||
'Search and extract context from the web with token-optimized results. Parallel compresses web results for optimal inference efficiency.',
|
||||
tags: ['search', 'web', 'extraction'],
|
||||
env: [
|
||||
{
|
||||
name: 'PARALLEL_API_KEY',
|
||||
description: 'API key for Parallel search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use when you need web search with minimal token usage',
|
||||
examples: ['Search for current information', 'Extract structured data from websites'],
|
||||
},
|
||||
apiKeyUrl: 'https://platform.parallel.ai',
|
||||
websiteUrl: 'https://parallel.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@parallel-web/ai-sdk-tools',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'extractTool',
|
||||
description: 'Extract structured content from web pages with token-optimized compression.',
|
||||
tags: ['extraction', 'web'],
|
||||
env: [
|
||||
{
|
||||
name: 'PARALLEL_API_KEY',
|
||||
description: 'API key for Parallel search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'URL of the page to extract content from',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Extracted and compressed page content',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use to extract clean content from specific URLs',
|
||||
examples: ['Extract article content', 'Parse documentation pages'],
|
||||
},
|
||||
websiteUrl: 'https://parallel.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: 'ctx-zip',
|
||||
category: 'code-generation',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'createVercelSandboxCodeMode',
|
||||
description:
|
||||
'Transform MCP tools and AI SDK tools into code, write to Vercel sandbox filesystem, and execute in isolated environment.',
|
||||
tags: ['code-execution', 'sandbox', 'mcp', 'code-mode'],
|
||||
env: [
|
||||
{
|
||||
name: 'VERCEL_OIDC_TOKEN',
|
||||
description: 'Vercel OIDC token for sandbox authentication',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use when you need to combine MCP tools with code execution in a sandbox',
|
||||
limitations: 'Requires Vercel Sandbox access',
|
||||
examples: ['Execute code using MCP server tools', 'Combine multiple tool sources'],
|
||||
},
|
||||
docsUrl: 'https://github.com/karthikscale3/ctx-zip/blob/main/README.md',
|
||||
apiKeyUrl: 'https://vercel.com/docs/vercel-sandbox#authentication',
|
||||
websiteUrl: 'https://github.com/karthikscale3/ctx-zip',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@perplexity-ai/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'perplexitySearch',
|
||||
description:
|
||||
'Search the web with real-time results and advanced filtering powered by Perplexity Search API. Supports ranked results with domain, language, date range, and recency filters.',
|
||||
tags: ['search', 'web'],
|
||||
env: [
|
||||
{
|
||||
name: 'PERPLEXITY_API_KEY',
|
||||
description: 'API key for Perplexity search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'Search query',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'filters',
|
||||
type: 'object',
|
||||
description: 'Optional filters for domain, language, date range, etc.',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'array',
|
||||
description: 'Ranked search results with metadata',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use for comprehensive web search with filtering capabilities',
|
||||
examples: [
|
||||
'Find latest AI developments',
|
||||
'Search with date range filters',
|
||||
'Domain-specific searches',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://docs.perplexity.ai/guides/search-quickstart',
|
||||
apiKeyUrl: 'https://www.perplexity.ai/account/api/keys',
|
||||
websiteUrl: 'https://www.perplexity.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@tavily/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'tavilySearch',
|
||||
description:
|
||||
'Real-time web search optimized for AI applications. Provides comprehensive web research including search, content extraction, website crawling, and site mapping.',
|
||||
tags: ['search', 'extract', 'crawl'],
|
||||
env: [
|
||||
{
|
||||
name: 'TAVILY_API_KEY',
|
||||
description: 'API key for Tavily search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use for comprehensive web research and content extraction',
|
||||
examples: [
|
||||
'Research complex topics',
|
||||
'Extract structured information',
|
||||
'Crawl entire websites',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://docs.tavily.com/documentation/integrations/vercel',
|
||||
apiKeyUrl: 'https://app.tavily.com/home',
|
||||
websiteUrl: 'https://tavily.com',
|
||||
},
|
||||
{
|
||||
npmPackageName: 'firecrawl-aisdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'scrapeTool',
|
||||
description:
|
||||
'Scrape any website into clean markdown format. Convert web pages to LLM-friendly markdown with automatic cleaning and formatting.',
|
||||
tags: ['scraping', 'web', 'extraction'],
|
||||
env: [
|
||||
{
|
||||
name: 'FIRECRAWL_API_KEY',
|
||||
description: 'API key for Firecrawl service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'URL of the website to scrape',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Scraped content in clean markdown format',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use when you need to convert web pages to clean, structured markdown',
|
||||
examples: [
|
||||
'Scrape documentation pages',
|
||||
'Extract article content',
|
||||
'Convert web pages to markdown',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://docs.firecrawl.dev/integrations/ai-sdk',
|
||||
apiKeyUrl: 'https://firecrawl.dev/app/api-keys',
|
||||
websiteUrl: 'https://firecrawl.dev',
|
||||
},
|
||||
{
|
||||
npmPackageName: 'firecrawl-aisdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'searchTool',
|
||||
description:
|
||||
'Search the web and get results in clean markdown format optimized for AI consumption.',
|
||||
tags: ['search', 'web'],
|
||||
env: [
|
||||
{
|
||||
name: 'FIRECRAWL_API_KEY',
|
||||
description: 'API key for Firecrawl service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
websiteUrl: 'https://firecrawl.dev',
|
||||
},
|
||||
{
|
||||
npmPackageName: 'firecrawl-aisdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'crawlTool',
|
||||
description:
|
||||
'Crawl entire websites and extract structured data. Automatically discover and process multiple pages.',
|
||||
tags: ['crawling', 'web', 'extraction'],
|
||||
env: [
|
||||
{
|
||||
name: 'FIRECRAWL_API_KEY',
|
||||
description: 'API key for Firecrawl service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'url',
|
||||
type: 'string',
|
||||
description: 'Starting URL to begin crawling',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'maxPages',
|
||||
type: 'number',
|
||||
description: 'Maximum number of pages to crawl',
|
||||
required: false,
|
||||
default: '10',
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use when you need to crawl and extract data from entire websites',
|
||||
examples: [
|
||||
'Crawl documentation sites',
|
||||
'Extract all articles from a blog',
|
||||
'Build knowledge base from website',
|
||||
],
|
||||
},
|
||||
websiteUrl: 'https://firecrawl.dev',
|
||||
},
|
||||
{
|
||||
npmPackageName: 'bedrock-agentcore',
|
||||
category: 'code-generation',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'CodeInterpreterTools',
|
||||
description:
|
||||
'Isolated sandbox for executing Python, JavaScript, and TypeScript code to solve complex tasks. Fully managed by Amazon Bedrock.',
|
||||
tags: ['code-execution', 'sandbox'],
|
||||
env: [
|
||||
{
|
||||
name: 'AWS_ROLE_ARN',
|
||||
description: 'AWS IAM role ARN for Bedrock access',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use for secure code execution in AWS-managed sandbox',
|
||||
limitations: 'Requires AWS credentials and Bedrock access',
|
||||
examples: [
|
||||
'Execute Python data analysis',
|
||||
'Run JavaScript computations',
|
||||
'TypeScript code execution',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://github.com/aws/bedrock-agentcore-sdk-typescript',
|
||||
apiKeyUrl: 'https://vercel.com/docs/oidc/aws',
|
||||
websiteUrl: 'https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/built-in-tools.html',
|
||||
},
|
||||
{
|
||||
npmPackageName: 'bedrock-agentcore',
|
||||
category: 'integration',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'BrowserTools',
|
||||
description:
|
||||
'Fast and secure cloud-based browser runtime for web automation. Fill forms, navigate websites, and extract information in managed environment.',
|
||||
tags: ['browser-automation', 'web'],
|
||||
env: [
|
||||
{
|
||||
name: 'AWS_ROLE_ARN',
|
||||
description: 'AWS IAM role ARN for Bedrock access',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use for browser automation and web interaction tasks',
|
||||
examples: ['Fill web forms', 'Navigate websites', 'Extract dynamic content'],
|
||||
},
|
||||
websiteUrl: 'https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/built-in-tools.html',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@superagent-ai/ai-sdk',
|
||||
category: 'other',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'guard',
|
||||
description:
|
||||
'Protect AI apps from prompt injection and security threats. Detect and block malicious inputs before they reach your LLM.',
|
||||
tags: ['security', 'guardrails', 'prompt-injection'],
|
||||
env: [
|
||||
{
|
||||
name: 'SUPERAGENT_API_KEY',
|
||||
description: 'API key for Superagent security service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'input',
|
||||
type: 'string',
|
||||
description: 'User input to check for security threats',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Security analysis with threat detection results',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use to validate user inputs for security threats before processing',
|
||||
examples: [
|
||||
'Detect prompt injection attempts',
|
||||
'Block malicious inputs',
|
||||
'Security validation',
|
||||
],
|
||||
},
|
||||
docsUrl: 'https://docs.superagent.sh',
|
||||
apiKeyUrl: 'https://dashboard.superagent.sh',
|
||||
websiteUrl: 'https://superagent.sh',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@superagent-ai/ai-sdk',
|
||||
category: 'other',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'redact',
|
||||
description:
|
||||
'Redact PII/PHI from text including SSNs, emails, phone numbers, and other sensitive information.',
|
||||
tags: ['security', 'pii', 'redaction'],
|
||||
env: [
|
||||
{
|
||||
name: 'SUPERAGENT_API_KEY',
|
||||
description: 'API key for Superagent security service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'text',
|
||||
type: 'string',
|
||||
description: 'Text containing potentially sensitive information',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Redacted text with PII/PHI removed or masked',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use to remove sensitive information from text before processing',
|
||||
examples: ['Redact SSNs from documents', 'Remove email addresses', 'Mask phone numbers'],
|
||||
},
|
||||
websiteUrl: 'https://superagent.sh',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@superagent-ai/ai-sdk',
|
||||
category: 'other',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'verify',
|
||||
description:
|
||||
'Verify AI-generated claims against source materials. Fact-check and validate LLM outputs for accuracy.',
|
||||
tags: ['verification', 'fact-checking'],
|
||||
env: [
|
||||
{
|
||||
name: 'SUPERAGENT_API_KEY',
|
||||
description: 'API key for Superagent security service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'claim',
|
||||
type: 'string',
|
||||
description: 'Claim to verify',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'sources',
|
||||
type: 'array',
|
||||
description: 'Source materials to verify against',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Verification result with confidence score',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use to fact-check LLM outputs against trusted sources',
|
||||
examples: ['Verify factual claims', 'Check citations', 'Validate information accuracy'],
|
||||
},
|
||||
websiteUrl: 'https://superagent.sh',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'webSearch',
|
||||
description: 'Real-time web search for current information and news.',
|
||||
tags: ['search', 'web'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use for general web search and current information',
|
||||
examples: ['Find latest news', 'Search for current events'],
|
||||
},
|
||||
docsUrl: 'https://docs.valyu.ai/integrations/vercel-ai-sdk',
|
||||
apiKeyUrl: 'https://platform.valyu.ai',
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'financeSearch',
|
||||
description:
|
||||
'Search financial data including stock prices, earnings, income statements, cash flows, and market data.',
|
||||
tags: ['search', 'finance', 'domain-search'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use for financial data and market research',
|
||||
examples: ['Get stock prices', 'Find earnings reports', 'Search financial statements'],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'paperSearch',
|
||||
description: 'Full-text search across PubMed, arXiv, bioRxiv, and medRxiv research papers.',
|
||||
tags: ['search', 'research', 'domain-search'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use to search academic and research papers',
|
||||
examples: ['Find medical research', 'Search arXiv papers', 'Look up scientific publications'],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'bioSearch',
|
||||
description:
|
||||
'Search biomedical information including clinical trials, FDA drug labels, PubMed, medRxiv, and bioRxiv.',
|
||||
tags: ['search', 'biomedical', 'domain-search'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use for biomedical and clinical research',
|
||||
examples: ['Search clinical trials', 'Find FDA drug information', 'Research medical studies'],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'patentSearch',
|
||||
description: 'Search USPTO patent database for patents and patent applications.',
|
||||
tags: ['search', 'patents', 'domain-search'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use to search and research patents',
|
||||
examples: ['Find existing patents', 'Research patent applications', 'Prior art search'],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'secSearch',
|
||||
description: 'Search SEC filings including 10-K, 10-Q, and 8-K reports.',
|
||||
tags: ['search', 'sec', 'domain-search'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use to search SEC filings and corporate reports',
|
||||
examples: [
|
||||
'Find 10-K annual reports',
|
||||
'Search quarterly filings',
|
||||
'Research corporate disclosures',
|
||||
],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'economicsSearch',
|
||||
description: 'Search economic data from BLS, FRED, and World Bank databases.',
|
||||
tags: ['search', 'economics', 'domain-search'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
aiAgent: {
|
||||
useCase: 'Use to search economic indicators and statistics',
|
||||
examples: ['Find employment data', 'Search GDP statistics', 'Research economic indicators'],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@valyu/ai-sdk',
|
||||
category: 'search',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'companyResearch',
|
||||
description:
|
||||
'Generate comprehensive company research reports with financial data, news, and analysis.',
|
||||
tags: ['search', 'research', 'company-analysis'],
|
||||
env: [
|
||||
{
|
||||
name: 'VALYU_API_KEY',
|
||||
description: 'API key for Valyu search service',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
parameters: [
|
||||
{
|
||||
name: 'company',
|
||||
type: 'string',
|
||||
description: 'Company name or ticker symbol',
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description: 'Comprehensive research report with financial and market data',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase: 'Use to generate detailed company research and analysis',
|
||||
examples: ['Company financial analysis', 'Competitive research', 'Market position analysis'],
|
||||
},
|
||||
websiteUrl: 'https://valyu.ai',
|
||||
},
|
||||
{
|
||||
npmPackageName: '@tpmjs/search-registry',
|
||||
category: 'integration',
|
||||
frameworks: ['vercel-ai'],
|
||||
exportName: 'searchTpmjsToolsTool',
|
||||
description:
|
||||
'Search the TPMJS tool registry to find AI SDK tools by keyword, category, or description. Returns tool metadata for dynamic loading.',
|
||||
parameters: [
|
||||
{
|
||||
name: 'query',
|
||||
type: 'string',
|
||||
description: 'Search query (keywords, tool names, descriptions)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'category',
|
||||
type: 'string',
|
||||
description: 'Filter by category (optional)',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'limit',
|
||||
type: 'number',
|
||||
description: 'Maximum results (1-20, default 10)',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
returns: {
|
||||
type: 'object',
|
||||
description:
|
||||
'Search results with tool metadata including packageName, exportName, version, importUrl',
|
||||
},
|
||||
aiAgent: {
|
||||
useCase:
|
||||
"Use when you need a tool that isn't currently available. Search for tools by keyword or domain.",
|
||||
examples: [
|
||||
'Search for "weather" when asked about weather',
|
||||
'Search for "wikipedia" when asked to search Wikipedia',
|
||||
'Search for "database" when working with SQL',
|
||||
],
|
||||
},
|
||||
tags: ['search', 'tool-discovery', 'registry'],
|
||||
websiteUrl: 'https://tpmjs.com',
|
||||
docsUrl: 'https://tpmjs.com/spec',
|
||||
},
|
||||
];
|
||||
|
|
@ -24,7 +24,7 @@
|
|||
"changeset": "changeset",
|
||||
"changeset:version": "changeset version && pnpm install --no-frozen-lockfile",
|
||||
"changeset:publish": "pnpm build && changeset publish",
|
||||
"prepare": "node -e \"if (require('fs').existsSync('.git')) { require('child_process').execSync('lefthook install', {stdio: 'inherit'}) }\""
|
||||
"prepare": "node -e \"if (!process.env.CI && !process.env.VERCEL && require('fs').existsSync('.git')) { require('child_process').execSync('lefthook install', {stdio: 'inherit'}) }\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "^1.9.4",
|
||||
|
|
@ -34,6 +34,8 @@
|
|||
"dependency-cruiser": "^17.3.1",
|
||||
"knip": "^5.70.2",
|
||||
"lefthook": "^1.10.1",
|
||||
"openai": "^6.9.1",
|
||||
"tsx": "^4.21.0",
|
||||
"turbo": "^2.6.1",
|
||||
"type-coverage": "^2.29.7",
|
||||
"typescript": "^5.9.3"
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
"eslint-plugin-jsx-a11y": "^6.10.2",
|
||||
"eslint-plugin-react": "^7.37.3",
|
||||
"eslint-plugin-react-hooks": "^5.1.0",
|
||||
"typescript-eslint": "^8.19.1"
|
||||
"typescript-eslint": "^8.19.1",
|
||||
"zod": "^4.1.13"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue