Compare commits
54 commits
@tpmjs/off
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ae0d5e37cf | ||
|
|
4fa8344b34 | ||
|
|
66fb7ef226 | ||
|
|
5ac3beab37 | ||
|
|
894f9842d1 | ||
|
|
dda28d642c | ||
|
|
e42bbdff34 | ||
|
|
1cd44b4e97 | ||
|
|
c8e8a7d9b4 | ||
|
|
cc69d98b6f | ||
|
|
7327992a9d | ||
|
|
6eb0ce7e23 | ||
|
|
022ecda6bf | ||
|
|
7dca00b5da | ||
|
|
2a2f0f487d | ||
|
|
198f9f7d1e | ||
|
|
9ec8bf2454 | ||
|
|
293fe08910 | ||
|
|
15fc413f9c | ||
|
|
a9e01fe772 | ||
|
|
36598fec61 | ||
|
|
99017322ba | ||
|
|
bd232a3407 | ||
|
|
727a44af63 | ||
|
|
4bcdab09f2 | ||
|
|
a52d32c367 | ||
|
|
f3a46045ba | ||
|
|
54bfbff71c | ||
|
|
90d575797f | ||
|
|
0d30a9cabb | ||
|
|
86e523f3dc | ||
|
|
ce44aeab3a | ||
|
|
1675e6ce6c | ||
|
|
0f7e5a3ace | ||
|
|
fa4e7754e6 | ||
|
|
32c6e097ed | ||
|
|
760cc4b77e | ||
|
|
b092ca490b | ||
|
|
ffc6ddcdbb | ||
|
|
3f62228c56 | ||
|
|
ee066a20ca | ||
|
|
f9c5d903a1 | ||
|
|
211be5d197 | ||
|
|
59eefe8d55 | ||
|
|
0e5815e4ce | ||
|
|
2df4b53354 | ||
|
|
13e4fd954d | ||
|
|
19417964de | ||
|
|
8542e3d7d5 | ||
|
|
16c7b8155e | ||
|
|
9fc928adae | ||
|
|
88e66d54ce | ||
|
|
4e3cb78ba4 | ||
|
|
c51fee1484 |
244 changed files with 33395 additions and 3346 deletions
1
.claude/skills/agentmail
Symbolic link
1
.claude/skills/agentmail
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
../../.agents/skills/agentmail
|
||||
296
.claude/skills/tpmjs-tool-creator/SKILL.md
Normal file
296
.claude/skills/tpmjs-tool-creator/SKILL.md
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
---
|
||||
name: tpmjs-tool-creator
|
||||
description: Guide for creating official TPMJS tools using the blocks CLI. Use when a user wants to create a new tool for the TPMJS registry, add a tool to packages/tools/official/, implement an AI SDK v6 tool, define a block in blocks.yml, validate a tool with `pnpm blocks run`, or publish a tool to npm with the tpmjs keyword.
|
||||
---
|
||||
|
||||
# TPMJS Tool Creator
|
||||
|
||||
Create production-ready tools for the TPMJS registry using the blocks CLI. Tools are npm packages following the AI SDK v6 pattern, validated by blocks, and automatically synced to tpmjs.com.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Define the tool block in `packages/tools/official/blocks.yml`
|
||||
2. Create the tool package directory
|
||||
3. Implement the tool using AI SDK v6 `tool()` + `jsonSchema()`
|
||||
4. Validate with `pnpm blocks run <tool-name>`
|
||||
5. Build and publish to npm
|
||||
|
||||
## Step 1: Define in blocks.yml
|
||||
|
||||
Add to the `blocks:` section of `packages/tools/official/blocks.yml`:
|
||||
|
||||
```yaml
|
||||
blocks:
|
||||
category.toolName:
|
||||
type: utility
|
||||
description: "LLM-friendly description of what the tool does"
|
||||
path: "tool-directory-name"
|
||||
domain_rules:
|
||||
- id: rule_name
|
||||
description: "What this implementation must do"
|
||||
inputs:
|
||||
- name: inputName
|
||||
type: string
|
||||
description: "Description for LLMs"
|
||||
- name: optionalInput
|
||||
type: number
|
||||
optional: true
|
||||
description: "Optional parameter"
|
||||
outputs:
|
||||
- name: result
|
||||
type: ResultType
|
||||
description: "What the tool returns"
|
||||
measures: [working_implementation, valid_output_structure, proper_error_handling, ai_sdk_compliance]
|
||||
```
|
||||
|
||||
**Category prefix** (before the dot): `research`, `web`, `data`, `documentation`, `engineering`, `security`, `statistics`, `ops`, `agent`, `sandbox`, `utilities`, `html`, `compliance`, `finance`, `legal`, `hr`, `marketing`, `cx`, `edu`, `sales`.
|
||||
|
||||
For domain entities and quality measures, see [references/domain.md](references/domain.md).
|
||||
|
||||
## Step 2: Create Package Directory
|
||||
|
||||
Create `packages/tools/official/<tool-name>/`:
|
||||
|
||||
```
|
||||
<tool-name>/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── tsup.config.ts
|
||||
├── README.md
|
||||
└── src/
|
||||
└── index.ts
|
||||
```
|
||||
|
||||
**package.json:**
|
||||
```json
|
||||
{
|
||||
"name": "@tpmjs/official-<tool-name>",
|
||||
"version": "0.1.0",
|
||||
"description": "Short description",
|
||||
"type": "module",
|
||||
"keywords": ["tpmjs", "<category>", "ai"],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"default": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"type-check": "tsc --noEmit",
|
||||
"clean": "rm -rf dist .turbo"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tpmjs/tsconfig": "workspace:*",
|
||||
"tsup": "^8.5.1",
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"ai": "6.0.49"
|
||||
},
|
||||
"publishConfig": { "access": "public" },
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/tpmjs/tpmjs.git",
|
||||
"directory": "packages/tools/official/<tool-name>"
|
||||
},
|
||||
"homepage": "https://tpmjs.com",
|
||||
"license": "MIT",
|
||||
"tpmjs": {
|
||||
"category": "<category>",
|
||||
"frameworks": ["vercel-ai"],
|
||||
"tools": [
|
||||
{
|
||||
"name": "toolName",
|
||||
"description": "Clear description (20+ chars)."
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**tsconfig.json:**
|
||||
```json
|
||||
{
|
||||
"extends": "@tpmjs/tsconfig/react-library.json",
|
||||
"compilerOptions": { "outDir": "dist", "rootDir": "src" },
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
```
|
||||
|
||||
**tsup.config.ts:**
|
||||
```typescript
|
||||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/index.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
sourcemap: true,
|
||||
target: 'es2022',
|
||||
});
|
||||
```
|
||||
|
||||
## Step 3: Implement the Tool
|
||||
|
||||
Every tool follows this AI SDK v6 pattern in `src/index.ts`:
|
||||
|
||||
```typescript
|
||||
import { jsonSchema, tool } from 'ai';
|
||||
|
||||
interface MyToolInput {
|
||||
param1: string;
|
||||
param2?: number;
|
||||
}
|
||||
|
||||
export interface MyToolResult {
|
||||
data: string;
|
||||
metadata: { processedAt: string };
|
||||
}
|
||||
|
||||
export const myTool = tool({
|
||||
description: 'Clear LLM-friendly description of what this tool does.',
|
||||
parameters: jsonSchema<MyToolInput>({
|
||||
type: 'object',
|
||||
properties: {
|
||||
param1: {
|
||||
type: 'string',
|
||||
description: 'What param1 is for',
|
||||
},
|
||||
param2: {
|
||||
type: 'number',
|
||||
description: 'Optional: what param2 is for',
|
||||
},
|
||||
},
|
||||
required: ['param1'],
|
||||
additionalProperties: false,
|
||||
}),
|
||||
execute: async (input): Promise<MyToolResult> => {
|
||||
if (!input.param1) {
|
||||
throw new Error('param1 is required and must be non-empty');
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await processData(input.param1);
|
||||
return {
|
||||
data: result,
|
||||
metadata: { processedAt: new Date().toISOString() },
|
||||
};
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to process: ${error instanceof Error ? error.message : String(error)}`
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export default myTool;
|
||||
```
|
||||
|
||||
**Hard rules:**
|
||||
- No stubs, TODOs, or placeholders — every tool must be fully working
|
||||
- Single-shot: one call in, one structured result out
|
||||
- Validate inputs before processing
|
||||
- Try-catch with descriptive errors including context
|
||||
- `additionalProperties: false` on jsonSchema
|
||||
- Description on every schema property
|
||||
- Export as both named and default export
|
||||
- Output interface must be exported
|
||||
|
||||
### Multi-Tool Packages
|
||||
|
||||
For packages with multiple tools, add root-level files:
|
||||
|
||||
**block.ts:**
|
||||
```typescript
|
||||
import { toolA, toolB } from './src/index.js';
|
||||
export const block = { name: 'package-name', tools: { toolA, toolB } };
|
||||
export default block;
|
||||
```
|
||||
|
||||
**index.ts (root):**
|
||||
```typescript
|
||||
export * from './src/index.js';
|
||||
export { default } from './src/index.js';
|
||||
```
|
||||
|
||||
Each tool gets its own entry in blocks.yml (same `path`) and in `tpmjs.tools` array.
|
||||
|
||||
## Step 4: Validate
|
||||
|
||||
The blocks CLI domain validator requires an OpenAI API key. Source it from `.env.local` before running:
|
||||
|
||||
```bash
|
||||
cd packages/tools/official
|
||||
|
||||
# Load the OpenAI API key for domain validation
|
||||
source ../../../.env.local
|
||||
export OPENAI_API_KEY
|
||||
|
||||
pnpm blocks run <tool-name> # Validate (schema → shape → domain)
|
||||
pnpm blocks run <tool-name> --force # Force full validation (skip cache)
|
||||
pnpm blocks run <tool-name> --json # JSON output for debugging
|
||||
pnpm blocks run --all # Validate all tools
|
||||
```
|
||||
|
||||
**Common errors:**
|
||||
- `Tool "X" not found in exports` → Export name must match blocks.yml
|
||||
- `Required file not found` → Check package root has all required files
|
||||
- `invalid tpmjs field` → Category must be valid, tools array required
|
||||
|
||||
## Step 5: Build and Publish
|
||||
|
||||
```bash
|
||||
pnpm --filter=@tpmjs/official-<tool-name> build
|
||||
cd packages/tools/official/<tool-name> && npm publish --access public
|
||||
```
|
||||
|
||||
The tool syncs to tpmjs.com automatically via the changes feed (every 2 min) and keyword search (every 15 min). To trigger immediately:
|
||||
|
||||
```bash
|
||||
source apps/web/.env.local
|
||||
curl -X POST https://tpmjs.com/api/sync/keyword \
|
||||
-H "Authorization: Bearer $CRON_SECRET"
|
||||
```
|
||||
|
||||
## README Template
|
||||
|
||||
Every tool needs a README:
|
||||
|
||||
```markdown
|
||||
# @tpmjs/official-<tool-name>
|
||||
|
||||
Short description.
|
||||
|
||||
## Installation
|
||||
|
||||
npm install @tpmjs/official-<tool-name>
|
||||
|
||||
## Usage
|
||||
|
||||
\`\`\`typescript
|
||||
import { myTool } from '@tpmjs/official-<tool-name>';
|
||||
|
||||
const result = await myTool.execute({ param1: 'example' });
|
||||
\`\`\`
|
||||
|
||||
## Parameters
|
||||
|
||||
| Name | Type | Required | Description |
|
||||
|--------|--------|----------|--------------------|
|
||||
| param1 | string | Yes | What param1 is for |
|
||||
|
||||
## Output
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|--------|----------------------|
|
||||
| data | string | The processed result |
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
```
|
||||
58
.claude/skills/tpmjs-tool-creator/references/domain.md
Normal file
58
.claude/skills/tpmjs-tool-creator/references/domain.md
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
# Domain Reference
|
||||
|
||||
## Entities
|
||||
|
||||
Reusable output types defined in blocks.yml. Reference these in your tool's output `type` field.
|
||||
|
||||
| Entity | Fields |
|
||||
|--------|--------|
|
||||
| url | href, domain, protocol, path, query, fragment |
|
||||
| webpage | url, title, html, text, metadata |
|
||||
| text_content | raw, sentences, paragraphs, wordCount |
|
||||
| claim | statement, confidence, needsCitation, category |
|
||||
| timeline | events, dateRange, gaps, eventCount |
|
||||
| evidence | source, type, strength, relevance |
|
||||
| summary | text, keyPoints, length, compressionRatio |
|
||||
| sentiment | score, label, confidence, aspects |
|
||||
| entity | name, type, mentions, context |
|
||||
| relationship | source, target, type, strength |
|
||||
| pattern | name, frequency, examples, significance |
|
||||
| anomaly | description, severity, context, recommendation |
|
||||
| metric | name, value, unit, trend |
|
||||
| comparison | items, criteria, rankings, analysis |
|
||||
| recommendation | action, priority, rationale, impact |
|
||||
| risk | description, likelihood, impact, mitigation |
|
||||
| code_snippet | language, code, explanation, complexity |
|
||||
| api_endpoint | method, path, parameters, response |
|
||||
| data_schema | fields, types, constraints, relationships |
|
||||
| workflow_step | action, input, output, conditions |
|
||||
|
||||
## Quality Measures
|
||||
|
||||
Reference these in your output's `measures` array.
|
||||
|
||||
| Measure | Severity | What it checks |
|
||||
|---------|----------|---------------|
|
||||
| working_implementation | error | No TODOs, stubs, or placeholders. Returns actual computed values. |
|
||||
| valid_output_structure | error | Returns object matching declared interface. All required fields present. Arrays never undefined. |
|
||||
| proper_error_handling | error | Throws descriptive Error with context. Validates inputs. Catches external API errors. |
|
||||
| ai_sdk_compliance | error | Uses `tool()` + `jsonSchema()` from 'ai'. Clear description. Every property has description. |
|
||||
| npm_publishable | error | Valid package.json with tpmjs field. Named + default exports. Proper types. Semver version. |
|
||||
| readme_documentation | error | README exists. Describes tool. Usage example. Documents inputs/outputs. |
|
||||
| deterministic_output | warning | Same input produces same output (where applicable). |
|
||||
| minimal_dependencies | warning | Uses stable, well-maintained packages. Avoids unnecessary deps. |
|
||||
|
||||
## Domain Rules
|
||||
|
||||
Common domain rule categories for the `domain_rules` field in blocks.yml:
|
||||
|
||||
- **Core implementation**: working code, proper types, error handling
|
||||
- **Web & fetch**: URL validation, content extraction, timeout handling
|
||||
- **Document generation**: format compliance, template rendering
|
||||
- **Data transformation**: schema validation, type coercion, encoding
|
||||
- **Engineering/code analysis**: AST parsing, complexity metrics
|
||||
- **Security & compliance**: input sanitization, safe execution
|
||||
- **Statistical rigor**: numerical accuracy, proper rounding
|
||||
- **Workflow/recipe**: step sequencing, state management
|
||||
|
||||
Define custom rules specific to your tool's requirements. Each rule needs an `id` and `description`.
|
||||
72
.github/workflows/build-omega-mac.yml
vendored
Normal file
72
.github/workflows/build-omega-mac.yml
vendored
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
name: Build Omega Mac
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-15
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Select Xcode
|
||||
run: sudo xcode-select -s /Applications/Xcode_16.2.app/Contents/Developer
|
||||
|
||||
- name: Resolve dependencies
|
||||
working-directory: apps/omega-mac
|
||||
run: swift package resolve
|
||||
|
||||
- name: Build release
|
||||
working-directory: apps/omega-mac
|
||||
run: swift build -c release
|
||||
|
||||
- name: Package .app bundle
|
||||
working-directory: apps/omega-mac
|
||||
run: |
|
||||
mkdir -p OmegaMac.app/Contents/MacOS
|
||||
mkdir -p OmegaMac.app/Contents/Resources
|
||||
cp .build/release/OmegaMac OmegaMac.app/Contents/MacOS/OmegaMac
|
||||
|
||||
cat > OmegaMac.app/Contents/Info.plist << 'PLIST'
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>OmegaMac</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.tpmjs.omega-mac</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Omega</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Omega</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
PLIST
|
||||
|
||||
codesign --force --sign - OmegaMac.app
|
||||
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: OmegaMac
|
||||
path: apps/omega-mac/OmegaMac.app
|
||||
114
.github/workflows/sync-enrich.yml
vendored
Normal file
114
.github/workflows/sync-enrich.yml
vendored
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
name: Sync Tool Enrichment
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Run every 2 minutes
|
||||
- cron: '*/2 * * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
sync-enrich:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger enrichment sync
|
||||
id: sync
|
||||
run: |
|
||||
# Call the sync API and capture response
|
||||
response=$(curl -X POST "${{ secrets.VERCEL_PRODUCTION_URL }}/api/sync/enrich" \
|
||||
-H "Authorization: Bearer ${{ secrets.CRON_SECRET }}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-f -s -S)
|
||||
|
||||
echo "Response: $response"
|
||||
|
||||
# Extract data using jq
|
||||
enriched=$(echo "$response" | jq -r '.data.enriched')
|
||||
discovered=$(echo "$response" | jq -r '.data.discovered')
|
||||
skipped=$(echo "$response" | jq -r '.data.skipped')
|
||||
errors=$(echo "$response" | jq -r '.data.errors')
|
||||
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 "⚠️ ENRICHMENT 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 "enriched=$enriched" >> $GITHUB_OUTPUT
|
||||
echo "discovered=$discovered" >> $GITHUB_OUTPUT
|
||||
echo "skipped=$skipped" >> $GITHUB_OUTPUT
|
||||
echo "errors=$errors" >> $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
|
||||
echo "• $errorSummary" > /tmp/error_summary.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
|
||||
error_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
|
||||
|
||||
# Build fields array dynamically
|
||||
base_fields='[
|
||||
{ "name": "🔧 Enriched", "value": "${{ steps.sync.outputs.enriched }}", "inline": true },
|
||||
{ "name": "🔍 Discovered", "value": "${{ steps.sync.outputs.discovered }}", "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 }} Tool Enrichment Sync" \
|
||||
--argjson color ${{ steps.sync.outputs.status_color }} \
|
||||
--argjson baseFields "$base_fields" \
|
||||
--arg error_text "$error_text" \
|
||||
--arg timestamp "$(date -u +%Y-%m-%dT%H:%M:%S.000Z)" \
|
||||
'
|
||||
{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
color: $color,
|
||||
fields: (
|
||||
$baseFields +
|
||||
(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"
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
|
|
@ -63,6 +63,22 @@ secrets.json
|
|||
|
||||
# ide
|
||||
.idea
|
||||
.agent/
|
||||
.agents/
|
||||
.continue/
|
||||
.cursor/
|
||||
.windsurf/
|
||||
|
||||
# temporary analysis docs
|
||||
COMPREHENSIVE_ANALYSIS.md
|
||||
PLAN.md
|
||||
REGISTRY_TOOLS_ANALYSIS.md
|
||||
USER_ACCOUNT_ANALYSIS_*.md
|
||||
*.skill
|
||||
|
||||
# symlinked skill dirs (source is .agents/ which is gitignored)
|
||||
/skills/
|
||||
.claude/skills/skill-creator
|
||||
|
||||
# storybook
|
||||
storybook-static
|
||||
|
|
@ -72,3 +88,4 @@ storybook-static
|
|||
!.changeset/README.md
|
||||
.vercel
|
||||
packages/tool-ideas/data/tools-export.json
|
||||
.env*.local
|
||||
|
|
|
|||
269
EXECUTOR_COMPLIANCE.md
Normal file
269
EXECUTOR_COMPLIANCE.md
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
# TPMJS Executor Compliance Report
|
||||
|
||||
> **Generated:** 2026-02-04
|
||||
> **Protocol Version:** 1.0
|
||||
> **Test Suite Version:** 0.1.0
|
||||
|
||||
## Overview
|
||||
|
||||
This document reports compliance testing results for the three reference TPMJS executor implementations against the Executor Protocol v1.0 specification.
|
||||
|
||||
## Compliance Summary
|
||||
|
||||
| Executor | Platform | Isolation | Core (L1) | Standard (L2) | Tests Passed |
|
||||
|----------|----------|-----------|-----------|---------------|--------------|
|
||||
| Railway Executor | Railway | Process | ✅ PASS | ✅ PASS | 15/15 |
|
||||
| Unsandbox Executor | Unsandbox | Container | ✅ PASS | ✅ PASS | 15/15 |
|
||||
| Vercel Executor | Vercel | VM | ✅ PASS* | ✅ PASS* | 15/15* |
|
||||
|
||||
\* Vercel Executor requires deployment to Vercel for full testing due to `@vercel/sandbox` dependency.
|
||||
|
||||
---
|
||||
|
||||
## Railway Executor
|
||||
|
||||
**Location:** `templates/railway-executor/`
|
||||
|
||||
### Test Results
|
||||
|
||||
```
|
||||
TPMJS Executor Compliance Test v0.1.0
|
||||
Protocol Version: 1.0
|
||||
Target: http://localhost:3456
|
||||
|
||||
Core Core Requirements:
|
||||
✓ GET /health returns 200 (65ms)
|
||||
✓ GET /health includes protocolVersion (5ms)
|
||||
✓ GET /health includes implementationVersion (5ms)
|
||||
✓ POST /execute-tool accepts valid request (4425ms)
|
||||
✓ POST /execute-tool returns structured response (2202ms)
|
||||
✓ POST /execute-tool returns error for invalid tool (1556ms)
|
||||
✓ CORS headers present (3ms)
|
||||
✓ OPTIONS preflight works (2ms)
|
||||
|
||||
Standard Standard Requirements:
|
||||
✓ GET /info returns 200 (6ms)
|
||||
✓ GET /info includes capabilities (3ms)
|
||||
✓ GET /info includes protocolVersion (3ms)
|
||||
✓ capabilities.isolation is valid (2ms)
|
||||
✓ Authentication enforced when configured (2181ms)
|
||||
✓ Execution timeout enforcement (2ms)
|
||||
✓ Structured error codes (2307ms)
|
||||
|
||||
Summary:
|
||||
Tests: 15 passed, 0 failed, 15 total
|
||||
Core Compliance: PASS
|
||||
Standard Compliance: PASS
|
||||
```
|
||||
|
||||
### Capabilities
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Railway Executor",
|
||||
"version": "1.0.0",
|
||||
"protocolVersion": "1.0",
|
||||
"capabilities": {
|
||||
"isolation": "process",
|
||||
"executionModes": ["sync"],
|
||||
"maxExecutionTimeMs": 120000,
|
||||
"maxRequestBodyBytes": 10485760,
|
||||
"supportsStreaming": false,
|
||||
"supportsCallbacks": false,
|
||||
"supportsCaching": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to Railway
|
||||
railway init
|
||||
railway up
|
||||
|
||||
# Or use the Docker image
|
||||
docker build -t tpmjs-executor .
|
||||
docker run -p 3000:3000 tpmjs-executor
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unsandbox Executor
|
||||
|
||||
**Location:** `templates/unsandbox-executor/`
|
||||
|
||||
### Test Results
|
||||
|
||||
```
|
||||
TPMJS Executor Compliance Test v0.1.0
|
||||
Protocol Version: 1.0
|
||||
Target: http://localhost:3457
|
||||
|
||||
Core Core Requirements:
|
||||
✓ GET /health returns 200 (44ms)
|
||||
✓ GET /health includes protocolVersion (5ms)
|
||||
✓ GET /health includes implementationVersion (2ms)
|
||||
✓ POST /execute-tool accepts valid request (1747ms)
|
||||
✓ POST /execute-tool returns structured response (1446ms)
|
||||
✓ POST /execute-tool returns error for invalid tool (701ms)
|
||||
✓ CORS headers present (2ms)
|
||||
✓ OPTIONS preflight works (1ms)
|
||||
|
||||
Standard Standard Requirements:
|
||||
✓ GET /info returns 200 (3ms)
|
||||
✓ GET /info includes capabilities (1ms)
|
||||
✓ GET /info includes protocolVersion (1ms)
|
||||
✓ capabilities.isolation is valid (0ms)
|
||||
✓ Authentication enforced when configured (1926ms)
|
||||
✓ Execution timeout enforcement (1ms)
|
||||
✓ Structured error codes (744ms)
|
||||
|
||||
Summary:
|
||||
Tests: 15 passed, 0 failed, 15 total
|
||||
Core Compliance: PASS
|
||||
Standard Compliance: PASS
|
||||
```
|
||||
|
||||
### Capabilities
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Unsandbox Executor",
|
||||
"version": "1.0.0",
|
||||
"protocolVersion": "1.0",
|
||||
"capabilities": {
|
||||
"isolation": "container",
|
||||
"executionModes": ["sync"],
|
||||
"maxExecutionTimeMs": 120000,
|
||||
"maxRequestBodyBytes": 10485760,
|
||||
"supportsStreaming": false,
|
||||
"supportsCallbacks": false,
|
||||
"supportsCaching": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
See `templates/unsandbox-executor/README.md` for Unsandbox deployment instructions.
|
||||
|
||||
---
|
||||
|
||||
## Vercel Executor
|
||||
|
||||
**Location:** `templates/vercel-executor/`
|
||||
|
||||
### Capabilities
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Vercel Sandbox Executor",
|
||||
"version": "1.0.0",
|
||||
"protocolVersion": "1.0",
|
||||
"capabilities": {
|
||||
"isolation": "vm",
|
||||
"executionModes": ["sync"],
|
||||
"maxExecutionTimeMs": 120000,
|
||||
"maxRequestBodyBytes": 10485760,
|
||||
"supportsStreaming": false,
|
||||
"supportsCallbacks": false,
|
||||
"supportsCaching": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Deployment
|
||||
|
||||
```bash
|
||||
# Deploy to Vercel
|
||||
vercel
|
||||
|
||||
# Or link and deploy
|
||||
vercel link
|
||||
vercel deploy --prod
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
The Vercel Executor uses `@vercel/sandbox` which provides VM-level isolation (strongest isolation level). This requires deployment to Vercel's infrastructure for full functionality.
|
||||
|
||||
---
|
||||
|
||||
## Test Categories
|
||||
|
||||
### Core Requirements (Level 1) - 8 Tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| GET /health returns 200 | Health endpoint responds with 200 OK |
|
||||
| GET /health includes protocolVersion | Response contains `protocolVersion` field |
|
||||
| GET /health includes implementationVersion | Response contains `implementationVersion` field |
|
||||
| POST /execute-tool accepts valid request | Execute endpoint accepts well-formed requests |
|
||||
| POST /execute-tool returns structured response | Response includes `success`, `output`/`error`, `executionTimeMs` |
|
||||
| POST /execute-tool returns error for invalid tool | Returns error with code for nonexistent package |
|
||||
| CORS headers present | `Access-Control-Allow-Origin` header included |
|
||||
| OPTIONS preflight works | OPTIONS request returns CORS headers |
|
||||
|
||||
### Standard Requirements (Level 2) - 7 Tests
|
||||
|
||||
| Test | Description |
|
||||
|------|-------------|
|
||||
| GET /info returns 200 | Info endpoint responds with 200 OK |
|
||||
| GET /info includes capabilities | Response contains `capabilities` object |
|
||||
| GET /info includes protocolVersion | Response contains `protocolVersion` field |
|
||||
| capabilities.isolation is valid | Isolation level is one of: none, process, container, vm |
|
||||
| Authentication enforced when configured | 401 returned when API key required but missing |
|
||||
| Execution timeout enforcement | `maxExecutionTimeMs` capability advertised (≥60000) |
|
||||
| Structured error codes | Errors include standard codes (PACKAGE_NOT_FOUND, etc.) |
|
||||
|
||||
---
|
||||
|
||||
## Running Compliance Tests
|
||||
|
||||
### Using npx (Published)
|
||||
|
||||
```bash
|
||||
npx @tpmjs/executor-test https://your-executor.example.com
|
||||
```
|
||||
|
||||
### Using Local Build
|
||||
|
||||
```bash
|
||||
cd packages/executor-test
|
||||
pnpm build
|
||||
node bin/run.js https://your-executor.example.com
|
||||
```
|
||||
|
||||
### With Authentication
|
||||
|
||||
```bash
|
||||
npx @tpmjs/executor-test https://your-executor.example.com --api-key sk-xxx
|
||||
```
|
||||
|
||||
### JSON Output
|
||||
|
||||
```bash
|
||||
npx @tpmjs/executor-test https://your-executor.example.com --json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Specification Reference
|
||||
|
||||
- **EXECUTOR_SPECIFICATION.md** - Full protocol specification
|
||||
- **executor-openapi.yaml** - OpenAPI 3.0 specification
|
||||
- **packages/executor-test/** - Compliance test suite source
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### 2026-02-04
|
||||
|
||||
- Initial compliance testing
|
||||
- All 3 executors updated to v1.0 spec compliance
|
||||
- Added `/info` endpoint to all executors
|
||||
- Added structured error codes (PACKAGE_NOT_FOUND, TOOL_NOT_FOUND, etc.)
|
||||
- Added `protocolVersion` and `implementationVersion` to health responses
|
||||
- Added `X-TPMJS-Protocol-Version` header support
|
||||
496
EXECUTOR_SPECIFICATION.md
Normal file
496
EXECUTOR_SPECIFICATION.md
Normal file
|
|
@ -0,0 +1,496 @@
|
|||
# TPMJS Executor Protocol Specification v1.0
|
||||
|
||||
> **Status:** Draft
|
||||
> **Version:** 1.0.0
|
||||
> **Last Updated:** 2026-02-03
|
||||
|
||||
## Overview
|
||||
|
||||
The TPMJS Executor Protocol defines a standard HTTP interface for executing TPMJS tools. Executors are **compute adapters** that provide a consistent API for running npm-packaged tools regardless of the underlying infrastructure.
|
||||
|
||||
### Design Philosophy
|
||||
|
||||
- **HTTP-First:** No SDK lock-in, deployable anywhere
|
||||
- **Minimal Surface:** Small core, optional extensions
|
||||
- **Executor ≠ Sandbox:** Standardize coordination, not security
|
||||
- **Declare, Don't Enforce:** Executors report capabilities, TPMJS decides policy
|
||||
|
||||
### Relationship to Other Specs
|
||||
|
||||
| Spec | Purpose |
|
||||
|------|---------|
|
||||
| **MCP** | Model ↔ Tool interface |
|
||||
| **TPMJS Executor** | Tool ↔ Compute interface |
|
||||
| **TPMJS Tools** | Tool contract (separate spec) |
|
||||
|
||||
---
|
||||
|
||||
## Protocol Versioning
|
||||
|
||||
### Version Header
|
||||
|
||||
All requests SHOULD include:
|
||||
|
||||
```http
|
||||
X-TPMJS-Protocol-Version: 1.0
|
||||
```
|
||||
|
||||
Executors MUST respond with their supported protocol version in `/health` and `/info` responses.
|
||||
|
||||
**Rationale:** Header-based versioning enables graceful evolution without URL fragmentation.
|
||||
|
||||
---
|
||||
|
||||
## Specification Levels
|
||||
|
||||
### Level 1: Core (REQUIRED)
|
||||
|
||||
Every executor MUST implement:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/health` | GET | Liveness + protocol discovery |
|
||||
| `/execute-tool` | POST | Synchronous tool execution |
|
||||
|
||||
### Level 2: Standard (RECOMMENDED)
|
||||
|
||||
Executors SHOULD implement:
|
||||
|
||||
| Endpoint | Method | Purpose |
|
||||
|----------|--------|---------|
|
||||
| `/info` | GET | Capability advertisement |
|
||||
|
||||
Plus:
|
||||
- API key authentication
|
||||
- Structured error responses
|
||||
- Execution timeout enforcement
|
||||
- CORS headers
|
||||
|
||||
### Level 3: Extended (OPTIONAL)
|
||||
|
||||
Reserved for future versions:
|
||||
|
||||
- `POST /execute-tool` with `Accept: text/event-stream` (streaming)
|
||||
- `POST /execute-async` (webhook callbacks)
|
||||
- `POST /validate-tool` (dry-run validation)
|
||||
- `POST /execute-batch` (multiple tools)
|
||||
|
||||
---
|
||||
|
||||
## Core Endpoints
|
||||
|
||||
### GET /health
|
||||
|
||||
**Purpose:** Verify executor is running and discover protocol version.
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "ok",
|
||||
"protocolVersion": "1.0",
|
||||
"implementationVersion": "1.0.0",
|
||||
"runtime": "node",
|
||||
"timestamp": "2026-02-03T12:00:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `status` | string | Yes | Always `"ok"` if healthy |
|
||||
| `protocolVersion` | string | Yes | TPMJS protocol version (e.g., `"1.0"`) |
|
||||
| `implementationVersion` | string | Yes | Executor software version |
|
||||
| `runtime` | string | No | Runtime identifier (e.g., `"node"`, `"deno"`, `"bun"`) |
|
||||
| `timestamp` | string | No | ISO 8601 timestamp |
|
||||
|
||||
**Requirements:**
|
||||
- MUST respond within 1 second
|
||||
- MUST return 200 OK if healthy
|
||||
- MUST include `protocolVersion`
|
||||
|
||||
---
|
||||
|
||||
### POST /execute-tool
|
||||
|
||||
**Purpose:** Execute a single TPMJS tool synchronously.
|
||||
|
||||
**Request Headers:**
|
||||
|
||||
```http
|
||||
Content-Type: application/json
|
||||
Authorization: Bearer <api-key> (if auth enabled)
|
||||
X-TPMJS-Protocol-Version: 1.0
|
||||
```
|
||||
|
||||
**Request Body:**
|
||||
|
||||
```json
|
||||
{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"version": "latest",
|
||||
"name": "helloWorldTool",
|
||||
"params": {
|
||||
"greeting": "Hello"
|
||||
},
|
||||
"env": {
|
||||
"OPENAI_API_KEY": "sk-..."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Request Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `packageName` | string | Yes | npm package name |
|
||||
| `version` | string | No | Package version (default: `"latest"`) |
|
||||
| `name` | string | Yes | Tool export name |
|
||||
| `params` | object | No | Parameters passed to `tool.execute()` |
|
||||
| `env` | object | No | Environment variables for execution |
|
||||
|
||||
**Success Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"output": {
|
||||
"message": "Hello, World!"
|
||||
},
|
||||
"executionTimeMs": 1234
|
||||
}
|
||||
```
|
||||
|
||||
**Error Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "TOOL_EXECUTION_ERROR",
|
||||
"message": "Tool threw an error: Invalid input"
|
||||
},
|
||||
"executionTimeMs": 123
|
||||
}
|
||||
```
|
||||
|
||||
**Response Fields:**
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
|-------|------|----------|-------------|
|
||||
| `success` | boolean | Yes | Whether execution succeeded |
|
||||
| `output` | any | If success | Return value from `tool.execute()` |
|
||||
| `error` | object | If failed | Error details |
|
||||
| `error.code` | string | If failed | Machine-readable error code |
|
||||
| `error.message` | string | If failed | Human-readable error message |
|
||||
| `executionTimeMs` | number | Yes | Total execution time in milliseconds |
|
||||
|
||||
**Error Codes:**
|
||||
|
||||
| Code | Description |
|
||||
|------|-------------|
|
||||
| `PACKAGE_NOT_FOUND` | npm package could not be installed |
|
||||
| `TOOL_NOT_FOUND` | Named export not found in package |
|
||||
| `TOOL_INVALID` | Export exists but has no `.execute()` method |
|
||||
| `TOOL_EXECUTION_ERROR` | Tool threw during execution |
|
||||
| `EXECUTION_TIMEOUT` | Execution exceeded time limit |
|
||||
| `INTERNAL_ERROR` | Unexpected executor error |
|
||||
|
||||
---
|
||||
|
||||
## Standard Endpoints
|
||||
|
||||
### GET /info
|
||||
|
||||
**Purpose:** Advertise executor capabilities for intelligent routing.
|
||||
|
||||
**Response (200 OK):**
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "Railway Executor",
|
||||
"version": "1.0.0",
|
||||
"protocolVersion": "1.0",
|
||||
"capabilities": {
|
||||
"isolation": "process",
|
||||
"executionModes": ["sync"],
|
||||
"maxExecutionTimeMs": 120000,
|
||||
"maxRequestBodyBytes": 10485760,
|
||||
"supportsStreaming": false,
|
||||
"supportsCallbacks": false,
|
||||
"supportsCaching": false
|
||||
},
|
||||
"runtime": {
|
||||
"platform": "linux",
|
||||
"nodeVersion": "20.10.0",
|
||||
"region": "us-west-1"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Capability Fields:**
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `isolation` | string | `"none"` \| `"process"` \| `"container"` \| `"vm"` |
|
||||
| `executionModes` | array | `["sync"]` (future: `"stream"`, `"async"`) |
|
||||
| `maxExecutionTimeMs` | number | Maximum execution time before timeout |
|
||||
| `maxRequestBodyBytes` | number | Maximum request body size |
|
||||
| `supportsStreaming` | boolean | Reserved for v1.1 |
|
||||
| `supportsCallbacks` | boolean | Reserved for v1.1 |
|
||||
| `supportsCaching` | boolean | Reserved for v1.1 |
|
||||
|
||||
**Isolation Levels:**
|
||||
|
||||
| Level | Description |
|
||||
|-------|-------------|
|
||||
| `none` | Tools run in executor process (development only) |
|
||||
| `process` | Tools run in separate OS process |
|
||||
| `container` | Tools run in isolated container |
|
||||
| `vm` | Tools run in isolated VM (strongest) |
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
### v1.0: API Key Only
|
||||
|
||||
Executors MAY require authentication via Bearer token.
|
||||
|
||||
**Request Header:**
|
||||
|
||||
```http
|
||||
Authorization: Bearer <api-key>
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
|
||||
Executors SHOULD use `EXECUTOR_API_KEY` environment variable:
|
||||
- If set: All requests MUST include valid Bearer token
|
||||
- If unset: No authentication required
|
||||
|
||||
**Unauthorized Response (401):**
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "UNAUTHORIZED",
|
||||
"message": "Invalid or missing API key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Future Versions:** JWT, OAuth, and per-tool authentication are deferred to v1.1+.
|
||||
|
||||
---
|
||||
|
||||
## CORS Requirements
|
||||
|
||||
All executors MUST support CORS for browser-based clients.
|
||||
|
||||
**Required Headers:**
|
||||
|
||||
```http
|
||||
Access-Control-Allow-Origin: *
|
||||
Access-Control-Allow-Methods: GET, POST, OPTIONS
|
||||
Access-Control-Allow-Headers: Content-Type, Authorization, X-TPMJS-Protocol-Version
|
||||
```
|
||||
|
||||
**OPTIONS Preflight:**
|
||||
|
||||
All endpoints MUST handle OPTIONS requests and return CORS headers with 200 OK.
|
||||
|
||||
---
|
||||
|
||||
## Execution Lifecycle
|
||||
|
||||
### Standard Flow
|
||||
|
||||
1. **Receive Request:** Parse JSON body, validate required fields
|
||||
2. **Check Auth:** Verify API key if configured
|
||||
3. **Create Isolation:** Create temporary execution environment
|
||||
4. **Install Package:** Run `npm install <package>@<version>`
|
||||
5. **Load Tool:** Import package, resolve named export
|
||||
6. **Execute:** Call `tool.execute(params)` with environment
|
||||
7. **Capture Result:** Collect output or error
|
||||
8. **Cleanup:** Remove temporary files/processes
|
||||
9. **Respond:** Return JSON response
|
||||
|
||||
### Tool Resolution
|
||||
|
||||
Executors MUST resolve a callable tool with an `.execute()` method.
|
||||
|
||||
**Recommended Resolution Order:**
|
||||
|
||||
1. `pkg[name]` - Direct named export
|
||||
2. `pkg.default?.[name]` - Named property on default export
|
||||
3. `pkg.default` - Default export itself (if `name` matches)
|
||||
|
||||
**Factory Functions:**
|
||||
|
||||
If export is a function without `.execute()`:
|
||||
1. Try calling `tool()` with no arguments
|
||||
2. Check if result has `.execute()` method
|
||||
|
||||
**Note:** Tool export patterns are intentionally not fully standardized in v1.0 to allow ecosystem evolution.
|
||||
|
||||
---
|
||||
|
||||
## Timeouts
|
||||
|
||||
### Required Timeouts
|
||||
|
||||
| Phase | Minimum | Recommended |
|
||||
|-------|---------|-------------|
|
||||
| npm install | 30s | 60s |
|
||||
| Tool execution | 60s | 120s |
|
||||
| Total request | 90s | 180s |
|
||||
|
||||
Executors MUST:
|
||||
- Enforce execution timeouts
|
||||
- Return `EXECUTION_TIMEOUT` error code when exceeded
|
||||
- Clean up resources on timeout
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
### HTTP Status Codes
|
||||
|
||||
| Code | Usage |
|
||||
|------|-------|
|
||||
| 200 | Successful execution OR tool error (with `success: false`) |
|
||||
| 400 | Invalid request (missing fields, malformed JSON) |
|
||||
| 401 | Authentication required but missing/invalid |
|
||||
| 404 | Unknown endpoint |
|
||||
| 500 | Internal executor error |
|
||||
|
||||
### Structured Errors
|
||||
|
||||
All error responses MUST include:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": false,
|
||||
"error": {
|
||||
"code": "ERROR_CODE",
|
||||
"message": "Human-readable description"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation Checklist
|
||||
|
||||
### Core (Required for Compliance)
|
||||
|
||||
- [ ] `GET /health` returns status and protocol version
|
||||
- [ ] `POST /execute-tool` accepts standard request format
|
||||
- [ ] Returns `{ success, output/error, executionTimeMs }`
|
||||
- [ ] Handles missing/invalid request body (400)
|
||||
- [ ] CORS headers on all responses
|
||||
- [ ] OPTIONS preflight handling
|
||||
|
||||
### Standard (Recommended)
|
||||
|
||||
- [ ] `GET /info` with capabilities
|
||||
- [ ] `EXECUTOR_API_KEY` environment variable support
|
||||
- [ ] Bearer token validation (401 on failure)
|
||||
- [ ] Execution timeout enforcement
|
||||
- [ ] npm install timeout (60s recommended)
|
||||
- [ ] Temporary file cleanup
|
||||
- [ ] Structured error codes
|
||||
|
||||
### Extended (Optional)
|
||||
|
||||
- [ ] Package caching
|
||||
- [ ] Concurrent execution limiting
|
||||
- [ ] Support for both `/path` and `/api/path` routes
|
||||
- [ ] Region/metadata in `/info` response
|
||||
|
||||
---
|
||||
|
||||
## Compliance Testing
|
||||
|
||||
Use the official compliance test suite:
|
||||
|
||||
```bash
|
||||
npx @tpmjs/executor-test https://my-executor.example.com
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
TPMJS Executor Compliance Test v1.0.0
|
||||
Target: https://my-executor.example.com
|
||||
|
||||
Core Requirements:
|
||||
✓ GET /health returns 200
|
||||
✓ GET /health includes protocolVersion
|
||||
✓ POST /execute-tool accepts valid request
|
||||
✓ POST /execute-tool returns success response
|
||||
✓ POST /execute-tool returns error for invalid tool
|
||||
✓ CORS headers present
|
||||
✓ OPTIONS preflight works
|
||||
|
||||
Standard Requirements:
|
||||
✓ GET /info returns capabilities
|
||||
✓ Authentication enforced when configured
|
||||
✓ Execution timeout enforced
|
||||
✗ Missing: maxExecutionTimeMs in capabilities
|
||||
|
||||
Result: 10/11 tests passed (Core: PASS, Standard: PARTIAL)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Reference Implementations
|
||||
|
||||
| Name | Platform | Isolation | Source |
|
||||
|------|----------|-----------|--------|
|
||||
| Railway Executor | Railway | Process | `templates/railway-executor/` |
|
||||
| Vercel Executor | Vercel | VM (Sandbox) | `templates/vercel-executor/` |
|
||||
| Unsandbox Executor | Unsandbox | Container | `templates/unsandbox-executor/` |
|
||||
|
||||
---
|
||||
|
||||
## Future Roadmap
|
||||
|
||||
### v1.1 (Planned)
|
||||
|
||||
- Streaming responses (`Accept: text/event-stream`)
|
||||
- Async execution with webhooks
|
||||
- Caching hints (`X-TPMJS-Cache-*` headers)
|
||||
- Tool validation endpoint
|
||||
|
||||
### v2.0 (Exploration)
|
||||
|
||||
- Multi-tool batch execution
|
||||
- Persistent execution contexts
|
||||
- Resource quotas and billing hooks
|
||||
- MCP bridge protocol
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v1.0.0 (2026-02-03)
|
||||
|
||||
- Initial formal specification
|
||||
- Core: `/health`, `/execute-tool`
|
||||
- Standard: `/info`, API key auth
|
||||
- Capability negotiation
|
||||
- Compliance test suite
|
||||
|
||||
---
|
||||
|
||||
## Appendix: OpenAPI Specification
|
||||
|
||||
See `executor-openapi.yaml` for the formal OpenAPI 3.0 specification.
|
||||
|
||||
## Appendix: JSON Schemas
|
||||
|
||||
See `packages/types/src/executor.ts` for TypeScript types and Zod schemas.
|
||||
1
apps/omega-mac/.gitignore
vendored
Normal file
1
apps/omega-mac/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
.build
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"colors": [
|
||||
{
|
||||
"color": {
|
||||
"color-space": "srgb",
|
||||
"components": {
|
||||
"alpha": "1.000",
|
||||
"blue": "0.996",
|
||||
"green": "0.475",
|
||||
"red": "0.325"
|
||||
}
|
||||
},
|
||||
"idiom": "universal"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "16x16"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "32x32"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "128x128"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "256x256"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "1x",
|
||||
"size": "512x512"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"scale": "2x",
|
||||
"size": "512x512"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
6
apps/omega-mac/OmegaMac/Assets.xcassets/Contents.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
41
apps/omega-mac/OmegaMac/Models/Conversation.swift
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class Conversation {
|
||||
var id: UUID
|
||||
var title: String?
|
||||
var createdAt: Date
|
||||
var updatedAt: Date
|
||||
var executionState: String // "idle" | "running"
|
||||
var inputTokensTotal: Int
|
||||
var outputTokensTotal: Int
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \Message.conversation)
|
||||
var messages: [Message]
|
||||
|
||||
@Relationship(deleteRule: .cascade, inverse: \ToolCallRecord.conversation)
|
||||
var toolRuns: [ToolCallRecord]
|
||||
|
||||
init(
|
||||
title: String? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.title = title
|
||||
self.createdAt = Date()
|
||||
self.updatedAt = Date()
|
||||
self.executionState = "idle"
|
||||
self.inputTokensTotal = 0
|
||||
self.outputTokensTotal = 0
|
||||
self.messages = []
|
||||
self.toolRuns = []
|
||||
}
|
||||
|
||||
var displayTitle: String {
|
||||
title ?? "New Conversation"
|
||||
}
|
||||
|
||||
var sortedMessages: [Message] {
|
||||
messages.sorted { $0.createdAt < $1.createdAt }
|
||||
}
|
||||
}
|
||||
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
18
apps/omega-mac/OmegaMac/Models/EnvVar.swift
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class EnvVar {
|
||||
var id: UUID
|
||||
var keyName: String
|
||||
/// Last 4 characters of the value (for display hint)
|
||||
var valueHint: String
|
||||
var createdAt: Date
|
||||
|
||||
init(keyName: String, valueHint: String) {
|
||||
self.id = UUID()
|
||||
self.keyName = keyName
|
||||
self.valueHint = valueHint
|
||||
self.createdAt = Date()
|
||||
}
|
||||
}
|
||||
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
141
apps/omega-mac/OmegaMac/Models/Message.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
enum MessageRole: String, Codable {
|
||||
case user = "USER"
|
||||
case assistant = "ASSISTANT"
|
||||
case tool = "TOOL"
|
||||
case system = "SYSTEM"
|
||||
}
|
||||
|
||||
@Model
|
||||
final class Message {
|
||||
var id: UUID
|
||||
var role: MessageRole
|
||||
var content: String
|
||||
var createdAt: Date
|
||||
var inputTokens: Int?
|
||||
var outputTokens: Int?
|
||||
|
||||
/// JSON-encoded array of tool calls (for assistant messages)
|
||||
var toolCallsJSON: Data?
|
||||
|
||||
var conversation: Conversation?
|
||||
|
||||
init(
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
conversation: Conversation? = nil,
|
||||
inputTokens: Int? = nil,
|
||||
outputTokens: Int? = nil,
|
||||
toolCalls: [ToolCallData]? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.role = role
|
||||
self.content = content
|
||||
self.createdAt = Date()
|
||||
self.inputTokens = inputTokens
|
||||
self.outputTokens = outputTokens
|
||||
self.conversation = conversation
|
||||
if let toolCalls {
|
||||
self.toolCallsJSON = try? JSONEncoder().encode(toolCalls)
|
||||
}
|
||||
}
|
||||
|
||||
var toolCalls: [ToolCallData] {
|
||||
get {
|
||||
guard let data = toolCallsJSON else { return [] }
|
||||
return (try? JSONDecoder().decode([ToolCallData].self, from: data)) ?? []
|
||||
}
|
||||
set {
|
||||
toolCallsJSON = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializable tool call data stored in messages
|
||||
struct ToolCallData: Codable, Identifiable {
|
||||
var id: String { toolCallId }
|
||||
let toolCallId: String
|
||||
let toolName: String
|
||||
let args: JSONValue?
|
||||
let output: JSONValue?
|
||||
|
||||
init(toolCallId: String, toolName: String, args: JSONValue? = nil, output: JSONValue? = nil) {
|
||||
self.toolCallId = toolCallId
|
||||
self.toolName = toolName
|
||||
self.args = args
|
||||
self.output = output
|
||||
}
|
||||
}
|
||||
|
||||
/// A type-erased JSON value for encoding/decoding arbitrary JSON
|
||||
enum JSONValue: Codable, Equatable, Sendable {
|
||||
case string(String)
|
||||
case number(Double)
|
||||
case bool(Bool)
|
||||
case object([String: JSONValue])
|
||||
case array([JSONValue])
|
||||
case null
|
||||
|
||||
init(from decoder: Decoder) throws {
|
||||
let container = try decoder.singleValueContainer()
|
||||
if container.decodeNil() {
|
||||
self = .null
|
||||
} else if let b = try? container.decode(Bool.self) {
|
||||
self = .bool(b)
|
||||
} else if let n = try? container.decode(Double.self) {
|
||||
self = .number(n)
|
||||
} else if let s = try? container.decode(String.self) {
|
||||
self = .string(s)
|
||||
} else if let arr = try? container.decode([JSONValue].self) {
|
||||
self = .array(arr)
|
||||
} else if let obj = try? container.decode([String: JSONValue].self) {
|
||||
self = .object(obj)
|
||||
} else {
|
||||
self = .null
|
||||
}
|
||||
}
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .string(let s): try container.encode(s)
|
||||
case .number(let n): try container.encode(n)
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .object(let o): try container.encode(o)
|
||||
case .array(let a): try container.encode(a)
|
||||
case .null: try container.encodeNil()
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert any Codable/Sendable value to JSONValue
|
||||
static func from(_ value: Any) -> JSONValue {
|
||||
if let s = value as? String { return .string(s) }
|
||||
if let n = value as? NSNumber {
|
||||
if CFBooleanGetTypeID() == CFGetTypeID(n) {
|
||||
return .bool(n.boolValue)
|
||||
}
|
||||
return .number(n.doubleValue)
|
||||
}
|
||||
if let b = value as? Bool { return .bool(b) }
|
||||
if let i = value as? Int { return .number(Double(i)) }
|
||||
if let d = value as? Double { return .number(d) }
|
||||
if let arr = value as? [Any] { return .array(arr.map { from($0) }) }
|
||||
if let obj = value as? [String: Any] {
|
||||
return .object(obj.mapValues { from($0) })
|
||||
}
|
||||
return .null
|
||||
}
|
||||
|
||||
/// Pretty-print JSON
|
||||
var prettyString: String {
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
guard let data = try? encoder.encode(self),
|
||||
let str = String(data: data, encoding: .utf8) else {
|
||||
return "null"
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
51
apps/omega-mac/OmegaMac/Models/ToolCallRecord.swift
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class ToolCallRecord {
|
||||
var id: UUID
|
||||
var toolName: String
|
||||
var toolCallId: String
|
||||
var status: String // "running" | "success" | "error"
|
||||
var inputJSON: Data?
|
||||
var outputJSON: Data?
|
||||
var errorMessage: String?
|
||||
var executionTimeMs: Int?
|
||||
var createdAt: Date
|
||||
var completedAt: Date?
|
||||
|
||||
var conversation: Conversation?
|
||||
|
||||
init(
|
||||
toolName: String,
|
||||
toolCallId: String,
|
||||
conversation: Conversation? = nil
|
||||
) {
|
||||
self.id = UUID()
|
||||
self.toolName = toolName
|
||||
self.toolCallId = toolCallId
|
||||
self.status = "running"
|
||||
self.createdAt = Date()
|
||||
self.conversation = conversation
|
||||
}
|
||||
|
||||
var input: JSONValue? {
|
||||
get {
|
||||
guard let data = inputJSON else { return nil }
|
||||
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
||||
}
|
||||
set {
|
||||
inputJSON = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
}
|
||||
|
||||
var output: JSONValue? {
|
||||
get {
|
||||
guard let data = outputJSON else { return nil }
|
||||
return try? JSONDecoder().decode(JSONValue.self, from: data)
|
||||
}
|
||||
set {
|
||||
outputJSON = try? JSONEncoder().encode(newValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
17
apps/omega-mac/OmegaMac/Models/UserSettings.swift
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
@Model
|
||||
final class UserSettings {
|
||||
var id: UUID
|
||||
var systemPrompt: String?
|
||||
var selectedModel: String
|
||||
var pinnedToolIds: [String]
|
||||
|
||||
init() {
|
||||
self.id = UUID()
|
||||
self.systemPrompt = nil
|
||||
self.selectedModel = "gpt-4.1-mini"
|
||||
self.pinnedToolIds = []
|
||||
}
|
||||
}
|
||||
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
14
apps/omega-mac/OmegaMac/OmegaMac.entitlements
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>keychain-access-groups</key>
|
||||
<array>
|
||||
<string>$(AppIdentifierPrefix)com.tpmjs.omega-mac</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
44
apps/omega-mac/OmegaMac/OmegaMacApp.swift
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct OmegaMacApp: App {
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
.modelContainer(for: [
|
||||
Conversation.self,
|
||||
Message.self,
|
||||
ToolCallRecord.self,
|
||||
EnvVar.self,
|
||||
UserSettings.self,
|
||||
])
|
||||
.defaultSize(width: 1100, height: 750)
|
||||
.commands {
|
||||
CommandGroup(replacing: .newItem) {
|
||||
Button("New Conversation") {
|
||||
NotificationCenter.default.post(
|
||||
name: .newConversation, object: nil)
|
||||
}
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
}
|
||||
}
|
||||
|
||||
#if os(macOS)
|
||||
Settings {
|
||||
SettingsView()
|
||||
.modelContainer(for: [
|
||||
EnvVar.self,
|
||||
UserSettings.self,
|
||||
])
|
||||
.preferredColorScheme(.dark)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let newConversation = Notification.Name("newConversation")
|
||||
}
|
||||
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
613
apps/omega-mac/OmegaMac/Services/ChatOrchestrator.swift
Normal file
|
|
@ -0,0 +1,613 @@
|
|||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
/// Represents a live tool call being displayed during streaming
|
||||
struct LiveToolCall: Identifiable, Sendable {
|
||||
let id: String // toolCallId
|
||||
let toolName: String
|
||||
var arguments: String
|
||||
var status: String // "running" | "success" | "error"
|
||||
var output: JSONValue?
|
||||
}
|
||||
|
||||
/// Main orchestrator for the Omega agentic chat loop.
|
||||
/// Coordinates between OpenAI, TPMJS registry, and SwiftData persistence.
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ChatOrchestrator {
|
||||
// MARK: - Published State
|
||||
|
||||
var streamingContent: String = ""
|
||||
var isStreaming: Bool = false
|
||||
var liveToolCalls: [LiveToolCall] = []
|
||||
var error: String?
|
||||
|
||||
// MARK: - Private State
|
||||
|
||||
private let openAI = OpenAIService()
|
||||
private let registry = TPMJSRegistryService()
|
||||
|
||||
/// Dynamically loaded tools for the current conversation (sanitizedName -> ToolMeta)
|
||||
private var loadedTools: [String: ToolMeta] = [:]
|
||||
|
||||
/// Maximum agentic loop iterations (search -> execute -> respond)
|
||||
private let maxIterations = 10
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// Send a user message and run the full agentic loop.
|
||||
/// Streams the response, handles tool calls, and persists everything to SwiftData.
|
||||
func sendMessage(
|
||||
_ text: String,
|
||||
conversation: Conversation,
|
||||
modelContext: ModelContext
|
||||
) async {
|
||||
// Reset state
|
||||
streamingContent = ""
|
||||
isStreaming = true
|
||||
liveToolCalls = []
|
||||
error = nil
|
||||
|
||||
// Get API key
|
||||
guard let apiKey = KeychainService.load(key: "OPENAI_API_KEY"), !apiKey.isEmpty else {
|
||||
error = "No OpenAI API key set. Open Settings (Cmd+,) to add your key."
|
||||
isStreaming = false
|
||||
return
|
||||
}
|
||||
|
||||
// Load user settings
|
||||
let settingsDescriptor = FetchDescriptor<UserSettings>()
|
||||
let settings = (try? modelContext.fetch(settingsDescriptor))?.first
|
||||
|
||||
let model = settings?.selectedModel ?? "gpt-4.1-mini"
|
||||
let customPrompt = settings?.systemPrompt
|
||||
let pinnedToolIds = settings?.pinnedToolIds ?? []
|
||||
|
||||
// Save user message
|
||||
let userMessage = Message(role: .user, content: text, conversation: conversation)
|
||||
modelContext.insert(userMessage)
|
||||
conversation.updatedAt = Date()
|
||||
conversation.executionState = "running"
|
||||
try? modelContext.save()
|
||||
|
||||
// Load env vars from Keychain
|
||||
let envVarDescriptor = FetchDescriptor<EnvVar>()
|
||||
let envVarRecords = (try? modelContext.fetch(envVarDescriptor)) ?? []
|
||||
let envVars = KeychainService.loadAllEnvVars(keyNames: envVarRecords.map(\.keyName))
|
||||
|
||||
// Auto-discover tools via BM25 search
|
||||
do {
|
||||
let relevantTools = try await registry.searchTools(query: text, limit: 10)
|
||||
for toolMeta in relevantTools {
|
||||
let sanitized = sanitizeToolName(toolMeta.toolId)
|
||||
if loadedTools[sanitized] == nil {
|
||||
loadedTools[sanitized] = toolMeta
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: continue without auto-discovered tools
|
||||
print("Auto-discovery failed: \(error)")
|
||||
}
|
||||
|
||||
// Build messages array from conversation history
|
||||
var chatMessages = buildChatMessages(
|
||||
conversation: conversation,
|
||||
customPrompt: customPrompt,
|
||||
pinnedToolIds: pinnedToolIds
|
||||
)
|
||||
|
||||
// Add the new user message
|
||||
chatMessages.append(.user(text))
|
||||
|
||||
// Build tools list
|
||||
let tools = buildToolsList()
|
||||
|
||||
// Agentic loop
|
||||
var iteration = 0
|
||||
var allToolCallData: [ToolCallData] = []
|
||||
var allToolResultData: [ToolCallData] = []
|
||||
var totalInputTokens = 0
|
||||
var totalOutputTokens = 0
|
||||
|
||||
while iteration < maxIterations {
|
||||
iteration += 1
|
||||
|
||||
var currentContent = ""
|
||||
var pendingToolCalls: [ChatToolCall] = []
|
||||
var receivedDone = false
|
||||
|
||||
do {
|
||||
let stream = await openAI.streamCompletion(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
messages: chatMessages,
|
||||
tools: tools.isEmpty ? nil : tools
|
||||
)
|
||||
|
||||
for try await event in stream {
|
||||
switch event {
|
||||
case .contentDelta(let delta):
|
||||
currentContent += delta
|
||||
streamingContent = currentContent
|
||||
|
||||
case .toolCallStarted(_, let id, let name):
|
||||
let liveTC = LiveToolCall(
|
||||
id: id,
|
||||
toolName: name,
|
||||
arguments: "",
|
||||
status: "running"
|
||||
)
|
||||
liveToolCalls.append(liveTC)
|
||||
|
||||
case .toolCallArgumentDelta(let index, let delta):
|
||||
if index < liveToolCalls.count {
|
||||
liveToolCalls[index].arguments += delta
|
||||
}
|
||||
|
||||
case .toolCallComplete(let toolCall):
|
||||
pendingToolCalls.append(toolCall)
|
||||
|
||||
case .usage(let input, let output):
|
||||
totalInputTokens += input
|
||||
totalOutputTokens += output
|
||||
|
||||
case .done:
|
||||
receivedDone = true
|
||||
|
||||
case .error(let msg):
|
||||
self.error = msg
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
self.error = error.localizedDescription
|
||||
break
|
||||
}
|
||||
|
||||
// If we got content with no tool calls, we're done
|
||||
if pendingToolCalls.isEmpty {
|
||||
streamingContent = currentContent
|
||||
break
|
||||
}
|
||||
|
||||
// Process tool calls
|
||||
// Add assistant message with tool calls to chat history
|
||||
chatMessages.append(.assistant(
|
||||
content: currentContent.isEmpty ? nil : currentContent,
|
||||
toolCalls: pendingToolCalls
|
||||
))
|
||||
|
||||
// Execute each tool call
|
||||
for toolCall in pendingToolCalls {
|
||||
let tcData = ToolCallData(
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.toolName,
|
||||
args: .object(toolCall.parsedArguments)
|
||||
)
|
||||
allToolCallData.append(tcData)
|
||||
|
||||
// Record tool run
|
||||
let record = ToolCallRecord(
|
||||
toolName: toolCall.toolName,
|
||||
toolCallId: toolCall.id,
|
||||
conversation: conversation
|
||||
)
|
||||
record.input = .object(toolCall.parsedArguments)
|
||||
modelContext.insert(record)
|
||||
|
||||
let result = await executeToolCall(
|
||||
toolCall: toolCall,
|
||||
envVars: envVars
|
||||
)
|
||||
|
||||
// Update live tool call status
|
||||
if let idx = liveToolCalls.firstIndex(where: { $0.id == toolCall.id }) {
|
||||
liveToolCalls[idx].status = result.isError ? "error" : "success"
|
||||
liveToolCalls[idx].output = result.output
|
||||
}
|
||||
|
||||
// Update record
|
||||
record.output = result.output
|
||||
record.status = result.isError ? "error" : "success"
|
||||
record.completedAt = Date()
|
||||
|
||||
// Add tool result to chat messages
|
||||
let resultJSON: String
|
||||
if let data = try? JSONEncoder().encode(result.output) {
|
||||
resultJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||
} else {
|
||||
resultJSON = "{}"
|
||||
}
|
||||
|
||||
chatMessages.append(.toolResult(
|
||||
toolCallId: toolCall.id,
|
||||
name: toolCall.toolName,
|
||||
content: resultJSON
|
||||
))
|
||||
|
||||
let trData = ToolCallData(
|
||||
toolCallId: toolCall.id,
|
||||
toolName: toolCall.toolName,
|
||||
args: .object(toolCall.parsedArguments),
|
||||
output: result.output
|
||||
)
|
||||
allToolResultData.append(trData)
|
||||
}
|
||||
|
||||
// Reset streaming for next iteration
|
||||
streamingContent = ""
|
||||
liveToolCalls = []
|
||||
}
|
||||
|
||||
// Save assistant message
|
||||
let assistantMessage = Message(
|
||||
role: .assistant,
|
||||
content: streamingContent,
|
||||
conversation: conversation,
|
||||
inputTokens: totalInputTokens,
|
||||
outputTokens: totalOutputTokens,
|
||||
toolCalls: allToolCallData.isEmpty ? nil : allToolCallData
|
||||
)
|
||||
modelContext.insert(assistantMessage)
|
||||
|
||||
// Save tool results as a TOOL message if we had tool calls
|
||||
if !allToolResultData.isEmpty {
|
||||
let toolMessage = Message(
|
||||
role: .tool,
|
||||
content: "Tool results",
|
||||
conversation: conversation,
|
||||
toolCalls: allToolResultData
|
||||
)
|
||||
modelContext.insert(toolMessage)
|
||||
}
|
||||
|
||||
// Update conversation
|
||||
conversation.executionState = "idle"
|
||||
conversation.inputTokensTotal += totalInputTokens
|
||||
conversation.outputTokensTotal += totalOutputTokens
|
||||
conversation.updatedAt = Date()
|
||||
|
||||
// Auto-title from first message
|
||||
if conversation.title == nil {
|
||||
let title = text.count > 50 ? String(text.prefix(50)) + "..." : text
|
||||
conversation.title = title
|
||||
}
|
||||
|
||||
try? modelContext.save()
|
||||
|
||||
isStreaming = false
|
||||
}
|
||||
|
||||
/// Clear loaded tools (when switching conversations)
|
||||
func resetConversation() {
|
||||
loadedTools = [:]
|
||||
streamingContent = ""
|
||||
isStreaming = false
|
||||
liveToolCalls = []
|
||||
error = nil
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
private struct ToolResult {
|
||||
let output: JSONValue
|
||||
let isError: Bool
|
||||
}
|
||||
|
||||
private func executeToolCall(
|
||||
toolCall: ChatToolCall,
|
||||
envVars: [String: String]
|
||||
) async -> ToolResult {
|
||||
let name = toolCall.toolName
|
||||
let args = toolCall.parsedArguments
|
||||
|
||||
// Handle registrySearch
|
||||
if name == "registrySearch" {
|
||||
return await handleRegistrySearch(args: args)
|
||||
}
|
||||
|
||||
// Handle registryExecute
|
||||
if name == "registryExecute" {
|
||||
return await handleRegistryExecute(args: args, envVars: envVars)
|
||||
}
|
||||
|
||||
// Handle dynamic tools (loaded from search)
|
||||
if let toolMeta = loadedTools[name] {
|
||||
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
|
||||
}
|
||||
|
||||
// Also check by finding the tool ID from the sanitized name
|
||||
if let toolId = findToolId(sanitizedName: name, in: loadedTools),
|
||||
let toolMeta = loadedTools.values.first(where: { $0.toolId == toolId }) {
|
||||
return await handleDynamicTool(meta: toolMeta, args: args, envVars: envVars)
|
||||
}
|
||||
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string("Unknown tool: \(name)"),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
|
||||
private func handleRegistrySearch(args: [String: JSONValue]) async -> ToolResult {
|
||||
guard case .string(let query) = args["query"] else {
|
||||
return ToolResult(
|
||||
output: .object(["error": .bool(true), "message": .string("Missing 'query' parameter")]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
|
||||
let limit: Int
|
||||
if case .number(let n) = args["limit"] {
|
||||
limit = Int(n)
|
||||
} else {
|
||||
limit = 5
|
||||
}
|
||||
|
||||
do {
|
||||
let tools = try await registry.searchTools(query: query, limit: limit)
|
||||
|
||||
// Inject found tools into loaded tools
|
||||
for toolMeta in tools {
|
||||
let sanitized = sanitizeToolName(toolMeta.toolId)
|
||||
if loadedTools[sanitized] == nil {
|
||||
loadedTools[sanitized] = toolMeta
|
||||
}
|
||||
}
|
||||
|
||||
let toolsJSON: [JSONValue] = tools.map { t in
|
||||
.object([
|
||||
"toolId": .string(t.toolId),
|
||||
"name": .string(t.name),
|
||||
"package": .string(t.packageName),
|
||||
"description": .string(t.description),
|
||||
])
|
||||
}
|
||||
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"query": .string(query),
|
||||
"matchCount": .number(Double(tools.count)),
|
||||
"tools": .array(toolsJSON),
|
||||
]),
|
||||
isError: false
|
||||
)
|
||||
} catch {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(error.localizedDescription),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleRegistryExecute(
|
||||
args: [String: JSONValue],
|
||||
envVars: [String: String]
|
||||
) async -> ToolResult {
|
||||
guard case .string(let toolId) = args["toolId"] else {
|
||||
return ToolResult(
|
||||
output: .object(["error": .bool(true), "message": .string("Missing 'toolId' parameter")]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
|
||||
let params = args["params"] ?? .object([:])
|
||||
|
||||
do {
|
||||
let response = try await registry.executeByToolId(
|
||||
toolId: toolId,
|
||||
params: params,
|
||||
env: envVars
|
||||
)
|
||||
|
||||
if response.success {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"toolId": .string(toolId),
|
||||
"executionTimeMs": .number(Double(response.executionTimeMs ?? 0)),
|
||||
"output": response.output ?? .null,
|
||||
]),
|
||||
isError: false
|
||||
)
|
||||
} else {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(response.error ?? "Tool execution failed"),
|
||||
"toolId": .string(toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(error.localizedDescription),
|
||||
"toolId": .string(toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleDynamicTool(
|
||||
meta: ToolMeta,
|
||||
args: [String: JSONValue],
|
||||
envVars: [String: String]
|
||||
) async -> ToolResult {
|
||||
do {
|
||||
let response = try await registry.executeTool(
|
||||
packageName: meta.packageName,
|
||||
name: meta.name,
|
||||
version: meta.version,
|
||||
importUrl: meta.importUrl,
|
||||
params: .object(args),
|
||||
env: envVars
|
||||
)
|
||||
|
||||
if response.success {
|
||||
return ToolResult(
|
||||
output: response.output ?? .null,
|
||||
isError: false
|
||||
)
|
||||
} else {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(response.error ?? "Tool execution failed"),
|
||||
"toolId": .string(meta.toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
return ToolResult(
|
||||
output: .object([
|
||||
"error": .bool(true),
|
||||
"message": .string(error.localizedDescription),
|
||||
"toolId": .string(meta.toolId),
|
||||
]),
|
||||
isError: true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build chat messages from conversation history
|
||||
private func buildChatMessages(
|
||||
conversation: Conversation,
|
||||
customPrompt: String?,
|
||||
pinnedToolIds: [String]
|
||||
) -> [ChatMessage] {
|
||||
var messages: [ChatMessage] = []
|
||||
|
||||
// System prompt
|
||||
let systemPrompt = SystemPromptBuilder.build(
|
||||
customSystemPrompt: customPrompt,
|
||||
pinnedToolIds: pinnedToolIds,
|
||||
loadedTools: loadedTools
|
||||
)
|
||||
messages.append(.system(systemPrompt))
|
||||
|
||||
// Last 20 messages from conversation history
|
||||
let sorted = conversation.sortedMessages
|
||||
let recent = sorted.suffix(20)
|
||||
|
||||
for msg in recent {
|
||||
switch msg.role {
|
||||
case .user:
|
||||
messages.append(.user(msg.content))
|
||||
|
||||
case .assistant:
|
||||
let toolCalls = msg.toolCalls
|
||||
if !toolCalls.isEmpty {
|
||||
let chatToolCalls = toolCalls.map { tc in
|
||||
ChatToolCall(
|
||||
id: tc.toolCallId,
|
||||
type: "function",
|
||||
function: ChatToolCallFunction(
|
||||
name: tc.toolName,
|
||||
arguments: {
|
||||
if let args = tc.args,
|
||||
let data = try? JSONEncoder().encode(args) {
|
||||
return String(data: data, encoding: .utf8) ?? "{}"
|
||||
}
|
||||
return "{}"
|
||||
}()
|
||||
)
|
||||
)
|
||||
}
|
||||
messages.append(.assistant(content: msg.content, toolCalls: chatToolCalls))
|
||||
} else {
|
||||
messages.append(.assistant(content: msg.content, toolCalls: nil))
|
||||
}
|
||||
|
||||
case .tool:
|
||||
for tc in msg.toolCalls {
|
||||
let outputJSON: String
|
||||
if let output = tc.output,
|
||||
let data = try? JSONEncoder().encode(output) {
|
||||
outputJSON = String(data: data, encoding: .utf8) ?? "{}"
|
||||
} else {
|
||||
outputJSON = "{}"
|
||||
}
|
||||
messages.append(.toolResult(
|
||||
toolCallId: tc.toolCallId,
|
||||
name: tc.toolName,
|
||||
content: outputJSON
|
||||
))
|
||||
}
|
||||
|
||||
case .system:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
/// Build the OpenAI tools array from static + dynamic tools
|
||||
private func buildToolsList() -> [ChatTool] {
|
||||
var tools: [ChatTool] = []
|
||||
|
||||
// Static: registrySearch
|
||||
tools.append(ChatTool(
|
||||
function: ChatFunction(
|
||||
name: "registrySearch",
|
||||
description: "Search the TPMJS tool registry to find AI SDK tools. Use this to discover tools for any task. Returns toolIds that can be executed with registryExecute.",
|
||||
parameters: JSONSchemaObject(
|
||||
type: "object",
|
||||
properties: [
|
||||
"query": JSONSchemaProperty(
|
||||
type: "string",
|
||||
description: "Search query (keywords, tool names, descriptions)"
|
||||
),
|
||||
"limit": JSONSchemaProperty(
|
||||
type: "number",
|
||||
description: "Maximum number of results (1-20, default 5)",
|
||||
minimum: 1,
|
||||
maximum: 20
|
||||
),
|
||||
],
|
||||
required: ["query"],
|
||||
additionalProperties: false
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
// Static: registryExecute
|
||||
tools.append(ChatTool(
|
||||
function: ChatFunction(
|
||||
name: "registryExecute",
|
||||
description: "Execute a tool from the TPMJS registry. Use registrySearch first to find the toolId. Tools run in a secure sandbox.",
|
||||
parameters: JSONSchemaObject(
|
||||
type: "object",
|
||||
properties: [
|
||||
"toolId": JSONSchemaProperty(
|
||||
type: "string",
|
||||
description: "Tool identifier from registrySearch (format: 'package::name')"
|
||||
),
|
||||
"params": JSONSchemaProperty(
|
||||
type: "object",
|
||||
description: "Parameters to pass to the tool",
|
||||
additionalProperties: .bool(true)
|
||||
),
|
||||
],
|
||||
required: ["toolId", "params"],
|
||||
additionalProperties: false
|
||||
)
|
||||
)
|
||||
))
|
||||
|
||||
// Dynamic tools
|
||||
for (_, meta) in loadedTools {
|
||||
tools.append(meta.toChatTool())
|
||||
}
|
||||
|
||||
return tools
|
||||
}
|
||||
}
|
||||
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
124
apps/omega-mac/OmegaMac/Services/KeychainService.swift
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Wrapper around macOS Keychain for storing API keys and env var values securely.
|
||||
enum KeychainService {
|
||||
private static let serviceName = "com.tpmjs.omega-mac"
|
||||
|
||||
/// Save or update a value in the Keychain
|
||||
static func save(key: String, value: String) throws {
|
||||
guard let data = value.data(using: .utf8) else {
|
||||
throw KeychainError.encodingFailed
|
||||
}
|
||||
|
||||
// Check if item exists
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
|
||||
let status = SecItemCopyMatching(query as CFDictionary, nil)
|
||||
|
||||
if status == errSecSuccess {
|
||||
// Update existing
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
]
|
||||
let updateStatus = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
guard updateStatus == errSecSuccess else {
|
||||
throw KeychainError.unhandledError(updateStatus)
|
||||
}
|
||||
} else if status == errSecItemNotFound {
|
||||
// Add new
|
||||
var addQuery = query
|
||||
addQuery[kSecValueData as String] = data
|
||||
let addStatus = SecItemAdd(addQuery as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw KeychainError.unhandledError(addStatus)
|
||||
}
|
||||
} else {
|
||||
throw KeychainError.unhandledError(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieve a value from the Keychain
|
||||
static func load(key: String) -> String? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
kSecReturnData as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let data = result as? Data,
|
||||
let value = String(data: data, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/// Delete a value from the Keychain
|
||||
static func delete(key: String) throws {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecAttrAccount as String: key,
|
||||
]
|
||||
|
||||
let status = SecItemDelete(query as CFDictionary)
|
||||
guard status == errSecSuccess || status == errSecItemNotFound else {
|
||||
throw KeychainError.unhandledError(status)
|
||||
}
|
||||
}
|
||||
|
||||
/// Load all stored keys (returns key names only, not values)
|
||||
static func allKeys() -> [String] {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: serviceName,
|
||||
kSecReturnAttributes as String: true,
|
||||
kSecMatchLimit as String: kSecMatchLimitAll,
|
||||
]
|
||||
|
||||
var result: AnyObject?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
|
||||
guard status == errSecSuccess,
|
||||
let items = result as? [[String: Any]] else {
|
||||
return []
|
||||
}
|
||||
|
||||
return items.compactMap { $0[kSecAttrAccount as String] as? String }
|
||||
}
|
||||
|
||||
/// Convenience: load all env vars as a dictionary
|
||||
static func loadAllEnvVars(keyNames: [String]) -> [String: String] {
|
||||
var result: [String: String] = [:]
|
||||
for key in keyNames {
|
||||
if let value = load(key: key) {
|
||||
result[key] = value
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
enum KeychainError: LocalizedError {
|
||||
case encodingFailed
|
||||
case unhandledError(OSStatus)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .encodingFailed:
|
||||
return "Failed to encode value for Keychain"
|
||||
case .unhandledError(let status):
|
||||
return "Keychain error: \(status)"
|
||||
}
|
||||
}
|
||||
}
|
||||
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
141
apps/omega-mac/OmegaMac/Services/OpenAIService.swift
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import Foundation
|
||||
|
||||
/// Actor that handles all communication with the OpenAI Chat Completions API.
|
||||
/// Supports streaming via Server-Sent Events (SSE).
|
||||
actor OpenAIService {
|
||||
private let session: URLSession
|
||||
|
||||
init() {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 300
|
||||
config.timeoutIntervalForResource = 300
|
||||
self.session = URLSession(configuration: config)
|
||||
}
|
||||
|
||||
/// Stream a chat completion, yielding parsed events as they arrive.
|
||||
func streamCompletion(
|
||||
apiKey: String,
|
||||
model: String,
|
||||
messages: [ChatMessage],
|
||||
tools: [ChatTool]?
|
||||
) -> AsyncThrowingStream<StreamParser.StreamEvent, Error> {
|
||||
AsyncThrowingStream { continuation in
|
||||
Task {
|
||||
do {
|
||||
let request = try buildRequest(
|
||||
apiKey: apiKey,
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: tools,
|
||||
stream: true
|
||||
)
|
||||
|
||||
let (bytes, response) = try await session.bytes(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse else {
|
||||
continuation.yield(.error("Invalid response type"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
guard httpResponse.statusCode == 200 else {
|
||||
// Try to read error body
|
||||
var errorBody = ""
|
||||
for try await line in bytes.lines {
|
||||
errorBody += line
|
||||
}
|
||||
continuation.yield(.error("API error \(httpResponse.statusCode): \(errorBody)"))
|
||||
continuation.finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Track accumulated tool calls
|
||||
var toolCallAccumulators: [Int: StreamParser.ToolCallAccumulator] = [:]
|
||||
|
||||
for try await line in bytes.lines {
|
||||
let events = StreamParser.parseLine(line)
|
||||
for event in events {
|
||||
switch event {
|
||||
case .toolCallStarted(let index, let id, let name):
|
||||
toolCallAccumulators[index] = StreamParser.ToolCallAccumulator(
|
||||
id: id,
|
||||
name: name,
|
||||
arguments: ""
|
||||
)
|
||||
continuation.yield(event)
|
||||
|
||||
case .toolCallArgumentDelta(let index, let delta):
|
||||
toolCallAccumulators[index]?.arguments += delta
|
||||
continuation.yield(event)
|
||||
|
||||
case .done:
|
||||
// Emit completed tool calls
|
||||
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
|
||||
let toolCall = ChatToolCall(
|
||||
id: acc.id,
|
||||
type: "function",
|
||||
function: ChatToolCallFunction(
|
||||
name: acc.name,
|
||||
arguments: acc.arguments
|
||||
)
|
||||
)
|
||||
continuation.yield(.toolCallComplete(toolCall))
|
||||
}
|
||||
continuation.yield(.done)
|
||||
continuation.finish()
|
||||
|
||||
default:
|
||||
continuation.yield(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we reach here without [DONE], still emit completed tool calls
|
||||
if !toolCallAccumulators.isEmpty {
|
||||
for (_, acc) in toolCallAccumulators.sorted(by: { $0.key < $1.key }) {
|
||||
let toolCall = ChatToolCall(
|
||||
id: acc.id,
|
||||
type: "function",
|
||||
function: ChatToolCallFunction(
|
||||
name: acc.name,
|
||||
arguments: acc.arguments
|
||||
)
|
||||
)
|
||||
continuation.yield(.toolCallComplete(toolCall))
|
||||
}
|
||||
}
|
||||
continuation.finish()
|
||||
|
||||
} catch {
|
||||
continuation.yield(.error(error.localizedDescription))
|
||||
continuation.finish(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func buildRequest(
|
||||
apiKey: String,
|
||||
model: String,
|
||||
messages: [ChatMessage],
|
||||
tools: [ChatTool]?,
|
||||
stream: Bool
|
||||
) throws -> URLRequest {
|
||||
var request = URLRequest(url: URL(string: "https://api.openai.com/v1/chat/completions")!)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = ChatCompletionRequest(
|
||||
model: model,
|
||||
messages: messages,
|
||||
tools: tools?.isEmpty == true ? nil : tools,
|
||||
stream: stream,
|
||||
maxTokens: 4096,
|
||||
streamOptions: stream ? StreamOptions(includeUsage: true) : nil
|
||||
)
|
||||
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
return request
|
||||
}
|
||||
}
|
||||
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
209
apps/omega-mac/OmegaMac/Services/OpenAITypes.swift
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Request Types
|
||||
|
||||
struct ChatCompletionRequest: Encodable {
|
||||
let model: String
|
||||
let messages: [ChatMessage]
|
||||
let tools: [ChatTool]?
|
||||
let stream: Bool
|
||||
let maxTokens: Int?
|
||||
let streamOptions: StreamOptions?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case model, messages, tools, stream
|
||||
case maxTokens = "max_tokens"
|
||||
case streamOptions = "stream_options"
|
||||
}
|
||||
}
|
||||
|
||||
struct StreamOptions: Encodable {
|
||||
let includeUsage: Bool
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case includeUsage = "include_usage"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatMessage: Codable {
|
||||
let role: String
|
||||
let content: String?
|
||||
let toolCalls: [ChatToolCall]?
|
||||
let toolCallId: String?
|
||||
let name: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content, name
|
||||
case toolCalls = "tool_calls"
|
||||
case toolCallId = "tool_call_id"
|
||||
}
|
||||
|
||||
static func system(_ content: String) -> ChatMessage {
|
||||
ChatMessage(role: "system", content: content, toolCalls: nil, toolCallId: nil, name: nil)
|
||||
}
|
||||
|
||||
static func user(_ content: String) -> ChatMessage {
|
||||
ChatMessage(role: "user", content: content, toolCalls: nil, toolCallId: nil, name: nil)
|
||||
}
|
||||
|
||||
static func assistant(content: String?, toolCalls: [ChatToolCall]?) -> ChatMessage {
|
||||
ChatMessage(role: "assistant", content: content, toolCalls: toolCalls, toolCallId: nil, name: nil)
|
||||
}
|
||||
|
||||
static func toolResult(toolCallId: String, name: String, content: String) -> ChatMessage {
|
||||
ChatMessage(role: "tool", content: content, toolCalls: nil, toolCallId: toolCallId, name: name)
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatTool: Encodable {
|
||||
let type: String = "function"
|
||||
let function: ChatFunction
|
||||
}
|
||||
|
||||
struct ChatFunction: Encodable {
|
||||
let name: String
|
||||
let description: String
|
||||
let parameters: JSONSchemaObject
|
||||
}
|
||||
|
||||
struct JSONSchemaObject: Encodable {
|
||||
let type: String
|
||||
let properties: [String: JSONSchemaProperty]
|
||||
let required: [String]?
|
||||
let additionalProperties: Bool?
|
||||
}
|
||||
|
||||
struct JSONSchemaProperty: Encodable {
|
||||
let type: String
|
||||
let description: String?
|
||||
let minimum: Int?
|
||||
let maximum: Int?
|
||||
let additionalProperties: JSONSchemaAdditional?
|
||||
|
||||
init(type: String, description: String? = nil, minimum: Int? = nil, maximum: Int? = nil, additionalProperties: JSONSchemaAdditional? = nil) {
|
||||
self.type = type
|
||||
self.description = description
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.additionalProperties = additionalProperties
|
||||
}
|
||||
}
|
||||
|
||||
indirect enum JSONSchemaAdditional: Encodable {
|
||||
case bool(Bool)
|
||||
case typed(JSONSchemaProperty)
|
||||
|
||||
func encode(to encoder: Encoder) throws {
|
||||
var container = encoder.singleValueContainer()
|
||||
switch self {
|
||||
case .bool(let b): try container.encode(b)
|
||||
case .typed(let p): try container.encode(p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatToolCall: Codable, Identifiable {
|
||||
var id: String
|
||||
let type: String?
|
||||
let function: ChatToolCallFunction?
|
||||
|
||||
var toolName: String { function?.name ?? "" }
|
||||
var arguments: String { function?.arguments ?? "{}" }
|
||||
|
||||
var parsedArguments: [String: JSONValue] {
|
||||
guard let data = arguments.data(using: .utf8),
|
||||
let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else {
|
||||
return [:]
|
||||
}
|
||||
return obj
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatToolCallFunction: Codable {
|
||||
let name: String?
|
||||
let arguments: String?
|
||||
}
|
||||
|
||||
// MARK: - Response Types (non-streaming)
|
||||
|
||||
struct ChatCompletionResponse: Decodable {
|
||||
let id: String
|
||||
let choices: [ChatChoice]
|
||||
let usage: ChatUsage?
|
||||
}
|
||||
|
||||
struct ChatChoice: Decodable {
|
||||
let index: Int
|
||||
let message: ChatResponseMessage
|
||||
let finishReason: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case index, message
|
||||
case finishReason = "finish_reason"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatResponseMessage: Decodable {
|
||||
let role: String
|
||||
let content: String?
|
||||
let toolCalls: [ChatToolCall]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
case toolCalls = "tool_calls"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChatUsage: Decodable {
|
||||
let promptTokens: Int?
|
||||
let completionTokens: Int?
|
||||
let totalTokens: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case promptTokens = "prompt_tokens"
|
||||
case completionTokens = "completion_tokens"
|
||||
case totalTokens = "total_tokens"
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Streaming Types
|
||||
|
||||
struct ChatCompletionChunk: Decodable {
|
||||
let id: String?
|
||||
let choices: [ChunkChoice]?
|
||||
let usage: ChatUsage?
|
||||
}
|
||||
|
||||
struct ChunkChoice: Decodable {
|
||||
let index: Int?
|
||||
let delta: ChunkDelta?
|
||||
let finishReason: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case index, delta
|
||||
case finishReason = "finish_reason"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkDelta: Decodable {
|
||||
let role: String?
|
||||
let content: String?
|
||||
let toolCalls: [ChunkToolCall]?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case role, content
|
||||
case toolCalls = "tool_calls"
|
||||
}
|
||||
}
|
||||
|
||||
struct ChunkToolCall: Decodable {
|
||||
let index: Int?
|
||||
let id: String?
|
||||
let type: String?
|
||||
let function: ChunkToolCallFunction?
|
||||
}
|
||||
|
||||
struct ChunkToolCallFunction: Decodable {
|
||||
let name: String?
|
||||
let arguments: String?
|
||||
}
|
||||
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
105
apps/omega-mac/OmegaMac/Services/StreamParser.swift
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import Foundation
|
||||
|
||||
/// Parses Server-Sent Events (SSE) from OpenAI's streaming API.
|
||||
/// Handles `data: {...}` lines and `data: [DONE]` termination.
|
||||
struct StreamParser {
|
||||
|
||||
/// Accumulated tool call state during streaming
|
||||
struct ToolCallAccumulator {
|
||||
var id: String = ""
|
||||
var name: String = ""
|
||||
var arguments: String = ""
|
||||
}
|
||||
|
||||
/// Result of parsing the stream - yields content deltas and complete tool calls
|
||||
enum StreamEvent: Sendable {
|
||||
case contentDelta(String)
|
||||
case toolCallStarted(index: Int, id: String, name: String)
|
||||
case toolCallArgumentDelta(index: Int, delta: String)
|
||||
case toolCallComplete(ChatToolCall)
|
||||
case usage(inputTokens: Int, outputTokens: Int)
|
||||
case done
|
||||
case error(String)
|
||||
}
|
||||
|
||||
/// Parse a single SSE line and return events
|
||||
static func parseLine(_ line: String) -> [StreamEvent] {
|
||||
let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
// Skip empty lines and comments
|
||||
guard !trimmed.isEmpty, !trimmed.hasPrefix(":") else {
|
||||
return []
|
||||
}
|
||||
|
||||
// Must start with "data: "
|
||||
guard trimmed.hasPrefix("data: ") else {
|
||||
return []
|
||||
}
|
||||
|
||||
let payload = String(trimmed.dropFirst(6))
|
||||
|
||||
// Check for stream end
|
||||
if payload == "[DONE]" {
|
||||
return [.done]
|
||||
}
|
||||
|
||||
// Parse JSON chunk
|
||||
guard let data = payload.data(using: .utf8) else {
|
||||
return [.error("Invalid UTF-8 in SSE payload")]
|
||||
}
|
||||
|
||||
do {
|
||||
let chunk = try JSONDecoder().decode(ChatCompletionChunk.self, from: data)
|
||||
return processChunk(chunk)
|
||||
} catch {
|
||||
return [.error("Failed to parse chunk: \(error.localizedDescription)")]
|
||||
}
|
||||
}
|
||||
|
||||
private static func processChunk(_ chunk: ChatCompletionChunk) -> [StreamEvent] {
|
||||
var events: [StreamEvent] = []
|
||||
|
||||
if let choices = chunk.choices {
|
||||
for choice in choices {
|
||||
guard let delta = choice.delta else { continue }
|
||||
|
||||
// Content delta
|
||||
if let content = delta.content, !content.isEmpty {
|
||||
events.append(.contentDelta(content))
|
||||
}
|
||||
|
||||
// Tool calls
|
||||
if let toolCalls = delta.toolCalls {
|
||||
for tc in toolCalls {
|
||||
let idx = tc.index ?? 0
|
||||
if let id = tc.id, !id.isEmpty {
|
||||
events.append(.toolCallStarted(
|
||||
index: idx,
|
||||
id: id,
|
||||
name: tc.function?.name ?? ""
|
||||
))
|
||||
}
|
||||
if let args = tc.function?.arguments, !args.isEmpty {
|
||||
events.append(.toolCallArgumentDelta(index: idx, delta: args))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finish reason
|
||||
if choice.finishReason == "stop" || choice.finishReason == "tool_calls" {
|
||||
// Will be handled by [DONE]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage info (sometimes included in last chunk)
|
||||
if let usage = chunk.usage {
|
||||
events.append(.usage(
|
||||
inputTokens: usage.promptTokens ?? 0,
|
||||
outputTokens: usage.completionTokens ?? 0
|
||||
))
|
||||
}
|
||||
|
||||
return events
|
||||
}
|
||||
}
|
||||
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
156
apps/omega-mac/OmegaMac/Services/TPMJSRegistryService.swift
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import Foundation
|
||||
|
||||
/// Actor that handles communication with the TPMJS tool registry API
|
||||
/// and the remote executor service.
|
||||
actor TPMJSRegistryService {
|
||||
private let session: URLSession
|
||||
private let registryBaseURL: String
|
||||
private let executorBaseURL: String
|
||||
|
||||
init(
|
||||
registryBaseURL: String = "https://tpmjs.com",
|
||||
executorBaseURL: String = "https://executor.tpmjs.com"
|
||||
) {
|
||||
let config = URLSessionConfiguration.default
|
||||
config.timeoutIntervalForRequest = 60
|
||||
self.session = URLSession(configuration: config)
|
||||
self.registryBaseURL = registryBaseURL
|
||||
self.executorBaseURL = executorBaseURL
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
/// Search for tools matching a query using BM25
|
||||
func searchTools(query: String, limit: Int = 10) async throws -> [ToolMeta] {
|
||||
var components = URLComponents(string: "\(registryBaseURL)/api/tools/search")!
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "q", value: query),
|
||||
URLQueryItem(name: "limit", value: String(limit)),
|
||||
]
|
||||
|
||||
guard let url = components.url else {
|
||||
throw TPMJSError.invalidURL
|
||||
}
|
||||
|
||||
let (data, response) = try await session.data(from: url)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
return []
|
||||
}
|
||||
|
||||
let searchResponse = try JSONDecoder().decode(TPMJSSearchResponse.self, from: data)
|
||||
let tools = searchResponse.results?.tools ?? []
|
||||
|
||||
return tools.map { tool in
|
||||
ToolMeta(
|
||||
toolId: "\(tool.package.npmPackageName)::\(tool.name)",
|
||||
packageName: tool.package.npmPackageName,
|
||||
name: tool.name,
|
||||
description: tool.description ?? "Tool: \(tool.name)",
|
||||
version: tool.package.npmVersion,
|
||||
importUrl: tool.importUrl ?? "https://esm.sh/\(tool.package.npmPackageName)@\(tool.package.npmVersion)",
|
||||
inputSchema: tool.inputSchema,
|
||||
env: tool.package.env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Execute via Executor
|
||||
|
||||
/// Execute a tool via the TPMJS remote sandbox executor
|
||||
func executeTool(
|
||||
packageName: String,
|
||||
name: String,
|
||||
version: String,
|
||||
importUrl: String,
|
||||
params: JSONValue,
|
||||
env: [String: String]
|
||||
) async throws -> TPMJSExecuteResponse {
|
||||
guard let url = URL(string: "\(executorBaseURL)/execute-tool") else {
|
||||
throw TPMJSError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
|
||||
let body = TPMJSExecuteRequest(
|
||||
packageName: packageName,
|
||||
name: name,
|
||||
version: version,
|
||||
importUrl: importUrl,
|
||||
params: params,
|
||||
env: env
|
||||
)
|
||||
|
||||
request.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, response) = try await session.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
let statusCode = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw TPMJSError.httpError(statusCode)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(TPMJSExecuteResponse.self, from: data)
|
||||
}
|
||||
|
||||
// MARK: - Registry Execute (uses search first to find metadata)
|
||||
|
||||
/// Execute a tool by its toolId (package::name format).
|
||||
/// Fetches metadata first via search, then executes via executor.
|
||||
func executeByToolId(
|
||||
toolId: String,
|
||||
params: JSONValue,
|
||||
env: [String: String]
|
||||
) async throws -> TPMJSExecuteResponse {
|
||||
// Parse toolId format: "package::name"
|
||||
guard let separatorIndex = toolId.range(of: "::", options: .backwards) else {
|
||||
throw TPMJSError.invalidToolId(toolId)
|
||||
}
|
||||
|
||||
let packageName = String(toolId[toolId.startIndex..<separatorIndex.lowerBound])
|
||||
let name = String(toolId[separatorIndex.upperBound...])
|
||||
|
||||
guard !packageName.isEmpty, !name.isEmpty else {
|
||||
throw TPMJSError.invalidToolId(toolId)
|
||||
}
|
||||
|
||||
// Search for the tool to get version metadata
|
||||
let searchResults = try await searchTools(query: name, limit: 10)
|
||||
guard let toolMeta = searchResults.first(where: {
|
||||
$0.packageName == packageName && $0.name == name
|
||||
}) else {
|
||||
throw TPMJSError.toolNotFound(toolId)
|
||||
}
|
||||
|
||||
return try await executeTool(
|
||||
packageName: toolMeta.packageName,
|
||||
name: toolMeta.name,
|
||||
version: toolMeta.version,
|
||||
importUrl: toolMeta.importUrl,
|
||||
params: params,
|
||||
env: env
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Errors
|
||||
|
||||
enum TPMJSError: LocalizedError {
|
||||
case invalidURL
|
||||
case httpError(Int)
|
||||
case invalidToolId(String)
|
||||
case toolNotFound(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .invalidURL: return "Invalid URL"
|
||||
case .httpError(let code): return "HTTP error: \(code)"
|
||||
case .invalidToolId(let id): return "Invalid tool ID format: \(id). Expected 'package::name'"
|
||||
case .toolNotFound(let id): return "Tool not found: \(id). Try using registrySearch to find available tools."
|
||||
}
|
||||
}
|
||||
}
|
||||
123
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
123
apps/omega-mac/OmegaMac/Services/TPMJSTypes.swift
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import Foundation
|
||||
|
||||
// MARK: - Search API Types
|
||||
|
||||
struct TPMJSSearchResponse: Decodable {
|
||||
let results: TPMJSSearchResults?
|
||||
}
|
||||
|
||||
struct TPMJSSearchResults: Decodable {
|
||||
let tools: [TPMJSToolResult]?
|
||||
}
|
||||
|
||||
struct TPMJSToolResult: Decodable {
|
||||
let name: String
|
||||
let description: String?
|
||||
let inputSchema: JSONValue?
|
||||
let qualityScore: Double?
|
||||
let executionHealth: String?
|
||||
let importUrl: String?
|
||||
let package: TPMJSPackageInfo
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case name, description, inputSchema, qualityScore, executionHealth, importUrl
|
||||
case package = "package"
|
||||
}
|
||||
}
|
||||
|
||||
struct TPMJSPackageInfo: Decodable {
|
||||
let npmPackageName: String
|
||||
let npmVersion: String
|
||||
let category: String?
|
||||
let env: [String]?
|
||||
}
|
||||
|
||||
// MARK: - Executor API Types
|
||||
|
||||
struct TPMJSExecuteRequest: Encodable {
|
||||
let packageName: String
|
||||
let name: String
|
||||
let version: String
|
||||
let importUrl: String
|
||||
let params: JSONValue
|
||||
let env: [String: String]
|
||||
}
|
||||
|
||||
struct TPMJSExecuteResponse: Decodable, Sendable {
|
||||
let success: Bool
|
||||
let output: JSONValue?
|
||||
let error: String?
|
||||
let executionTimeMs: Int?
|
||||
}
|
||||
|
||||
// MARK: - Tool Metadata (internal tracking)
|
||||
|
||||
struct ToolMeta: Sendable {
|
||||
let toolId: String
|
||||
let packageName: String
|
||||
let name: String
|
||||
let description: String
|
||||
let version: String
|
||||
let importUrl: String
|
||||
let inputSchema: JSONValue?
|
||||
let env: [String]?
|
||||
|
||||
/// Convert to an OpenAI function tool definition
|
||||
func toChatTool() -> ChatTool {
|
||||
let properties: [String: JSONSchemaProperty]
|
||||
let required: [String]?
|
||||
|
||||
if case .object(let schemaObj) = inputSchema {
|
||||
// Extract properties from schema
|
||||
var props: [String: JSONSchemaProperty] = [:]
|
||||
var reqs: [String] = []
|
||||
|
||||
if case .object(let propsObj) = schemaObj["properties"] {
|
||||
for (key, value) in propsObj {
|
||||
if case .object(let propDef) = value {
|
||||
let typeStr: String
|
||||
if case .string(let t) = propDef["type"] {
|
||||
typeStr = t
|
||||
} else {
|
||||
typeStr = "string"
|
||||
}
|
||||
let desc: String?
|
||||
if case .string(let d) = propDef["description"] {
|
||||
desc = d
|
||||
} else {
|
||||
desc = nil
|
||||
}
|
||||
props[key] = JSONSchemaProperty(type: typeStr, description: desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if case .array(let reqArr) = schemaObj["required"] {
|
||||
for item in reqArr {
|
||||
if case .string(let s) = item {
|
||||
reqs.append(s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
properties = props
|
||||
required = reqs.isEmpty ? nil : reqs
|
||||
} else {
|
||||
properties = [:]
|
||||
required = nil
|
||||
}
|
||||
|
||||
return ChatTool(
|
||||
function: ChatFunction(
|
||||
name: sanitizeToolName(toolId),
|
||||
description: description,
|
||||
parameters: JSONSchemaObject(
|
||||
type: "object",
|
||||
properties: properties,
|
||||
required: required,
|
||||
additionalProperties: true
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
40
apps/omega-mac/OmegaMac/Utilities/SanitizeToolName.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import Foundation
|
||||
|
||||
/// Sanitize a tool ID to be a valid OpenAI function name.
|
||||
/// Port of the web's sanitizeToolName logic.
|
||||
/// OpenAI requires tool names to be <= 64 characters and match [a-zA-Z0-9_-].
|
||||
func sanitizeToolName(_ name: String) -> String {
|
||||
var sanitized = name
|
||||
.replacingOccurrences(of: "@", with: "")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "-", with: "_")
|
||||
.replacingOccurrences(of: "::", with: "_")
|
||||
|
||||
// Remove any remaining invalid characters
|
||||
sanitized = String(sanitized.unicodeScalars.filter { scalar in
|
||||
CharacterSet.alphanumerics.contains(scalar) || scalar == "_"
|
||||
})
|
||||
|
||||
// OpenAI API requires tool names <= 64 characters
|
||||
if sanitized.count <= 64 {
|
||||
return sanitized
|
||||
}
|
||||
|
||||
// Truncate but try to keep the meaningful part (tool name at the end)
|
||||
let last64 = String(sanitized.suffix(64))
|
||||
if let first = last64.first, first.isLetter {
|
||||
return last64
|
||||
}
|
||||
return String(sanitized.prefix(64))
|
||||
}
|
||||
|
||||
/// Reverse lookup: find the original toolId from a sanitized name
|
||||
/// by checking against loaded tool metadata.
|
||||
func findToolId(sanitizedName: String, in tools: [String: ToolMeta]) -> String? {
|
||||
for (_, meta) in tools {
|
||||
if sanitizeToolName(meta.toolId) == sanitizedName {
|
||||
return meta.toolId
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
134
apps/omega-mac/OmegaMac/Utilities/SystemPromptBuilder.swift
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import Foundation
|
||||
|
||||
/// Builds the system prompt for the Omega agent.
|
||||
/// Port of the web's buildSystemPrompt logic from system-prompt.ts.
|
||||
enum SystemPromptBuilder {
|
||||
|
||||
static let basePrompt = """
|
||||
You are Omega, an AI assistant powered by the TPMJS tool registry - a collection of 1M+ AI-ready tools.
|
||||
|
||||
## Core Tools
|
||||
|
||||
You have access to two powerful meta-tools that give you access to the entire TPMJS registry:
|
||||
|
||||
1. **registrySearch** - Search for tools by keyword, category, or description
|
||||
2. **registryExecute** - Execute any tool by its toolId
|
||||
|
||||
These tools are importable by users into their own AI agents via:
|
||||
```typescript
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. When the user asks for something, relevant tools are automatically discovered and loaded
|
||||
2. You can also explicitly search using registrySearch
|
||||
3. Once tools are found, you have two options:
|
||||
- Use registryExecute with the toolId to execute any tool
|
||||
- Call dynamically loaded tools directly by their sanitized name
|
||||
|
||||
## Workflow Examples
|
||||
|
||||
### Example 1: User wants weather data
|
||||
1. Call registrySearch({ query: "weather api" })
|
||||
2. Review the results (toolIds like "@weather-api/sdk::getWeather")
|
||||
3. Call registryExecute({ toolId: "@weather-api/sdk::getWeather", params: { city: "Tokyo" } })
|
||||
4. Explain the result to the user
|
||||
|
||||
### Example 2: Tool already loaded
|
||||
If you see a tool like "weatherapi_sdk_getWeather" in the dynamically loaded tools list, call it directly instead of using registryExecute.
|
||||
|
||||
## Best Practices
|
||||
|
||||
- **Search first** - If you don't see a relevant tool loaded, use registrySearch
|
||||
- **Execute don't describe** - Actually call tools to get real results
|
||||
- **Handle errors** - If a tool fails, explain and try an alternative
|
||||
- **Be efficient** - If a tool is already loaded, call it directly
|
||||
|
||||
## Response Style
|
||||
|
||||
- Keep responses concise and helpful
|
||||
- Present tool outputs in a clear, readable format
|
||||
- Tell the user which tool you used
|
||||
- Offer to do more if the user might need it
|
||||
|
||||
Remember: Your value is in EXECUTING tools to get real results, not describing what tools could do.
|
||||
"""
|
||||
|
||||
/// Build the complete system prompt with tool listings and user customizations
|
||||
static func build(
|
||||
customSystemPrompt: String?,
|
||||
pinnedToolIds: [String],
|
||||
loadedTools: [String: ToolMeta]
|
||||
) -> String {
|
||||
var parts: [String] = [basePrompt]
|
||||
|
||||
// Pinned tools
|
||||
if !pinnedToolIds.isEmpty {
|
||||
let pinned = pinnedToolIds.map { "- Tool ID: \($0)" }.joined(separator: "\n")
|
||||
parts.append("""
|
||||
## Pinned Tools
|
||||
|
||||
The user has pinned the following tools as favorites. Consider using these first when they match the task:
|
||||
\(pinned)
|
||||
""")
|
||||
}
|
||||
|
||||
// Custom system prompt
|
||||
if let custom = customSystemPrompt, !custom.isEmpty {
|
||||
parts.append("""
|
||||
## User Instructions
|
||||
|
||||
The user has provided the following custom instructions:
|
||||
|
||||
\(custom)
|
||||
""")
|
||||
}
|
||||
|
||||
// Static tools
|
||||
let staticToolsList = """
|
||||
- registrySearch: Search the TPMJS registry to find AI SDK tools by keyword. Returns toolIds for registryExecute.
|
||||
- registryExecute: Execute any tool from the TPMJS registry by toolId. Use registrySearch first to find tools.
|
||||
"""
|
||||
|
||||
parts.append("""
|
||||
## Static Tools (Always Available)
|
||||
|
||||
These tools let you access the entire TPMJS registry of 1M+ tools:
|
||||
|
||||
\(staticToolsList)
|
||||
""")
|
||||
|
||||
// Dynamic tools
|
||||
let dynamicToolsList: String
|
||||
if loadedTools.isEmpty {
|
||||
dynamicToolsList = "No tools loaded yet. Use registrySearch to find tools, or they will be auto-loaded based on your requests."
|
||||
} else {
|
||||
dynamicToolsList = loadedTools.map { (name, meta) in
|
||||
"- \(name): \(meta.description)"
|
||||
}.joined(separator: "\n")
|
||||
}
|
||||
|
||||
parts.append("""
|
||||
## Dynamically Loaded Tools
|
||||
|
||||
These tools have been discovered and loaded for this conversation. Call them directly:
|
||||
|
||||
\(dynamicToolsList)
|
||||
""")
|
||||
|
||||
// Usage instructions
|
||||
parts.append("""
|
||||
## How to Use Tools
|
||||
|
||||
1. **To find a tool**: Use registrySearch with a keyword (e.g., "weather", "web scraping", "database")
|
||||
2. **To execute a found tool**: Use registryExecute with the toolId returned from search
|
||||
3. **Direct execution**: If a tool is already loaded above, call it directly by name
|
||||
|
||||
Remember: Your value is in EXECUTING tools to get real results, not just describing what tools could do.
|
||||
""")
|
||||
|
||||
return parts.joined(separator: "\n\n")
|
||||
}
|
||||
}
|
||||
119
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
119
apps/omega-mac/OmegaMac/Views/Chat/ChatInputBar.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ChatInputBar: View {
|
||||
@Binding var text: String
|
||||
let isStreaming: Bool
|
||||
let onSend: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Divider()
|
||||
|
||||
HStack(alignment: .bottom, spacing: 8) {
|
||||
SendableTextEditor(text: $text, onSend: {
|
||||
if canSend { onSend() }
|
||||
})
|
||||
.font(.body)
|
||||
.frame(minHeight: 40, maxHeight: 160)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Button(action: onSend) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.title2)
|
||||
.foregroundStyle(canSend ? Color.accentColor : Color.secondary)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.disabled(!canSend)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
Text("Enter to send, Shift+Enter for new line")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.background(.background)
|
||||
}
|
||||
|
||||
private var canSend: Bool {
|
||||
!isStreaming && !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
/// NSTextView-backed editor that intercepts Return (send) vs Shift+Return (newline)
|
||||
struct SendableTextEditor: NSViewRepresentable {
|
||||
@Binding var text: String
|
||||
let onSend: () -> Void
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator(self)
|
||||
}
|
||||
|
||||
func makeNSView(context: Context) -> NSScrollView {
|
||||
let scrollView = NSScrollView()
|
||||
let textView = SendableNSTextView()
|
||||
textView.delegate = context.coordinator
|
||||
textView.sendAction = onSend
|
||||
textView.isRichText = false
|
||||
textView.allowsUndo = true
|
||||
textView.font = .systemFont(ofSize: NSFont.systemFontSize)
|
||||
textView.textColor = .labelColor
|
||||
textView.drawsBackground = false
|
||||
textView.isVerticallyResizable = true
|
||||
textView.isHorizontallyResizable = false
|
||||
textView.textContainerInset = NSSize(width: 8, height: 8)
|
||||
textView.textContainer?.widthTracksTextView = true
|
||||
textView.autoresizingMask = [.width]
|
||||
|
||||
scrollView.documentView = textView
|
||||
scrollView.hasVerticalScroller = false
|
||||
scrollView.drawsBackground = false
|
||||
scrollView.borderType = .noBorder
|
||||
scrollView.contentView.drawsBackground = false
|
||||
|
||||
// Style the scroll view as a rounded input field
|
||||
scrollView.wantsLayer = true
|
||||
scrollView.layer?.cornerRadius = 10
|
||||
scrollView.layer?.backgroundColor = NSColor.quaternaryLabelColor.withAlphaComponent(0.3).cgColor
|
||||
|
||||
return scrollView
|
||||
}
|
||||
|
||||
func updateNSView(_ scrollView: NSScrollView, context: Context) {
|
||||
guard let textView = scrollView.documentView as? NSTextView else { return }
|
||||
if textView.string != text {
|
||||
textView.string = text
|
||||
}
|
||||
}
|
||||
|
||||
class Coordinator: NSObject, NSTextViewDelegate {
|
||||
var parent: SendableTextEditor
|
||||
|
||||
init(_ parent: SendableTextEditor) {
|
||||
self.parent = parent
|
||||
}
|
||||
|
||||
func textDidChange(_ notification: Notification) {
|
||||
guard let textView = notification.object as? NSTextView else { return }
|
||||
parent.text = textView.string
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Custom NSTextView that sends on Return and inserts newline on Shift+Return
|
||||
class SendableNSTextView: NSTextView {
|
||||
var sendAction: (() -> Void)?
|
||||
|
||||
override func keyDown(with event: NSEvent) {
|
||||
if event.keyCode == 36 { // Return key
|
||||
if event.modifierFlags.contains(.shift) {
|
||||
super.keyDown(with: event) // Insert newline
|
||||
} else {
|
||||
sendAction?()
|
||||
}
|
||||
return
|
||||
}
|
||||
super.keyDown(with: event)
|
||||
}
|
||||
}
|
||||
129
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
129
apps/omega-mac/OmegaMac/Views/Chat/ChatView.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import AppKit
|
||||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ChatView: View {
|
||||
@Bindable var conversation: Conversation
|
||||
var orchestrator: ChatOrchestrator
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var inputText: String = ""
|
||||
@State private var showCopied: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Messages
|
||||
MessageList(
|
||||
conversation: conversation,
|
||||
streamingContent: orchestrator.streamingContent,
|
||||
isStreaming: orchestrator.isStreaming,
|
||||
liveToolCalls: orchestrator.liveToolCalls
|
||||
)
|
||||
|
||||
// Error banner
|
||||
if let error = orchestrator.error {
|
||||
HStack {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.red)
|
||||
Text(error)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.red)
|
||||
Spacer()
|
||||
Button("Dismiss") {
|
||||
orchestrator.error = nil
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.font(.callout)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.padding(.vertical, 8)
|
||||
.background(.red.opacity(0.1))
|
||||
}
|
||||
|
||||
// Input bar
|
||||
ChatInputBar(
|
||||
text: $inputText,
|
||||
isStreaming: orchestrator.isStreaming,
|
||||
onSend: sendMessage
|
||||
)
|
||||
}
|
||||
.navigationTitle(conversation.displayTitle)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
if orchestrator.isStreaming {
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(action: copyConversationAsJSON) {
|
||||
Label(showCopied ? "Copied!" : "Copy JSON",
|
||||
systemImage: showCopied ? "checkmark" : "doc.on.doc")
|
||||
}
|
||||
.help("Copy conversation as JSON (Cmd+Shift+C)")
|
||||
.keyboardShortcut("c", modifiers: [.command, .shift])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !text.isEmpty else { return }
|
||||
inputText = ""
|
||||
|
||||
Task {
|
||||
await orchestrator.sendMessage(text, conversation: conversation, modelContext: modelContext)
|
||||
}
|
||||
}
|
||||
|
||||
private func copyConversationAsJSON() {
|
||||
let messages = conversation.sortedMessages.map { msg -> [String: Any] in
|
||||
var dict: [String: Any] = [
|
||||
"role": msg.role.rawValue.lowercased(),
|
||||
"content": msg.content,
|
||||
"createdAt": ISO8601DateFormatter().string(from: msg.createdAt),
|
||||
]
|
||||
if let input = msg.inputTokens { dict["inputTokens"] = input }
|
||||
if let output = msg.outputTokens { dict["outputTokens"] = output }
|
||||
|
||||
let toolCalls = msg.toolCalls
|
||||
if !toolCalls.isEmpty {
|
||||
dict["toolCalls"] = toolCalls.map { tc -> [String: Any] in
|
||||
var tcDict: [String: Any] = [
|
||||
"toolCallId": tc.toolCallId,
|
||||
"toolName": tc.toolName,
|
||||
]
|
||||
if let args = tc.args,
|
||||
let data = try? JSONEncoder().encode(args),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) {
|
||||
tcDict["args"] = json
|
||||
}
|
||||
if let output = tc.output,
|
||||
let data = try? JSONEncoder().encode(output),
|
||||
let json = try? JSONSerialization.jsonObject(with: data) {
|
||||
tcDict["output"] = json
|
||||
}
|
||||
return tcDict
|
||||
}
|
||||
}
|
||||
return dict
|
||||
}
|
||||
|
||||
let payload: [String: Any] = [
|
||||
"conversationId": conversation.id.uuidString,
|
||||
"title": conversation.displayTitle,
|
||||
"createdAt": ISO8601DateFormatter().string(from: conversation.createdAt),
|
||||
"messages": messages,
|
||||
]
|
||||
|
||||
if let data = try? JSONSerialization.data(withJSONObject: payload, options: [.prettyPrinted, .sortedKeys]),
|
||||
let json = String(data: data, encoding: .utf8) {
|
||||
NSPasteboard.general.clearContents()
|
||||
NSPasteboard.general.setString(json, forType: .string)
|
||||
showCopied = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
showCopied = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
69
apps/omega-mac/OmegaMac/Views/Chat/MessageBubble.swift
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageBubble: View {
|
||||
let message: Message
|
||||
|
||||
var body: some View {
|
||||
switch message.role {
|
||||
case .user:
|
||||
userBubble
|
||||
case .assistant:
|
||||
assistantBubble
|
||||
case .tool:
|
||||
toolResultsBubble
|
||||
case .system:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
|
||||
private var userBubble: some View {
|
||||
HStack(alignment: .top) {
|
||||
Spacer(minLength: 60)
|
||||
Text(message.content)
|
||||
.font(.body)
|
||||
.foregroundStyle(.white)
|
||||
.padding(12)
|
||||
.background(Color.accentColor)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var assistantBubble: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
if !message.content.isEmpty {
|
||||
MarkdownView(content: message.content)
|
||||
}
|
||||
|
||||
// Token usage
|
||||
if let input = message.inputTokens, let output = message.outputTokens {
|
||||
Text("In: \(input) | Out: \(output)")
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
.padding(.top, 4)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.textSelection(.enabled)
|
||||
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
private var toolResultsBubble: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ForEach(message.toolCalls) { tc in
|
||||
JSONToolResultView(toolCallData: tc)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
}
|
||||
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
95
apps/omega-mac/OmegaMac/Views/Chat/MessageList.swift
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import SwiftUI
|
||||
|
||||
struct MessageList: View {
|
||||
let conversation: Conversation
|
||||
let streamingContent: String
|
||||
let isStreaming: Bool
|
||||
let liveToolCalls: [LiveToolCall]
|
||||
|
||||
var body: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 12) {
|
||||
ForEach(conversation.sortedMessages) { message in
|
||||
MessageBubble(message: message)
|
||||
.id(message.id)
|
||||
}
|
||||
|
||||
// Live tool calls
|
||||
ForEach(isStreaming ? liveToolCalls : []) { tc in
|
||||
ToolCallView(toolCall: tc)
|
||||
.id("live-tc-\(tc.id)")
|
||||
}
|
||||
|
||||
// Streaming content
|
||||
if isStreaming && !streamingContent.isEmpty {
|
||||
HStack(alignment: .top) {
|
||||
assistantBubble(content: streamingContent, isStreaming: true)
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.id("streaming")
|
||||
}
|
||||
|
||||
// Thinking indicator
|
||||
if isStreaming && streamingContent.isEmpty && liveToolCalls.isEmpty {
|
||||
HStack {
|
||||
StreamingIndicator()
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
.id("thinking")
|
||||
}
|
||||
|
||||
// Bottom spacer for scroll padding
|
||||
Color.clear.frame(height: 8)
|
||||
.id("bottom")
|
||||
}
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.onChange(of: streamingContent) {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: conversation.messages.count) {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
.onChange(of: liveToolCalls.count) {
|
||||
withAnimation(.easeOut(duration: 0.15)) {
|
||||
proxy.scrollTo("bottom", anchor: .bottom)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func assistantBubble(content: String, isStreaming: Bool) -> some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
MarkdownView(content: content)
|
||||
|
||||
if isStreaming {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor)
|
||||
.frame(width: 2, height: 16)
|
||||
.opacity(0.8)
|
||||
.modifier(PulseAnimation())
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
}
|
||||
}
|
||||
|
||||
private struct PulseAnimation: ViewModifier {
|
||||
@State private var isAnimating = false
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.opacity(isAnimating ? 0.3 : 1.0)
|
||||
.animation(.easeInOut(duration: 0.6).repeatForever(autoreverses: true), value: isAnimating)
|
||||
.onAppear { isAnimating = true }
|
||||
}
|
||||
}
|
||||
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
24
apps/omega-mac/OmegaMac/Views/Chat/StreamingIndicator.swift
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import SwiftUI
|
||||
|
||||
struct StreamingIndicator: View {
|
||||
@State private var dotCount = 0
|
||||
private let timer = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.font(.caption)
|
||||
|
||||
Text("Omega is thinking" + String(repeating: ".", count: dotCount))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.5))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.onReceive(timer) { _ in
|
||||
dotCount = (dotCount + 1) % 4
|
||||
}
|
||||
}
|
||||
}
|
||||
39
apps/omega-mac/OmegaMac/Views/ContentView.swift
Normal file
39
apps/omega-mac/OmegaMac/Views/ContentView.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
@State private var selectedConversation: Conversation?
|
||||
@State private var orchestrator = ChatOrchestrator()
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
SidebarView(
|
||||
selectedConversation: $selectedConversation,
|
||||
onNewConversation: createNewConversation
|
||||
)
|
||||
.navigationSplitViewColumnWidth(min: 220, ideal: 260, max: 340)
|
||||
} detail: {
|
||||
if let conversation = selectedConversation {
|
||||
ChatView(conversation: conversation, orchestrator: orchestrator)
|
||||
} else {
|
||||
EmptyStateView(onNewConversation: createNewConversation)
|
||||
}
|
||||
}
|
||||
.navigationSplitViewStyle(.balanced)
|
||||
.onChange(of: selectedConversation) {
|
||||
orchestrator.resetConversation()
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: .newConversation)) { _ in
|
||||
createNewConversation()
|
||||
}
|
||||
}
|
||||
|
||||
private func createNewConversation() {
|
||||
let conversation = Conversation()
|
||||
modelContext.insert(conversation)
|
||||
try? modelContext.save()
|
||||
selectedConversation = conversation
|
||||
orchestrator.resetConversation()
|
||||
}
|
||||
}
|
||||
129
apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
Normal file
129
apps/omega-mac/OmegaMac/Views/Settings/APIKeySettings.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct APIKeySettings: View {
|
||||
@State private var apiKey: String = ""
|
||||
@State private var hasKey: Bool = false
|
||||
@State private var showKey: Bool = false
|
||||
@State private var saveStatus: String?
|
||||
|
||||
@Query private var settingsArray: [UserSettings]
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
private var settings: UserSettings {
|
||||
if let existing = settingsArray.first {
|
||||
return existing
|
||||
}
|
||||
let s = UserSettings()
|
||||
modelContext.insert(s)
|
||||
try? modelContext.save()
|
||||
return s
|
||||
}
|
||||
|
||||
@State private var selectedModel: String = "gpt-4.1-mini"
|
||||
|
||||
private let availableModels = [
|
||||
"gpt-4.1-mini",
|
||||
"gpt-4.1",
|
||||
"gpt-4.1-nano",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"o4-mini",
|
||||
]
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("OpenAI API Key") {
|
||||
HStack {
|
||||
if showKey {
|
||||
TextField("sk-...", text: $apiKey)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
} else {
|
||||
SecureField("sk-...", text: $apiKey)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
}
|
||||
|
||||
Button {
|
||||
showKey.toggle()
|
||||
} label: {
|
||||
Image(systemName: showKey ? "eye.slash" : "eye")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Save Key") {
|
||||
saveAPIKey()
|
||||
}
|
||||
.disabled(apiKey.isEmpty)
|
||||
|
||||
if hasKey {
|
||||
Button("Remove Key", role: .destructive) {
|
||||
removeAPIKey()
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let status = saveStatus {
|
||||
Text(status)
|
||||
.font(.caption)
|
||||
.foregroundStyle(status.contains("Error") ? .red : .green)
|
||||
}
|
||||
}
|
||||
|
||||
if hasKey {
|
||||
Label("API key is stored securely in Keychain", systemImage: "lock.shield")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Section("Model") {
|
||||
Picker("Model", selection: $selectedModel) {
|
||||
ForEach(availableModels, id: \.self) { model in
|
||||
Text(model).tag(model)
|
||||
}
|
||||
}
|
||||
.onChange(of: selectedModel) { _, newValue in
|
||||
settings.selectedModel = newValue
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
.onAppear {
|
||||
hasKey = KeychainService.load(key: "OPENAI_API_KEY") != nil
|
||||
selectedModel = settings.selectedModel
|
||||
}
|
||||
}
|
||||
|
||||
private func saveAPIKey() {
|
||||
do {
|
||||
try KeychainService.save(key: "OPENAI_API_KEY", value: apiKey)
|
||||
hasKey = true
|
||||
apiKey = ""
|
||||
saveStatus = "Saved"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
saveStatus = nil
|
||||
}
|
||||
} catch {
|
||||
saveStatus = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func removeAPIKey() {
|
||||
do {
|
||||
try KeychainService.delete(key: "OPENAI_API_KEY")
|
||||
hasKey = false
|
||||
apiKey = ""
|
||||
saveStatus = "Removed"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
saveStatus = nil
|
||||
}
|
||||
} catch {
|
||||
saveStatus = "Error: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
131
apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
Normal file
131
apps/omega-mac/OmegaMac/Views/Settings/EnvVarsSettings.swift
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct EnvVarsSettings: View {
|
||||
@Query(sort: \EnvVar.keyName) private var envVars: [EnvVar]
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var newKeyName: String = ""
|
||||
@State private var newKeyValue: String = ""
|
||||
@State private var errorMessage: String?
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Stored Environment Variables") {
|
||||
if envVars.isEmpty {
|
||||
Text("No environment variables configured.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.vertical, 4)
|
||||
} else {
|
||||
ForEach(envVars) { envVar in
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
Text(envVar.keyName)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
Text("....\(envVar.valueHint)")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(role: .destructive) {
|
||||
deleteEnvVar(envVar)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Add New") {
|
||||
TextField("Key name (e.g., WEATHER_API_KEY)", text: $newKeyName)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
SecureField("Value", text: $newKeyValue)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
|
||||
HStack {
|
||||
Button("Add") {
|
||||
addEnvVar()
|
||||
}
|
||||
.disabled(newKeyName.isEmpty || newKeyValue.isEmpty)
|
||||
|
||||
if let error = errorMessage {
|
||||
Text(error)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Label(
|
||||
"Values are stored in macOS Keychain. Only key names are visible in the app.",
|
||||
systemImage: "lock.shield"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
|
||||
Label(
|
||||
"All environment variables are passed to tool executions automatically.",
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func addEnvVar() {
|
||||
let name = newKeyName.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
|
||||
let value = newKeyValue
|
||||
|
||||
guard !name.isEmpty, !value.isEmpty else { return }
|
||||
|
||||
// Check for duplicates
|
||||
if envVars.contains(where: { $0.keyName == name }) {
|
||||
// Update existing
|
||||
do {
|
||||
try KeychainService.save(key: name, value: value)
|
||||
if let existing = envVars.first(where: { $0.keyName == name }) {
|
||||
existing.valueHint = String(value.suffix(4))
|
||||
}
|
||||
try? modelContext.save()
|
||||
newKeyName = ""
|
||||
newKeyValue = ""
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try KeychainService.save(key: name, value: value)
|
||||
|
||||
let hint = String(value.suffix(4))
|
||||
let envVar = EnvVar(keyName: name, valueHint: hint)
|
||||
modelContext.insert(envVar)
|
||||
try? modelContext.save()
|
||||
|
||||
newKeyName = ""
|
||||
newKeyValue = ""
|
||||
errorMessage = nil
|
||||
} catch {
|
||||
errorMessage = error.localizedDescription
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteEnvVar(_ envVar: EnvVar) {
|
||||
try? KeychainService.delete(key: envVar.keyName)
|
||||
modelContext.delete(envVar)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
23
apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
Normal file
23
apps/omega-mac/OmegaMac/Views/Settings/SettingsView.swift
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import SwiftUI
|
||||
|
||||
struct SettingsView: View {
|
||||
var body: some View {
|
||||
TabView {
|
||||
APIKeySettings()
|
||||
.tabItem {
|
||||
Label("API Key", systemImage: "key")
|
||||
}
|
||||
|
||||
EnvVarsSettings()
|
||||
.tabItem {
|
||||
Label("Environment", systemImage: "server.rack")
|
||||
}
|
||||
|
||||
SystemPromptSettings()
|
||||
.tabItem {
|
||||
Label("System Prompt", systemImage: "text.bubble")
|
||||
}
|
||||
}
|
||||
.frame(width: 520, height: 420)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct SystemPromptSettings: View {
|
||||
@Query private var settingsArray: [UserSettings]
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
@State private var promptText: String = ""
|
||||
@State private var saved: Bool = false
|
||||
|
||||
private var settings: UserSettings {
|
||||
if let existing = settingsArray.first {
|
||||
return existing
|
||||
}
|
||||
let s = UserSettings()
|
||||
modelContext.insert(s)
|
||||
try? modelContext.save()
|
||||
return s
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Custom System Prompt") {
|
||||
TextEditor(text: $promptText)
|
||||
.font(.system(.body, design: .monospaced))
|
||||
.frame(minHeight: 200)
|
||||
.scrollContentBackground(.hidden)
|
||||
.padding(4)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
|
||||
HStack {
|
||||
Button("Save") {
|
||||
settings.systemPrompt = promptText.isEmpty ? nil : promptText
|
||||
try? modelContext.save()
|
||||
saved = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
||||
saved = false
|
||||
}
|
||||
}
|
||||
|
||||
Button("Reset to Default") {
|
||||
promptText = ""
|
||||
settings.systemPrompt = nil
|
||||
try? modelContext.save()
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if saved {
|
||||
Text("Saved")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.green)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section {
|
||||
Label(
|
||||
"Custom instructions are appended to the default Omega system prompt. Leave empty to use the default.",
|
||||
systemImage: "info.circle"
|
||||
)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
.onAppear {
|
||||
promptText = settings.systemPrompt ?? ""
|
||||
}
|
||||
}
|
||||
}
|
||||
52
apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
Normal file
52
apps/omega-mac/OmegaMac/Views/Shared/EmptyStateView.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import SwiftUI
|
||||
|
||||
struct EmptyStateView: View {
|
||||
let onNewConversation: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 20) {
|
||||
Image(systemName: "star.circle.fill")
|
||||
.font(.system(size: 56))
|
||||
.foregroundStyle(Color.accentColor)
|
||||
.symbolEffect(.pulse, options: .repeating)
|
||||
|
||||
Text("Omega")
|
||||
.font(.largeTitle.bold())
|
||||
|
||||
Text("AI assistant powered by 1M+ tools from the TPMJS registry")
|
||||
.font(.body)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 400)
|
||||
|
||||
Button(action: onNewConversation) {
|
||||
Label("New Conversation", systemImage: "plus")
|
||||
.font(.headline)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
.keyboardShortcut("n", modifiers: .command)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
featureRow(icon: "magnifyingglass", text: "Search 1M+ tools by keyword")
|
||||
featureRow(icon: "play.circle", text: "Execute tools in a secure sandbox")
|
||||
featureRow(icon: "bolt", text: "Auto-discovers relevant tools")
|
||||
featureRow(icon: "key", text: "Securely store API keys in Keychain")
|
||||
}
|
||||
.padding(.top, 8)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(.background)
|
||||
}
|
||||
|
||||
private func featureRow(icon: String, text: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: icon)
|
||||
.frame(width: 20)
|
||||
.foregroundStyle(Color.accentColor)
|
||||
Text(text)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
79
apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
Normal file
79
apps/omega-mac/OmegaMac/Views/Shared/MarkdownView.swift
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
import MarkdownUI
|
||||
import SwiftUI
|
||||
|
||||
/// Renders markdown content from assistant responses using MarkdownUI.
|
||||
struct MarkdownView: View {
|
||||
let content: String
|
||||
|
||||
var body: some View {
|
||||
Markdown(content)
|
||||
.markdownTheme(.omega)
|
||||
.textSelection(.enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Custom Markdown Theme
|
||||
|
||||
extension MarkdownUI.Theme {
|
||||
static let omega = Theme()
|
||||
.text {
|
||||
ForegroundColor(.primary)
|
||||
FontSize(14)
|
||||
}
|
||||
.code {
|
||||
FontFamilyVariant(.monospaced)
|
||||
FontSize(12)
|
||||
ForegroundColor(.secondary)
|
||||
}
|
||||
.codeBlock { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontFamilyVariant(.monospaced)
|
||||
FontSize(12)
|
||||
ForegroundColor(.secondary)
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color.black.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.link {
|
||||
ForegroundColor(.accentColor)
|
||||
}
|
||||
.heading1 { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontWeight(.bold)
|
||||
FontSize(20)
|
||||
}
|
||||
.markdownMargin(top: 16, bottom: 8)
|
||||
}
|
||||
.heading2 { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontWeight(.semibold)
|
||||
FontSize(17)
|
||||
}
|
||||
.markdownMargin(top: 12, bottom: 6)
|
||||
}
|
||||
.heading3 { configuration in
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
FontWeight(.semibold)
|
||||
FontSize(15)
|
||||
}
|
||||
.markdownMargin(top: 10, bottom: 4)
|
||||
}
|
||||
.blockquote { configuration in
|
||||
HStack(spacing: 0) {
|
||||
Rectangle()
|
||||
.fill(Color.accentColor.opacity(0.4))
|
||||
.frame(width: 3)
|
||||
configuration.label
|
||||
.markdownTextStyle {
|
||||
ForegroundColor(.secondary)
|
||||
FontSize(13)
|
||||
}
|
||||
.padding(.leading, 10)
|
||||
}
|
||||
}
|
||||
}
|
||||
40
apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
Normal file
40
apps/omega-mac/OmegaMac/Views/Sidebar/ConversationRow.swift
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ConversationRow: View {
|
||||
let conversation: Conversation
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(conversation.displayTitle)
|
||||
.font(.system(.body, design: .default))
|
||||
.lineLimit(1)
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Text(timeAgo(conversation.updatedAt))
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
let count = conversation.messages.count
|
||||
if count > 0 {
|
||||
Text("\(count)")
|
||||
.font(.caption2.weight(.medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(.quaternary)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
|
||||
private func timeAgo(_ date: Date) -> String {
|
||||
let formatter = RelativeDateTimeFormatter()
|
||||
formatter.unitsStyle = .abbreviated
|
||||
return formatter.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
52
apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
Normal file
52
apps/omega-mac/OmegaMac/Views/Sidebar/SidebarView.swift
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import SwiftData
|
||||
import SwiftUI
|
||||
|
||||
struct SidebarView: View {
|
||||
@Binding var selectedConversation: Conversation?
|
||||
let onNewConversation: () -> Void
|
||||
|
||||
@Query(sort: \Conversation.updatedAt, order: .reverse)
|
||||
private var conversations: [Conversation]
|
||||
|
||||
@Environment(\.modelContext) private var modelContext
|
||||
|
||||
var body: some View {
|
||||
List(selection: $selectedConversation) {
|
||||
ForEach(conversations) { conversation in
|
||||
ConversationRow(conversation: conversation)
|
||||
.tag(conversation)
|
||||
.contextMenu {
|
||||
Button("Delete", role: .destructive) {
|
||||
deleteConversation(conversation)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .automatic) {
|
||||
Button(action: onNewConversation) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.help("New Conversation (Cmd+N)")
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
if conversations.isEmpty {
|
||||
ContentUnavailableView {
|
||||
Label("No Conversations", systemImage: "bubble.left.and.bubble.right")
|
||||
} description: {
|
||||
Text("Press Cmd+N to start a new conversation")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func deleteConversation(_ conversation: Conversation) {
|
||||
if selectedConversation == conversation {
|
||||
selectedConversation = nil
|
||||
}
|
||||
modelContext.delete(conversation)
|
||||
try? modelContext.save()
|
||||
}
|
||||
}
|
||||
98
apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
Normal file
98
apps/omega-mac/OmegaMac/Views/Tools/JSONToolResultView.swift
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import SwiftUI
|
||||
|
||||
/// Displays a persisted tool call result from a ToolCallData record.
|
||||
/// Collapsible card with monospaced JSON input/output.
|
||||
struct JSONToolResultView: View {
|
||||
let toolCallData: ToolCallData
|
||||
@State private var isExpanded: Bool = false
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 0) {
|
||||
// Header
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
isExpanded.toggle()
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: isError ? "xmark.circle.fill" : "checkmark.circle.fill")
|
||||
.foregroundStyle(isError ? .red : .green)
|
||||
.font(.caption)
|
||||
|
||||
Text(toolCallData.toolName)
|
||||
.font(.system(.caption, design: .monospaced).bold())
|
||||
.foregroundStyle(.primary)
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
.rotationEffect(.degrees(isExpanded ? 90 : 0))
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
|
||||
if isExpanded {
|
||||
Divider()
|
||||
.padding(.horizontal, 10)
|
||||
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Input
|
||||
if let args = toolCallData.args {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("INPUT")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(args.prettyString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
}
|
||||
}
|
||||
|
||||
// Output
|
||||
if let output = toolCallData.output {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("OUTPUT")
|
||||
.font(.system(size: 9, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
Text(output.prettyString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.15))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 4))
|
||||
.lineLimit(20)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
}
|
||||
}
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.strokeBorder(isError ? .red.opacity(0.2) : .green.opacity(0.15), lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
}
|
||||
|
||||
private var isError: Bool {
|
||||
if case .object(let obj) = toolCallData.output,
|
||||
case .bool(true) = obj["error"] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
119
apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
Normal file
119
apps/omega-mac/OmegaMac/Views/Tools/ToolCallView.swift
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import SwiftUI
|
||||
|
||||
struct ToolCallView: View {
|
||||
let toolCall: LiveToolCall
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top) {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 8) {
|
||||
statusIcon
|
||||
Text(toolCall.toolName)
|
||||
.font(.system(.callout, design: .monospaced).bold())
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
statusBadge
|
||||
}
|
||||
|
||||
// Input arguments
|
||||
if !toolCall.arguments.isEmpty {
|
||||
DisclosureGroup("Input") {
|
||||
Text(prettyJSON(toolCall.arguments))
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
// Output
|
||||
if let output = toolCall.output {
|
||||
DisclosureGroup("Output") {
|
||||
Text(output.prettyString)
|
||||
.font(.system(size: 11, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(8)
|
||||
.background(.black.opacity(0.2))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 6))
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.background(.quaternary.opacity(0.3))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10)
|
||||
.strokeBorder(borderColor, lineWidth: 1)
|
||||
)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||
|
||||
Spacer(minLength: 60)
|
||||
}
|
||||
.padding(.horizontal, 16)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusIcon: some View {
|
||||
switch toolCall.status {
|
||||
case "running":
|
||||
ProgressView()
|
||||
.controlSize(.small)
|
||||
case "success":
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
case "error":
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.red)
|
||||
default:
|
||||
Image(systemName: "questionmark.circle")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var statusBadge: some View {
|
||||
Text(toolCall.status.capitalized)
|
||||
.font(.system(size: 10, design: .monospaced))
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(badgeColor.opacity(0.15))
|
||||
.foregroundStyle(badgeColor)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
private var badgeColor: Color {
|
||||
switch toolCall.status {
|
||||
case "running": return .orange
|
||||
case "success": return .green
|
||||
case "error": return .red
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var borderColor: Color {
|
||||
switch toolCall.status {
|
||||
case "running": return .orange.opacity(0.3)
|
||||
case "success": return .green.opacity(0.2)
|
||||
case "error": return .red.opacity(0.3)
|
||||
default: return .clear
|
||||
}
|
||||
}
|
||||
|
||||
private func prettyJSON(_ jsonString: String) -> String {
|
||||
guard let data = jsonString.data(using: .utf8),
|
||||
let obj = try? JSONSerialization.jsonObject(with: data),
|
||||
let pretty = try? JSONSerialization.data(withJSONObject: obj, options: [.prettyPrinted, .sortedKeys]),
|
||||
let str = String(data: pretty, encoding: .utf8) else {
|
||||
return jsonString
|
||||
}
|
||||
return str
|
||||
}
|
||||
}
|
||||
33
apps/omega-mac/Package.resolved
Normal file
33
apps/omega-mac/Package.resolved
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
{
|
||||
"originHash" : "4ab20d6f9b3760314be6c8d471432a312fe8c0dbb4f3f28d71f6a6e9d6f6262d",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "networkimage",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/NetworkImage",
|
||||
"state" : {
|
||||
"revision" : "2849f5323265386e200484b0d0f896e73c3411b9",
|
||||
"version" : "6.0.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-cmark",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/swiftlang/swift-cmark",
|
||||
"state" : {
|
||||
"revision" : "5d9bdaa4228b381639fff09403e39a04926e2dbe",
|
||||
"version" : "0.7.1"
|
||||
}
|
||||
},
|
||||
{
|
||||
"identity" : "swift-markdown-ui",
|
||||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/gonzalezreal/swift-markdown-ui",
|
||||
"state" : {
|
||||
"revision" : "5f613358148239d0292c0cef674a3c2314737f9e",
|
||||
"version" : "2.4.1"
|
||||
}
|
||||
}
|
||||
],
|
||||
"version" : 3
|
||||
}
|
||||
19
apps/omega-mac/Package.swift
Normal file
19
apps/omega-mac/Package.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
// swift-tools-version: 5.10
|
||||
import PackageDescription
|
||||
|
||||
let package = Package(
|
||||
name: "OmegaMac",
|
||||
platforms: [.macOS(.v14)],
|
||||
dependencies: [
|
||||
.package(url: "https://github.com/gonzalezreal/swift-markdown-ui", from: "2.4.0"),
|
||||
],
|
||||
targets: [
|
||||
.executableTarget(
|
||||
name: "OmegaMac",
|
||||
dependencies: [
|
||||
.product(name: "MarkdownUI", package: "swift-markdown-ui"),
|
||||
],
|
||||
path: "OmegaMac"
|
||||
),
|
||||
]
|
||||
)
|
||||
115
apps/omega-mac/README.md
Normal file
115
apps/omega-mac/README.md
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
# Omega Mac
|
||||
|
||||
Native macOS chat app powered by the [TPMJS tool registry](https://tpmjs.com) — 1M+ AI-ready tools at your fingertips.
|
||||
|
||||
Omega Mac is the desktop counterpart to the web-based Omega agent. It connects directly to the OpenAI API and the TPMJS registry to search, discover, and execute tools in a secure remote sandbox — all from a native SwiftUI interface.
|
||||
|
||||
## Requirements
|
||||
|
||||
- macOS 15 (Sequoia) or later
|
||||
- Xcode 16+
|
||||
- An OpenAI API key
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Open `Package.swift` in Xcode
|
||||
2. Wait for Swift Package Manager to resolve dependencies
|
||||
3. Build and run (Cmd+R)
|
||||
4. Open Settings (Cmd+,) and enter your OpenAI API key
|
||||
5. Press Cmd+N to start a new conversation
|
||||
|
||||
## How It Works
|
||||
|
||||
Omega Mac implements a full **agentic tool-use loop**:
|
||||
|
||||
```
|
||||
User message
|
||||
→ Auto-discover relevant tools (BM25 search against tpmjs.com)
|
||||
→ Build tool list (registrySearch + registryExecute + discovered tools)
|
||||
→ Stream OpenAI response
|
||||
→ If tool calls returned:
|
||||
→ Execute tools via remote sandbox (executor.tpmjs.com)
|
||||
→ Feed results back to OpenAI
|
||||
→ Loop (up to 10 iterations)
|
||||
→ Display final response
|
||||
```
|
||||
|
||||
### Two Core Tools
|
||||
|
||||
Every conversation has access to two meta-tools that unlock the entire registry:
|
||||
|
||||
- **registrySearch** — Search 1M+ tools by keyword. Returns tool IDs and metadata.
|
||||
- **registryExecute** — Execute any tool by its ID. Runs in a secure remote sandbox.
|
||||
|
||||
When you send a message, Omega also auto-discovers relevant tools via BM25 search and injects them as directly-callable functions — so the AI can call them without going through registryExecute.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
OmegaMac/
|
||||
├── Models/ SwiftData persistence
|
||||
│ ├── Conversation Chat sessions with token tracking
|
||||
│ ├── Message User/assistant/tool messages with JSON tool call data
|
||||
│ ├── ToolCallRecord Individual tool execution records
|
||||
│ ├── EnvVar Environment variable metadata (values in Keychain)
|
||||
│ └── UserSettings Model selection, system prompt, pinned tools
|
||||
├── Services/ Actor-based networking
|
||||
│ ├── OpenAIService Streaming chat completions via SSE
|
||||
│ ├── StreamParser Server-Sent Events line parser
|
||||
│ ├── TPMJSRegistry Tool search + remote execution
|
||||
│ ├── KeychainService Secure storage for API keys and env vars
|
||||
│ └── ChatOrchestrator @Observable coordinator for the agentic loop
|
||||
├── Views/ SwiftUI interface
|
||||
│ ├── Sidebar/ Conversation list with @Query
|
||||
│ ├── Chat/ Messages, input bar, streaming indicator
|
||||
│ ├── Tools/ Tool call cards with collapsible JSON
|
||||
│ ├── Settings/ API key, env vars, system prompt, model picker
|
||||
│ └── Shared/ Markdown rendering, empty state
|
||||
└── Utilities/ Tool name sanitization, system prompt builder
|
||||
```
|
||||
|
||||
### Key Design Decisions
|
||||
|
||||
- **SwiftData** for local persistence — no server, no auth, everything on-device
|
||||
- **Keychain** for secrets — API keys and env var values are encrypted at rest
|
||||
- **Actors** for networking — `OpenAIService` and `TPMJSRegistryService` are actors for safe concurrent access
|
||||
- **@Observable** — `ChatOrchestrator` drives all UI state with zero Combine boilerplate
|
||||
- **Dark theme** by default — matches the web Omega aesthetic
|
||||
|
||||
## Settings
|
||||
|
||||
### API Key (required)
|
||||
|
||||
Your OpenAI API key is stored in the macOS Keychain. Omega Mac calls the OpenAI API directly — no proxy server.
|
||||
|
||||
### Model Selection
|
||||
|
||||
Choose from: `gpt-4.1-mini` (default), `gpt-4.1`, `gpt-4.1-nano`, `gpt-4o`, `gpt-4o-mini`, `o4-mini`.
|
||||
|
||||
### Environment Variables
|
||||
|
||||
Many tools in the TPMJS registry require API keys (e.g., `WEATHER_API_KEY`, `GITHUB_TOKEN`). Add them in Settings → Environment. Values are stored in Keychain; only key names and last-4-char hints are visible in the app.
|
||||
|
||||
All stored env vars are automatically passed to every tool execution.
|
||||
|
||||
### Custom System Prompt
|
||||
|
||||
Append custom instructions to Omega's default system prompt. Useful for constraining behavior, adding domain context, or specifying preferred tools.
|
||||
|
||||
## Keyboard Shortcuts
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Cmd+N | New conversation |
|
||||
| Cmd+, | Open settings |
|
||||
| Enter | Send message |
|
||||
| Shift+Enter | New line in input |
|
||||
|
||||
## Dependencies
|
||||
|
||||
- [MarkdownUI](https://github.com/gonzalezreal/swift-markdown-ui) — GitHub-flavored markdown rendering
|
||||
- Everything else uses Apple frameworks (SwiftUI, SwiftData, Security, Foundation)
|
||||
|
||||
## Relationship to Web Omega
|
||||
|
||||
This app ports the core logic from the web implementation at `apps/web/src/app/api/omega/`. The agentic loop, system prompt, tool name sanitization, and search/execute flow are all faithful Swift translations of the TypeScript originals. The key difference is that web Omega uses server-side auth and a database, while Omega Mac stores everything locally with SwiftData and Keychain.
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
# Use official Deno image
|
||||
FROM denoland/deno:1.39.0
|
||||
# Use latest Deno LTS image for stability
|
||||
FROM denoland/deno:2.1.9
|
||||
|
||||
# Install OpenSSH client for tools that need SSH access (e.g., exe-dev)
|
||||
USER root
|
||||
RUN apt-get update && apt-get install -y openssh-client && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends openssh-client curl && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# Set working directory
|
||||
WORKDIR /app
|
||||
|
|
@ -19,5 +19,9 @@ RUN chmod +x start.sh
|
|||
# Expose port (Railway will set PORT env var)
|
||||
EXPOSE 3002
|
||||
|
||||
# Docker-level health check as a fallback
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \
|
||||
CMD curl -f http://localhost:${PORT:-3002}/health || exit 1
|
||||
|
||||
# Run startup script that fixes permissions then starts Deno as deno user
|
||||
CMD ["./start.sh"]
|
||||
|
|
|
|||
8
apps/railway-executor/railway.toml
Normal file
8
apps/railway-executor/railway.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[build]
|
||||
builder = "DOCKERFILE"
|
||||
dockerfilePath = "Dockerfile"
|
||||
|
||||
[deploy]
|
||||
healthcheckPath = "/health"
|
||||
healthcheckTimeout = 30
|
||||
restartPolicyType = "ALWAYS"
|
||||
|
|
@ -6,9 +6,25 @@
|
|||
// Import zod-to-json-schema for Zod v3 support
|
||||
import { zodToJsonSchema } from 'https://esm.sh/zod-to-json-schema@3.25.0';
|
||||
|
||||
// ─── Crash Protection ───────────────────────────────────────────────────────
|
||||
// Catch unhandled promise rejections so they don't crash the process
|
||||
globalThis.addEventListener('unhandledrejection', (event) => {
|
||||
event.preventDefault();
|
||||
console.error('⚠️ Unhandled promise rejection (caught, process continues):', event.reason);
|
||||
});
|
||||
|
||||
// Catch uncaught errors
|
||||
globalThis.addEventListener('error', (event) => {
|
||||
console.error('⚠️ Uncaught error (caught, process continues):', event.error || event.message);
|
||||
event.preventDefault();
|
||||
});
|
||||
|
||||
// Cache TTL: 2 minutes
|
||||
const CACHE_TTL_MS = 2 * 60 * 1000;
|
||||
|
||||
// Max cache entries to prevent unbounded memory growth
|
||||
const MAX_CACHE_SIZE = 200;
|
||||
|
||||
// Cache entry with expiration
|
||||
interface CacheEntry {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
|
||||
|
|
@ -41,6 +57,16 @@ function getCachedModule(cacheKey: string): CacheEntry | null {
|
|||
*/
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Tool types are dynamic and vary by package
|
||||
function setCachedModule(cacheKey: string, module: any, isFactory: boolean): void {
|
||||
// Evict oldest entries if cache is full
|
||||
if (moduleCache.size >= MAX_CACHE_SIZE) {
|
||||
const entriesToEvict = Math.max(1, Math.floor(MAX_CACHE_SIZE * 0.2)); // Evict 20%
|
||||
const keys = Array.from(moduleCache.keys());
|
||||
for (let i = 0; i < entriesToEvict && i < keys.length; i++) {
|
||||
moduleCache.delete(keys[i]);
|
||||
}
|
||||
console.log(`🗑️ Evicted ${entriesToEvict} cache entries (cache was full at ${MAX_CACHE_SIZE})`);
|
||||
}
|
||||
|
||||
moduleCache.set(cacheKey, {
|
||||
module,
|
||||
expiresAt: Date.now() + CACHE_TTL_MS,
|
||||
|
|
@ -845,6 +871,9 @@ async function listExports(req: Request): Promise<Response> {
|
|||
}
|
||||
}
|
||||
|
||||
// Track startup time for uptime reporting
|
||||
const startedAt = Date.now();
|
||||
|
||||
/**
|
||||
* Health check
|
||||
*/
|
||||
|
|
@ -852,7 +881,9 @@ function health(): Response {
|
|||
return Response.json({
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptimeSeconds: Math.floor((Date.now() - startedAt) / 1000),
|
||||
cacheSize: moduleCache.size,
|
||||
maxCacheSize: MAX_CACHE_SIZE,
|
||||
denoVersion: Deno.version.deno,
|
||||
v8Version: Deno.version.v8,
|
||||
httpImports: true,
|
||||
|
|
@ -893,9 +924,15 @@ function clearCache(): Response {
|
|||
}
|
||||
|
||||
/**
|
||||
* Main request handler
|
||||
* Main request handler — wrapped with crash protection so no single request
|
||||
* can take down the process.
|
||||
*/
|
||||
async function handler(req: Request): Promise<Response> {
|
||||
// Reject requests during shutdown
|
||||
if (isShuttingDown) {
|
||||
return new Response('Service shutting down', { status: 503 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
|
||||
// Add CORS headers
|
||||
|
|
@ -946,6 +983,24 @@ async function handler(req: Request): Promise<Response> {
|
|||
}
|
||||
}
|
||||
|
||||
// ─── Graceful Shutdown ──────────────────────────────────────────────────────
|
||||
let isShuttingDown = false;
|
||||
|
||||
function handleShutdown(signal: string) {
|
||||
if (isShuttingDown) return;
|
||||
isShuttingDown = true;
|
||||
console.log(`\n🛑 Received ${signal}, shutting down gracefully...`);
|
||||
moduleCache.clear();
|
||||
// Give in-flight requests a moment to complete
|
||||
setTimeout(() => {
|
||||
console.log('👋 Goodbye');
|
||||
Deno.exit(0);
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
Deno.addSignalListener('SIGTERM', () => handleShutdown('SIGTERM'));
|
||||
Deno.addSignalListener('SIGINT', () => handleShutdown('SIGINT'));
|
||||
|
||||
// Start server
|
||||
const port = Number.parseInt(Deno.env.get('PORT') || '3002', 10);
|
||||
|
||||
|
|
|
|||
|
|
@ -4,4 +4,5 @@ mkdir -p /tmp/deno-cache
|
|||
chown -R deno:deno /tmp/deno-cache
|
||||
|
||||
# Switch to deno user and run the server
|
||||
# Deno 2.x: --allow-net, --allow-env, --allow-read, --allow-write, --allow-run for tool execution
|
||||
exec su deno -c "deno run --allow-net --allow-env --allow-read --allow-write --allow-run server.ts"
|
||||
|
|
|
|||
2
apps/web/next-env.d.ts
vendored
2
apps/web/next-env.d.ts
vendored
|
|
@ -1,6 +1,6 @@
|
|||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
import "./.next/dev/types/routes.d.ts";
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -12,9 +12,7 @@ const nextConfig: NextConfig = {
|
|||
'@tpmjs/registry-execute',
|
||||
],
|
||||
reactStrictMode: true,
|
||||
serverExternalPackages: [
|
||||
'@tpmjs/package-executor',
|
||||
],
|
||||
serverExternalPackages: ['@tpmjs/package-executor'],
|
||||
async redirects() {
|
||||
return [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "prisma generate --schema=../../packages/db/prisma/schema.prisma",
|
||||
"dev": "npx @react-grab/claude-code@latest && next dev",
|
||||
"dev": "next dev",
|
||||
"build": "prisma generate --schema=../../packages/db/prisma/schema.prisma && next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint .",
|
||||
|
|
@ -45,6 +45,8 @@
|
|||
"@vercel/blob": "^2.0.0",
|
||||
"@vercel/kv": "^3.0.0",
|
||||
"ai": "6.0.49",
|
||||
"ajv": "^8.17.1",
|
||||
"ajv-formats": "^3.0.1",
|
||||
"better-auth": "^1.4.10",
|
||||
"bm25": "^0.1.1",
|
||||
"d3": "^7.9.0",
|
||||
|
|
|
|||
148
apps/web/public/isoflow-embed.html
Normal file
148
apps/web/public/isoflow-embed.html
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>TPMJS Architecture</title>
|
||||
<style>
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
html, body, #root { width: 100%; height: 100%; overflow: hidden; }
|
||||
.loading {
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
height: 100%; font-family: system-ui, sans-serif; color: #666;
|
||||
}
|
||||
.error { color: #666; text-align: center; }
|
||||
.error a { color: #3b82f6; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"><div class="loading">Loading diagram...</div></div>
|
||||
<script type="importmap">
|
||||
{
|
||||
"imports": {
|
||||
"react": "https://esm.sh/react@18.2.0",
|
||||
"react-dom": "https://esm.sh/react-dom@18.2.0",
|
||||
"react/jsx-runtime": "https://esm.sh/react@18.2.0/jsx-runtime",
|
||||
"isoflow": "https://esm.sh/isoflow@1.1.1?external=react,react-dom",
|
||||
"@isoflow/isopacks/dist/isoflow": "https://esm.sh/@isoflow/isopacks@0.0.10/dist/isoflow.js"
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<script type="module">
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import isoflowPack from '@isoflow/isopacks/dist/isoflow';
|
||||
|
||||
const collection = isoflowPack.default || isoflowPack;
|
||||
const icons = (collection.icons || []).map(icon => ({
|
||||
id: icon.id, name: icon.name, url: icon.url,
|
||||
collection: collection.name || 'Isoflow', isIsometric: icon.isIsometric
|
||||
}));
|
||||
|
||||
const initialData = {
|
||||
title: 'TPMJS Ecosystem',
|
||||
icons,
|
||||
colors: [
|
||||
{ id: 'orange', value: '#f97316' },
|
||||
{ id: 'blue', value: '#3b82f6' },
|
||||
{ id: 'green', value: '#22c55e' },
|
||||
{ id: 'purple', value: '#a855f7' },
|
||||
{ id: 'pink', value: '#ec4899' }
|
||||
],
|
||||
items: [
|
||||
// External Services
|
||||
{ id: 'npm', name: 'npm Registry', icon: 'cloud' },
|
||||
{ id: 'vercel', name: 'Vercel', icon: 'server' },
|
||||
{ id: 'postgres', name: 'PostgreSQL', icon: 'storage' },
|
||||
{ id: 'ai', name: 'AI Providers', icon: 'diamond' },
|
||||
// Applications
|
||||
{ id: 'web', name: 'tpmjs.com', icon: 'desktop' },
|
||||
{ id: 'playground', name: 'Playground', icon: 'laptop' },
|
||||
{ id: 'executor', name: 'Executor', icon: 'vm' },
|
||||
// Published Packages
|
||||
{ id: 'cli', name: '@tpmjs/cli', icon: 'function-module' },
|
||||
{ id: 'types', name: '@tpmjs/types', icon: 'cube' },
|
||||
{ id: 'ui', name: '@tpmjs/ui', icon: 'block' },
|
||||
{ id: 'mcp', name: '@tpmjs/mcp-client', icon: 'loadbalancer' },
|
||||
// Internal Packages
|
||||
{ id: 'db', name: '@tpmjs/db', icon: 'storage' },
|
||||
{ id: 'npm-client', name: '@tpmjs/npm-client', icon: 'package-module' },
|
||||
// Official Tools
|
||||
{ id: 'tools', name: '191+ AI Tools', icon: 'tower' }
|
||||
],
|
||||
views: [{
|
||||
id: 'overview',
|
||||
name: 'Ecosystem Overview',
|
||||
items: [
|
||||
// Row 0: External Services (2-tile spacing, compact)
|
||||
{ id: 'npm', tile: { x: 0, y: 0 } },
|
||||
{ id: 'vercel', tile: { x: 2, y: 0 } },
|
||||
{ id: 'postgres', tile: { x: 4, y: 0 } },
|
||||
{ id: 'ai', tile: { x: 6, y: 0 } },
|
||||
// Row 2: Applications
|
||||
{ id: 'web', tile: { x: 1, y: 2 } },
|
||||
{ id: 'playground', tile: { x: 3, y: 2 } },
|
||||
{ id: 'executor', tile: { x: 5, y: 2 } },
|
||||
// Row 4: Published Packages
|
||||
{ id: 'cli', tile: { x: 0, y: 4 } },
|
||||
{ id: 'types', tile: { x: 2, y: 4 } },
|
||||
{ id: 'ui', tile: { x: 4, y: 4 } },
|
||||
{ id: 'mcp', tile: { x: 6, y: 4 } },
|
||||
// Row 6: Internal + Tools
|
||||
{ id: 'db', tile: { x: 1, y: 6 } },
|
||||
{ id: 'npm-client', tile: { x: 3, y: 6 } },
|
||||
{ id: 'tools', tile: { x: 5, y: 6 } }
|
||||
],
|
||||
connectors: [
|
||||
// External → Apps
|
||||
{ id: 'c1', color: 'orange', style: 'SOLID', anchors: [{ id: 'a1', ref: { item: 'vercel' } }, { id: 'a2', ref: { item: 'web' } }] },
|
||||
{ id: 'c2', color: 'orange', style: 'DASHED', anchors: [{ id: 'a3', ref: { item: 'vercel' } }, { id: 'a4', ref: { item: 'playground' } }] },
|
||||
{ id: 'c3', color: 'orange', style: 'SOLID', anchors: [{ id: 'a5', ref: { item: 'ai' } }, { id: 'a6', ref: { item: 'web' } }] },
|
||||
{ id: 'c4', color: 'orange', style: 'DASHED', anchors: [{ id: 'a7', ref: { item: 'ai' } }, { id: 'a8', ref: { item: 'executor' } }] },
|
||||
{ id: 'c5', color: 'purple', style: 'SOLID', anchors: [{ id: 'a9', ref: { item: 'postgres' } }, { id: 'a10', ref: { item: 'db' } }] },
|
||||
// Apps → Packages
|
||||
{ id: 'c6', color: 'blue', style: 'SOLID', anchors: [{ id: 'a11', ref: { item: 'web' } }, { id: 'a12', ref: { item: 'types' } }] },
|
||||
{ id: 'c7', color: 'blue', style: 'SOLID', anchors: [{ id: 'a13', ref: { item: 'web' } }, { id: 'a14', ref: { item: 'ui' } }] },
|
||||
{ id: 'c8', color: 'blue', style: 'DASHED', anchors: [{ id: 'a15', ref: { item: 'web' } }, { id: 'a16', ref: { item: 'mcp' } }] },
|
||||
// Internal → Apps
|
||||
{ id: 'c9', color: 'purple', style: 'SOLID', anchors: [{ id: 'a17', ref: { item: 'db' } }, { id: 'a18', ref: { item: 'web' } }] },
|
||||
{ id: 'c10', color: 'purple', style: 'DASHED', anchors: [{ id: 'a19', ref: { item: 'npm-client' } }, { id: 'a20', ref: { item: 'web' } }] },
|
||||
{ id: 'c11', color: 'orange', style: 'DASHED', anchors: [{ id: 'a21', ref: { item: 'npm' } }, { id: 'a22', ref: { item: 'npm-client' } }] },
|
||||
// Tools pipeline
|
||||
{ id: 'c12', color: 'pink', style: 'SOLID', anchors: [{ id: 'a23', ref: { item: 'tools' } }, { id: 'a24', ref: { item: 'npm' } }] },
|
||||
{ id: 'c13', color: 'pink', style: 'DASHED', anchors: [{ id: 'a25', ref: { item: 'tools' } }, { id: 'a26', ref: { item: 'cli' } }] }
|
||||
]
|
||||
}],
|
||||
fitToView: true,
|
||||
view: 'overview'
|
||||
};
|
||||
|
||||
try {
|
||||
const { default: Isoflow } = await import('isoflow');
|
||||
ReactDOM.render(
|
||||
React.createElement(Isoflow, {
|
||||
initialData,
|
||||
editorMode: 'EXPLORABLE_READONLY',
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
renderer: { showGrid: true }
|
||||
}),
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
||||
// After fitToView renders, zoom in for better label readability
|
||||
setTimeout(() => {
|
||||
const zoomIn = document.querySelector('[aria-label="Zoom in"]') ||
|
||||
[...document.querySelectorAll('button')].find(b => b.textContent.trim() === '+');
|
||||
if (zoomIn) {
|
||||
for (let i = 0; i < 4; i++) setTimeout(() => zoomIn.click(), i * 100);
|
||||
}
|
||||
}, 1500);
|
||||
} catch (err) {
|
||||
console.error('Isoflow load error:', err);
|
||||
document.getElementById('root').innerHTML =
|
||||
'<div class="loading error"><p>Failed to load diagram. <a href="/architecture">View alternative</a></p></div>';
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
85
apps/web/public/llms.txt
Normal file
85
apps/web/public/llms.txt
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
# TPMJS — The NPM for AI Tools
|
||||
|
||||
> TPMJS is an open tool registry and platform for discovering, publishing, and executing AI agent tools from npm. Any npm package with the `tpmjs` keyword is automatically discovered and made available. Tools use the Vercel AI SDK `tool()` pattern and work with Claude, GPT, LangChain, and any MCP-compatible client. The platform provides collections for organizing tools, custom agents with multi-provider support, scenario testing, living skills documentation, and a unified MCP endpoint.
|
||||
|
||||
## Getting Started
|
||||
|
||||
- [Homepage](https://tpmjs.com): Browse the registry and discover AI tools
|
||||
- [Quickstart Guide](https://tpmjs.com/docs/quickstart): Get started with TPMJS in minutes
|
||||
- [How It Works](https://tpmjs.com/how-it-works): Overview of the platform architecture and tool lifecycle
|
||||
- [Platform Guide](https://tpmjs.com/docs/platform-guide): Comprehensive guide to all platform features
|
||||
- [Publishing Tools](https://tpmjs.com/publish): How to create and publish your own AI tools to the registry
|
||||
- [FAQ](https://tpmjs.com/faq): Frequently asked questions
|
||||
|
||||
## Documentation
|
||||
|
||||
- [Docs Home](https://tpmjs.com/docs): Documentation index
|
||||
- [Architecture](https://tpmjs.com/docs/architecture): System design, monorepo structure, and technical decisions
|
||||
- [SDK Reference](https://tpmjs.com/docs/sdk): JavaScript/TypeScript SDK for building with TPMJS tools
|
||||
- [Tool Specification](https://tpmjs.com/spec): The TPMJS package.json `tpmjs` field specification for tool authors
|
||||
- [Agents Guide](https://tpmjs.com/docs/agents): Creating and managing AI agents with multi-provider support
|
||||
- [Scenarios Guide](https://tpmjs.com/docs/scenarios): AI-generated test scenarios for validating tool integrations
|
||||
- [Skills Guide](https://tpmjs.com/docs/skills): Living documentation that evolves from agent usage patterns
|
||||
- [Sharing Guide](https://tpmjs.com/docs/sharing): How to share collections, agents, and tools publicly
|
||||
- [Developer Guide](https://tpmjs.com/docs/developers/guide): In-depth guide for tool developers
|
||||
|
||||
## Tutorials
|
||||
|
||||
- [MCP Integration](https://tpmjs.com/docs/tutorials/mcp): Connect TPMJS tools to Claude Desktop, Cursor, and other MCP clients
|
||||
- [Bridge Setup](https://tpmjs.com/docs/tutorials/bridge): Bridge local MCP servers to the TPMJS cloud
|
||||
- [Custom Agents](https://tpmjs.com/docs/tutorials/agents): Build AI agents with curated tool collections
|
||||
- [Custom Executors](https://tpmjs.com/docs/tutorials/custom-executor): Run tools on your own infrastructure
|
||||
|
||||
## API Reference
|
||||
|
||||
- [API Overview](https://tpmjs.com/docs/api): REST API introduction and authentication
|
||||
- [Authentication](https://tpmjs.com/docs/api/authentication): API key management and auth patterns
|
||||
- [Tools API](https://tpmjs.com/docs/api/tools): Search, list, execute, and validate tools
|
||||
- [Collections API](https://tpmjs.com/docs/api/collections): Create and manage tool collections
|
||||
- [Agents API](https://tpmjs.com/docs/api/agents): Create, configure, and chat with AI agents
|
||||
- [Scenarios API](https://tpmjs.com/docs/api/scenarios): Generate, run, and manage test scenarios
|
||||
|
||||
## Executors
|
||||
|
||||
- [Executors Overview](https://tpmjs.com/docs/executors): How tool execution works in TPMJS
|
||||
- [Unsandbox Executor](https://tpmjs.com/docs/executors/unsandbox): Sandboxed code execution environment
|
||||
- [Vercel Executor](https://tpmjs.com/docs/executors/vercel): Deploy executors on Vercel
|
||||
- [Railway Executor](https://tpmjs.com/docs/executors/railway): Deploy executors on Railway
|
||||
|
||||
## Explore
|
||||
|
||||
- [Tool Search](https://tpmjs.com/tool/tool-search): Search the full tool registry with filters
|
||||
- [Collections](https://tpmjs.com/collections): Browse public tool collections
|
||||
- [Agents](https://tpmjs.com/agents): Browse public AI agents
|
||||
- [Scenarios](https://tpmjs.com/scenarios): Browse featured test scenarios
|
||||
- [Use Cases](https://tpmjs.com/use-cases): Real-world use cases generated from scenario testing
|
||||
- [Playground](https://tpmjs.com/playground): Interactive tool testing playground
|
||||
- [Registry Stats](https://tpmjs.com/stats): Live registry statistics and health dashboard
|
||||
- [Tool Ideas](https://tpmjs.com/tool-ideas): Community-sourced AI tool ideas
|
||||
|
||||
## CLI
|
||||
|
||||
- [CLI Auth](https://tpmjs.com/cli/auth): Authenticate the TPMJS CLI
|
||||
|
||||
## npm Packages
|
||||
|
||||
- [@tpmjs/cli](https://www.npmjs.com/package/@tpmjs/cli): Command-line interface with 37 commands for tools, agents, collections, scenarios, and MCP
|
||||
- [@tpmjs/types](https://www.npmjs.com/package/@tpmjs/types): Shared TypeScript types and Zod schemas
|
||||
- [@tpmjs/ui](https://www.npmjs.com/package/@tpmjs/ui): React component library for TPMJS applications
|
||||
- [@tpmjs/utils](https://www.npmjs.com/package/@tpmjs/utils): Utility functions including className merging and formatters
|
||||
- [@tpmjs/env](https://www.npmjs.com/package/@tpmjs/env): Environment variable validation with Zod
|
||||
- [@tpmjs/mcp-client](https://www.npmjs.com/package/@tpmjs/mcp-client): MCP client library for connecting to Model Context Protocol servers
|
||||
- [@tpmjs/bridge](https://www.npmjs.com/package/@tpmjs/bridge): Bridge CLI for connecting local MCP servers to TPMJS cloud
|
||||
- [@tpmjs/executor-test](https://www.npmjs.com/package/@tpmjs/executor-test): Executor protocol compliance test suite
|
||||
|
||||
## Optional
|
||||
|
||||
- [Features Overview](https://tpmjs.com/features): Marketing overview of all platform capabilities
|
||||
- [Integrations](https://tpmjs.com/integrations): Supported AI platforms and MCP clients
|
||||
- [Changelog](https://tpmjs.com/changelog): Release history and updates
|
||||
- [About](https://tpmjs.com/about): About the TPMJS project
|
||||
- [Privacy Policy](https://tpmjs.com/privacy): Privacy policy
|
||||
- [Terms of Service](https://tpmjs.com/terms): Terms of service
|
||||
- [Broken Tools](https://tpmjs.com/tool/broken): Tools with health issues
|
||||
- [Omega Agent](https://tpmjs.com/omega): Chat-based tool discovery and execution agent
|
||||
- [Style Guide](https://tpmjs.com/style-guide): Visual design system reference
|
||||
|
|
@ -11,6 +11,7 @@ import { AppHeader } from '~/components/AppHeader';
|
|||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||
import { LikeButton } from '~/components/LikeButton';
|
||||
import { useTrackView } from '~/hooks/useTrackView';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
interface AgentTool {
|
||||
|
|
@ -183,6 +184,7 @@ const response = await fetch(\`${apiUrl}/\${conversation.id}\`, {
|
|||
);
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large detail page with many conditional sections
|
||||
export default function PrettyAgentDetailPage(): React.ReactElement {
|
||||
const params = useParams();
|
||||
const rawUsername = params.username as string;
|
||||
|
|
@ -194,6 +196,9 @@ export default function PrettyAgentDetailPage(): React.ReactElement {
|
|||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Track page view
|
||||
useTrackView('agent', agent?.id ?? '');
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && agent?.createdBy?.id === session.user.id;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { Button } from '@tpmjs/ui/Button/Button';
|
|||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
import { ForkedFromBadge } from '~/components/ForkedFromBadge';
|
||||
|
|
@ -13,8 +13,37 @@ import { LikeButton } from '~/components/LikeButton';
|
|||
import { ScenariosSection } from '~/components/ScenariosSection';
|
||||
import { ShareButton } from '~/components/ShareButton';
|
||||
import { SkillsSection } from '~/components/skills/SkillsSection';
|
||||
import { UseCasesSection } from '~/components/UseCasesSection';
|
||||
import { useTrackView } from '~/hooks/useTrackView';
|
||||
import { useSession } from '~/lib/auth-client';
|
||||
|
||||
/**
|
||||
* Locked state for private collections viewed by non-owners
|
||||
* Shows minimal information: just name and "Private" badge
|
||||
*/
|
||||
export function PrivateCollectionLocked({ name }: { name: string }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<AppHeader />
|
||||
|
||||
<main className="max-w-5xl mx-auto px-4 py-8">
|
||||
<div className="flex flex-col items-center justify-center py-24 text-center">
|
||||
<div className="w-16 h-16 rounded-full bg-foreground-tertiary/10 flex items-center justify-center mb-6">
|
||||
<Icon icon="key" className="w-8 h-8 text-foreground-tertiary" />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<h1 className="text-2xl font-bold text-foreground">{name}</h1>
|
||||
<Badge variant="secondary">Private</Badge>
|
||||
</div>
|
||||
<p className="text-foreground-secondary max-w-md">
|
||||
This collection is private and can only be viewed by its owner.
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export interface CollectionTool {
|
||||
id: string;
|
||||
toolId: string;
|
||||
|
|
@ -32,6 +61,20 @@ export interface CollectionTool {
|
|||
};
|
||||
}
|
||||
|
||||
export interface UseCaseToolStep {
|
||||
toolName: string;
|
||||
packageName: string;
|
||||
purpose: string;
|
||||
order: number;
|
||||
}
|
||||
|
||||
export interface UseCase {
|
||||
id: string;
|
||||
userPrompt: string;
|
||||
description: string;
|
||||
toolSequence: UseCaseToolStep[];
|
||||
}
|
||||
|
||||
export interface PublicCollection {
|
||||
id: string;
|
||||
slug: string; // Already coerced to empty string if null in server component
|
||||
|
|
@ -57,6 +100,8 @@ export interface PublicCollection {
|
|||
username: string;
|
||||
};
|
||||
} | null;
|
||||
useCases?: UseCase[] | null;
|
||||
useCasesGeneratedAt?: string | null;
|
||||
}
|
||||
|
||||
function McpUrlSection({
|
||||
|
|
@ -82,14 +127,15 @@ function McpUrlSection({
|
|||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
};
|
||||
|
||||
// Claude Code CLI command (correct arg order: options before name and url)
|
||||
const claudeCodeCommand = `claude mcp add tpmjs-${slug} ${httpUrl} -t http`;
|
||||
|
||||
// Claude Desktop native HTTP config
|
||||
const configSnippet = `{
|
||||
"mcpServers": {
|
||||
"tpmjs-${slug}": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${httpUrl}"
|
||||
]
|
||||
"type": "http",
|
||||
"url": "${httpUrl}"
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
|
@ -188,6 +234,27 @@ const response = await fetch("${httpUrl}", {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Claude Code CLI command */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">Add to Claude Code</h4>
|
||||
<div className="relative">
|
||||
<pre className="p-3 bg-surface border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{claudeCodeCommand}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(claudeCodeCommand)}
|
||||
className="absolute top-1.5 right-1.5"
|
||||
>
|
||||
<Icon icon="copy" className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-foreground-tertiary">
|
||||
Run <code className="font-mono">/mcp</code> in Claude Code to verify the connection.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Config snippet toggle */}
|
||||
<div className="mt-4 pt-4 border-t border-border/50 space-y-2">
|
||||
<button
|
||||
|
|
@ -240,12 +307,31 @@ interface CollectionDetailClientProps {
|
|||
username: string;
|
||||
}
|
||||
|
||||
export function CollectionDetailClient({ collection, username }: CollectionDetailClientProps) {
|
||||
export function CollectionDetailClient({
|
||||
collection: initialCollection,
|
||||
username,
|
||||
}: CollectionDetailClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const [collection, setCollection] = useState(initialCollection);
|
||||
|
||||
// Track page view
|
||||
useTrackView('collection', collection.id);
|
||||
|
||||
// Check if current user is the owner
|
||||
const isOwner = session?.user?.id && collection.createdBy?.id === session.user.id;
|
||||
|
||||
// Handler for when use cases are generated
|
||||
const handleUseCasesGenerated = useCallback(
|
||||
(useCases: UseCase[], generatedAt: string) => {
|
||||
setCollection({
|
||||
...collection,
|
||||
useCases,
|
||||
useCasesGeneratedAt: generatedAt,
|
||||
});
|
||||
},
|
||||
[collection]
|
||||
);
|
||||
|
||||
// Generate tweet text
|
||||
const tweetText = collection.description
|
||||
? `${collection.name} - ${collection.description.slice(0, 100)}${collection.description.length > 100 ? '...' : ''}`
|
||||
|
|
@ -361,6 +447,16 @@ export function CollectionDetailClient({ collection, username }: CollectionDetai
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Use Cases Section */}
|
||||
{collection.tools.length > 0 && (
|
||||
<UseCasesSection
|
||||
collectionId={collection.id}
|
||||
useCases={collection.useCases ?? null}
|
||||
generatedAt={collection.useCasesGeneratedAt ?? null}
|
||||
onUseCasesGenerated={handleUseCasesGenerated}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Scenarios Section */}
|
||||
{collection.tools.length > 0 && (
|
||||
<ScenariosSection
|
||||
|
|
|
|||
|
|
@ -0,0 +1,359 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
import { API_KEY_SCOPES } from '~/lib/api-keys';
|
||||
import { authenticateRequest, getClientMetadata, hasScope } from '~/lib/api-keys/middleware';
|
||||
import {
|
||||
checkApiKeyRateLimit,
|
||||
createRateLimitResponse,
|
||||
getRateLimitHeaders,
|
||||
} from '~/lib/api-keys/rate-limit';
|
||||
import { trackUsage } from '~/lib/api-keys/usage';
|
||||
import { handleInitialize, handleToolsCall, handleToolsList } from '~/lib/mcp/handlers';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const DB_TIMEOUT_MS = 10000; // 10 second timeout for database queries
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ username: string; slug: string }>;
|
||||
}
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: string;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
id?: string | number;
|
||||
}
|
||||
|
||||
interface JsonRpcResponse {
|
||||
jsonrpc: '2.0';
|
||||
id: string | number | null;
|
||||
result?: unknown;
|
||||
error?: { code: number; message: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap a promise with a timeout
|
||||
*/
|
||||
function withTimeout<T>(promise: Promise<T>, ms: number, errorMessage: string): Promise<T> {
|
||||
return Promise.race([
|
||||
promise,
|
||||
new Promise<T>((_, reject) => setTimeout(() => reject(new Error(errorMessage)), ms)),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a user by username (strips @ prefix if present)
|
||||
*/
|
||||
async function getUserByUsername(username: string) {
|
||||
// Strip @ prefix if present (from pretty URLs like /@username)
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
|
||||
return withTimeout(
|
||||
prisma.user.findUnique({
|
||||
where: { username: cleanUsername },
|
||||
select: { id: true, username: true },
|
||||
}),
|
||||
DB_TIMEOUT_MS,
|
||||
`Database query timed out after ${DB_TIMEOUT_MS}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a collection by user ID and slug
|
||||
*/
|
||||
async function getCollectionByUserIdAndSlug(userId: string, slug: string) {
|
||||
return withTimeout(
|
||||
prisma.collection.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
slug,
|
||||
},
|
||||
select: { id: true, name: true, description: true, userId: true, isPublic: true },
|
||||
}),
|
||||
DB_TIMEOUT_MS,
|
||||
`Database query timed out after ${DB_TIMEOUT_MS}ms`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Process a JSON-RPC request and return the response
|
||||
*/
|
||||
async function processJsonRpcRequest(
|
||||
collectionId: string,
|
||||
collectionName: string,
|
||||
body: JsonRpcRequest,
|
||||
isOwner: boolean
|
||||
): Promise<JsonRpcResponse> {
|
||||
const requestId = body.id ?? null;
|
||||
|
||||
switch (body.method) {
|
||||
case 'initialize':
|
||||
return handleInitialize(collectionName, requestId);
|
||||
|
||||
case 'tools/list':
|
||||
return await handleToolsList(collectionId, requestId);
|
||||
|
||||
case 'tools/call': {
|
||||
const params = body.params as {
|
||||
name: string;
|
||||
arguments?: Record<string, unknown>;
|
||||
env?: Record<string, string>;
|
||||
};
|
||||
|
||||
// For non-owners, use caller-provided env vars (or empty if not provided)
|
||||
// For owners, callerEnvVars is undefined so handleToolsCall uses stored env vars
|
||||
const callerEnvVars = isOwner ? undefined : params.env || {};
|
||||
|
||||
return await handleToolsCall(collectionId, params, requestId, callerEnvVars);
|
||||
}
|
||||
|
||||
case 'notifications/initialized':
|
||||
case 'ping':
|
||||
return { jsonrpc: '2.0', id: requestId, result: {} };
|
||||
|
||||
default:
|
||||
return {
|
||||
jsonrpc: '2.0',
|
||||
id: requestId,
|
||||
error: { code: -32601, message: `Method not found: ${body.method}` },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* POST /@username/collections/[slug]/mcp
|
||||
* MCP JSON-RPC endpoint (HTTP transport only)
|
||||
*
|
||||
* Authentication:
|
||||
* - Public collections: No auth required
|
||||
* - Private collections: Requires Authorization: Bearer header with valid API key
|
||||
*/
|
||||
export async function POST(request: NextRequest, context: RouteContext): Promise<Response> {
|
||||
const startTime = Date.now();
|
||||
let authResult: Awaited<ReturnType<typeof authenticateRequest>> | null = null;
|
||||
|
||||
try {
|
||||
const { username, slug } = await context.params;
|
||||
|
||||
// First, find the user by username
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: `User '${username}' not found. Check the username in your MCP endpoint URL.`,
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Then find the collection by user ID and slug
|
||||
const collection = await getCollectionByUserIdAndSlug(user.id, slug);
|
||||
|
||||
if (!collection) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32001,
|
||||
message: `Collection '${slug}' not found for user '${user.username}'.`,
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Authenticate the request
|
||||
authResult = await authenticateRequest();
|
||||
|
||||
// Determine if the authenticated user is the owner
|
||||
const isOwner = authResult.authenticated && authResult.userId === collection.userId;
|
||||
|
||||
// Authorization check:
|
||||
// - Owners can always access their own collections (public or private)
|
||||
// - Non-owners can access PUBLIC collections without auth
|
||||
// - Private collections require auth as the owner
|
||||
if (!isOwner && !collection.isPublic) {
|
||||
// Private collection, not the owner - require authentication
|
||||
if (!authResult.authenticated) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: {
|
||||
code: -32000,
|
||||
message: 'Authentication required. Add header: Authorization: Bearer YOUR_API_KEY',
|
||||
},
|
||||
id: null,
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
// Authenticated but not the owner of a private collection - don't reveal existence
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32001, message: 'Collection not found' }, id: null },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Check scope if authenticated
|
||||
if (authResult.authenticated && !hasScope(authResult, API_KEY_SCOPES.MCP_EXECUTE)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
jsonrpc: '2.0',
|
||||
error: { code: -32000, message: 'Missing required scope: mcp:execute' },
|
||||
id: null,
|
||||
},
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Rate limit if authenticated via API key
|
||||
if (authResult.authenticated && authResult.apiKeyId) {
|
||||
const rateLimitResult = await checkApiKeyRateLimit(
|
||||
authResult.apiKeyId,
|
||||
authResult.tier || 'FREE'
|
||||
);
|
||||
|
||||
if (!rateLimitResult.allowed) {
|
||||
return createRateLimitResponse(rateLimitResult);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON-RPC request body
|
||||
let body: JsonRpcRequest;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32700, message: 'Parse error' }, id: null },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Process the request
|
||||
const response = await processJsonRpcRequest(collection.id, collection.name, body, isOwner);
|
||||
const jsonResponse = NextResponse.json(response);
|
||||
|
||||
// Track usage for authenticated requests
|
||||
if (authResult.authenticated && authResult.userId) {
|
||||
const clientMeta = await getClientMetadata();
|
||||
trackUsage({
|
||||
apiKeyId: authResult.apiKeyId,
|
||||
userId: authResult.userId,
|
||||
endpoint: `/@${user.username}/collections/${slug}/mcp`,
|
||||
method: 'POST',
|
||||
statusCode: jsonResponse.status,
|
||||
latencyMs: Date.now() - startTime,
|
||||
resourceType: 'mcp',
|
||||
resourceId: collection.id,
|
||||
userAgent: clientMeta.userAgent,
|
||||
ipAddress: clientMeta.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
// Add rate limit headers for authenticated requests
|
||||
if (authResult.authenticated && authResult.apiKeyId) {
|
||||
const rateLimitResult = await checkApiKeyRateLimit(
|
||||
authResult.apiKeyId,
|
||||
authResult.tier || 'FREE'
|
||||
);
|
||||
const headers = getRateLimitHeaders(rateLimitResult);
|
||||
for (const [key, value] of Object.entries(headers)) {
|
||||
jsonResponse.headers.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
return jsonResponse;
|
||||
} catch (error) {
|
||||
console.error('[MCP POST] Error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
|
||||
// Track error for authenticated requests
|
||||
if (authResult?.authenticated && authResult.userId) {
|
||||
const { username, slug } = await context.params;
|
||||
const clientMeta = await getClientMetadata();
|
||||
trackUsage({
|
||||
apiKeyId: authResult.apiKeyId,
|
||||
userId: authResult.userId,
|
||||
endpoint: `/@${username}/collections/${slug}/mcp`,
|
||||
method: 'POST',
|
||||
statusCode: 500,
|
||||
latencyMs: Date.now() - startTime,
|
||||
resourceType: 'mcp',
|
||||
errorCode: 'INTERNAL_ERROR',
|
||||
errorMessage: message,
|
||||
userAgent: clientMeta.userAgent,
|
||||
ipAddress: clientMeta.ipAddress,
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: '2.0', error: { code: -32603, message }, id: null },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /@username/collections/[slug]/mcp
|
||||
* Returns server info for the MCP endpoint
|
||||
*/
|
||||
export async function GET(_request: NextRequest, context: RouteContext): Promise<Response> {
|
||||
try {
|
||||
const { username, slug } = await context.params;
|
||||
|
||||
// First, find the user by username
|
||||
const user = await getUserByUsername(username);
|
||||
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ error: `User '${username}' not found. Check the username in your MCP endpoint URL.` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// Then find the collection
|
||||
const collection = await getCollectionByUserIdAndSlug(user.id, slug);
|
||||
|
||||
if (!collection) {
|
||||
return NextResponse.json(
|
||||
{ error: `Collection '${slug}' not found for user '${user.username}'.` },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
|
||||
// For GET requests, check if user can access this collection:
|
||||
// - Public collections are accessible to anyone
|
||||
// - Private collections are only accessible to the owner (when authenticated)
|
||||
if (!collection.isPublic) {
|
||||
const authResult = await authenticateRequest();
|
||||
if (!authResult.authenticated || authResult.userId !== collection.userId) {
|
||||
// Don't reveal existence of private collections
|
||||
return NextResponse.json({ error: 'Collection not found' }, { status: 404 });
|
||||
}
|
||||
}
|
||||
|
||||
// Return server info
|
||||
return NextResponse.json({
|
||||
name: `TPMJS: ${collection.name}`,
|
||||
description: collection.description,
|
||||
protocol: 'mcp',
|
||||
transport: 'http',
|
||||
endpoint: `/@${user.username}/collections/${slug}/mcp`,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[MCP GET] Error:', error);
|
||||
const message = error instanceof Error ? error.message : 'Internal server error';
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import type { Metadata } from 'next';
|
||||
import { notFound } from 'next/navigation';
|
||||
import { CollectionDetailClient, type PublicCollection } from './CollectionDetailClient';
|
||||
import {
|
||||
CollectionDetailClient,
|
||||
PrivateCollectionLocked,
|
||||
type PublicCollection,
|
||||
} from './CollectionDetailClient';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
|
|
@ -9,18 +13,25 @@ interface CollectionPageProps {
|
|||
params: Promise<{ username: string; slug: string }>;
|
||||
}
|
||||
|
||||
interface CollectionResult {
|
||||
collection: PublicCollection | null;
|
||||
isPrivate: boolean;
|
||||
privateName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch collection data from database
|
||||
* Returns both public collections fully, and private collections with minimal info (locked state)
|
||||
*/
|
||||
async function getCollection(username: string, slug: string): Promise<PublicCollection | null> {
|
||||
async function getCollection(username: string, slug: string): Promise<CollectionResult> {
|
||||
// Remove @ prefix if present
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
|
||||
// First, check if the collection exists at all (public or private)
|
||||
const collection = await prisma.collection.findFirst({
|
||||
where: {
|
||||
slug,
|
||||
user: { username: cleanUsername },
|
||||
isPublic: true,
|
||||
},
|
||||
include: {
|
||||
user: {
|
||||
|
|
@ -57,51 +68,64 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
|
|||
});
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
return { collection: null, isPrivate: false };
|
||||
}
|
||||
|
||||
// If private, return minimal info for locked state
|
||||
if (!collection.isPublic) {
|
||||
return {
|
||||
collection: null,
|
||||
isPrivate: true,
|
||||
privateName: collection.name,
|
||||
};
|
||||
}
|
||||
|
||||
// Public collection - return full data
|
||||
return {
|
||||
id: collection.id,
|
||||
slug: collection.slug || '',
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
likeCount: collection.likeCount,
|
||||
toolCount: collection.tools.length,
|
||||
forkCount: collection.forkCount,
|
||||
createdAt: collection.createdAt.toISOString(),
|
||||
createdBy: {
|
||||
id: collection.user.id,
|
||||
username: collection.user.username || '',
|
||||
name: collection.user.name || '',
|
||||
image: collection.user.image,
|
||||
},
|
||||
tools: collection.tools.map((ct) => ({
|
||||
id: ct.id,
|
||||
toolId: ct.toolId,
|
||||
position: ct.position,
|
||||
note: ct.note,
|
||||
tool: {
|
||||
id: ct.tool.id,
|
||||
name: ct.tool.name,
|
||||
description: ct.tool.description,
|
||||
likeCount: ct.tool.likeCount,
|
||||
package: {
|
||||
npmPackageName: ct.tool.package.npmPackageName,
|
||||
category: ct.tool.package.category,
|
||||
},
|
||||
collection: {
|
||||
id: collection.id,
|
||||
slug: collection.slug || '',
|
||||
name: collection.name,
|
||||
description: collection.description,
|
||||
likeCount: collection.likeCount,
|
||||
toolCount: collection.tools.length,
|
||||
forkCount: collection.forkCount,
|
||||
createdAt: collection.createdAt.toISOString(),
|
||||
createdBy: {
|
||||
id: collection.user.id,
|
||||
username: collection.user.username || '',
|
||||
name: collection.user.name || '',
|
||||
image: collection.user.image,
|
||||
},
|
||||
})),
|
||||
forkedFromId: collection.forkedFromId,
|
||||
forkedFrom: collection.forkedFrom
|
||||
? {
|
||||
id: collection.forkedFrom.id,
|
||||
name: collection.forkedFrom.name,
|
||||
slug: collection.forkedFrom.slug || '',
|
||||
user: {
|
||||
username: collection.forkedFrom.user.username || '',
|
||||
tools: collection.tools.map((ct) => ({
|
||||
id: ct.id,
|
||||
toolId: ct.toolId,
|
||||
position: ct.position,
|
||||
note: ct.note,
|
||||
tool: {
|
||||
id: ct.tool.id,
|
||||
name: ct.tool.name,
|
||||
description: ct.tool.description,
|
||||
likeCount: ct.tool.likeCount,
|
||||
package: {
|
||||
npmPackageName: ct.tool.package.npmPackageName,
|
||||
category: ct.tool.package.category,
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
})),
|
||||
forkedFromId: collection.forkedFromId,
|
||||
forkedFrom: collection.forkedFrom
|
||||
? {
|
||||
id: collection.forkedFrom.id,
|
||||
name: collection.forkedFrom.name,
|
||||
slug: collection.forkedFrom.slug || '',
|
||||
user: {
|
||||
username: collection.forkedFrom.user.username || '',
|
||||
},
|
||||
}
|
||||
: null,
|
||||
},
|
||||
isPrivate: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -111,15 +135,25 @@ async function getCollection(username: string, slug: string): Promise<PublicColl
|
|||
export async function generateMetadata({ params }: CollectionPageProps): Promise<Metadata> {
|
||||
const { username, slug } = await params;
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
const collection = await getCollection(username, slug);
|
||||
const result = await getCollection(username, slug);
|
||||
|
||||
if (!collection) {
|
||||
// Private collection - minimal metadata
|
||||
if (result.isPrivate) {
|
||||
return {
|
||||
title: `${result.privateName} (Private) | TPMJS`,
|
||||
description: 'This collection is private.',
|
||||
robots: { index: false, follow: false },
|
||||
};
|
||||
}
|
||||
|
||||
if (!result.collection) {
|
||||
return {
|
||||
title: 'Collection Not Found | TPMJS',
|
||||
description: 'The requested collection could not be found.',
|
||||
};
|
||||
}
|
||||
|
||||
const collection = result.collection;
|
||||
const title = `${collection.name} | TPMJS`;
|
||||
const description =
|
||||
collection.description ||
|
||||
|
|
@ -174,11 +208,17 @@ export async function generateMetadata({ params }: CollectionPageProps): Promise
|
|||
export default async function CollectionDetailPage({ params }: CollectionPageProps) {
|
||||
const { username, slug } = await params;
|
||||
const cleanUsername = username.startsWith('@') ? username.slice(1) : username;
|
||||
const collection = await getCollection(username, slug);
|
||||
const result = await getCollection(username, slug);
|
||||
|
||||
if (!collection) {
|
||||
// Private collection - show locked state
|
||||
if (result.isPrivate && result.privateName) {
|
||||
return <PrivateCollectionLocked name={result.privateName} />;
|
||||
}
|
||||
|
||||
// Collection not found
|
||||
if (!result.collection) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <CollectionDetailClient collection={collection} username={cleanUsername} />;
|
||||
return <CollectionDetailClient collection={result.collection} username={cleanUsername} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,130 @@
|
|||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import type { ScenarioRun } from './page';
|
||||
|
||||
interface ExpandedRunDetailsProps {
|
||||
run: ScenarioRun;
|
||||
}
|
||||
|
||||
/** Displays passed and failed assertions */
|
||||
function AssertionsSection({ assertions }: { assertions: { passed: string[]; failed: string[] } }) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide mb-2">
|
||||
Assertions
|
||||
</h4>
|
||||
<div className="p-3 bg-surface-secondary rounded-lg space-y-3">
|
||||
{assertions.passed.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-success text-sm font-medium mb-1.5">
|
||||
<Icon icon="check" className="w-4 h-4" />
|
||||
Passed ({assertions.passed.length})
|
||||
</div>
|
||||
<div className="space-y-1 ml-5">
|
||||
{assertions.passed.map((assertion) => (
|
||||
<div key={assertion} className="text-sm text-foreground-secondary font-mono">
|
||||
{assertion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{assertions.failed.length > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center gap-1.5 text-error text-sm font-medium mb-1.5">
|
||||
<Icon icon="x" className="w-4 h-4" />
|
||||
Failed ({assertions.failed.length})
|
||||
</div>
|
||||
<div className="space-y-1 ml-5">
|
||||
{assertions.failed.map((assertion) => (
|
||||
<div key={assertion} className="text-sm text-error/80 font-mono">
|
||||
{assertion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Displays conversation messages */
|
||||
function ConversationSection({
|
||||
conversation,
|
||||
}: {
|
||||
conversation: NonNullable<ScenarioRun['conversation']>;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide">
|
||||
Conversation History
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{conversation.map((msg) => (
|
||||
<ConversationMessage key={msg.id} msg={msg} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Single conversation message */
|
||||
function ConversationMessage({ msg }: { msg: NonNullable<ScenarioRun['conversation']>[number] }) {
|
||||
if (msg.role === 'USER') {
|
||||
return (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-primary text-primary-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (msg.role === 'ASSISTANT') {
|
||||
return msg.content ? (
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
|
||||
if (msg.role === 'TOOL') {
|
||||
return (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg border border-border bg-surface-secondary overflow-hidden">
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{msg.toolName || 'Unknown Tool'}
|
||||
</div>
|
||||
{msg.toolResult != null && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
<pre className="text-xs text-success overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof msg.toolResult === 'string'
|
||||
? msg.toolResult
|
||||
: JSON.stringify(msg.toolResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) {
|
||||
const hasAssertions =
|
||||
run.assertions && (run.assertions.passed.length > 0 || run.assertions.failed.length > 0);
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4 border-t border-border/50">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||
|
|
@ -15,7 +135,24 @@ export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) {
|
|||
LLM Evaluation
|
||||
</h4>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{run.evaluator.model && <span className="text-xs">{run.evaluator.model}</span>}
|
||||
<Badge
|
||||
className={
|
||||
run.evaluator.verdict === 'pass'
|
||||
? 'bg-success/10 text-success border-success/20'
|
||||
: 'bg-error/10 text-error border-error/20'
|
||||
}
|
||||
>
|
||||
<Icon
|
||||
icon={run.evaluator.verdict === 'pass' ? 'check' : 'x'}
|
||||
className="w-3 h-3 mr-1"
|
||||
/>
|
||||
{run.evaluator.verdict === 'pass' ? 'Pass' : 'Fail'}
|
||||
</Badge>
|
||||
{run.evaluator.model && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
{run.evaluator.model}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{run.evaluator?.reason && (
|
||||
<p className="text-sm text-foreground-secondary">{run.evaluator.reason}</p>
|
||||
|
|
@ -55,6 +192,9 @@ export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Assertions Results */}
|
||||
{hasAssertions && run.assertions && <AssertionsSection assertions={run.assertions} />}
|
||||
|
||||
{/* Output (if owner) */}
|
||||
{run.output && (
|
||||
<div className="mt-4">
|
||||
|
|
@ -80,61 +220,7 @@ export function ExpandedRunDetails({ run }: ExpandedRunDetailsProps) {
|
|||
)}
|
||||
|
||||
{/* Conversation History */}
|
||||
{run.conversation && (
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-xs font-semibold text-foreground-secondary uppercase tracking-wide">
|
||||
Conversation History
|
||||
</h4>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{run.conversation.map((msg) => (
|
||||
<div key={msg.id}>
|
||||
{msg.role === 'USER' && (
|
||||
<div className="flex justify-end">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-primary text-primary-foreground">
|
||||
<div className="text-sm whitespace-pre-wrap">{msg.content}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{msg.role === 'ASSISTANT' && (
|
||||
<div className="space-y-2">
|
||||
{msg.content && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg p-4 bg-surface-secondary">
|
||||
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
|
||||
{msg.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{msg.role === 'TOOL' && (
|
||||
<div className="flex justify-start">
|
||||
<div className="max-w-[80%] rounded-lg border border-border bg-surface-secondary overflow-hidden">
|
||||
<div className="p-3">
|
||||
<div className="text-sm font-medium text-foreground">
|
||||
{msg.toolName || 'Unknown Tool'}
|
||||
</div>
|
||||
{msg.toolResult != null && (
|
||||
<div className="mt-2 pt-2 border-t border-border/50">
|
||||
<pre className="text-xs text-success overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{typeof msg.toolResult === 'string'
|
||||
? msg.toolResult
|
||||
: JSON.stringify(msg.toolResult, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{run.conversation && <ConversationSection conversation={run.conversation} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,7 +131,11 @@ export function QuestionsListClient({
|
|||
|
||||
const clearSkillFilter = () => {
|
||||
setSkillFilter(undefined);
|
||||
window.history.replaceState(null, '', `/${collection.username}/collections/${collection.slug}/skills/questions`);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
'',
|
||||
`/${collection.username}/collections/${collection.slug}/skills/questions`
|
||||
);
|
||||
};
|
||||
|
||||
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
||||
|
|
@ -224,7 +228,7 @@ export function QuestionsListClient({
|
|||
description={
|
||||
skillFilter
|
||||
? `No questions found for skill "${skillFilter}"`
|
||||
: 'Be the first to ask a question about this collection\'s tools.'
|
||||
: "Be the first to ask a question about this collection's tools."
|
||||
}
|
||||
size="md"
|
||||
/>
|
||||
|
|
@ -243,11 +247,7 @@ export function QuestionsListClient({
|
|||
{!loading && !error && questions.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
{questions.map((q) => (
|
||||
<Link
|
||||
key={q.id}
|
||||
href={`${basePath}/skills/questions/${q.id}`}
|
||||
className="block"
|
||||
>
|
||||
<Link key={q.id} href={`${basePath}/skills/questions/${q.id}`} className="block">
|
||||
<Card
|
||||
variant="default"
|
||||
className="hover:border-primary/30 hover:bg-muted/30 transition-all cursor-pointer"
|
||||
|
|
@ -311,11 +311,7 @@ export function QuestionsListClient({
|
|||
{/* Load More */}
|
||||
{hasMore && (
|
||||
<div className="text-center pt-4">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleLoadMore}
|
||||
disabled={loadingMore}
|
||||
>
|
||||
<Button variant="secondary" onClick={handleLoadMore} disabled={loadingMore}>
|
||||
{loadingMore ? (
|
||||
<>
|
||||
<Icon icon="loader" className="w-4 h-4 mr-2 animate-spin" />
|
||||
|
|
|
|||
|
|
@ -90,9 +90,10 @@ export function QuestionDetailClient({
|
|||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const basePath = `/${collection.username}/collections/${collection.slug}`;
|
||||
const questionUrl = typeof window !== 'undefined'
|
||||
? window.location.href
|
||||
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
|
||||
const questionUrl =
|
||||
typeof window !== 'undefined'
|
||||
? window.location.href
|
||||
: `https://tpmjs.com${basePath}/skills/questions/${question.id}`;
|
||||
|
||||
const copyLink = async () => {
|
||||
await navigator.clipboard.writeText(questionUrl);
|
||||
|
|
@ -140,10 +141,7 @@ export function QuestionDetailClient({
|
|||
<Icon icon="clock" className="w-4 h-4" />
|
||||
{formatDate(question.createdAt)}
|
||||
</span>
|
||||
<Badge
|
||||
variant={question.confidence >= 0.7 ? 'success' : 'secondary'}
|
||||
size="md"
|
||||
>
|
||||
<Badge variant={question.confidence >= 0.7 ? 'success' : 'secondary'} size="md">
|
||||
{Math.round(question.confidence * 100)}% confidence
|
||||
</Badge>
|
||||
{question.similarCount > 0 && (
|
||||
|
|
@ -233,7 +231,9 @@ export function QuestionDetailClient({
|
|||
className="block p-2 bg-muted/50 border border-border rounded hover:border-primary/30 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-foreground">{sn.skill.name}</span>
|
||||
<span className="font-medium text-sm text-foreground">
|
||||
{sn.skill.name}
|
||||
</span>
|
||||
<Badge variant="outline" size="sm">
|
||||
{sn.skill.questionCount} Q
|
||||
</Badge>
|
||||
|
|
@ -288,10 +288,7 @@ export function QuestionDetailClient({
|
|||
>
|
||||
<p className="text-sm text-foreground line-clamp-2">{sq.question}</p>
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<Badge
|
||||
variant={sq.confidence >= 0.7 ? 'success' : 'secondary'}
|
||||
size="sm"
|
||||
>
|
||||
<Badge variant={sq.confidence >= 0.7 ? 'success' : 'secondary'} size="sm">
|
||||
{Math.round(sq.confidence * 100)}%
|
||||
</Badge>
|
||||
<span className="text-xs text-foreground-tertiary">
|
||||
|
|
|
|||
|
|
@ -110,9 +110,7 @@ export async function generateMetadata({ params }: QuestionPageProps): Promise<M
|
|||
}
|
||||
|
||||
const truncatedQuestion =
|
||||
question.question.length > 60
|
||||
? question.question.slice(0, 60) + '...'
|
||||
: question.question;
|
||||
question.question.length > 60 ? `${question.question.slice(0, 60)}...` : question.question;
|
||||
|
||||
return {
|
||||
title: `${truncatedQuestion} | TPMJS Skills`,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ function truncateText(text: string, maxLength: number): string {
|
|||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering
|
||||
export default function PublicAgentsPage(): React.ReactElement {
|
||||
const [search, setSearch] = useState('');
|
||||
const [sort, setSort] = useState<SortOption>('likes');
|
||||
|
|
@ -70,9 +71,10 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
() => (
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[200px]">Name</th>
|
||||
<th className="px-4 py-3 w-[250px]">Description</th>
|
||||
<th className="px-4 py-3 w-[200px]">Description</th>
|
||||
<th className="px-4 py-3 w-[100px]">Provider</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
||||
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Chat</th>
|
||||
|
|
@ -106,6 +108,9 @@ export default function PublicAgentsPage(): React.ReactElement {
|
|||
{agent.toolCount}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
|
||||
{agent.forkCount > 0 ? agent.forkCount : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<LikeButton
|
||||
entityType="agent"
|
||||
|
|
|
|||
87
apps/web/src/app/api/activity/public/route.ts
Normal file
87
apps/web/src/app/api/activity/public/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 10;
|
||||
|
||||
// Only show these activity types publicly (positive actions, not deletions/unlikes)
|
||||
const PUBLIC_ACTIVITY_TYPES = [
|
||||
'TOOL_LIKED',
|
||||
'COLLECTION_CREATED',
|
||||
'COLLECTION_FORKED',
|
||||
'COLLECTION_TOOL_ADDED',
|
||||
'AGENT_CREATED',
|
||||
'AGENT_FORKED',
|
||||
'AGENT_LIKED',
|
||||
'COLLECTION_LIKED',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* GET /api/activity/public
|
||||
* Returns recent public activity for the homepage activity stream.
|
||||
* Cached for 30 seconds with stale-while-revalidate.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
const activities = await prisma.userActivity.findMany({
|
||||
where: {
|
||||
type: { in: [...PUBLIC_ACTIVITY_TYPES] },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 20,
|
||||
select: {
|
||||
id: true,
|
||||
type: true,
|
||||
targetName: true,
|
||||
targetType: true,
|
||||
createdAt: true,
|
||||
user: {
|
||||
select: {
|
||||
username: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const data = activities.map((a) => ({
|
||||
id: a.id,
|
||||
type: mapActivityType(a.type),
|
||||
username: a.user.username || a.user.name,
|
||||
targetName: a.targetName,
|
||||
targetType: a.targetType,
|
||||
createdAt: a.createdAt,
|
||||
}));
|
||||
|
||||
return NextResponse.json(
|
||||
{ success: true, data },
|
||||
{
|
||||
headers: {
|
||||
'Cache-Control': 's-maxage=30, stale-while-revalidate=60',
|
||||
},
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('[activity/public] Error:', error);
|
||||
return NextResponse.json({ success: true, data: [] });
|
||||
}
|
||||
}
|
||||
|
||||
function mapActivityType(type: string): 'invoked' | 'published' | 'updated' {
|
||||
switch (type) {
|
||||
case 'TOOL_LIKED':
|
||||
case 'COLLECTION_LIKED':
|
||||
case 'AGENT_LIKED':
|
||||
return 'invoked';
|
||||
case 'COLLECTION_CREATED':
|
||||
case 'AGENT_CREATED':
|
||||
return 'published';
|
||||
case 'COLLECTION_FORKED':
|
||||
case 'AGENT_FORKED':
|
||||
case 'COLLECTION_TOOL_ADDED':
|
||||
return 'updated';
|
||||
default:
|
||||
return 'updated';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* POST /api/admin/make-agents-public
|
||||
* One-off endpoint to update all existing agents to be public.
|
||||
* Protected by CRON_SECRET.
|
||||
*/
|
||||
export async function POST(request: NextRequest): Promise<NextResponse> {
|
||||
// Verify authorization
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (!cronSecret || authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Update all agents to be public
|
||||
const result = await prisma.agent.updateMany({
|
||||
where: { isPublic: false },
|
||||
data: { isPublic: true },
|
||||
});
|
||||
|
||||
// Get all agents for verification
|
||||
const agents = await prisma.agent.findMany({
|
||||
select: { id: true, name: true, isPublic: true },
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
updated: result.count,
|
||||
agents: agents.map((a) => ({ name: a.name, isPublic: a.isPublic })),
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to update agents:', error);
|
||||
return NextResponse.json({ success: false, error: 'Failed to update agents' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
import { Prisma, prisma } from '@tpmjs/db';
|
||||
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import { jsonSchema, wrapLanguageModel, type ModelMessage } from 'ai';
|
||||
import { jsonSchema, type ModelMessage, wrapLanguageModel } from 'ai';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { z } from 'zod';
|
||||
import { authenticateRequest } from '~/lib/api-keys/middleware';
|
||||
|
|
@ -145,7 +145,8 @@ const conversationStates = new Map<string, { loadedTools: Record<string, any> }>
|
|||
*/
|
||||
async function searchRelevantTools(
|
||||
query: string,
|
||||
limit = 15
|
||||
limit = 15,
|
||||
requestUrl?: string
|
||||
): Promise<
|
||||
Array<{
|
||||
toolId: string;
|
||||
|
|
@ -163,11 +164,21 @@ async function searchRelevantTools(
|
|||
limit: String(limit),
|
||||
});
|
||||
|
||||
// Use internal API (same server)
|
||||
const baseUrl = process.env.VERCEL_URL
|
||||
? `https://${process.env.VERCEL_URL}`
|
||||
: 'http://localhost:3000';
|
||||
// Determine base URL from request or environment
|
||||
let baseUrl: string;
|
||||
if (process.env.VERCEL_URL) {
|
||||
baseUrl = `https://${process.env.VERCEL_URL}`;
|
||||
} else if (requestUrl) {
|
||||
// Extract origin from the incoming request URL
|
||||
const url = new URL(requestUrl);
|
||||
baseUrl = url.origin;
|
||||
} else {
|
||||
// Fallback to PORT env var or default
|
||||
const port = process.env.PORT || '3000';
|
||||
baseUrl = `http://localhost:${port}`;
|
||||
}
|
||||
|
||||
console.log(`🔍 Tool search using baseUrl: ${baseUrl}`);
|
||||
const response = await fetch(`${baseUrl}/api/tools/search?${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
|
|
@ -192,6 +203,49 @@ async function searchRelevantTools(
|
|||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the tool's inputSchema from the executor's loadAndDescribe endpoint.
|
||||
* This is used when the schema isn't available in the database yet.
|
||||
*/
|
||||
async function fetchSchemaFromExecutor(toolMeta: {
|
||||
packageName: string;
|
||||
name: string;
|
||||
version: string;
|
||||
importUrl: string;
|
||||
}): Promise<unknown | null> {
|
||||
try {
|
||||
console.log(`📋 Fetching schema from executor for ${toolMeta.packageName}/${toolMeta.name}`);
|
||||
const response = await fetch(`${EXECUTOR_URL}/load-and-describe`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName: toolMeta.packageName,
|
||||
name: toolMeta.name,
|
||||
version: toolMeta.version,
|
||||
importUrl: toolMeta.importUrl,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.warn(
|
||||
`⚠️ Schema fetch failed (${response.status}) for ${toolMeta.packageName}/${toolMeta.name}`
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Executor response format varies
|
||||
const result = (await response.json()) as any;
|
||||
if (result.success && result.tool?.inputSchema) {
|
||||
console.log(`✅ Got schema from executor for ${toolMeta.packageName}/${toolMeta.name}`);
|
||||
return result.tool.inputSchema;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.warn(`⚠️ Schema fetch error for ${toolMeta.packageName}/${toolMeta.name}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a dynamic tool wrapper that executes via the sandbox executor
|
||||
*/
|
||||
|
|
@ -210,10 +264,16 @@ async function createDynamicTool(
|
|||
// Import tool() dynamically to avoid top-level await
|
||||
const { tool } = await import('ai');
|
||||
|
||||
// If inputSchema is missing from the database, fetch it from the executor
|
||||
let schema = toolMeta.inputSchema;
|
||||
if (!schema) {
|
||||
schema = await fetchSchemaFromExecutor(toolMeta);
|
||||
}
|
||||
|
||||
return tool({
|
||||
description: toolMeta.description,
|
||||
inputSchema: toolMeta.inputSchema
|
||||
? jsonSchema(toolMeta.inputSchema as Parameters<typeof jsonSchema>[0])
|
||||
inputSchema: schema
|
||||
? jsonSchema(schema as Parameters<typeof jsonSchema>[0])
|
||||
: jsonSchema({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
|
|
@ -223,43 +283,71 @@ async function createDynamicTool(
|
|||
execute: async (params: any) => {
|
||||
console.log(`🚀 Executing ${toolMeta.packageName}/${toolMeta.name} with params:`, params);
|
||||
|
||||
const response = await fetch(`${EXECUTOR_URL}/execute-tool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName: toolMeta.packageName,
|
||||
name: toolMeta.name,
|
||||
version: toolMeta.version,
|
||||
importUrl: toolMeta.importUrl,
|
||||
params,
|
||||
env: userEnvVars,
|
||||
}),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(`${EXECUTOR_URL}/execute-tool`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
packageName: toolMeta.packageName,
|
||||
name: toolMeta.name,
|
||||
version: toolMeta.version,
|
||||
importUrl: toolMeta.importUrl,
|
||||
params,
|
||||
env: userEnvVars,
|
||||
}),
|
||||
});
|
||||
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API response types vary
|
||||
const result = (await response.json()) as any;
|
||||
// biome-ignore lint/suspicious/noExplicitAny: API response types vary
|
||||
const result = (await response.json()) as any;
|
||||
|
||||
if (!result.success) {
|
||||
console.error(`❌ Tool execution failed: ${result.error}`);
|
||||
throw new Error(result.error || 'Tool execution failed');
|
||||
if (!result.success) {
|
||||
console.error(`❌ Tool execution failed: ${result.error}`);
|
||||
// Return error as result instead of throwing so AI can see it
|
||||
return {
|
||||
error: true,
|
||||
message: result.error || 'Tool execution failed',
|
||||
toolId: toolMeta.toolId,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`✅ Tool executed in ${result.executionTimeMs}ms`);
|
||||
return result.output;
|
||||
} catch (error) {
|
||||
console.error(`❌ Tool execution error:`, error);
|
||||
return {
|
||||
error: true,
|
||||
message: error instanceof Error ? error.message : 'Unknown error during tool execution',
|
||||
toolId: toolMeta.toolId,
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`✅ Tool executed in ${result.executionTimeMs}ms`);
|
||||
return result.output;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize tool name to be a valid JS identifier
|
||||
* Sanitize tool name to be a valid JS identifier.
|
||||
* OpenAI has a 64-character limit for tool names.
|
||||
*/
|
||||
function sanitizeToolName(name: string): string {
|
||||
return name
|
||||
const sanitized = name
|
||||
.replace(/@/g, '')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/-/g, '_')
|
||||
.replace(/::/g, '_')
|
||||
.replace(/[^a-zA-Z0-9_]/g, '');
|
||||
|
||||
// OpenAI API requires tool names <= 64 characters
|
||||
if (sanitized.length <= 64) {
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
// Truncate but try to keep the meaningful part (tool name at the end)
|
||||
// Use last 64 chars if it starts with a letter, otherwise use first 64
|
||||
const last64 = sanitized.slice(-64);
|
||||
if (/^[a-zA-Z]/.test(last64)) {
|
||||
return last64;
|
||||
}
|
||||
return sanitized.slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -401,7 +489,7 @@ export async function POST(request: NextRequest, context: RouteContext): Promise
|
|||
|
||||
// 🔍 Auto-search for relevant tools based on user's message (BM25)
|
||||
console.log(`🔍 Auto-searching for tools matching: "${parsed.data.message}"`);
|
||||
const relevantTools = await searchRelevantTools(parsed.data.message, 10);
|
||||
const relevantTools = await searchRelevantTools(parsed.data.message, 10, request.url);
|
||||
console.log(`📦 Found ${relevantTools.length} relevant tools via BM25`);
|
||||
|
||||
// Add auto-discovered tools to conversation state
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
|
|||
provider: agent.provider,
|
||||
modelId: agent.modelId,
|
||||
likeCount: agent.likeCount,
|
||||
forkCount: agent.forkCount,
|
||||
toolCount: agent._count.tools,
|
||||
collectionCount: agent._count.collections,
|
||||
createdAt: agent.createdAt,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ export async function GET(request: NextRequest): Promise<NextResponse<ApiRespons
|
|||
name: collection.name,
|
||||
description: collection.description,
|
||||
likeCount: collection.likeCount,
|
||||
forkCount: collection.forkCount,
|
||||
toolCount: collection._count.tools,
|
||||
createdAt: collection.createdAt,
|
||||
createdBy: collection.user,
|
||||
|
|
|
|||
|
|
@ -87,32 +87,36 @@ export async function GET(_request: NextRequest, context: RouteContext) {
|
|||
|
||||
// Check if collection is public
|
||||
if (!question.collection.isPublic) {
|
||||
return NextResponse.json({ error: 'Question belongs to a private collection' }, { status: 403 });
|
||||
return NextResponse.json(
|
||||
{ error: 'Question belongs to a private collection' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
|
||||
// Fetch similar questions (based on same skills)
|
||||
const skillIds = question.skillNodes.map((sn) => sn.skill.id);
|
||||
const similarQuestions = skillIds.length > 0
|
||||
? await prisma.skillQuestion.findMany({
|
||||
where: {
|
||||
id: { not: id },
|
||||
collectionId: question.collection.id,
|
||||
skillNodes: {
|
||||
some: {
|
||||
skillId: { in: skillIds },
|
||||
const similarQuestions =
|
||||
skillIds.length > 0
|
||||
? await prisma.skillQuestion.findMany({
|
||||
where: {
|
||||
id: { not: id },
|
||||
collectionId: question.collection.id,
|
||||
skillNodes: {
|
||||
some: {
|
||||
skillId: { in: skillIds },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
question: true,
|
||||
confidence: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
take: 5,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
question: true,
|
||||
confidence: true,
|
||||
createdAt: true,
|
||||
},
|
||||
})
|
||||
: [];
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
|
|
|||
|
|
@ -1,30 +1,23 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { fetchChanges, fetchLatestPackageWithMetadata } from '@tpmjs/npm-client';
|
||||
import type { TpmjsToolDefinition } from '@tpmjs/types/tpmjs';
|
||||
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';
|
||||
import {
|
||||
convertJsonSchemaToParameters,
|
||||
extractToolSchema,
|
||||
listToolExports,
|
||||
} from '~/lib/schema-extraction';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes max for cron jobs
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* POST /api/sync/changes
|
||||
* Sync tools from NPM changes feed
|
||||
* Discovery-only sync: monitors NPM changes feed, upserts packages and tools.
|
||||
* Does NOT call the executor for schema extraction or health checks — that's handled by /api/sync/enrich.
|
||||
*
|
||||
* This endpoint is called by Vercel Cron (every 2 minutes)
|
||||
* Called by Vercel Cron (every 4 hours) or GitHub Actions.
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify cron secret for security
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
|
|
@ -39,7 +32,6 @@ export async function POST(request: NextRequest) {
|
|||
const errorMessages: string[] = [];
|
||||
|
||||
try {
|
||||
// Get last checkpoint
|
||||
const checkpoint = await prisma.syncCheckpoint.findUnique({
|
||||
where: { source: 'changes-feed' },
|
||||
});
|
||||
|
|
@ -48,46 +40,37 @@ export async function POST(request: NextRequest) {
|
|||
? String((checkpoint.checkpoint as { lastSeq?: string })?.lastSeq || '0')
|
||||
: '0';
|
||||
|
||||
// Fetch changes from NPM (limit to 30 per run to allow time for schema extraction)
|
||||
// Increased limit since we no longer spend time on schema extraction
|
||||
const changesResult = await fetchChanges({
|
||||
since: lastSeq,
|
||||
limit: 30,
|
||||
limit: 100,
|
||||
includeDocs: false,
|
||||
});
|
||||
|
||||
// Process each change
|
||||
for (const change of changesResult.results) {
|
||||
try {
|
||||
// Fetch full package metadata with README
|
||||
const pkg = await fetchLatestPackageWithMetadata(change.id);
|
||||
|
||||
// Skip if package not found
|
||||
if (!pkg) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if package has tpmjs field
|
||||
if (!pkg.tpmjs) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Validate tpmjs field (supports both new multi-tool and legacy formats)
|
||||
const validation = validateTpmjsField(pkg.tpmjs);
|
||||
if (!validation.valid || !validation.packageData || !validation.tools) {
|
||||
if (!validation.valid || !validation.packageData) {
|
||||
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;
|
||||
|
||||
// Upsert Package record
|
||||
const packageRecord = await prisma.package.upsert({
|
||||
where: { npmPackageName: pkg.name },
|
||||
|
|
@ -109,8 +92,8 @@ export async function POST(request: NextRequest) {
|
|||
tier: validation.tier || 'minimal',
|
||||
discoveryMethod: 'changes-feed',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs') || false,
|
||||
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
||||
githubStars: githubStars,
|
||||
npmDownloadsLastMonth: 0,
|
||||
githubStars: null,
|
||||
},
|
||||
update: {
|
||||
npmVersion: pkg.version,
|
||||
|
|
@ -131,53 +114,30 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
// Get existing tools for this package
|
||||
// For auto-discovery packages, skip tool creation — enrichment will handle it
|
||||
if (validation.needsAutoDiscovery) {
|
||||
console.log(
|
||||
`Package ${pkg.name} needs auto-discovery — enrichment will handle tool creation`
|
||||
);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Upsert tools from the tpmjs.tools array (manual discovery only)
|
||||
const toolsToProcess = validation.tools || [];
|
||||
|
||||
const existingTools = await prisma.tool.findMany({
|
||||
where: { packageId: packageRecord.id },
|
||||
});
|
||||
|
||||
// Determine the tools to process
|
||||
let toolsToProcess: TpmjsToolDefinition[] = validation.tools || [];
|
||||
let toolDiscoverySource: 'auto' | 'manual' = 'manual';
|
||||
|
||||
// If tools need auto-discovery, call the executor to list exports
|
||||
if (validation.needsAutoDiscovery) {
|
||||
console.log(`Auto-discovering tools for ${pkg.name}...`);
|
||||
const exportsResult = await listToolExports(pkg.name, pkg.version, null);
|
||||
|
||||
if (exportsResult.success) {
|
||||
// Convert discovered tools to TpmjsToolDefinition format
|
||||
toolsToProcess = exportsResult.tools
|
||||
.filter((t) => t.isValidTool)
|
||||
.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: undefined,
|
||||
returns: undefined,
|
||||
aiAgent: undefined,
|
||||
}));
|
||||
toolDiscoverySource = 'auto';
|
||||
console.log(
|
||||
`Auto-discovered ${toolsToProcess.length} tools for ${pkg.name}: ${toolsToProcess.map((t) => t.name).join(', ')}`
|
||||
);
|
||||
} else {
|
||||
console.log(`Failed to auto-discover tools for ${pkg.name}: ${exportsResult.error}`);
|
||||
// Skip this package if we can't discover tools
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert each tool
|
||||
for (const toolDef of toolsToProcess) {
|
||||
// Get tool name from validated schema
|
||||
const toolName = toolDef.name;
|
||||
if (!toolName) {
|
||||
console.warn(`Skipping tool without name in ${pkg.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const upsertedTool = await prisma.tool.upsert({
|
||||
await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_name: {
|
||||
packageId: packageRecord.id,
|
||||
|
|
@ -194,10 +154,9 @@ export async function POST(request: NextRequest) {
|
|||
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
|
||||
// Schema will be extracted below
|
||||
qualityScore: null,
|
||||
schemaSource: toolDef.parameters ? 'author' : null,
|
||||
toolDiscoverySource,
|
||||
toolDiscoverySource: 'manual',
|
||||
},
|
||||
update: {
|
||||
description: toolDef.description || undefined,
|
||||
|
|
@ -207,52 +166,9 @@ export async function POST(request: NextRequest) {
|
|||
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,
|
||||
toolDiscoverySource,
|
||||
toolDiscoverySource: 'manual',
|
||||
},
|
||||
});
|
||||
|
||||
// Extract schema synchronously from executor
|
||||
const schemaResult = await extractToolSchema(pkg.name, toolName, pkg.version, null);
|
||||
|
||||
if (schemaResult.success) {
|
||||
// Update tool with extracted schema (and description if not provided)
|
||||
await prisma.tool.update({
|
||||
where: { id: upsertedTool.id },
|
||||
data: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
inputSchema: schemaResult.inputSchema as any,
|
||||
// Also update parameters array for backward compatibility
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
|
||||
schemaSource: 'extracted',
|
||||
schemaExtractedAt: new Date(),
|
||||
// Update description if not provided by author
|
||||
...(!toolDef.description && schemaResult.description
|
||||
? { description: schemaResult.description }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
console.log(`Schema extracted for ${pkg.name}/${toolName}`);
|
||||
} else {
|
||||
// Extraction failed - mark schema source appropriately
|
||||
console.log(
|
||||
`Schema extraction failed for ${pkg.name}/${toolName}: ${schemaResult.error}`
|
||||
);
|
||||
await prisma.tool.update({
|
||||
where: { id: upsertedTool.id },
|
||||
data: {
|
||||
schemaSource: toolDef.parameters ? 'author' : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger health check (non-blocking) for execution testing
|
||||
performHealthCheck(upsertedTool.id, 'sync').catch((err) => {
|
||||
console.error(
|
||||
`Health check failed for ${pkg.name}/${toolName} (${upsertedTool.id}):`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned tools (tools removed from package.json)
|
||||
|
|
@ -278,7 +194,6 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// Update checkpoint with new sequence
|
||||
await prisma.syncCheckpoint.upsert({
|
||||
where: { source: 'changes-feed' },
|
||||
create: {
|
||||
|
|
@ -296,7 +211,6 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
// Log sync operation
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'changes-feed',
|
||||
|
|
@ -330,7 +244,6 @@ export async function POST(request: NextRequest) {
|
|||
} catch (error) {
|
||||
console.error('Changes feed sync failed:', error);
|
||||
|
||||
// Log failed sync
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'changes-feed',
|
||||
|
|
|
|||
261
apps/web/src/app/api/sync/enrich/route.ts
Normal file
261
apps/web/src/app/api/sync/enrich/route.ts
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { env } from '~/env';
|
||||
import { performHealthCheck } from '~/lib/health-check/health-check-service';
|
||||
import {
|
||||
convertJsonSchemaToParameters,
|
||||
extractToolSchema,
|
||||
listToolExports,
|
||||
} from '~/lib/schema-extraction';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 60;
|
||||
|
||||
const TIME_BUDGET_MS = 45_000; // Stop starting new work after 45s (leaves 15s buffer)
|
||||
const RETRY_COOLDOWN_MS = 60 * 60 * 1000; // 1 hour before retrying failed extractions
|
||||
|
||||
/**
|
||||
* POST /api/sync/enrich
|
||||
* Enrichment queue processor: extracts schemas and runs health checks for tools
|
||||
* that haven't been enriched yet. Also handles auto-discovery for packages with no tools.
|
||||
*
|
||||
* Called by Vercel Cron (every 2 minutes) or GitHub Actions.
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward queue processing
|
||||
export async function POST(request: NextRequest) {
|
||||
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();
|
||||
let enriched = 0;
|
||||
let discovered = 0;
|
||||
let errors = 0;
|
||||
let skipped = 0;
|
||||
const errorMessages: string[] = [];
|
||||
|
||||
try {
|
||||
const now = new Date();
|
||||
const retryCutoff = new Date(now.getTime() - RETRY_COOLDOWN_MS);
|
||||
|
||||
// Phase 1: Auto-discover tools for packages that have 0 tools
|
||||
const packagesNeedingDiscovery = await prisma.package.findMany({
|
||||
where: {
|
||||
tools: { none: {} },
|
||||
},
|
||||
take: 5,
|
||||
});
|
||||
|
||||
for (const pkg of packagesNeedingDiscovery) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
console.log('Time budget exceeded during auto-discovery phase, stopping');
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`Auto-discovering tools for ${pkg.npmPackageName}...`);
|
||||
const exportsResult = await listToolExports(
|
||||
pkg.npmPackageName,
|
||||
pkg.npmVersion,
|
||||
pkg.env as Record<string, unknown> | null
|
||||
);
|
||||
|
||||
if (!exportsResult.success) {
|
||||
console.log(
|
||||
`Failed to auto-discover tools for ${pkg.npmPackageName}: ${exportsResult.error}`
|
||||
);
|
||||
errors++;
|
||||
errorMessages.push(
|
||||
`Auto-discovery failed for ${pkg.npmPackageName}: ${exportsResult.error}`
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const validTools = exportsResult.tools.filter((t) => t.isValidTool);
|
||||
|
||||
for (const tool of validTools) {
|
||||
await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_name: {
|
||||
packageId: pkg.id,
|
||||
name: tool.name,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
packageId: pkg.id,
|
||||
name: tool.name,
|
||||
description: tool.description || 'No description provided',
|
||||
qualityScore: null,
|
||||
schemaSource: null,
|
||||
toolDiscoverySource: 'auto',
|
||||
},
|
||||
update: {
|
||||
description: tool.description || undefined,
|
||||
toolDiscoverySource: 'auto',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
console.log(
|
||||
`Auto-discovered ${validTools.length} tools for ${pkg.npmPackageName}: ${validTools.map((t) => t.name).join(', ')}`
|
||||
);
|
||||
discovered++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
const errorMsg = `Auto-discovery error for ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errorMessages.push(errorMsg);
|
||||
console.error(errorMsg);
|
||||
}
|
||||
}
|
||||
|
||||
// Phase 2: Enrich tools that need schema extraction
|
||||
const toolsToEnrich = await prisma.tool.findMany({
|
||||
where: {
|
||||
schemaSource: null,
|
||||
OR: [
|
||||
{ schemaExtractionAttemptAt: null },
|
||||
{ schemaExtractionAttemptAt: { lt: retryCutoff } },
|
||||
],
|
||||
},
|
||||
include: { package: true },
|
||||
take: 10, // Fetch a few more than we'll likely process
|
||||
orderBy: { createdAt: 'asc' }, // Oldest first
|
||||
});
|
||||
|
||||
for (const tool of toolsToEnrich) {
|
||||
if (Date.now() - startTime > TIME_BUDGET_MS) {
|
||||
console.log('Time budget exceeded during enrichment phase, stopping');
|
||||
skipped += toolsToEnrich.length - enriched;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
// Mark attempt time before starting (prevents concurrent processing)
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: { schemaExtractionAttemptAt: now },
|
||||
});
|
||||
|
||||
console.log(`Extracting schema for ${tool.package.npmPackageName}/${tool.name}...`);
|
||||
|
||||
const schemaResult = await extractToolSchema(
|
||||
tool.package.npmPackageName,
|
||||
tool.name,
|
||||
tool.package.npmVersion,
|
||||
tool.package.env as Record<string, unknown> | null
|
||||
);
|
||||
|
||||
if (schemaResult.success) {
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
inputSchema: schemaResult.inputSchema as any,
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
|
||||
schemaSource: 'extracted',
|
||||
schemaExtractedAt: new Date(),
|
||||
schemaExtractionError: null,
|
||||
// Update description if not already set meaningfully
|
||||
...(tool.description === 'No description provided' && schemaResult.description
|
||||
? { description: schemaResult.description }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
console.log(`Schema extracted for ${tool.package.npmPackageName}/${tool.name}`);
|
||||
|
||||
// Trigger health check after successful extraction
|
||||
performHealthCheck(tool.id, 'enrich').catch((err) => {
|
||||
console.error(
|
||||
`Health check failed for ${tool.package.npmPackageName}/${tool.name}:`,
|
||||
err
|
||||
);
|
||||
});
|
||||
|
||||
enriched++;
|
||||
} else {
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
schemaExtractionError: schemaResult.error,
|
||||
},
|
||||
});
|
||||
console.log(
|
||||
`Schema extraction failed for ${tool.package.npmPackageName}/${tool.name}: ${schemaResult.error}`
|
||||
);
|
||||
errors++;
|
||||
errorMessages.push(`${tool.package.npmPackageName}/${tool.name}: ${schemaResult.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors++;
|
||||
const errorMsg = `${tool.package.npmPackageName}/${tool.name}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errorMessages.push(errorMsg);
|
||||
console.error(`Enrichment error: ${errorMsg}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Log sync operation
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'enrichment',
|
||||
status: errors > 0 ? 'partial' : 'success',
|
||||
processed: enriched + discovered,
|
||||
skipped,
|
||||
errors,
|
||||
message:
|
||||
errors > 0
|
||||
? `Enriched ${enriched} tools, discovered ${discovered} packages. Errors: ${errorMessages.slice(0, 3).join('; ')}`
|
||||
: `Enriched ${enriched} tools, discovered ${discovered} packages`,
|
||||
metadata: {
|
||||
durationMs: Date.now() - startTime,
|
||||
enriched,
|
||||
discovered,
|
||||
toolsInQueue: toolsToEnrich.length,
|
||||
packagesNeedingDiscovery: packagesNeedingDiscovery.length,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
enriched,
|
||||
discovered,
|
||||
skipped,
|
||||
errors,
|
||||
durationMs: Date.now() - startTime,
|
||||
errorMessages: errorMessages.slice(0, 5),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Enrichment sync failed:', error);
|
||||
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'enrichment',
|
||||
status: 'error',
|
||||
processed: enriched + discovered,
|
||||
skipped,
|
||||
errors: errors + 1,
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
metadata: {
|
||||
durationMs: Date.now() - startTime,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Enrichment failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +1,23 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { fetchLatestPackageWithMetadata, searchByKeyword } from '@tpmjs/npm-client';
|
||||
import type { TpmjsToolDefinition } from '@tpmjs/types/tpmjs';
|
||||
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';
|
||||
import {
|
||||
convertJsonSchemaToParameters,
|
||||
extractToolSchema,
|
||||
listToolExports,
|
||||
} from '~/lib/schema-extraction';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes max for cron jobs
|
||||
export const maxDuration = 60;
|
||||
|
||||
/**
|
||||
* POST /api/sync/keyword
|
||||
* Sync tools by searching NPM for 'tpmjs' keyword
|
||||
* Discovery-only sync: searches NPM for 'tpmjs' keyword, upserts packages and tools.
|
||||
* Does NOT call the executor for schema extraction or health checks — that's handled by /api/sync/enrich.
|
||||
*
|
||||
* This endpoint is called by Vercel Cron (every 15 minutes)
|
||||
* Called by Vercel Cron (every 6 hours) or GitHub Actions.
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify cron secret for security
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
|
|
@ -40,19 +33,15 @@ export async function POST(request: NextRequest) {
|
|||
const skippedPackages: Array<{ name: string; author: string; reason: string }> = [];
|
||||
|
||||
try {
|
||||
// Search for packages with 'tpmjs' keyword
|
||||
const searchResults = await searchByKeyword({
|
||||
keyword: 'tpmjs',
|
||||
size: 250, // Get up to 250 packages per sync
|
||||
size: 250,
|
||||
});
|
||||
|
||||
// Process each package
|
||||
for (const result of searchResults) {
|
||||
try {
|
||||
// Fetch full package metadata with README
|
||||
const pkg = await fetchLatestPackageWithMetadata(result.package.name);
|
||||
|
||||
// Skip if package not found
|
||||
if (!pkg) {
|
||||
skipped++;
|
||||
skippedPackages.push({
|
||||
|
|
@ -63,7 +52,6 @@ export async function POST(request: NextRequest) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Extract author name
|
||||
const authorName =
|
||||
typeof pkg.author === 'string'
|
||||
? pkg.author
|
||||
|
|
@ -71,11 +59,10 @@ export async function POST(request: NextRequest) {
|
|||
? pkg.author.name
|
||||
: 'unknown';
|
||||
|
||||
// Check if package has tpmjs field - if not, we'll auto-discover with defaults
|
||||
// Validate tpmjs field or use auto-discovery defaults
|
||||
let validation: ReturnType<typeof validateTpmjsField>;
|
||||
|
||||
if (!pkg.tpmjs) {
|
||||
// No tpmjs field - use auto-discovery with default category
|
||||
console.log(
|
||||
`Package ${pkg.name} has tpmjs keyword but no tpmjs field - using auto-discovery`
|
||||
);
|
||||
|
|
@ -83,14 +70,13 @@ export async function POST(request: NextRequest) {
|
|||
valid: true,
|
||||
tier: 'minimal',
|
||||
packageData: {
|
||||
category: 'utilities', // Default category for keyword-only packages
|
||||
category: 'utilities',
|
||||
},
|
||||
tools: [],
|
||||
needsAutoDiscovery: true,
|
||||
wasLegacyFormat: false,
|
||||
};
|
||||
} else {
|
||||
// Validate tpmjs field (supports both new multi-tool and legacy formats)
|
||||
validation = validateTpmjsField(pkg.tpmjs);
|
||||
if (!validation.valid || !validation.packageData) {
|
||||
skipped++;
|
||||
|
|
@ -102,18 +88,14 @@ export async function POST(request: NextRequest) {
|
|||
continue;
|
||||
}
|
||||
|
||||
// Log auto-migration from legacy format
|
||||
if (validation.wasLegacyFormat) {
|
||||
console.log(`Auto-migrated legacy package: ${pkg.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Extract packageData (guaranteed to exist at this point)
|
||||
// biome-ignore lint/style/noNonNullAssertion: guaranteed by validation check above
|
||||
const packageData = validation.packageData!;
|
||||
|
||||
// Extract repository URL and GitHub stars
|
||||
const githubStars: number | null = null;
|
||||
|
||||
// Upsert Package record
|
||||
const packageRecord = await prisma.package.upsert({
|
||||
where: { npmPackageName: pkg.name },
|
||||
|
|
@ -135,8 +117,8 @@ export async function POST(request: NextRequest) {
|
|||
tier: validation.tier || 'minimal',
|
||||
discoveryMethod: 'keyword',
|
||||
isOfficial: pkg.keywords?.includes('tpmjs') || false,
|
||||
npmDownloadsLastMonth: 0, // Will be updated by metrics sync
|
||||
githubStars: githubStars,
|
||||
npmDownloadsLastMonth: 0,
|
||||
githubStars: null,
|
||||
},
|
||||
update: {
|
||||
npmVersion: pkg.version,
|
||||
|
|
@ -157,58 +139,30 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
// Get existing tools for this package
|
||||
// For auto-discovery packages, skip tool creation — enrichment will handle it
|
||||
if (validation.needsAutoDiscovery) {
|
||||
console.log(
|
||||
`Package ${pkg.name} needs auto-discovery — enrichment will handle tool creation`
|
||||
);
|
||||
processed++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Upsert tools from the tpmjs.tools array (manual discovery only)
|
||||
const toolsToProcess = validation.tools || [];
|
||||
|
||||
const existingTools = await prisma.tool.findMany({
|
||||
where: { packageId: packageRecord.id },
|
||||
});
|
||||
|
||||
// Determine the tools to process
|
||||
let toolsToProcess: TpmjsToolDefinition[] = validation.tools || [];
|
||||
let toolDiscoverySource: 'auto' | 'manual' = 'manual';
|
||||
|
||||
// If tools need auto-discovery, call the executor to list exports
|
||||
if (validation.needsAutoDiscovery) {
|
||||
console.log(`Auto-discovering tools for ${pkg.name}...`);
|
||||
const exportsResult = await listToolExports(pkg.name, pkg.version, null);
|
||||
|
||||
if (exportsResult.success) {
|
||||
// Convert discovered tools to TpmjsToolDefinition format
|
||||
toolsToProcess = exportsResult.tools
|
||||
.filter((t) => t.isValidTool)
|
||||
.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
parameters: undefined,
|
||||
returns: undefined,
|
||||
aiAgent: undefined,
|
||||
}));
|
||||
toolDiscoverySource = 'auto';
|
||||
console.log(
|
||||
`Auto-discovered ${toolsToProcess.length} tools for ${pkg.name}: ${toolsToProcess.map((t) => t.name).join(', ')}`
|
||||
);
|
||||
} else {
|
||||
console.log(`Failed to auto-discover tools for ${pkg.name}: ${exportsResult.error}`);
|
||||
// Skip this package if we can't discover tools
|
||||
skipped++;
|
||||
skippedPackages.push({
|
||||
name: pkg.name,
|
||||
author: authorName,
|
||||
reason: `auto-discovery failed: ${exportsResult.error}`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Upsert each tool
|
||||
for (const toolDef of toolsToProcess) {
|
||||
// Get tool name from validated schema
|
||||
const toolName = toolDef.name;
|
||||
if (!toolName) {
|
||||
console.warn(`Skipping tool without name in ${pkg.name}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const upsertedTool = await prisma.tool.upsert({
|
||||
await prisma.tool.upsert({
|
||||
where: {
|
||||
packageId_name: {
|
||||
packageId: packageRecord.id,
|
||||
|
|
@ -225,10 +179,9 @@ export async function POST(request: NextRequest) {
|
|||
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
|
||||
// Schema will be extracted below
|
||||
qualityScore: null,
|
||||
schemaSource: toolDef.parameters ? 'author' : null,
|
||||
toolDiscoverySource,
|
||||
toolDiscoverySource: 'manual',
|
||||
},
|
||||
update: {
|
||||
description: toolDef.description || undefined,
|
||||
|
|
@ -238,52 +191,9 @@ export async function POST(request: NextRequest) {
|
|||
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,
|
||||
toolDiscoverySource,
|
||||
toolDiscoverySource: 'manual',
|
||||
},
|
||||
});
|
||||
|
||||
// Extract schema synchronously from executor
|
||||
const schemaResult = await extractToolSchema(pkg.name, toolName, pkg.version, null);
|
||||
|
||||
if (schemaResult.success) {
|
||||
// Update tool with extracted schema (and description if not provided)
|
||||
await prisma.tool.update({
|
||||
where: { id: upsertedTool.id },
|
||||
data: {
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
inputSchema: schemaResult.inputSchema as any,
|
||||
// Also update parameters array for backward compatibility
|
||||
// biome-ignore lint/suspicious/noExplicitAny: Prisma Json type compatibility workaround
|
||||
parameters: convertJsonSchemaToParameters(schemaResult.inputSchema) as any,
|
||||
schemaSource: 'extracted',
|
||||
schemaExtractedAt: new Date(),
|
||||
// Update description if not provided by author
|
||||
...(!toolDef.description && schemaResult.description
|
||||
? { description: schemaResult.description }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
console.log(`Schema extracted for ${pkg.name}/${toolName}`);
|
||||
} else {
|
||||
// Extraction failed - mark schema source appropriately
|
||||
console.log(
|
||||
`Schema extraction failed for ${pkg.name}/${toolName}: ${schemaResult.error}`
|
||||
);
|
||||
await prisma.tool.update({
|
||||
where: { id: upsertedTool.id },
|
||||
data: {
|
||||
schemaSource: toolDef.parameters ? 'author' : null,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger health check (non-blocking) for execution testing
|
||||
performHealthCheck(upsertedTool.id, 'sync').catch((err) => {
|
||||
console.error(
|
||||
`Health check failed for ${pkg.name}/${toolName} (${upsertedTool.id}):`,
|
||||
err
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Delete orphaned tools (tools removed from package.json)
|
||||
|
|
@ -309,7 +219,6 @@ export async function POST(request: NextRequest) {
|
|||
}
|
||||
}
|
||||
|
||||
// Update checkpoint with last run timestamp
|
||||
await prisma.syncCheckpoint.upsert({
|
||||
where: { source: 'keyword-search' },
|
||||
create: {
|
||||
|
|
@ -327,7 +236,6 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
// Log sync operation
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'keyword-search',
|
||||
|
|
@ -354,14 +262,13 @@ 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
|
||||
errorMessages: errorMessages.slice(0, 5),
|
||||
skippedPackages: skippedPackages,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Keyword search sync failed:', error);
|
||||
|
||||
// Log failed sync
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'keyword-search',
|
||||
|
|
|
|||
|
|
@ -7,16 +7,17 @@ export const runtime = 'nodejs';
|
|||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300; // 5 minutes max for cron jobs
|
||||
|
||||
const BATCH_SIZE = 5;
|
||||
|
||||
/**
|
||||
* POST /api/sync/metrics
|
||||
* Update download stats and quality scores for all packages and tools
|
||||
*
|
||||
* This endpoint is called by Vercel Cron (every hour)
|
||||
* This endpoint is called by Vercel Cron (daily)
|
||||
* Requires Authorization: Bearer <CRON_SECRET>
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Complex but straightforward CRUD operation
|
||||
export async function POST(request: NextRequest) {
|
||||
// Verify cron secret for security
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const token = authHeader?.replace('Bearer ', '');
|
||||
|
||||
|
|
@ -31,62 +32,69 @@ export async function POST(request: NextRequest) {
|
|||
const errorMessages: string[] = [];
|
||||
|
||||
try {
|
||||
// Get all packages with their tools from database
|
||||
const packages = await prisma.package.findMany({
|
||||
include: {
|
||||
tools: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Process each package
|
||||
for (const pkg of packages) {
|
||||
try {
|
||||
// Fetch download stats from NPM (package-level metric)
|
||||
const downloads = await fetchDownloadStats(pkg.npmPackageName);
|
||||
// Process packages in batches of BATCH_SIZE concurrently
|
||||
for (let i = 0; i < packages.length; i += BATCH_SIZE) {
|
||||
const batch = packages.slice(i, i + BATCH_SIZE);
|
||||
|
||||
// Fetch GitHub stars if repository is available
|
||||
const githubStars = await fetchGitHubStarsFromRepository(
|
||||
pkg.npmRepository as { type?: string; url?: string } | string | null
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (pkg) => {
|
||||
// Fetch downloads and GitHub stars in parallel for each package
|
||||
const [downloads, githubStars] = await Promise.all([
|
||||
fetchDownloadStats(pkg.npmPackageName),
|
||||
fetchGitHubStarsFromRepository(
|
||||
pkg.npmRepository as { type?: string; url?: string } | string | null
|
||||
),
|
||||
]);
|
||||
|
||||
// Update package metrics
|
||||
await prisma.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: {
|
||||
npmDownloadsLastMonth: downloads,
|
||||
githubStars,
|
||||
},
|
||||
});
|
||||
|
||||
// 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, // Use freshly fetched stars
|
||||
hasParameters: !!tool.parameters,
|
||||
hasReturns: !!tool.returns,
|
||||
hasAiAgent: !!tool.aiAgent,
|
||||
});
|
||||
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
await prisma.package.update({
|
||||
where: { id: pkg.id },
|
||||
data: {
|
||||
qualityScore,
|
||||
npmDownloadsLastMonth: downloads,
|
||||
githubStars,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
processed++;
|
||||
} catch (error) {
|
||||
errors++;
|
||||
const errorMsg = `Failed to process ${pkg.npmPackageName}: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
||||
errorMessages.push(errorMsg);
|
||||
console.error(errorMsg);
|
||||
// Calculate and update quality score for each tool in this package
|
||||
for (const tool of pkg.tools) {
|
||||
const qualityScore = calculateQualityScore({
|
||||
tier: pkg.tier,
|
||||
downloads,
|
||||
githubStars,
|
||||
hasParameters: !!tool.parameters,
|
||||
hasReturns: !!tool.returns,
|
||||
hasAiAgent: !!tool.aiAgent,
|
||||
});
|
||||
|
||||
await prisma.tool.update({
|
||||
where: { id: tool.id },
|
||||
data: {
|
||||
qualityScore,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return pkg.npmPackageName;
|
||||
})
|
||||
);
|
||||
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled') {
|
||||
processed++;
|
||||
} else {
|
||||
errors++;
|
||||
const errorMsg = `Failed to process package: ${result.reason instanceof Error ? result.reason.message : 'Unknown error'}`;
|
||||
errorMessages.push(errorMsg);
|
||||
console.error(errorMsg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update checkpoint with last run timestamp
|
||||
await prisma.syncCheckpoint.upsert({
|
||||
where: { source: 'metrics' },
|
||||
create: {
|
||||
|
|
@ -106,7 +114,6 @@ export async function POST(request: NextRequest) {
|
|||
},
|
||||
});
|
||||
|
||||
// Log sync operation
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'metrics',
|
||||
|
|
@ -140,7 +147,6 @@ export async function POST(request: NextRequest) {
|
|||
} catch (error) {
|
||||
console.error('Metrics sync failed:', error);
|
||||
|
||||
// Log failed sync
|
||||
await prisma.syncLog.create({
|
||||
data: {
|
||||
source: 'metrics',
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ export const maxDuration = 60;
|
|||
* Captures a daily snapshot of registry statistics for historical tracking.
|
||||
* Should be run once per day via cron.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with many parallel queries
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
@ -75,6 +76,12 @@ export async function POST(request: NextRequest) {
|
|||
// Daily health checks
|
||||
dailyHealthChecks,
|
||||
|
||||
// Social proof
|
||||
activeDevsResult,
|
||||
publicCollectionsCount,
|
||||
publicAgentsCount,
|
||||
totalSimulationsCount,
|
||||
|
||||
// Quality distribution
|
||||
qualityDistribution,
|
||||
] = await Promise.all([
|
||||
|
|
@ -142,6 +149,17 @@ export async function POST(request: NextRequest) {
|
|||
where: { createdAt: { gte: yesterday, lt: today } },
|
||||
}),
|
||||
|
||||
// Social proof fields
|
||||
prisma.userActivity.groupBy({
|
||||
by: ['userId'],
|
||||
where: {
|
||||
createdAt: { gte: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) },
|
||||
},
|
||||
}),
|
||||
prisma.collection.count({ where: { isPublic: true } }),
|
||||
prisma.agent.count({ where: { isPublic: true } }),
|
||||
prisma.simulation.count(),
|
||||
|
||||
// Quality score distribution
|
||||
prisma.$queryRaw<{ bucket: string; count: bigint }[]>`
|
||||
SELECT
|
||||
|
|
@ -241,6 +259,12 @@ export async function POST(request: NextRequest) {
|
|||
|
||||
// Categories
|
||||
categories,
|
||||
|
||||
// Social proof
|
||||
activeDevs7d: activeDevsResult.length,
|
||||
totalCollections: publicCollectionsCount,
|
||||
totalAgents: publicAgentsCount,
|
||||
totalSimulations: totalSimulationsCount,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
86
apps/web/src/app/api/sync/view-rollup/route.ts
Normal file
86
apps/web/src/app/api/sync/view-rollup/route.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 300;
|
||||
|
||||
/**
|
||||
* POST /api/sync/view-rollup
|
||||
* Daily cron: aggregates PageView counts into denormalized viewCount fields
|
||||
* on Tool, Collection, and Agent models.
|
||||
*/
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: cron handler with sequential entity type processing
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
// Verify cron secret
|
||||
const authHeader = request.headers.get('authorization');
|
||||
const cronSecret = process.env.CRON_SECRET;
|
||||
|
||||
if (cronSecret && authHeader !== `Bearer ${cronSecret}`) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
// Aggregate views by entity type and entity ID (all-time sum)
|
||||
const entityTypes = ['tool', 'collection', 'agent'] as const;
|
||||
let totalUpdated = 0;
|
||||
|
||||
for (const entityType of entityTypes) {
|
||||
// Get aggregated view counts per entity
|
||||
const viewCounts = await prisma.pageView.groupBy({
|
||||
by: ['entityId'],
|
||||
where: { entityType },
|
||||
_sum: { viewCount: true },
|
||||
});
|
||||
|
||||
// Update denormalized viewCount on each entity
|
||||
for (const vc of viewCounts) {
|
||||
const totalViews = vc._sum.viewCount || 0;
|
||||
if (totalViews === 0) continue;
|
||||
|
||||
try {
|
||||
if (entityType === 'tool') {
|
||||
await prisma.tool.update({
|
||||
where: { id: vc.entityId },
|
||||
data: { viewCount: totalViews },
|
||||
});
|
||||
} else if (entityType === 'collection') {
|
||||
await prisma.collection.update({
|
||||
where: { id: vc.entityId },
|
||||
data: { viewCount: totalViews },
|
||||
});
|
||||
} else if (entityType === 'agent') {
|
||||
await prisma.agent.update({
|
||||
where: { id: vc.entityId },
|
||||
data: { viewCount: totalViews },
|
||||
});
|
||||
}
|
||||
totalUpdated++;
|
||||
} catch {
|
||||
// Entity may have been deleted - skip silently
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const durationMs = Date.now() - startTime;
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data: {
|
||||
totalUpdated,
|
||||
durationMs,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[sync/view-rollup] Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -235,6 +235,7 @@ export async function GET(request: NextRequest) {
|
|||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
qualityScore: tool.qualityScore,
|
||||
importHealth: tool.importHealth,
|
||||
executionHealth: tool.executionHealth,
|
||||
|
|
|
|||
87
apps/web/src/app/api/track/view/route.ts
Normal file
87
apps/web/src/app/api/track/view/route.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { prisma } from '@tpmjs/db';
|
||||
import { type NextRequest, NextResponse } from 'next/server';
|
||||
import { getClientId } from '~/lib/rate-limit';
|
||||
|
||||
export const runtime = 'nodejs';
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const maxDuration = 10;
|
||||
|
||||
const VALID_ENTITY_TYPES = ['tool', 'collection', 'agent'] as const;
|
||||
|
||||
// Simple in-memory dedup: 1 view per entity per IP per hour
|
||||
const recentViews = new Map<string, number>();
|
||||
|
||||
// Clean up every 10 minutes
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, timestamp] of recentViews) {
|
||||
if (now - timestamp > 3600_000) {
|
||||
recentViews.delete(key);
|
||||
}
|
||||
}
|
||||
// Prevent unbounded growth
|
||||
if (recentViews.size > 50_000) {
|
||||
recentViews.clear();
|
||||
}
|
||||
}, 600_000);
|
||||
|
||||
/**
|
||||
* POST /api/track/view
|
||||
* Fire-and-forget view tracking. Upserts into PageView with daily bucket.
|
||||
*/
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { entityType, entityId } = body;
|
||||
|
||||
// Validate input
|
||||
if (!entityType || !entityId) {
|
||||
return NextResponse.json({ error: 'Missing entityType or entityId' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!VALID_ENTITY_TYPES.includes(entityType)) {
|
||||
return NextResponse.json({ error: 'Invalid entityType' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Rate-limit: 1 view per entity per IP per hour
|
||||
const clientId = getClientId(request);
|
||||
const dedupKey = `${clientId}:${entityType}:${entityId}`;
|
||||
const lastView = recentViews.get(dedupKey);
|
||||
|
||||
if (lastView && Date.now() - lastView < 3600_000) {
|
||||
return NextResponse.json({ ok: true, deduped: true });
|
||||
}
|
||||
|
||||
recentViews.set(dedupKey, Date.now());
|
||||
|
||||
// Today's date bucket (midnight UTC)
|
||||
const today = new Date();
|
||||
today.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
// Upsert page view (fire-and-forget style, don't await in production but we need to for correctness)
|
||||
await prisma.pageView.upsert({
|
||||
where: {
|
||||
entityType_entityId_date: {
|
||||
entityType,
|
||||
entityId,
|
||||
date: today,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
entityType,
|
||||
entityId,
|
||||
date: today,
|
||||
viewCount: 1,
|
||||
},
|
||||
update: {
|
||||
viewCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (error) {
|
||||
// Silently fail - view tracking should never break the user experience
|
||||
console.error('[track/view] Error:', error);
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,7 @@ interface PublicCollection {
|
|||
name: string;
|
||||
description: string | null;
|
||||
likeCount: number;
|
||||
forkCount: number;
|
||||
toolCount: number;
|
||||
createdAt: string;
|
||||
createdBy: {
|
||||
|
|
@ -53,6 +54,7 @@ function truncateText(text: string, maxLength: number): string {
|
|||
return `${text.slice(0, maxLength).trim()}...`;
|
||||
}
|
||||
|
||||
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: large page component with table rendering
|
||||
export default function PublicCollectionsPage(): React.ReactElement {
|
||||
const [collections, setCollections] = useState<PublicCollection[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
|
@ -126,8 +128,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
() => (
|
||||
<tr className="bg-surface-secondary text-left text-xs font-semibold uppercase tracking-wider text-foreground-secondary border-b border-border">
|
||||
<th className="px-4 py-3 w-[250px]">Name</th>
|
||||
<th className="px-4 py-3 w-[300px]">Description</th>
|
||||
<th className="px-4 py-3 w-[250px]">Description</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Tools</th>
|
||||
<th className="px-4 py-3 w-[70px] text-center">Forks</th>
|
||||
<th className="px-4 py-3 w-[80px] text-center">Likes</th>
|
||||
<th className="px-4 py-3 w-[150px]">Creator</th>
|
||||
<th className="px-4 py-3 w-[100px] text-right">Copy</th>
|
||||
|
|
@ -141,7 +144,11 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
<>
|
||||
<td className="px-4 py-3">
|
||||
<Link
|
||||
href={collection.createdBy.username ? `/${collection.createdBy.username}/collections/${collection.slug}` : `/collections/${collection.id}`}
|
||||
href={
|
||||
collection.createdBy.username
|
||||
? `/${collection.createdBy.username}/collections/${collection.slug}`
|
||||
: `/collections/${collection.id}`
|
||||
}
|
||||
className="font-semibold text-foreground hover:text-primary group-hover:text-primary transition-colors"
|
||||
>
|
||||
{collection.name}
|
||||
|
|
@ -155,6 +162,9 @@ export default function PublicCollectionsPage(): React.ReactElement {
|
|||
{collection.toolCount}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center text-sm text-foreground-secondary">
|
||||
{collection.forkCount > 0 ? collection.forkCount : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
<LikeButton
|
||||
entityType="collection"
|
||||
|
|
|
|||
|
|
@ -713,7 +713,7 @@ export default function AgentChatPage(): React.ReactElement {
|
|||
className={`max-w-[85%] rounded-lg p-4 ${
|
||||
message.role === 'USER'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-surface border border-dashed border-border'
|
||||
: 'bg-surface text-foreground border border-dashed border-border'
|
||||
}`}
|
||||
>
|
||||
{message.role === 'USER' ? (
|
||||
|
|
|
|||
|
|
@ -369,16 +369,18 @@ export default function CollectionDetailPage(): React.ReactElement {
|
|||
const httpUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/http`;
|
||||
const sseUrl = `${baseUrl}/api/mcp/${collection.user.username}/${collection.slug}/sse`;
|
||||
|
||||
// Claude Code CLI command (correct arg order: options before name and url)
|
||||
const claudeCodeCommand = `claude mcp add ${collection.slug} ${httpUrl} -t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`;
|
||||
|
||||
// Claude Desktop native HTTP config
|
||||
const configSnippet = `{
|
||||
"mcpServers": {
|
||||
"${collection.slug}": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"${httpUrl}",
|
||||
"--header",
|
||||
"Authorization: Bearer YOUR_TPMJS_API_KEY"
|
||||
]
|
||||
"type": "http",
|
||||
"url": "${httpUrl}",
|
||||
"headers": {
|
||||
"Authorization": "Bearer YOUR_TPMJS_API_KEY"
|
||||
}
|
||||
}
|
||||
}
|
||||
}`;
|
||||
|
|
@ -573,7 +575,35 @@ export default function CollectionDetailPage(): React.ReactElement {
|
|||
<McpUrlDisplay url={sseUrl} label="SSE Transport" sublabel="streaming" />
|
||||
</div>
|
||||
|
||||
{/* Claude Code CLI command */}
|
||||
<div className="mt-6 pt-4 border-t border-border">
|
||||
<h4 className="text-sm font-medium text-foreground mb-2">Add to Claude Code</h4>
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-surface-secondary border border-border rounded-lg text-xs font-mono text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{claudeCodeCommand}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigator.clipboard.writeText(claudeCodeCommand)}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<Icon icon="copy" size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||
Replace <code className="font-mono">YOUR_TPMJS_API_KEY</code> with your{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/tpmjs-api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
TPMJS API key
|
||||
</Link>
|
||||
. Then run <code className="font-mono">/mcp</code> in Claude Code to verify.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClaudeConfig(!showClaudeConfig)}
|
||||
|
|
|
|||
|
|
@ -113,7 +113,7 @@ export default function LikedCollectionsPage(): React.ReactElement {
|
|||
<p className="text-foreground-secondary mb-4">
|
||||
Browse public collections and click the heart icon to save your favorites
|
||||
</p>
|
||||
<Link href="/collections">
|
||||
<Link href="/">
|
||||
<Button>Browse Collections</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import type { Metadata } from 'next';
|
||||
|
|
@ -10,7 +9,7 @@ import { AppHeader } from '~/components/AppHeader';
|
|||
export const metadata: Metadata = {
|
||||
title: 'Custom Executors - TPMJS',
|
||||
description:
|
||||
'Learn how to deploy and configure custom executors for running TPMJS tools on your own infrastructure.',
|
||||
'Deploy your own executor to run TPMJS tools on your own infrastructure with full control and privacy.',
|
||||
};
|
||||
|
||||
const executeToolExample = `// POST /execute-tool
|
||||
|
|
@ -46,69 +45,39 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
<div className="mb-12">
|
||||
<h1 className="text-3xl font-bold text-foreground mb-4">Custom Executors</h1>
|
||||
<p className="text-lg text-foreground-secondary">
|
||||
Deploy your own executor to run TPMJS tools on your own infrastructure.
|
||||
Deploy your own executor to run TPMJS tools on your infrastructure with full control
|
||||
over environment, secrets, and data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Start Banner */}
|
||||
<section className="mb-12">
|
||||
<Link
|
||||
href="/docs/tutorials/custom-executor"
|
||||
className="block p-4 bg-gradient-to-r from-primary/10 via-primary/5 to-transparent border border-primary/30 rounded-lg hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="text-3xl">🚀</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium text-foreground">
|
||||
New to custom executors? Start with the tutorial
|
||||
</p>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Deploy your own executor in 10 minutes with our step-by-step guide
|
||||
</p>
|
||||
</div>
|
||||
<Icon icon="chevronRight" className="w-5 h-5 text-primary" />
|
||||
</div>
|
||||
</Link>
|
||||
</section>
|
||||
|
||||
{/* Overview Section */}
|
||||
{/* What is an Executor */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">What is an Executor?</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
An executor is a service that runs TPMJS tools. When you use a collection or agent,
|
||||
TPMJS sends tool execution requests to an executor, which dynamically loads and runs
|
||||
the tool code.
|
||||
TPMJS sends tool execution requests to an executor, which dynamically loads the npm
|
||||
package and calls the tool's{' '}
|
||||
<code className="px-1 bg-surface rounded">execute()</code> function.
|
||||
</p>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
By default, TPMJS uses a shared executor. You can deploy your own for:
|
||||
<p className="text-foreground-secondary">
|
||||
By default, TPMJS uses a shared executor. Deploying your own gives you complete
|
||||
control over the execution environment.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Benefits Grid */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
||||
Why Deploy Your Own Executor?
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="folder" className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-medium text-foreground">Full Control</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Run tools on your own infrastructure with complete control over the execution
|
||||
environment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="globe" className="w-5 h-5 text-primary" />
|
||||
<Icon icon="key" className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-medium text-foreground">Privacy</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Keep tool execution data on your own servers. No data leaves your infrastructure.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="clock" className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-medium text-foreground">Performance</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Deploy in regions closest to your users for lower latency tool execution.
|
||||
Keep tool execution data on your own servers. No data passes through TPMJS.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
|
|
@ -117,68 +86,279 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
<h3 className="font-medium text-foreground">Custom Environment</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Inject your own environment variables, secrets, and configuration into tool
|
||||
execution.
|
||||
Inject your own API keys, database connections, and secrets into tool execution.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="folder" className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-medium text-foreground">Full Control</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Choose your infrastructure, scale resources, and customize the execution
|
||||
environment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<Icon icon="clock" className="w-5 h-5 text-primary" />
|
||||
<h3 className="font-medium text-foreground">No Timeouts</h3>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Run long-running tools without hitting shared executor time limits.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Deploy Section */}
|
||||
{/* Choose Your Platform */}
|
||||
<section id="deploy" className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
||||
Deploy Your Own Executor
|
||||
</h2>
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Choose Your Platform</h2>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
The fastest way to get started is to deploy our template to Vercel with one click:
|
||||
We provide deployment templates for multiple platforms. Choose the one that fits your
|
||||
needs:
|
||||
</p>
|
||||
<div className="mb-6">
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https://github.com/tpmjs/tpmjs/tree/main/templates/vercel-executor&project-name=tpmjs-executor&repository-name=tpmjs-executor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{/* Railway Card */}
|
||||
<Link
|
||||
href="/docs/executors/railway"
|
||||
className="group p-6 bg-surface border border-border rounded-lg hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<Button size="lg">
|
||||
<Icon icon="externalLink" className="w-4 h-4 mr-2" />
|
||||
Deploy to Vercel
|
||||
</Button>
|
||||
</a>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-[#0B0D0E] rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-7 h-7"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-labelledby="railway-logo-title"
|
||||
>
|
||||
<title id="railway-logo-title">Railway logo</title>
|
||||
<path
|
||||
d="M.113 12.611c-.139.312-.107.633.092.917.156.222.38.35.651.357h5.523a.28.28 0 00.216-.1c.07-.078.135-.22.2-.426.224-.718.428-1.09.65-1.492l.003-.005c.247-.448.51-.924.817-1.762.26-.712.296-1.371.113-2.008-.178-.62-.627-1.213-1.406-1.823a.248.248 0 00-.23-.044c-.078.025-.14.08-.178.157l-5.46 4.67a1.017 1.017 0 00-.287.41z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
Railway
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Always-on with auto-scaling and a $5/month free tier. One-click deploy.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="px-2 py-0.5 text-xs bg-success/10 text-success rounded">
|
||||
Official
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
|
||||
Free tier
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
|
||||
Auto-scaling
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevronRight"
|
||||
className="w-5 h-5 text-foreground-tertiary group-hover:text-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Unsandbox Card */}
|
||||
<Link
|
||||
href="/docs/executors/unsandbox"
|
||||
className="group p-6 bg-surface border border-border rounded-lg hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
un
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
Unsandbox
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Always-on container execution with automatic HTTPS. Deploy with one CLI
|
||||
command.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
|
||||
No cold starts
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
|
||||
Unlimited runtime
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevronRight"
|
||||
className="w-5 h-5 text-foreground-tertiary group-hover:text-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
{/* Vercel Card */}
|
||||
<Link
|
||||
href="/docs/executors/vercel"
|
||||
className="group p-6 bg-surface border border-border rounded-lg hover:border-primary/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
viewBox="0 0 76 65"
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
aria-labelledby="vercel-logo-title"
|
||||
>
|
||||
<title id="vercel-logo-title">Vercel logo</title>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
Vercel
|
||||
</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Serverless execution with VM-level isolation using Vercel Sandbox.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
|
||||
One-click deploy
|
||||
</span>
|
||||
<span className="px-2 py-0.5 text-xs bg-surface-secondary rounded text-foreground-tertiary">
|
||||
Pay-per-use
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<Icon
|
||||
icon="chevronRight"
|
||||
className="w-5 h-5 text-foreground-tertiary group-hover:text-primary transition-colors"
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-tertiary">
|
||||
After deployment, you'll get a URL like{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded text-foreground-secondary">
|
||||
https://tpmjs-executor.vercel.app
|
||||
</code>
|
||||
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
You can also build your own executor on any platform that runs Node.js. Just implement
|
||||
the API specification below.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Comparison Table */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Platform Comparison</h2>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 pr-4 font-medium text-foreground">Feature</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-foreground">Railway</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-foreground">Unsandbox</th>
|
||||
<th className="text-left py-3 pl-4 font-medium text-foreground">Vercel</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Deploy method</td>
|
||||
<td className="py-3 px-4">One-click / CLI</td>
|
||||
<td className="py-3 px-4">CLI command</td>
|
||||
<td className="py-3 pl-4">One-click button</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Isolation</td>
|
||||
<td className="py-3 px-4">Container-level</td>
|
||||
<td className="py-3 px-4">Container-level</td>
|
||||
<td className="py-3 pl-4">VM-level (Sandbox)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Cold starts</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-success">None (always-on)</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-success">None (always-on)</span>
|
||||
</td>
|
||||
<td className="py-3 pl-4">Yes (serverless)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Max runtime</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-success">Unlimited</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-success">Unlimited</span>
|
||||
</td>
|
||||
<td className="py-3 pl-4">45min (Hobby) / 5hr (Pro)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Free tier</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-success">$5/month credit</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">None</td>
|
||||
<td className="py-3 pl-4">Limited</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Pricing</td>
|
||||
<td className="py-3 px-4">Per usage</td>
|
||||
<td className="py-3 px-4">Per uptime</td>
|
||||
<td className="py-3 pl-4">Per compute time</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Auto-scaling</td>
|
||||
<td className="py-3 px-4">
|
||||
<span className="text-success">Yes</span>
|
||||
</td>
|
||||
<td className="py-3 px-4">Manual</td>
|
||||
<td className="py-3 pl-4">Yes</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Custom domains</td>
|
||||
<td className="py-3 px-4">Yes</td>
|
||||
<td className="py-3 px-4">Yes</td>
|
||||
<td className="py-3 pl-4">Yes</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 pr-4">Docker support</td>
|
||||
<td className="py-3 px-4">Yes</td>
|
||||
<td className="py-3 px-4">Yes</td>
|
||||
<td className="py-3 pl-4">No</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Configuration Section */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Configuration</h2>
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
||||
Connecting to Your Executor
|
||||
</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Once you have your executor deployed, configure your collections or agents to use it:
|
||||
Once deployed, configure your collections or agents to use your executor:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside text-foreground-secondary space-y-3 mb-6">
|
||||
<li>Go to your collection or agent settings</li>
|
||||
<li>
|
||||
In the "Executor Configuration" section, select "Custom
|
||||
Executor"
|
||||
</li>
|
||||
<li>In "Executor Configuration", select "Custom Executor"</li>
|
||||
<li>
|
||||
Enter your executor URL (e.g.,{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://tpmjs-executor.vercel.app
|
||||
https://my-executor.on.unsandbox.com
|
||||
</code>
|
||||
)
|
||||
</li>
|
||||
<li>Optionally add an API key if your executor requires authentication</li>
|
||||
<li>Click "Verify Connection" to test the configuration</li>
|
||||
<li>Add your API key if authentication is enabled</li>
|
||||
<li>Click "Verify Connection" to test</li>
|
||||
</ol>
|
||||
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Security tip:</strong> Set the{' '}
|
||||
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> environment
|
||||
variable in your Vercel project to require authentication for all requests.
|
||||
<strong>Security:</strong> Always set{' '}
|
||||
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> to require
|
||||
authentication. Without it, anyone can execute tools on your executor.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -188,7 +368,10 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
<h2 className="text-2xl font-semibold text-foreground mb-4">
|
||||
Executor API Specification
|
||||
</h2>
|
||||
<p className="text-foreground-secondary mb-6">All executors must implement this API:</p>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
All executors must implement these endpoints. Use this spec if building a custom
|
||||
executor.
|
||||
</p>
|
||||
|
||||
{/* POST /execute-tool */}
|
||||
<div className="mb-8">
|
||||
|
|
@ -197,7 +380,9 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
/execute-tool
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Execute a TPMJS tool with the provided parameters.
|
||||
Execute a TPMJS tool. The executor should install the npm package, find the named
|
||||
export, and call its{' '}
|
||||
<code className="px-1 bg-surface rounded">execute(params)</code> function.
|
||||
</p>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
|
|
@ -217,22 +402,33 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
<code className="px-2 py-1 bg-success/10 text-success rounded">GET</code> /health
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Check executor health status. Used by TPMJS to verify the executor is reachable.
|
||||
Health check endpoint. TPMJS uses this to verify the executor is reachable and
|
||||
working.
|
||||
</p>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-foreground mb-2">Response:</p>
|
||||
<CodeBlock language="json" code={healthExample} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
<strong>Note:</strong> Both{' '}
|
||||
<code className="px-1 bg-surface-secondary rounded">/api/health</code> and{' '}
|
||||
<code className="px-1 bg-surface-secondary rounded">/health</code> paths should work
|
||||
(same for <code className="px-1 bg-surface-secondary rounded">/execute-tool</code>).
|
||||
Our templates support both.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Cascade Section */}
|
||||
{/* Executor Cascade */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Executor Cascade</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Executor configuration follows a cascade resolution order:
|
||||
When a tool is executed, TPMJS resolves which executor to use in this order:
|
||||
</p>
|
||||
<div className="flex items-center gap-2 text-foreground-secondary mb-4">
|
||||
<div className="flex items-center gap-2 text-foreground-secondary mb-4 flex-wrap">
|
||||
<span className="px-3 py-1 bg-primary/10 text-primary rounded-full text-sm font-medium">
|
||||
Agent Config
|
||||
</span>
|
||||
|
|
@ -255,35 +451,47 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
</ul>
|
||||
</section>
|
||||
|
||||
{/* FAQ Section */}
|
||||
{/* FAQ */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">FAQ</h2>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-2">Can I use any cloud provider?</h3>
|
||||
<h3 className="font-medium text-foreground mb-2">
|
||||
Which platform should I choose?
|
||||
</h3>
|
||||
<p className="text-foreground-secondary text-sm">
|
||||
Yes! While we provide a Vercel template, you can deploy an executor anywhere that
|
||||
can run Node.js and expose an HTTP endpoint. The executor just needs to implement
|
||||
the API specification above.
|
||||
<strong>Railway</strong> is our official recommendation. It offers one-click
|
||||
deployment, no cold starts, auto-scaling, and a generous $5/month free tier. Use{' '}
|
||||
<strong>Unsandbox</strong> if you prefer CLI deployment, or{' '}
|
||||
<strong>Vercel</strong> if you're already on Vercel and prefer pay-per-use
|
||||
serverless pricing.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-2">What about timeouts?</h3>
|
||||
<h3 className="font-medium text-foreground mb-2">Can I use other platforms?</h3>
|
||||
<p className="text-foreground-secondary text-sm">
|
||||
The default timeout for tool execution is 30 seconds. On Vercel's free tier,
|
||||
you get up to 10 seconds per request. For longer-running tools, consider deploying
|
||||
to a platform with higher timeout limits.
|
||||
Yes! Any platform that runs Node.js and exposes HTTP endpoints works. AWS Lambda,
|
||||
Google Cloud Run, Render, Fly.io—just implement the API specification above.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-2">How do tools get loaded?</h3>
|
||||
<p className="text-foreground-secondary text-sm">
|
||||
Tools are dynamically imported from{' '}
|
||||
<Link href="https://esm.sh" className="text-primary hover:underline">
|
||||
esm.sh
|
||||
</Link>
|
||||
, a CDN for npm packages. The executor fetches the package, finds the tool export,
|
||||
and calls its <code className="px-1 bg-surface rounded">execute()</code> function.
|
||||
The executor runs <code className="px-1 bg-surface rounded">npm install</code> for
|
||||
the requested package, then dynamically imports it and calls the tool's{' '}
|
||||
<code className="px-1 bg-surface rounded">execute()</code> function. Each
|
||||
execution uses a fresh temporary directory.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground mb-2">
|
||||
Are environment variables secure?
|
||||
</h3>
|
||||
<p className="text-foreground-secondary text-sm">
|
||||
Yes. Environment variables are stored encrypted by the platform (Vercel/Unsandbox)
|
||||
and only available during execution. You can also pass per-request environment
|
||||
variables in the <code className="px-1 bg-surface rounded">env</code> field of the
|
||||
execute-tool request.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -302,16 +510,22 @@ export default function ExecutorsDocsPage(): React.ReactElement {
|
|||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button variant="secondary" size="sm">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md bg-surface-secondary hover:bg-surface-tertiary text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="github" className="w-4 h-4 mr-2" />
|
||||
Open an Issue
|
||||
</Button>
|
||||
</button>
|
||||
</a>
|
||||
<a href="https://discord.gg/tpmjs" target="_blank" rel="noopener noreferrer">
|
||||
<Button variant="ghost" size="sm">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center px-3 py-1.5 text-sm font-medium rounded-md hover:bg-surface text-foreground-secondary transition-colors"
|
||||
>
|
||||
<Icon icon="discord" className="w-4 h-4 mr-2" />
|
||||
Join Discord
|
||||
</Button>
|
||||
</button>
|
||||
</a>
|
||||
</div>
|
||||
</section>
|
||||
|
|
|
|||
442
apps/web/src/app/docs/executors/railway/page.tsx
Normal file
442
apps/web/src/app/docs/executors/railway/page.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppFooter } from '~/components/AppFooter';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Deploy to Railway - Custom Executors - TPMJS',
|
||||
description:
|
||||
'Deploy a TPMJS executor to Railway with one click. Always-on, auto-scaling, with a generous free tier.',
|
||||
};
|
||||
|
||||
const healthCheck = `curl https://your-executor.up.railway.app/health`;
|
||||
|
||||
const healthResponse = `{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"info": {
|
||||
"runtime": "railway",
|
||||
"timestamp": "2024-01-01T00:00:00.000Z",
|
||||
"region": "us-west1"
|
||||
}
|
||||
}`;
|
||||
|
||||
const executeExample = `curl -X POST https://your-executor.up.railway.app/execute-tool \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer your-api-key" \\
|
||||
-d '{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"name": "helloWorldTool",
|
||||
"version": "latest",
|
||||
"params": { "includeTimestamp": true }
|
||||
}'`;
|
||||
|
||||
const cliDeploy = `# Clone the template
|
||||
git clone https://github.com/tpmjs/tpmjs.git
|
||||
cd tpmjs/templates/railway-executor
|
||||
|
||||
# Install Railway CLI
|
||||
npm install -g @railway/cli
|
||||
|
||||
# Login to Railway
|
||||
railway login
|
||||
|
||||
# Create a new project and deploy
|
||||
railway init
|
||||
railway up`;
|
||||
|
||||
const envVars = `# Set environment variables via CLI
|
||||
railway variables set EXECUTOR_API_KEY=your-secure-key
|
||||
railway variables set OPENAI_API_KEY=sk-xxx
|
||||
railway variables set DATABASE_URL=postgres://...`;
|
||||
|
||||
const localDev = `# Clone the repository
|
||||
git clone https://github.com/tpmjs/tpmjs.git
|
||||
cd tpmjs/templates/railway-executor
|
||||
|
||||
# Run locally
|
||||
PORT=3000 node index.js
|
||||
|
||||
# Test health endpoint
|
||||
curl http://localhost:3000/health`;
|
||||
|
||||
const dockerDeploy = `# Build the image
|
||||
docker build -t tpmjs-executor .
|
||||
|
||||
# Run locally
|
||||
docker run -p 3000:3000 -e EXECUTOR_API_KEY=your-key tpmjs-executor`;
|
||||
|
||||
export default function RailwayExecutorPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-8">
|
||||
<Link href="/docs/executors" className="hover:text-foreground transition-colors">
|
||||
Executors
|
||||
</Link>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<span className="text-foreground">Railway</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-[#0B0D0E] rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-7 h-7"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-labelledby="railway-header-logo"
|
||||
>
|
||||
<title id="railway-header-logo">Railway logo</title>
|
||||
<path
|
||||
d="M.113 12.611c-.139.312-.107.633.092.917.156.222.38.35.651.357h5.523a.28.28 0 00.216-.1c.07-.078.135-.22.2-.426.224-.718.428-1.09.65-1.492l.003-.005c.247-.448.51-.924.817-1.762.26-.712.296-1.371.113-2.008-.178-.62-.627-1.213-1.406-1.823a.248.248 0 00-.23-.044c-.078.025-.14.08-.178.157l-5.46 4.67a1.017 1.017 0 00-.287.41z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M8.904 6.16a.258.258 0 00-.22.108c-.06.08-.08.178-.053.27.147.517.147 1.023 0 1.56-.114.416-.304.81-.49 1.194l-.04.083c-.214.443-.43.889-.595 1.42-.204.66-.21 1.336-.017 2.015.287 1.013.986 1.826 1.93 2.252l.014.006c.15.07.252.116.341.196.09.08.17.195.28.368l.023.035c.217.34.517.81.827 1.142a.244.244 0 00.204.091h1.694a.252.252 0 00.217-.12.234.234 0 00.006-.243 7.639 7.639 0 00-.66-1.016c-.253-.34-.523-.665-.76-.948a6.876 6.876 0 01-.507-.66c-.15-.228-.288-.53-.296-.857-.016-.679.413-1.39.85-1.994.168-.232.35-.484.52-.767.306-.51.383-1.054.227-1.62a2.568 2.568 0 00-.943-1.317 6.487 6.487 0 00-.98-.636 2.27 2.27 0 01-.516-.346.248.248 0 00-.214-.073l-.842.057z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M20.756 6.225h-6.243a.255.255 0 00-.23.146.243.243 0 00.035.266c.32.362.618.742.892 1.137.317.46.597.946.833 1.45a.243.243 0 00.218.142h5.808c.278-.005.507-.133.667-.36.203-.287.234-.62.091-.94l-.765-1.563a.264.264 0 00-.24-.152l-1.066-.126z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M15.82 11.152a.247.247 0 00-.229.158c-.243.648-.36.972-.618 1.46-.257.49-.527.84-1.058 1.468a.244.244 0 00.006.325c.235.264.496.505.78.722.425.327.883.608 1.365.837a.237.237 0 00.108.026h4.16a.255.255 0 00.229-.146.243.243 0 00-.035-.267c-.444-.502-.8-.98-1.172-1.632l-.006-.01c-.367-.642-.57-1.092-.83-1.872a.243.243 0 00-.232-.169l-2.468-.9z"
|
||||
fill="#fff"
|
||||
/>
|
||||
<path
|
||||
d="M13.873 16.68a.25.25 0 00-.2.074 4.588 4.588 0 01-.637.52c-.456.314-.797.472-1.236.647a.24.24 0 00-.147.137.249.249 0 00.004.2l.463.95c.098.2.285.32.5.317h6.357c.273-.013.497-.144.653-.374.198-.29.223-.622.074-.935l-.553-1.131a.257.257 0 00-.236-.152l-5.042-.253z"
|
||||
fill="#fff"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Deploy to Railway</h1>
|
||||
<p className="text-foreground-secondary">
|
||||
Always-on execution with auto-scaling and a free tier
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why Railway */}
|
||||
<section className="mb-12">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">$5</div>
|
||||
<div className="text-sm text-foreground-secondary">Free monthly credit</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">0ms</div>
|
||||
<div className="text-sm text-foreground-secondary">No cold starts</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">Auto</div>
|
||||
<div className="text-sm text-foreground-secondary">Scaling built-in</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* One-Click Deploy */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">One-Click Deploy</h2>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
Deploy the TPMJS executor to Railway with a single click:
|
||||
</p>
|
||||
<a
|
||||
href="https://railway.app/template/tpmjs-executor?referralCode=tpmjs"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button size="lg">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M.113 12.611c-.139.312-.107.633.092.917.156.222.38.35.651.357h5.523a.28.28 0 00.216-.1c.07-.078.135-.22.2-.426.224-.718.428-1.09.65-1.492l.003-.005c.247-.448.51-.924.817-1.762.26-.712.296-1.371.113-2.008-.178-.62-.627-1.213-1.406-1.823a.248.248 0 00-.23-.044c-.078.025-.14.08-.178.157l-5.46 4.67a1.017 1.017 0 00-.287.41z" />
|
||||
</svg>
|
||||
Deploy on Railway
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
After deployment, your executor will be available at{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://your-project.up.railway.app
|
||||
</code>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* CLI Deploy */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Deploy via CLI</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Prefer the command line? Deploy with the Railway CLI:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={cliDeploy} />
|
||||
</section>
|
||||
|
||||
{/* Test Your Deployment */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Test Your Deployment</h2>
|
||||
<p className="text-foreground-secondary mb-4">Verify your executor is running:</p>
|
||||
<CodeBlock language="bash" code={healthCheck} />
|
||||
<p className="text-sm text-foreground-secondary mt-4 mb-2">Expected response:</p>
|
||||
<CodeBlock language="json" code={healthResponse} />
|
||||
</section>
|
||||
|
||||
{/* Authentication */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Add Authentication</h2>
|
||||
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg mb-4">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Important:</strong> Without an API key, anyone can execute tools on your
|
||||
executor. Always set{' '}
|
||||
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> in production.
|
||||
</p>
|
||||
</div>
|
||||
<ol className="text-foreground-secondary space-y-3">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span>Go to your Railway project dashboard</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span>Click on your service, then go to "Variables"</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
Add <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> with a
|
||||
secure random value
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<span>Railway will automatically redeploy with the new variable</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Environment Variables</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Add environment variables via the Railway dashboard or CLI:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={envVars} />
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
These variables will be available during tool execution.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Execute a Tool */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Execute a Tool</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Test tool execution with a curl request:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={executeExample} />
|
||||
</section>
|
||||
|
||||
{/* Local Development */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Local Development</h2>
|
||||
<p className="text-foreground-secondary mb-4">Run the executor locally for testing:</p>
|
||||
<CodeBlock language="bash" code={localDev} />
|
||||
</section>
|
||||
|
||||
{/* Docker */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Docker Deployment</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
The template includes a Dockerfile for container deployments:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={dockerDeploy} />
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
Railway will automatically detect and use the Dockerfile if present.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">How It Works</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
The Railway executor runs as an always-on Node.js service:
|
||||
</p>
|
||||
<ol className="text-foreground-secondary space-y-3">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
Receives tool execution request via HTTP POST to{' '}
|
||||
<code className="px-1 bg-surface rounded">/execute-tool</code>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span>Creates an isolated temporary directory for the execution</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
Installs the npm package using{' '}
|
||||
<code className="px-1 bg-surface rounded">npm install</code>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
Loads the tool and calls its{' '}
|
||||
<code className="px-1 bg-surface rounded">execute()</code> function
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
5
|
||||
</span>
|
||||
<span>Returns the result and cleans up the temporary directory</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Security</h2>
|
||||
<ul className="text-foreground-secondary space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Set <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> to require
|
||||
authentication
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Each tool execution uses an isolated temporary directory</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Environment variables stored encrypted by Railway</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>All traffic encrypted via HTTPS</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Auto-restart on failure for high availability</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Pricing</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Railway offers usage-based pricing with a generous free tier:
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 pr-4 font-medium text-foreground">Tier</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-foreground">Price</th>
|
||||
<th className="text-left py-3 pl-4 font-medium text-foreground">Includes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Free Tier</td>
|
||||
<td className="py-3 px-4">$0/month</td>
|
||||
<td className="py-3 pl-4">$5 credit, enough for light usage</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 pr-4">Usage-based</td>
|
||||
<td className="py-3 px-4">~$0.000463/min</td>
|
||||
<td className="py-3 pl-4">0.5 vCPU, 512MB RAM</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
See{' '}
|
||||
<a
|
||||
href="https://railway.app/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Railway Pricing
|
||||
</a>{' '}
|
||||
for current rates.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Connect to TPMJS */}
|
||||
<section className="mb-12 p-6 bg-primary/5 border border-primary/20 rounded-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Connect to TPMJS</h2>
|
||||
<ol className="text-foreground-secondary space-y-2">
|
||||
<li>1. Go to your collection or agent settings on TPMJS</li>
|
||||
<li>2. Select "Custom Executor" in Executor Configuration</li>
|
||||
<li>
|
||||
3. Enter URL:{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://your-project.up.railway.app
|
||||
</code>
|
||||
</li>
|
||||
<li>4. Enter your API key (if configured)</li>
|
||||
<li>5. Click "Verify Connection"</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between pt-8 border-t border-border">
|
||||
<Link
|
||||
href="/docs/executors"
|
||||
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="chevronLeft" className="w-4 h-4" />
|
||||
<span>Back to Executors</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/executors/unsandbox"
|
||||
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Unsandbox Guide</span>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
420
apps/web/src/app/docs/executors/unsandbox/page.tsx
Normal file
420
apps/web/src/app/docs/executors/unsandbox/page.tsx
Normal file
|
|
@ -0,0 +1,420 @@
|
|||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppFooter } from '~/components/AppFooter';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Deploy to Unsandbox - Custom Executors - TPMJS',
|
||||
description:
|
||||
'Deploy a TPMJS executor to Unsandbox with one CLI command. Always-on, no cold starts, unlimited runtime.',
|
||||
};
|
||||
|
||||
const deployCommand = `# Install the Unsandbox CLI
|
||||
curl -fsSL https://unsandbox.com/install.sh | bash
|
||||
|
||||
# Deploy the TPMJS executor
|
||||
un service --name tpmjs-executor --ports 80 -n semitrusted \\
|
||||
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
|
||||
|
||||
const deployWithApiKey = `un service --name tpmjs-executor --ports 80 -n semitrusted \\
|
||||
-e EXECUTOR_API_KEY=your-secure-random-key \\
|
||||
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
|
||||
|
||||
const deployWithEnvVars = `un service --name tpmjs-executor --ports 80 -n semitrusted \\
|
||||
-e EXECUTOR_API_KEY=your-key \\
|
||||
-e OPENAI_API_KEY=sk-xxx \\
|
||||
-e DATABASE_URL=postgres://... \\
|
||||
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
|
||||
|
||||
const deployWithEnvFile = `# Create .env file with your secrets
|
||||
cat > .env << EOF
|
||||
EXECUTOR_API_KEY=your-key
|
||||
OPENAI_API_KEY=sk-xxx
|
||||
DATABASE_URL=postgres://...
|
||||
EOF
|
||||
|
||||
# Deploy with env file
|
||||
un service --name tpmjs-executor --ports 80 -n semitrusted \\
|
||||
--env-file .env \\
|
||||
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
|
||||
|
||||
const healthCheck = `curl https://tpmjs-executor.on.unsandbox.com/api/health`;
|
||||
|
||||
const healthResponse = `{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"info": {
|
||||
"runtime": "unsandbox",
|
||||
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
}`;
|
||||
|
||||
const executeExample = `curl -X POST https://tpmjs-executor.on.unsandbox.com/api/execute-tool \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer your-api-key" \\
|
||||
-d '{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"name": "helloWorldTool",
|
||||
"version": "latest",
|
||||
"params": { "includeTimestamp": true }
|
||||
}'`;
|
||||
|
||||
const localDev = `# Clone the repository
|
||||
git clone https://github.com/tpmjs/tpmjs.git
|
||||
cd tpmjs/templates/unsandbox-executor
|
||||
|
||||
# Run locally
|
||||
PORT=3000 node executor.js
|
||||
|
||||
# Test health endpoint
|
||||
curl http://localhost:3000/api/health`;
|
||||
|
||||
const managementCommands = `# View logs
|
||||
un service --logs tpmjs-executor
|
||||
|
||||
# Redeploy (after updating)
|
||||
un service --redeploy tpmjs-executor
|
||||
|
||||
# Freeze when not in use (save costs)
|
||||
un service --freeze tpmjs-executor
|
||||
|
||||
# Unfreeze when needed
|
||||
un service --unfreeze tpmjs-executor
|
||||
|
||||
# Scale resources (4 vCPU, 8GB RAM)
|
||||
un service --resize tpmjs-executor --vcpu 4
|
||||
|
||||
# Destroy service
|
||||
un service --destroy tpmjs-executor`;
|
||||
|
||||
const customDomain = `un service --name tpmjs-executor --ports 80 -n semitrusted \\
|
||||
--domains executor.yourdomain.com \\
|
||||
--bootstrap "curl -fsSL https://raw.githubusercontent.com/tpmjs/tpmjs/main/templates/unsandbox-executor/bootstrap.sh | bash"`;
|
||||
|
||||
export default function UnsandboxExecutorPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-8">
|
||||
<Link href="/docs/executors" className="hover:text-foreground transition-colors">
|
||||
Executors
|
||||
</Link>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<span className="text-foreground">Unsandbox</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-orange-500 to-red-600 rounded-lg flex items-center justify-center text-white font-bold text-lg">
|
||||
un
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Deploy to Unsandbox</h1>
|
||||
<p className="text-foreground-secondary">
|
||||
Always-on execution with one CLI command
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why Unsandbox */}
|
||||
<section className="mb-12">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">0ms</div>
|
||||
<div className="text-sm text-foreground-secondary">No cold starts</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">∞</div>
|
||||
<div className="text-sm text-foreground-secondary">Unlimited runtime</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">1 cmd</div>
|
||||
<div className="text-sm text-foreground-secondary">Deploy in seconds</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Deploy */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Quick Deploy</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Deploy a TPMJS executor with a single command. Your executor will be live at{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://tpmjs-executor.on.unsandbox.com
|
||||
</code>
|
||||
</p>
|
||||
<CodeBlock language="bash" code={deployCommand} />
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
This creates an always-on service that runs the executor. HTTPS is automatically
|
||||
configured.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Test Your Deployment */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Test Your Deployment</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Verify your executor is running with a health check:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={healthCheck} />
|
||||
<p className="text-sm text-foreground-secondary mt-4 mb-2">Expected response:</p>
|
||||
<CodeBlock language="json" code={healthResponse} />
|
||||
</section>
|
||||
|
||||
{/* Authentication */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Add Authentication</h2>
|
||||
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg mb-4">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Important:</strong> Without an API key, anyone can execute tools on your
|
||||
executor. Always set{' '}
|
||||
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> in production.
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Deploy with an API key to require authentication:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={deployWithApiKey} />
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
When configured, requests must include{' '}
|
||||
<code className="px-1 bg-surface rounded">Authorization: Bearer your-api-key</code>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Environment Variables</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Pass environment variables that your tools need. These are available during tool
|
||||
execution.
|
||||
</p>
|
||||
|
||||
<h3 className="text-lg font-medium text-foreground mb-3">Inline Variables</h3>
|
||||
<CodeBlock language="bash" code={deployWithEnvVars} />
|
||||
|
||||
<h3 className="text-lg font-medium text-foreground mt-6 mb-3">Using an Env File</h3>
|
||||
<CodeBlock language="bash" code={deployWithEnvFile} />
|
||||
|
||||
<div className="p-4 bg-surface border border-border rounded-lg mt-4">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
All environment variables are stored encrypted and only available to your executor.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Execute a Tool */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Execute a Tool</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Test tool execution with a curl request:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={executeExample} />
|
||||
</section>
|
||||
|
||||
{/* Local Development */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Local Development</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Run the executor locally for testing and development:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={localDev} />
|
||||
</section>
|
||||
|
||||
{/* Management Commands */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Managing Your Service</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Unsandbox provides commands to manage your executor:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={managementCommands} />
|
||||
|
||||
<h3 className="text-lg font-medium text-foreground mt-6 mb-3">Cost Optimization</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Freeze your executor when not in use to stop billing:
|
||||
</p>
|
||||
<ul className="text-foreground-secondary text-sm space-y-2">
|
||||
<li>
|
||||
• <code className="px-1 bg-surface rounded">un service --freeze</code> stops the
|
||||
service and billing
|
||||
</li>
|
||||
<li>
|
||||
• <code className="px-1 bg-surface rounded">un service --unfreeze</code> restarts it
|
||||
when needed
|
||||
</li>
|
||||
<li>• Configure auto-unfreeze to wake on HTTP request (incurs cold start)</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Custom Domains */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Custom Domains</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Use your own domain instead of the default{' '}
|
||||
<code className="px-1 bg-surface rounded">*.on.unsandbox.com</code>:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={customDomain} />
|
||||
<p className="text-foreground-secondary text-sm mt-4">
|
||||
After deploying, add a CNAME record pointing{' '}
|
||||
<code className="px-1 bg-surface rounded">executor.yourdomain.com</code> to your
|
||||
Unsandbox service domain.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">How It Works</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
The Unsandbox executor runs as an always-on HTTP server:
|
||||
</p>
|
||||
<ol className="text-foreground-secondary space-y-3">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span>
|
||||
Receives tool execution request via HTTP POST to{' '}
|
||||
<code className="px-1 bg-surface rounded">/api/execute-tool</code>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span>Creates an isolated temporary directory for the execution</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
Installs the npm package using{' '}
|
||||
<code className="px-1 bg-surface rounded">npm install</code>
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<span>
|
||||
Loads the tool and calls its{' '}
|
||||
<code className="px-1 bg-surface rounded">execute()</code> function
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
5
|
||||
</span>
|
||||
<span>Returns the result and cleans up the temporary directory</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-foreground-secondary text-sm mt-4">
|
||||
Since Unsandbox containers are already isolated, no additional sandbox layer is
|
||||
needed. Network access is controlled by Unsandbox's semitrusted mode.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Security</h2>
|
||||
<ul className="text-foreground-secondary space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Set <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> to require
|
||||
authentication
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Tools run in isolated Unsandbox containers</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Each execution uses a fresh temporary directory</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Network controlled by semitrusted mode</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Environment variables stored encrypted</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Pricing */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Pricing</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Unsandbox services are billed based on uptime. See{' '}
|
||||
<a
|
||||
href="https://unsandbox.com/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Unsandbox Pricing
|
||||
</a>{' '}
|
||||
for current rates.
|
||||
</p>
|
||||
<ul className="text-foreground-secondary text-sm space-y-2">
|
||||
<li>
|
||||
• HTTPS included via{' '}
|
||||
<code className="px-1 bg-surface rounded">*.on.unsandbox.com</code>
|
||||
</li>
|
||||
<li>• Freeze when not in use to pause billing</li>
|
||||
<li>• Scale vCPU and RAM as needed</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Connect to TPMJS */}
|
||||
<section className="mb-12 p-6 bg-primary/5 border border-primary/20 rounded-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Connect to TPMJS</h2>
|
||||
<ol className="text-foreground-secondary space-y-2">
|
||||
<li>1. Go to your collection or agent settings on TPMJS</li>
|
||||
<li>2. Select "Custom Executor" in Executor Configuration</li>
|
||||
<li>
|
||||
3. Enter URL:{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://tpmjs-executor.on.unsandbox.com
|
||||
</code>
|
||||
</li>
|
||||
<li>4. Enter your API key (if configured)</li>
|
||||
<li>5. Click "Verify Connection"</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between pt-8 border-t border-border">
|
||||
<Link
|
||||
href="/docs/executors/railway"
|
||||
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="chevronLeft" className="w-4 h-4" />
|
||||
<span>Railway Guide</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/executors/vercel"
|
||||
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Vercel Guide</span>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
426
apps/web/src/app/docs/executors/vercel/page.tsx
Normal file
426
apps/web/src/app/docs/executors/vercel/page.tsx
Normal file
|
|
@ -0,0 +1,426 @@
|
|||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import type { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
import { AppFooter } from '~/components/AppFooter';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Deploy to Vercel - Custom Executors - TPMJS',
|
||||
description:
|
||||
'Deploy a TPMJS executor to Vercel with one click. VM-level isolation using Vercel Sandbox.',
|
||||
};
|
||||
|
||||
const healthCheck = `curl https://your-executor.vercel.app/api/health`;
|
||||
|
||||
const healthResponse = `{
|
||||
"status": "ok",
|
||||
"version": "1.0.0",
|
||||
"info": {
|
||||
"runtime": "vercel-sandbox",
|
||||
"region": "iad1",
|
||||
"timestamp": "2024-01-01T00:00:00.000Z"
|
||||
}
|
||||
}`;
|
||||
|
||||
const executeExample = `curl -X POST https://your-executor.vercel.app/api/execute-tool \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-H "Authorization: Bearer your-api-key" \\
|
||||
-d '{
|
||||
"packageName": "@tpmjs/hello",
|
||||
"name": "helloWorld",
|
||||
"version": "latest",
|
||||
"params": { "name": "World" }
|
||||
}'`;
|
||||
|
||||
const localDev = `# Clone and install
|
||||
git clone https://github.com/tpmjs/tpmjs.git
|
||||
cd tpmjs/templates/vercel-executor
|
||||
npm install
|
||||
|
||||
# Login to Vercel (required for sandbox)
|
||||
vercel login
|
||||
vercel link
|
||||
|
||||
# Pull environment variables
|
||||
vercel env pull
|
||||
|
||||
# Run development server
|
||||
npm run dev
|
||||
|
||||
# Test health endpoint
|
||||
curl http://localhost:3000/api/health`;
|
||||
|
||||
export default function VercelExecutorPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen bg-background flex flex-col">
|
||||
<AppHeader />
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="max-w-4xl mx-auto px-4 py-12">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="flex items-center gap-2 text-sm text-foreground-secondary mb-8">
|
||||
<Link href="/docs/executors" className="hover:text-foreground transition-colors">
|
||||
Executors
|
||||
</Link>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
<span className="text-foreground">Vercel</span>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<div className="w-12 h-12 bg-black rounded-lg flex items-center justify-center">
|
||||
<svg
|
||||
className="w-6 h-6 text-white"
|
||||
viewBox="0 0 76 65"
|
||||
fill="currentColor"
|
||||
aria-label="Vercel logo"
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-foreground">Deploy to Vercel</h1>
|
||||
<p className="text-foreground-secondary">
|
||||
One-click deploy with VM-level isolation
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Why Vercel */}
|
||||
<section className="mb-12">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">1-click</div>
|
||||
<div className="text-sm text-foreground-secondary">Deploy instantly</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">VM</div>
|
||||
<div className="text-sm text-foreground-secondary">Sandbox isolation</div>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg">
|
||||
<div className="text-2xl mb-2">Free</div>
|
||||
<div className="text-sm text-foreground-secondary">Hobby tier available</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* One-Click Deploy */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">One-Click Deploy</h2>
|
||||
<p className="text-foreground-secondary mb-6">
|
||||
Deploy the TPMJS executor template to your Vercel account:
|
||||
</p>
|
||||
<a
|
||||
href="https://vercel.com/new/clone?repository-url=https://github.com/tpmjs/tpmjs/tree/main/templates/vercel-executor&project-name=tpmjs-executor&repository-name=tpmjs-executor"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button size="lg">
|
||||
<svg
|
||||
className="w-4 h-4 mr-2"
|
||||
viewBox="0 0 76 65"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M37.5274 0L75.0548 65H0L37.5274 0Z" />
|
||||
</svg>
|
||||
Deploy with Vercel
|
||||
</Button>
|
||||
</a>
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
After deployment, your executor will be available at{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://tpmjs-executor.vercel.app
|
||||
</code>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Test Your Deployment */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Test Your Deployment</h2>
|
||||
<p className="text-foreground-secondary mb-4">Verify your executor is running:</p>
|
||||
<CodeBlock language="bash" code={healthCheck} />
|
||||
<p className="text-sm text-foreground-secondary mt-4 mb-2">Expected response:</p>
|
||||
<CodeBlock language="json" code={healthResponse} />
|
||||
</section>
|
||||
|
||||
{/* Authentication */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Add Authentication</h2>
|
||||
<div className="p-4 bg-warning/10 border border-warning/30 rounded-lg mb-4">
|
||||
<p className="text-sm text-warning">
|
||||
<strong>Important:</strong> Without an API key, anyone can execute tools on your
|
||||
executor. Always set{' '}
|
||||
<code className="px-1 bg-warning/20 rounded">EXECUTOR_API_KEY</code> in production.
|
||||
</p>
|
||||
</div>
|
||||
<ol className="text-foreground-secondary space-y-3">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span>Go to your Vercel project settings</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span>Navigate to Environment Variables</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<span>
|
||||
Add <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> with a
|
||||
secure random value
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-surface-secondary text-foreground-secondary text-sm flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<span>Redeploy your project to apply the changes</span>
|
||||
</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Environment Variables</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Add custom environment variables for your tools in Vercel project settings:
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 pr-4 font-medium text-foreground">Variable</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-foreground">Required</th>
|
||||
<th className="text-left py-3 pl-4 font-medium text-foreground">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">
|
||||
<code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">No*</td>
|
||||
<td className="py-3 pl-4">
|
||||
API key for authentication. Required for production.
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">
|
||||
<code className="px-1 bg-surface rounded">OPENAI_API_KEY</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 pl-4">Example: Pass through to tools that need OpenAI</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 pr-4">
|
||||
<code className="px-1 bg-surface rounded">DATABASE_URL</code>
|
||||
</td>
|
||||
<td className="py-3 px-4">No</td>
|
||||
<td className="py-3 pl-4">Example: Pass through to tools that need database</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p className="text-sm text-foreground-tertiary mt-4">
|
||||
* Strongly recommended for production deployments
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Execute a Tool */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Execute a Tool</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Test tool execution with a curl request:
|
||||
</p>
|
||||
<CodeBlock language="bash" code={executeExample} />
|
||||
</section>
|
||||
|
||||
{/* Local Development */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Local Development</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Run the executor locally for testing. Note: Vercel Sandbox requires authentication
|
||||
even in development.
|
||||
</p>
|
||||
<CodeBlock language="bash" code={localDev} />
|
||||
<div className="p-4 bg-surface border border-border rounded-lg mt-4">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
<strong>Note:</strong> You must run{' '}
|
||||
<code className="px-1 bg-surface-secondary rounded">vercel login</code> and{' '}
|
||||
<code className="px-1 bg-surface-secondary rounded">vercel link</code> before local
|
||||
development. Vercel Sandbox requires authentication to create VMs.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* How It Works */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">How It Works</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
The Vercel executor uses{' '}
|
||||
<a
|
||||
href="https://vercel.com/docs/vercel-sandbox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Vercel Sandbox
|
||||
</a>{' '}
|
||||
for isolated execution:
|
||||
</p>
|
||||
<ol className="text-foreground-secondary space-y-3">
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
1
|
||||
</span>
|
||||
<span>Creates an isolated VM for each tool execution</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
2
|
||||
</span>
|
||||
<span>Installs the npm package in the sandbox</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
3
|
||||
</span>
|
||||
<span>Executes the tool with your parameters</span>
|
||||
</li>
|
||||
<li className="flex gap-3">
|
||||
<span className="flex-shrink-0 w-6 h-6 rounded-full bg-primary/10 text-primary text-sm flex items-center justify-center">
|
||||
4
|
||||
</span>
|
||||
<span>Returns the result and destroys the sandbox</span>
|
||||
</li>
|
||||
</ol>
|
||||
<p className="text-foreground-secondary text-sm mt-4">
|
||||
This provides VM-level isolation without the limitations of Node.js serverless
|
||||
functions.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Security */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Security</h2>
|
||||
<ul className="text-foreground-secondary space-y-2">
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
Set <code className="px-1 bg-surface rounded">EXECUTOR_API_KEY</code> to require
|
||||
authentication
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Tools run in isolated VMs with no access to your Vercel project</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Each execution gets a fresh sandbox instance</span>
|
||||
</li>
|
||||
<li className="flex items-start gap-2">
|
||||
<Icon icon="check" className="w-5 h-5 text-success flex-shrink-0 mt-0.5" />
|
||||
<span>Sandboxes are destroyed after execution completes</span>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Pricing & Limits */}
|
||||
<section className="mb-12">
|
||||
<h2 className="text-2xl font-semibold text-foreground mb-4">Pricing & Limits</h2>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Vercel Sandbox usage is billed based on compute time. See{' '}
|
||||
<a
|
||||
href="https://vercel.com/docs/vercel-sandbox/pricing"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Vercel Sandbox Pricing
|
||||
</a>{' '}
|
||||
for current rates.
|
||||
</p>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-3 pr-4 font-medium text-foreground">Plan</th>
|
||||
<th className="text-left py-3 px-4 font-medium text-foreground">Max Runtime</th>
|
||||
<th className="text-left py-3 pl-4 font-medium text-foreground">Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-3 pr-4">Hobby</td>
|
||||
<td className="py-3 px-4">45 minutes</td>
|
||||
<td className="py-3 pl-4">Free tier</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-3 pr-4">Pro</td>
|
||||
<td className="py-3 px-4">5 hours</td>
|
||||
<td className="py-3 pl-4">For longer-running tools</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="p-4 bg-surface border border-border rounded-lg mt-4">
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
<strong>Region:</strong> Vercel Sandbox is currently only available in{' '}
|
||||
<code className="px-1 bg-surface-secondary rounded">iad1</code> (US East).
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connect to TPMJS */}
|
||||
<section className="mb-12 p-6 bg-primary/5 border border-primary/20 rounded-lg">
|
||||
<h2 className="text-lg font-semibold text-foreground mb-4">Connect to TPMJS</h2>
|
||||
<ol className="text-foreground-secondary space-y-2">
|
||||
<li>1. Go to your collection or agent settings on TPMJS</li>
|
||||
<li>2. Select "Custom Executor" in Executor Configuration</li>
|
||||
<li>
|
||||
3. Enter URL:{' '}
|
||||
<code className="px-1.5 py-0.5 bg-surface rounded">
|
||||
https://tpmjs-executor.vercel.app
|
||||
</code>
|
||||
</li>
|
||||
<li>4. Enter your API key (if configured)</li>
|
||||
<li>5. Click "Verify Connection"</li>
|
||||
</ol>
|
||||
</section>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex items-center justify-between pt-8 border-t border-border">
|
||||
<Link
|
||||
href="/docs/executors/unsandbox"
|
||||
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<Icon icon="chevronLeft" className="w-4 h-4" />
|
||||
<span>Unsandbox Guide</span>
|
||||
</Link>
|
||||
<Link
|
||||
href="/docs/executors"
|
||||
className="flex items-center gap-2 text-foreground-secondary hover:text-foreground transition-colors"
|
||||
>
|
||||
<span>Back to Executors</span>
|
||||
<Icon icon="chevronRight" className="w-4 h-4" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<AppFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1308,8 +1308,8 @@ export TPMJS_EXECUTOR_URL=https://executor.mycompany.com`}
|
|||
<CodeBlock
|
||||
language="bash"
|
||||
code={`claude mcp add tpmjs-my-collection \\
|
||||
-- npx mcp-remote https://tpmjs.com/api/mcp/<username>/<collection-slug>/http \\
|
||||
--header "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
|
||||
https://tpmjs.com/api/mcp/<username>/<collection-slug>/http \\
|
||||
-t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
|
||||
/>
|
||||
<p className="text-foreground-secondary mt-4">
|
||||
This automatically adds the server to your Claude Code configuration.
|
||||
|
|
|
|||
|
|
@ -802,8 +802,8 @@ Invalid usernames:
|
|||
<CodeBlock
|
||||
language="bash"
|
||||
code={`claude mcp add tpmjs-my-collection \\
|
||||
-- npx mcp-remote https://tpmjs.com/api/mcp/YOUR_USERNAME/YOUR_COLLECTION_SLUG/http \\
|
||||
--header "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
|
||||
https://tpmjs.com/api/mcp/YOUR_USERNAME/YOUR_COLLECTION_SLUG/http \\
|
||||
-t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`}
|
||||
/>
|
||||
</DocSubSection>
|
||||
</DocSection>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { Analytics } from '@vercel/analytics/next';
|
||||
import type { Metadata } from 'next';
|
||||
import { Space_Grotesk, Space_Mono } from 'next/font/google';
|
||||
import Script from 'next/script';
|
||||
import { Toaster } from 'sonner';
|
||||
import { AppFooter } from '../components/AppFooter';
|
||||
import { ThemeProvider } from '../components/providers/ThemeProvider';
|
||||
|
|
@ -130,18 +129,6 @@ export default function RootLayout({
|
|||
className={`${spaceGrotesk.variable} ${spaceMono.variable}`}
|
||||
>
|
||||
<head>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<>
|
||||
<Script
|
||||
src="//unpkg.com/react-grab/dist/index.global.js"
|
||||
strategy="beforeInteractive"
|
||||
/>
|
||||
<Script
|
||||
src="//unpkg.com/@react-grab/claude-code/dist/client.global.js"
|
||||
strategy="lazyOnload"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
<script
|
||||
type="application/ld+json"
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(organizationSchema) }}
|
||||
|
|
|
|||
|
|
@ -5,8 +5,8 @@ import { Container } from '@tpmjs/ui/Container/Container';
|
|||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { AppHeader } from '../components/AppHeader';
|
||||
// import { ArchitectureDiagramWrapper } from '../components/home/ArchitectureDiagramWrapper';
|
||||
// import { FeaturesSection } from '../components/home/FeaturesSection';
|
||||
import { EcosystemStats } from '../components/home/EcosystemStats';
|
||||
import { FeaturesSection } from '../components/home/FeaturesSection';
|
||||
import { HeroSection } from '../components/home/HeroSection';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
|
@ -14,95 +14,127 @@ export const dynamic = 'force-dynamic';
|
|||
async function getHomePageData() {
|
||||
try {
|
||||
// Fetch stats in parallel
|
||||
const [packageCount, toolCount, featuredTools, categoryStats, featuredScenarios] =
|
||||
await Promise.all([
|
||||
// Total package count
|
||||
prisma.package.count(),
|
||||
const [
|
||||
packageCount,
|
||||
toolCount,
|
||||
featuredTools,
|
||||
categoryStats,
|
||||
featuredScenarios,
|
||||
latestSnapshot,
|
||||
] = await Promise.all([
|
||||
// Total package count
|
||||
prisma.package.count(),
|
||||
|
||||
// Total tool count
|
||||
prisma.tool.count(),
|
||||
// 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,
|
||||
name: true,
|
||||
description: true,
|
||||
qualityScore: true,
|
||||
package: {
|
||||
// Top 6 featured tools by quality score
|
||||
prisma.tool.findMany({
|
||||
orderBy: [{ qualityScore: 'desc' }, { package: { npmDownloadsLastMonth: 'desc' } }],
|
||||
take: 6,
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
description: true,
|
||||
qualityScore: true,
|
||||
likeCount: 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,
|
||||
},
|
||||
}),
|
||||
|
||||
// Featured scenarios - mix of high quality, diverse, and fresh
|
||||
(async () => {
|
||||
// Get high quality scenarios
|
||||
const highQuality = await prisma.scenario.findMany({
|
||||
where: {
|
||||
collection: { isPublic: true },
|
||||
qualityScore: { gte: 0.3 },
|
||||
totalRuns: { gte: 1 },
|
||||
},
|
||||
orderBy: { qualityScore: 'desc' },
|
||||
take: 3,
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
npmPackageName: true,
|
||||
category: true,
|
||||
npmDownloadsLastMonth: true,
|
||||
isOfficial: true,
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// Category distribution for stats (group by package category)
|
||||
prisma.package.groupBy({
|
||||
by: ['category'],
|
||||
_count: {
|
||||
_all: true,
|
||||
// Get fresh scenarios (excluding already selected)
|
||||
const seenIds = new Set(highQuality.map((s) => s.id));
|
||||
const fresh = await prisma.scenario.findMany({
|
||||
where: {
|
||||
collection: { isPublic: true },
|
||||
id: { notIn: Array.from(seenIds) },
|
||||
},
|
||||
}),
|
||||
|
||||
// Featured scenarios - mix of high quality, diverse, and fresh
|
||||
(async () => {
|
||||
// Get high quality scenarios
|
||||
const highQuality = await prisma.scenario.findMany({
|
||||
where: {
|
||||
collection: { isPublic: true },
|
||||
qualityScore: { gte: 0.3 },
|
||||
totalRuns: { gte: 1 },
|
||||
},
|
||||
orderBy: { qualityScore: 'desc' },
|
||||
take: 3,
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Get fresh scenarios (excluding already selected)
|
||||
const seenIds = new Set(highQuality.map((s) => s.id));
|
||||
const fresh = await prisma.scenario.findMany({
|
||||
where: {
|
||||
collection: { isPublic: true },
|
||||
id: { notIn: Array.from(seenIds) },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 3,
|
||||
include: {
|
||||
collection: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
user: { select: { username: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
return [...highQuality, ...fresh].slice(0, 6);
|
||||
})(),
|
||||
|
||||
return [...highQuality, ...fresh].slice(0, 6);
|
||||
})(),
|
||||
]);
|
||||
// Latest stats snapshot (pre-computed daily)
|
||||
prisma.statsSnapshot.findFirst({
|
||||
orderBy: { date: 'desc' },
|
||||
select: {
|
||||
totalTools: true,
|
||||
totalPackages: true,
|
||||
totalNpmDownloads: true,
|
||||
totalGithubStars: true,
|
||||
executionsTotal: true,
|
||||
executionsAvgTimeMs: true,
|
||||
activeDevs7d: true,
|
||||
totalSimulations: true,
|
||||
categories: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
stats: {
|
||||
packageCount,
|
||||
toolCount,
|
||||
categoryCount: categoryStats.length,
|
||||
totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0,
|
||||
totalStars: latestSnapshot?.totalGithubStars ?? 0,
|
||||
},
|
||||
ecosystemStats: {
|
||||
publishedTools: latestSnapshot?.totalTools ?? toolCount,
|
||||
activeDevelopers: latestSnapshot?.activeDevs7d ?? 0,
|
||||
totalExecutions: latestSnapshot?.totalSimulations ?? 0,
|
||||
avgResponseMs: latestSnapshot?.executionsAvgTimeMs ?? null,
|
||||
totalDownloads: latestSnapshot?.totalNpmDownloads ?? 0,
|
||||
},
|
||||
featuredTools,
|
||||
categories: categoryStats.slice(0, 5).map((c) => ({
|
||||
|
|
@ -118,6 +150,15 @@ async function getHomePageData() {
|
|||
packageCount: 0,
|
||||
toolCount: 0,
|
||||
categoryCount: 0,
|
||||
totalDownloads: 0,
|
||||
totalStars: 0,
|
||||
},
|
||||
ecosystemStats: {
|
||||
publishedTools: 0,
|
||||
activeDevelopers: 0,
|
||||
totalExecutions: 0,
|
||||
avgResponseMs: null,
|
||||
totalDownloads: 0,
|
||||
},
|
||||
featuredTools: [],
|
||||
categories: [],
|
||||
|
|
@ -136,9 +177,11 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
{/* Hero Section - Dithered Design */}
|
||||
<HeroSection stats={data.stats} />
|
||||
|
||||
{/* Features Section - Interactive & Animated - temporarily disabled
|
||||
<FeaturesSection />
|
||||
*/}
|
||||
{/* Ecosystem Stats */}
|
||||
<EcosystemStats stats={data.ecosystemStats} />
|
||||
|
||||
{/* Features Section */}
|
||||
<FeaturesSection toolCount={data.stats.toolCount} />
|
||||
|
||||
{/* Architecture Diagram Section - temporarily disabled
|
||||
<section className="py-16 bg-background border-b border-border">
|
||||
|
|
@ -202,14 +245,20 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
</div>
|
||||
|
||||
<div className="mt-4 pt-4 border-t border-border flex items-center justify-between text-xs text-foreground-tertiary">
|
||||
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-brutalist-accent">★</span>
|
||||
{Number(tool.qualityScore).toFixed(2)}
|
||||
</span>
|
||||
) : (
|
||||
<span />
|
||||
)}
|
||||
<div className="flex items-center gap-3">
|
||||
{tool.qualityScore && Number(tool.qualityScore) > 0 ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<span className="text-brutalist-accent">★</span>
|
||||
{Number(tool.qualityScore).toFixed(2)}
|
||||
</span>
|
||||
) : null}
|
||||
{tool.likeCount > 0 && (
|
||||
<span className="flex items-center gap-1">
|
||||
<Icon icon="heart" className="w-3 h-3 text-error" />
|
||||
{tool.likeCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span>
|
||||
{(tool.package.npmDownloadsLastMonth ?? 0) > 0
|
||||
? `${tool.package.npmDownloadsLastMonth?.toLocaleString()} downloads/mo`
|
||||
|
|
@ -461,7 +510,9 @@ export default async function HomePage(): Promise<React.ReactElement> {
|
|||
<div className="w-2 h-2 bg-success rounded-full animate-pulse" />
|
||||
<p className="font-mono text-xs text-foreground-secondary">
|
||||
add to config → instant access to{' '}
|
||||
<span className="text-primary font-medium">170+ tools</span>
|
||||
<span className="text-primary font-medium">
|
||||
{data.stats.toolCount > 0 ? `${data.stats.toolCount}+` : '100+'} tools
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
|
|
|||
|
|
@ -116,13 +116,12 @@ export default function SDKPage(): React.ReactElement {
|
|||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`import { streamText } from 'ai';
|
||||
// Import AI SDK provider for your selected model (OpenAI, Anthropic, Google, etc.)
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||
|
||||
const result = streamText({
|
||||
model: anthropic('claude-sonnet-4-20250514'),
|
||||
model: openai('gpt-4.1-mini'),
|
||||
tools: {
|
||||
// Your existing tools
|
||||
weather: weatherTool,
|
||||
|
|
@ -199,7 +198,12 @@ Use registrySearch to find tools, then registryExecute to run them.\`,
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 rounded-md hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z" />
|
||||
</svg>
|
||||
npm
|
||||
|
|
@ -325,7 +329,12 @@ Use registrySearch to find tools, then registryExecute to run them.\`,
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium bg-red-500/10 text-red-500 rounded-md hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg
|
||||
className="w-4 h-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z" />
|
||||
</svg>
|
||||
npm
|
||||
|
|
@ -514,13 +523,12 @@ export const registryExecute = tool({
|
|||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`import { streamText } from 'ai';
|
||||
// Import AI SDK provider for your selected model (OpenAI, Anthropic, Google, etc.)
|
||||
import { anthropic } from '@ai-sdk/anthropic';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import { registryExecute } from './tools'; // Your wrapped version
|
||||
|
||||
const result = streamText({
|
||||
model: anthropic('claude-sonnet-4-20250514'),
|
||||
model: openai('gpt-4.1-mini'),
|
||||
tools: {
|
||||
registrySearch: registrySearchTool,
|
||||
registryExecute, // Keys are auto-injected
|
||||
|
|
@ -698,6 +706,542 @@ const tools = await tpmjs.loadCollection('my-company/internal-tools');`}
|
|||
</div>
|
||||
</section>
|
||||
|
||||
{/* MCP Server Integration */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold mb-6 text-foreground">
|
||||
MCP Server Integration
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
TPMJS supports the{' '}
|
||||
<a
|
||||
href="https://modelcontextprotocol.io"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Model Context Protocol (MCP)
|
||||
</a>
|
||||
, allowing you to use your tool collections directly in Claude Desktop, Cursor, VS
|
||||
Code, and other MCP-compatible clients.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Create a Collection */}
|
||||
<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">Create a Collection</h3>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Sign in to{' '}
|
||||
<a href="https://tpmjs.com" className="text-primary hover:underline">
|
||||
tpmjs.com
|
||||
</a>{' '}
|
||||
and create a collection of tools. Add the tools you want your agent to have access
|
||||
to, and configure any required API keys.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Get Your MCP URL */}
|
||||
<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">Get Your MCP URL</h3>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Your collection has a unique MCP endpoint URL:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="text"
|
||||
code="https://tpmjs.com/api/mcp/{username}/{collection-slug}/http"
|
||||
/>
|
||||
<p className="text-foreground-secondary mt-4 text-sm">
|
||||
Replace <code className="text-primary">{'{username}'}</code> with your username
|
||||
and <code className="text-primary">{'{collection-slug}'}</code> with your
|
||||
collection's slug.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Configure Your Client */}
|
||||
<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">
|
||||
3
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold text-foreground">Configure Your Client</h3>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-foreground">Claude Desktop</h4>
|
||||
<p className="text-foreground-secondary text-sm mb-2">
|
||||
Add to your <code>claude_desktop_config.json</code>:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"mcpServers": {
|
||||
"tpmjs": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://tpmjs.com/api/mcp/username/my-tools/http",
|
||||
"--header",
|
||||
"Authorization: Bearer YOUR_API_KEY"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="font-semibold mb-2 text-foreground">Cursor / VS Code</h4>
|
||||
<p className="text-foreground-secondary text-sm mb-2">
|
||||
Add to your <code>.cursor/mcp.json</code> or VS Code MCP settings:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"mcpServers": {
|
||||
"tpmjs": {
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"mcp-remote",
|
||||
"https://tpmjs.com/api/mcp/username/my-tools/http",
|
||||
"--header",
|
||||
"Authorization: Bearer YOUR_API_KEY"
|
||||
]
|
||||
}
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Get an API Key */}
|
||||
<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">
|
||||
4
|
||||
</span>
|
||||
<h3 className="text-xl font-semibold text-foreground">Get an API Key</h3>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Generate an API key from your{' '}
|
||||
<a
|
||||
href="https://tpmjs.com/settings/api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
account settings
|
||||
</a>
|
||||
. API keys authenticate your MCP requests and enable access to your private
|
||||
collections.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* REST API Reference */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold mb-6 text-foreground">
|
||||
REST API Reference
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
For advanced integrations, you can use the TPMJS REST API directly. All endpoints are
|
||||
available at <code className="text-primary">https://tpmjs.com/api</code>.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* List Tools */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2 py-1 text-xs font-bold bg-green-500/20 text-green-500 rounded">
|
||||
GET
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-foreground font-mono">/api/tools</h3>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
List all tools with filtering, sorting, and pagination.
|
||||
</p>
|
||||
|
||||
<h4 className="font-semibold mb-2 text-foreground">Query Parameters</h4>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 pr-4 text-foreground">Parameter</th>
|
||||
<th className="text-left py-2 pr-4 text-foreground">Type</th>
|
||||
<th className="text-left py-2 text-foreground">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-primary">q</td>
|
||||
<td className="py-2 pr-4">string</td>
|
||||
<td className="py-2">Search query (package name, description)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-primary">category</td>
|
||||
<td className="py-2 pr-4">string</td>
|
||||
<td className="py-2">Filter by category</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-primary">official</td>
|
||||
<td className="py-2 pr-4">boolean</td>
|
||||
<td className="py-2">Filter by official status</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-primary">limit</td>
|
||||
<td className="py-2 pr-4">number</td>
|
||||
<td className="py-2">Results per page (1-1000, default 20)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-mono text-primary">offset</td>
|
||||
<td className="py-2 pr-4">number</td>
|
||||
<td className="py-2">Pagination offset (default 0)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 className="font-semibold mb-2 text-foreground">Example</h4>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl "https://tpmjs.com/api/tools?category=web-scraping&limit=10"`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Search Tools */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2 py-1 text-xs font-bold bg-green-500/20 text-green-500 rounded">
|
||||
GET
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-foreground font-mono">
|
||||
/api/tools/search
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Semantic search using BM25 algorithm. Better for natural language queries.
|
||||
</p>
|
||||
|
||||
<h4 className="font-semibold mb-2 text-foreground">Query Parameters</h4>
|
||||
<div className="overflow-x-auto mb-4">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border">
|
||||
<th className="text-left py-2 pr-4 text-foreground">Parameter</th>
|
||||
<th className="text-left py-2 pr-4 text-foreground">Type</th>
|
||||
<th className="text-left py-2 text-foreground">Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-foreground-secondary">
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-primary">q</td>
|
||||
<td className="py-2 pr-4">string</td>
|
||||
<td className="py-2">Search query (natural language)</td>
|
||||
</tr>
|
||||
<tr className="border-b border-border/50">
|
||||
<td className="py-2 pr-4 font-mono text-primary">category</td>
|
||||
<td className="py-2 pr-4">string</td>
|
||||
<td className="py-2">Filter by category</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="py-2 pr-4 font-mono text-primary">limit</td>
|
||||
<td className="py-2 pr-4">number</td>
|
||||
<td className="py-2">Max results (1-100, default 10)</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<h4 className="font-semibold mb-2 text-foreground">Example</h4>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl "https://tpmjs.com/api/tools/search?q=scrape%20website%20to%20markdown"`}
|
||||
/>
|
||||
|
||||
<h4 className="font-semibold mb-2 mt-4 text-foreground">Response</h4>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"success": true,
|
||||
"query": "scrape website to markdown",
|
||||
"results": {
|
||||
"total": 5,
|
||||
"tools": [
|
||||
{
|
||||
"id": "clx...",
|
||||
"name": "scrapeTool",
|
||||
"description": "Scrape any website into clean markdown",
|
||||
"package": {
|
||||
"npmPackageName": "@firecrawl/ai-sdk",
|
||||
"category": "web-scraping",
|
||||
"env": ["FIRECRAWL_API_KEY"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Execute Tool */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<span className="px-2 py-1 text-xs font-bold bg-yellow-500/20 text-yellow-500 rounded">
|
||||
POST
|
||||
</span>
|
||||
<h3 className="text-lg font-semibold text-foreground font-mono">
|
||||
/api/tools/execute/{'{package}'}/{'{tool}'}
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Execute a tool in the secure sandbox. Returns the tool output.
|
||||
</p>
|
||||
|
||||
<h4 className="font-semibold mb-2 text-foreground">Request Body</h4>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"params": {
|
||||
"url": "https://example.com"
|
||||
},
|
||||
"env": {
|
||||
"FIRECRAWL_API_KEY": "your-api-key"
|
||||
}
|
||||
}`}
|
||||
/>
|
||||
|
||||
<h4 className="font-semibold mb-2 mt-4 text-foreground">Example</h4>
|
||||
<CodeBlock
|
||||
language="bash"
|
||||
code={`curl -X POST "https://tpmjs.com/api/tools/execute/@firecrawl/ai-sdk/scrapeTool" \\
|
||||
-H "Content-Type: application/json" \\
|
||||
-d '{
|
||||
"params": { "url": "https://example.com" },
|
||||
"env": { "FIRECRAWL_API_KEY": "your-key" }
|
||||
}'`}
|
||||
/>
|
||||
|
||||
<h4 className="font-semibold mb-2 mt-4 text-foreground">Response</h4>
|
||||
<CodeBlock
|
||||
language="json"
|
||||
code={`{
|
||||
"success": true,
|
||||
"result": {
|
||||
"markdown": "# Example Domain\\n\\nThis domain is for use...",
|
||||
"metadata": {
|
||||
"title": "Example Domain",
|
||||
"url": "https://example.com"
|
||||
}
|
||||
},
|
||||
"executionTimeMs": 1234
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Building an Agent Like Omega */}
|
||||
<section className="mb-16">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold mb-6 text-foreground">
|
||||
Building an Agent Like Omega
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary mb-6">
|
||||
<a href="/omega" className="text-primary hover:underline">
|
||||
Omega
|
||||
</a>{' '}
|
||||
is our flagship AI agent that demonstrates dynamic tool discovery at scale.
|
||||
Here's how to build something similar.
|
||||
</p>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Architecture Overview */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-4 text-foreground">
|
||||
Architecture Overview
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
Omega uses a two-tier tool discovery pattern:
|
||||
</p>
|
||||
<ol className="list-decimal list-inside space-y-2 text-foreground-secondary">
|
||||
<li>
|
||||
<strong>Automatic discovery</strong> — Every message triggers a BM25 search to
|
||||
find relevant tools
|
||||
</li>
|
||||
<li>
|
||||
<strong>Agent-driven search</strong> — The agent can explicitly search for more
|
||||
tools using <code className="text-primary">registrySearchTool</code>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
{/* Complete Implementation */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-4 text-foreground">
|
||||
Complete Implementation
|
||||
</h3>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`import { streamText, tool } from 'ai';
|
||||
import { openai } from '@ai-sdk/openai';
|
||||
import { registrySearchTool } from '@tpmjs/registry-search';
|
||||
import { registryExecuteTool } from '@tpmjs/registry-execute';
|
||||
|
||||
// Pre-configure API keys for tool execution
|
||||
const API_KEYS: Record<string, string> = {
|
||||
FIRECRAWL_API_KEY: process.env.FIRECRAWL_API_KEY!,
|
||||
EXA_API_KEY: process.env.EXA_API_KEY!,
|
||||
OPENAI_API_KEY: process.env.OPENAI_API_KEY!,
|
||||
};
|
||||
|
||||
// Wrapped execute tool with pre-configured keys
|
||||
const registryExecute = tool({
|
||||
description: registryExecuteTool.description,
|
||||
parameters: registryExecuteTool.parameters,
|
||||
execute: async ({ toolId, params }) => {
|
||||
return registryExecuteTool.execute({ toolId, params, env: API_KEYS });
|
||||
},
|
||||
});
|
||||
|
||||
// System prompt for Omega-like behavior
|
||||
const SYSTEM_PROMPT = \`You are an AI assistant with access to thousands of tools via the TPMJS registry.
|
||||
|
||||
## Available Tools
|
||||
- registrySearch: Search the registry to find tools for any task
|
||||
- registryExecute: Execute any tool by its toolId
|
||||
|
||||
## Workflow
|
||||
1. When given a task, first search for relevant tools
|
||||
2. Review the results - each tool has: toolId, name, description, requiredEnvVars
|
||||
3. Execute tools with appropriate parameters
|
||||
4. Synthesize results into a helpful response
|
||||
|
||||
## Best Practices
|
||||
- Search first when unsure what tools exist
|
||||
- Execute tools to get real results (not just descriptions)
|
||||
- Handle errors gracefully - suggest alternatives if a tool fails
|
||||
- Be efficient - don't search repeatedly for the same thing\`;
|
||||
|
||||
// Auto-discover tools based on user message
|
||||
async function discoverTools(message: string) {
|
||||
const response = await fetch(
|
||||
\`https://tpmjs.com/api/tools/search?q=\${encodeURIComponent(message)}&limit=10\`
|
||||
);
|
||||
const data = await response.json();
|
||||
return data.results?.tools || [];
|
||||
}
|
||||
|
||||
// Main agent function
|
||||
async function runAgent(userMessage: string) {
|
||||
// Step 1: Auto-discover relevant tools
|
||||
const discoveredTools = await discoverTools(userMessage);
|
||||
console.log(\`Found \${discoveredTools.length} relevant tools\`);
|
||||
|
||||
// Step 2: Create dynamic tool context for the prompt
|
||||
const toolContext = discoveredTools.length > 0
|
||||
? \`\\n\\n## Pre-discovered Tools\\nBased on your request, these tools may be helpful:\\n\${
|
||||
discoveredTools.map((t: { toolId: string; description: string }) =>
|
||||
\`- \${t.toolId}: \${t.description}\`
|
||||
).join('\\n')
|
||||
}\`
|
||||
: '';
|
||||
|
||||
// Step 3: Run the agent with tool access
|
||||
const result = await streamText({
|
||||
model: openai('gpt-4.1-mini'),
|
||||
tools: {
|
||||
registrySearch: registrySearchTool,
|
||||
registryExecute,
|
||||
},
|
||||
maxSteps: 10,
|
||||
system: SYSTEM_PROMPT + toolContext,
|
||||
prompt: userMessage,
|
||||
});
|
||||
|
||||
// Step 4: Stream the response
|
||||
for await (const chunk of result.textStream) {
|
||||
process.stdout.write(chunk);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// Usage
|
||||
await runAgent('Scrape https://example.com and summarize the content');`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Streaming with SSE */}
|
||||
<div className="p-6 border border-border rounded-lg bg-surface">
|
||||
<h3 className="text-xl font-semibold mb-4 text-foreground">
|
||||
Streaming with Server-Sent Events
|
||||
</h3>
|
||||
<p className="text-foreground-secondary mb-4">
|
||||
For real-time UI updates, stream tool execution status via SSE:
|
||||
</p>
|
||||
<CodeBlock
|
||||
language="typescript"
|
||||
code={`// API Route: POST /api/chat
|
||||
export async function POST(request: Request) {
|
||||
const { message } = await request.json();
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Emit tool discovery event
|
||||
const tools = await discoverTools(message);
|
||||
controller.enqueue(encoder.encode(
|
||||
\`event: tools.discovered\\ndata: \${JSON.stringify({ tools })}\\n\\n\`
|
||||
));
|
||||
|
||||
// Run agent and stream events
|
||||
const result = await streamText({
|
||||
model: openai('gpt-4.1-mini'),
|
||||
tools: { registrySearch: registrySearchTool, registryExecute },
|
||||
maxSteps: 10,
|
||||
prompt: message,
|
||||
onStepFinish: ({ stepType, toolCalls, toolResults }) => {
|
||||
if (stepType === 'tool-result') {
|
||||
controller.enqueue(encoder.encode(
|
||||
\`event: tool.completed\\ndata: \${JSON.stringify({ toolCalls, toolResults })}\\n\\n\`
|
||||
));
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Stream text chunks
|
||||
for await (const chunk of result.textStream) {
|
||||
controller.enqueue(encoder.encode(
|
||||
\`event: message.delta\\ndata: \${JSON.stringify({ content: chunk })}\\n\\n\`
|
||||
));
|
||||
}
|
||||
|
||||
controller.enqueue(encoder.encode(\`event: done\\ndata: {}\\n\\n\`));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA */}
|
||||
<section className="text-center py-12 border border-border rounded-lg bg-surface">
|
||||
<h2 className="text-xl sm:text-2xl md:text-3xl font-bold mb-4 text-foreground">
|
||||
|
|
@ -725,7 +1269,7 @@ const tools = await tpmjs.loadCollection('my-company/internal-tools');`}
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 font-medium bg-red-500/10 text-red-500 rounded-md hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z" />
|
||||
</svg>
|
||||
@tpmjs/registry-search
|
||||
|
|
@ -736,7 +1280,7 @@ const tools = await tpmjs.loadCollection('my-company/internal-tools');`}
|
|||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 font-medium bg-red-500/10 text-red-500 rounded-md hover:bg-red-500/20 transition-colors"
|
||||
>
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor">
|
||||
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
|
||||
<path d="M0 7.334v8h6.666v1.332H12v-1.332h12v-8H0zm6.666 6.664H5.334v-4H3.999v4H1.335V8.667h5.331v5.331zm4 0v1.336H8.001V8.667h5.334v5.332h-2.669v-.001zm12.001 0h-1.33v-4h-1.336v4h-1.335v-4h-1.33v4h-2.671V8.667h8.002v5.331zM10.665 10H12v2.667h-1.335V10z" />
|
||||
</svg>
|
||||
@tpmjs/registry-execute
|
||||
|
|
|
|||
65
apps/web/src/app/tech/page.tsx
Normal file
65
apps/web/src/app/tech/page.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Metadata } from 'next';
|
||||
import { AppHeader } from '~/components/AppHeader';
|
||||
import { TechDiagram } from '~/components/tech/TechDiagram';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Tech Stack | TPMJS',
|
||||
description:
|
||||
'Interactive isometric diagram of the TPMJS ecosystem architecture. Explore external services, applications, published packages, internal packages, and official tools.',
|
||||
};
|
||||
|
||||
export default function TechPage(): React.ReactElement {
|
||||
return (
|
||||
<div className="min-h-screen flex flex-col bg-background">
|
||||
<AppHeader />
|
||||
<main className="flex-1">
|
||||
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8 sm:py-12">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8 sm:mb-12">
|
||||
<h1 className="text-2xl sm:text-3xl md:text-4xl font-bold mb-4 text-foreground">
|
||||
TPMJS Tech Stack
|
||||
</h1>
|
||||
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto">
|
||||
Interactive isometric view of the entire TPMJS ecosystem. Pan and zoom to explore how
|
||||
services, apps, and packages connect.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Diagram */}
|
||||
<TechDiagram />
|
||||
|
||||
{/* Legend */}
|
||||
<div className="mt-8 sm:mt-12 grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||
{[
|
||||
{
|
||||
label: 'External Services',
|
||||
color: 'bg-orange-100 dark:bg-orange-900/30 border-orange-500',
|
||||
},
|
||||
{
|
||||
label: 'Applications',
|
||||
color: 'bg-blue-100 dark:bg-blue-900/30 border-blue-500',
|
||||
},
|
||||
{
|
||||
label: 'Published Packages',
|
||||
color: 'bg-green-100 dark:bg-green-900/30 border-green-500',
|
||||
},
|
||||
{
|
||||
label: 'Internal Packages',
|
||||
color: 'bg-purple-100 dark:bg-purple-900/30 border-purple-500',
|
||||
},
|
||||
{
|
||||
label: 'Official Tools',
|
||||
color: 'bg-pink-100 dark:bg-pink-900/30 border-pink-500',
|
||||
},
|
||||
].map((item) => (
|
||||
<div key={item.label} className="flex items-center gap-2">
|
||||
<div className={`w-4 h-4 rounded border-2 ${item.color}`} />
|
||||
<span className="text-sm text-foreground-secondary">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ import { LikeButton } from '~/components/LikeButton';
|
|||
import { Markdown } from '~/components/Markdown';
|
||||
import { Rating } from '~/components/Rating';
|
||||
import { ToolPlayground } from '~/components/ToolPlayground';
|
||||
import { useTrackView } from '~/hooks/useTrackView';
|
||||
|
||||
interface Package {
|
||||
id: string;
|
||||
|
|
@ -70,6 +71,7 @@ export interface Tool {
|
|||
healthCheckError?: string | null;
|
||||
lastHealthCheck?: string | null;
|
||||
likeCount?: number;
|
||||
viewCount?: number;
|
||||
averageRating?: string | null;
|
||||
ratingCount?: number;
|
||||
reviewCount?: number;
|
||||
|
|
@ -88,6 +90,9 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
|
|||
const [recheckLoading, setRecheckLoading] = useState(false);
|
||||
const [extractSchemaLoading, setExtractSchemaLoading] = useState(false);
|
||||
|
||||
// Track page view
|
||||
useTrackView('tool', tool.id);
|
||||
|
||||
const pkg = tool.package;
|
||||
const authorName = typeof pkg.npmAuthor === 'string' ? pkg.npmAuthor : pkg.npmAuthor?.name;
|
||||
|
||||
|
|
@ -194,6 +199,7 @@ export function ToolDetailClient({ tool, slug }: ToolDetailClientProps): React.R
|
|||
<div className="min-h-screen bg-background">
|
||||
<script
|
||||
type="application/ld+json"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: required for structured data ld+json
|
||||
dangerouslySetInnerHTML={{ __html: JSON.stringify(softwareApplicationSchema) }}
|
||||
/>
|
||||
<AppHeader />
|
||||
|
|
@ -600,6 +606,22 @@ console.log(result.text);`}
|
|||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(tool.viewCount ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-foreground-secondary mb-1">Views</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{tool.viewCount?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{(tool.reviewCount ?? 0) > 0 && (
|
||||
<div>
|
||||
<p className="text-sm text-foreground-secondary mb-1">Reviews</p>
|
||||
<p className="text-2xl font-bold text-foreground">
|
||||
{tool.reviewCount?.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="text-sm text-foreground-secondary mb-2">Quality Score</p>
|
||||
<ProgressBar
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ async function getTool(slug: string[]): Promise<Tool | null> {
|
|||
healthCheckError: tool.healthCheckError ?? null,
|
||||
lastHealthCheck: tool.lastHealthCheck?.toISOString() ?? null,
|
||||
likeCount: tool.likeCount,
|
||||
viewCount: tool.viewCount,
|
||||
averageRating: tool.averageRating?.toString() ?? null,
|
||||
ratingCount: tool.ratingCount,
|
||||
reviewCount: tool.reviewCount,
|
||||
|
|
|
|||
304
apps/web/src/components/collections/InstallationSection.tsx
Normal file
304
apps/web/src/components/collections/InstallationSection.tsx
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
'use client';
|
||||
|
||||
import { Badge } from '@tpmjs/ui/Badge/Badge';
|
||||
import { Button } from '@tpmjs/ui/Button/Button';
|
||||
import { CodeBlock } from '@tpmjs/ui/CodeBlock/CodeBlock';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useState } from 'react';
|
||||
import { ForkButton } from '~/components/ForkButton';
|
||||
|
||||
interface EnvVar {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface InstallationSectionProps {
|
||||
collection: {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
toolCount: number;
|
||||
envVars?: Record<string, string> | null;
|
||||
};
|
||||
username: string;
|
||||
isPrivate: boolean;
|
||||
/** Whether to show the fork button (typically false for dashboard/owner view) */
|
||||
showForkButton?: boolean;
|
||||
}
|
||||
|
||||
export function InstallationSection({
|
||||
collection,
|
||||
username,
|
||||
isPrivate,
|
||||
showForkButton = true,
|
||||
}: InstallationSectionProps) {
|
||||
const [copiedCommand, setCopiedCommand] = useState(false);
|
||||
const [showClaudeDesktop, setShowClaudeDesktop] = useState(false);
|
||||
const [showTroubleshooting, setShowTroubleshooting] = useState(false);
|
||||
|
||||
const baseUrl = typeof window !== 'undefined' ? window.location.origin : 'https://tpmjs.com';
|
||||
const mcpUrl = `${baseUrl}/@${username}/collections/${collection.slug}/mcp`;
|
||||
|
||||
// Build the claude mcp add command
|
||||
// Positional args (name, url) must come before flags to avoid -H swallowing them
|
||||
const commandParts = ['claude mcp add'];
|
||||
commandParts.push(collection.slug);
|
||||
commandParts.push(mcpUrl);
|
||||
commandParts.push('-t http');
|
||||
if (isPrivate) {
|
||||
commandParts.push('-H "Authorization: Bearer YOUR_TPMJS_API_KEY"');
|
||||
}
|
||||
|
||||
const installCommand = commandParts.join(' \\\n ');
|
||||
|
||||
// Build Claude Desktop config JSON
|
||||
const claudeDesktopConfig = isPrivate
|
||||
? JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[collection.slug]: {
|
||||
type: 'http',
|
||||
url: mcpUrl,
|
||||
headers: {
|
||||
Authorization: 'Bearer YOUR_TPMJS_API_KEY',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
: JSON.stringify(
|
||||
{
|
||||
mcpServers: {
|
||||
[collection.slug]: {
|
||||
type: 'http',
|
||||
url: mcpUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
|
||||
// Extract env var names from collection
|
||||
const envVarsList: EnvVar[] = collection.envVars
|
||||
? Object.keys(collection.envVars).map((name) => ({ name }))
|
||||
: [];
|
||||
|
||||
const copyCommand = async () => {
|
||||
// Copy the flat command (without line breaks for easy pasting)
|
||||
const flatCommand = isPrivate
|
||||
? `claude mcp add ${collection.slug} ${mcpUrl} -t http -H "Authorization: Bearer YOUR_TPMJS_API_KEY"`
|
||||
: `claude mcp add ${collection.slug} ${mcpUrl} -t http`;
|
||||
|
||||
await navigator.clipboard.writeText(flatCommand);
|
||||
setCopiedCommand(true);
|
||||
setTimeout(() => setCopiedCommand(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="bg-surface border border-border rounded-lg p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-foreground">Installation</h2>
|
||||
<Badge variant="secondary" size="sm">
|
||||
{collection.toolCount} {collection.toolCount === 1 ? 'tool' : 'tools'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Env Vars Warning */}
|
||||
{envVarsList.length > 0 && (
|
||||
<div className="mb-6 p-4 bg-warning/10 border border-warning/30 rounded-lg">
|
||||
<div className="flex items-start gap-3">
|
||||
<Icon icon="alertTriangle" size="sm" className="text-warning mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h3 className="font-medium text-foreground">Required Environment Variables</h3>
|
||||
<p className="text-sm text-foreground-secondary mt-1">
|
||||
Set these before tools will work:
|
||||
</p>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{envVarsList.map((envVar) => (
|
||||
<li key={envVar.name} className="text-sm">
|
||||
<code className="font-mono text-xs bg-surface px-1.5 py-0.5 rounded border border-border">
|
||||
{envVar.name}
|
||||
</code>
|
||||
{envVar.description && (
|
||||
<span className="text-foreground-tertiary ml-2">— {envVar.description}</span>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 1: Add to Claude */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Step 1: Add to Claude</h3>
|
||||
<div className="relative">
|
||||
<pre className="p-4 bg-surface-secondary border border-border rounded-lg font-mono text-sm text-foreground-secondary overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{installCommand}
|
||||
</pre>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={copyCommand}
|
||||
className="absolute top-2 right-2"
|
||||
>
|
||||
<Icon icon={copiedCommand ? 'check' : 'copy'} size="xs" />
|
||||
</Button>
|
||||
</div>
|
||||
{isPrivate && (
|
||||
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||
Get your API key from{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/tpmjs-api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
Settings
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Step 2: Verify */}
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-medium text-foreground mb-2">Step 2: Verify</h3>
|
||||
<p className="text-sm text-foreground-secondary">
|
||||
Run{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
|
||||
/mcp
|
||||
</code>{' '}
|
||||
in Claude Code to confirm the server is connected.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Fork Button */}
|
||||
{showForkButton && (
|
||||
<div className="mb-6">
|
||||
<ForkButton
|
||||
type="collection"
|
||||
sourceId={collection.id}
|
||||
sourceName={collection.name}
|
||||
variant="full"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collapsible Sections */}
|
||||
<div className="pt-4 border-t border-border space-y-2">
|
||||
{/* Claude Desktop */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowClaudeDesktop(!showClaudeDesktop)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showClaudeDesktop ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||
<span>Using Claude Desktop instead?</span>
|
||||
</button>
|
||||
|
||||
{showClaudeDesktop && (
|
||||
<div className="mt-3 ml-6">
|
||||
<p className="text-sm text-foreground-secondary mb-2">
|
||||
Add this to your{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1.5 py-0.5 rounded border border-border">
|
||||
claude_desktop_config.json
|
||||
</code>{' '}
|
||||
file:
|
||||
</p>
|
||||
<CodeBlock language="json" code={claudeDesktopConfig} />
|
||||
{isPrivate && (
|
||||
<p className="mt-2 text-xs text-foreground-tertiary">
|
||||
Replace <code className="font-mono">YOUR_TPMJS_API_KEY</code> with your{' '}
|
||||
<Link
|
||||
href="/dashboard/settings/tpmjs-api-keys"
|
||||
className="text-primary hover:underline"
|
||||
>
|
||||
TPMJS API key
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Troubleshooting */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowTroubleshooting(!showTroubleshooting)}
|
||||
className="flex items-center gap-2 text-sm text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Icon icon={showTroubleshooting ? 'chevronDown' : 'chevronRight'} size="xs" />
|
||||
<span>Troubleshooting</span>
|
||||
</button>
|
||||
|
||||
{showTroubleshooting && (
|
||||
<div className="mt-3 ml-6 space-y-4 text-sm">
|
||||
{/* Connection timeout */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Connection timeout</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>
|
||||
• Try increasing the timeout:{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
MCP_TIMEOUT=10000 claude
|
||||
</code>
|
||||
</li>
|
||||
<li>• Check firewall/VPN settings</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Auth failures */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Authentication failures</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>• Verify your API key is correct</li>
|
||||
<li>
|
||||
• Ensure the header format is{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
Authorization: Bearer YOUR_KEY
|
||||
</code>
|
||||
</li>
|
||||
<li>• Check that your API key hasn't expired</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Server not appearing */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Server not appearing</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>
|
||||
• Run{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
claude mcp list
|
||||
</code>{' '}
|
||||
to see configured servers
|
||||
</li>
|
||||
<li>
|
||||
• Try removing and re-adding:{' '}
|
||||
<code className="font-mono text-xs bg-surface-secondary px-1 rounded">
|
||||
claude mcp remove {collection.slug}
|
||||
</code>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{/* Tools not loading */}
|
||||
<div>
|
||||
<h4 className="font-medium text-foreground">Tools not loading</h4>
|
||||
<ul className="mt-1 text-foreground-secondary space-y-1">
|
||||
<li>• The server may take a moment to initialize on first connection</li>
|
||||
<li>• Check that required environment variables are set</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,18 +1,48 @@
|
|||
/**
|
||||
* EcosystemStats Component
|
||||
*
|
||||
* Redesigned statistics section with dithered numbers and live activity stream.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { ActivityStream } from '@tpmjs/ui/ActivityStream/ActivityStream';
|
||||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { DitherSectionHeader } from '@tpmjs/ui/DitherText/DitherSectionHeader';
|
||||
import { StatCard } from '@tpmjs/ui/StatCard/StatCard';
|
||||
import { statistics } from '../../data/homePageData';
|
||||
import { PublicActivityStream } from './PublicActivityStream';
|
||||
|
||||
interface EcosystemStatsProps {
|
||||
stats: {
|
||||
publishedTools: number;
|
||||
activeDevelopers: number;
|
||||
totalExecutions: number;
|
||||
avgResponseMs: number | null;
|
||||
totalDownloads: number;
|
||||
};
|
||||
}
|
||||
|
||||
export function EcosystemStats({ stats }: EcosystemStatsProps): React.ReactElement {
|
||||
const statistics = [
|
||||
{
|
||||
value: stats.publishedTools,
|
||||
label: 'Published Tools',
|
||||
subtext: 'Auto-synced from npm',
|
||||
suffix: '',
|
||||
},
|
||||
{
|
||||
value: stats.activeDevelopers,
|
||||
label: 'Active Developers',
|
||||
subtext: 'Last 7 days',
|
||||
suffix: '',
|
||||
},
|
||||
{
|
||||
value: stats.totalExecutions,
|
||||
label: 'Total Executions',
|
||||
subtext: 'All-time simulations',
|
||||
suffix: '',
|
||||
},
|
||||
{
|
||||
value: stats.avgResponseMs ?? 0,
|
||||
label: 'Avg Response',
|
||||
subtext: 'Execution latency',
|
||||
suffix: 'ms',
|
||||
},
|
||||
];
|
||||
|
||||
export function EcosystemStats(): React.ReactElement {
|
||||
return (
|
||||
<section className="py-16 md:py-24 bg-surface relative overflow-hidden">
|
||||
{/* Subtle grid background */}
|
||||
|
|
@ -23,34 +53,28 @@ export function EcosystemStats(): React.ReactElement {
|
|||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-12">
|
||||
{statistics.map((stat, index) => {
|
||||
// Extract number from value string
|
||||
const numValue = Number.parseInt(stat.value.replace(/[^0-9]/g, ''), 10) || 0;
|
||||
const suffix = stat.value.replace(/[0-9,]/g, '');
|
||||
|
||||
return (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
|
||||
>
|
||||
<StatCard
|
||||
value={numValue}
|
||||
label={stat.label}
|
||||
subtext={stat.subtext}
|
||||
suffix={suffix}
|
||||
variant="brutalist"
|
||||
size="md"
|
||||
showBar={true}
|
||||
barProgress={60 + index * 10}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{statistics.map((stat, index) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className={`opacity-0 animate-brutalist-entrance stagger-${index + 1}`}
|
||||
>
|
||||
<StatCard
|
||||
value={stat.value}
|
||||
label={stat.label}
|
||||
subtext={stat.subtext}
|
||||
suffix={stat.suffix}
|
||||
variant="brutalist"
|
||||
size="md"
|
||||
showBar={true}
|
||||
barProgress={60 + index * 10}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Activity Stream */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<ActivityStream updateInterval={6000} maxItems={5} />
|
||||
<PublicActivityStream />
|
||||
</div>
|
||||
</Container>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -5,608 +5,189 @@ import { Button } from '@tpmjs/ui/Button/Button';
|
|||
import { Container } from '@tpmjs/ui/Container/Container';
|
||||
import { Icon } from '@tpmjs/ui/Icon/Icon';
|
||||
import Link from 'next/link';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ToolConnectionViz } from './ToolConnectionViz';
|
||||
|
||||
// ============================================================================
|
||||
// Animated Terminal Component
|
||||
// ============================================================================
|
||||
|
||||
function AnimatedTerminal(): React.ReactElement {
|
||||
const [currentLine, setCurrentLine] = useState(0);
|
||||
const [displayedText, setDisplayedText] = useState('');
|
||||
const [isTyping, setIsTyping] = useState(true);
|
||||
|
||||
const lines = [
|
||||
{ type: 'input', text: '$ npx @tpmjs/tools-unsandbox' },
|
||||
{ type: 'output', text: '✓ Tool loaded: executeCodeAsync' },
|
||||
{ type: 'input', text: '$ execute --lang python --code "print(sum(range(100)))"' },
|
||||
{ type: 'output', text: '→ Spinning up secure sandbox...' },
|
||||
{ type: 'output', text: '→ Executing code...' },
|
||||
{ type: 'success', text: '✓ Output: 4950' },
|
||||
{ type: 'input', text: '$ _' },
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (currentLine >= lines.length) {
|
||||
// Reset after delay
|
||||
const timeout = setTimeout(() => {
|
||||
setCurrentLine(0);
|
||||
setDisplayedText('');
|
||||
setIsTyping(true);
|
||||
}, 3000);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
|
||||
const line = lines[currentLine];
|
||||
if (!line) return;
|
||||
|
||||
if (line.type === 'input') {
|
||||
// Type out input lines character by character
|
||||
let charIndex = 0;
|
||||
setIsTyping(true);
|
||||
const interval = setInterval(() => {
|
||||
if (charIndex <= line.text.length) {
|
||||
setDisplayedText(line.text.slice(0, charIndex));
|
||||
charIndex++;
|
||||
} else {
|
||||
clearInterval(interval);
|
||||
setIsTyping(false);
|
||||
setTimeout(() => {
|
||||
setCurrentLine((prev) => prev + 1);
|
||||
setDisplayedText('');
|
||||
}, 500);
|
||||
}
|
||||
}, 50);
|
||||
return () => clearInterval(interval);
|
||||
} else {
|
||||
// Show output lines instantly
|
||||
setDisplayedText(line.text);
|
||||
setIsTyping(false);
|
||||
const timeout = setTimeout(() => {
|
||||
setCurrentLine((prev) => prev + 1);
|
||||
setDisplayedText('');
|
||||
}, 800);
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [currentLine]);
|
||||
|
||||
const getLineColor = (type: string) => {
|
||||
switch (type) {
|
||||
case 'input':
|
||||
return 'text-foreground';
|
||||
case 'output':
|
||||
return 'text-foreground-secondary';
|
||||
case 'success':
|
||||
return 'text-success';
|
||||
case 'error':
|
||||
return 'text-error';
|
||||
default:
|
||||
return 'text-foreground-secondary';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-[#1a1715] rounded-none border-2 border-foreground overflow-hidden shadow-[8px_8px_0_0_rgba(166,89,45,0.3)]">
|
||||
{/* Terminal Header */}
|
||||
<div className="flex items-center gap-2 px-4 py-3 bg-[#2a2520] border-b border-foreground/20">
|
||||
<div className="flex gap-2">
|
||||
<div className="w-3 h-3 rounded-full bg-error/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-warning/80" />
|
||||
<div className="w-3 h-3 rounded-full bg-success/80" />
|
||||
</div>
|
||||
<span className="ml-4 font-mono text-xs text-foreground/40 uppercase tracking-wider">
|
||||
tpmjs terminal
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Terminal Content */}
|
||||
<div className="p-6 font-mono text-sm min-h-[280px]">
|
||||
{/* Previous lines */}
|
||||
{lines.slice(0, currentLine).map((line, i) => (
|
||||
<div key={i} className={`${getLineColor(line.type)} mb-1`}>
|
||||
{line.text}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Current line being typed */}
|
||||
{currentLine < lines.length && (
|
||||
<div className={`${getLineColor(lines[currentLine]?.type || 'input')} flex`}>
|
||||
<span>{displayedText}</span>
|
||||
{isTyping && <span className="ml-0.5 w-2 h-5 bg-primary animate-pulse" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Feature Card with Hover Animation
|
||||
// Feature Card Component
|
||||
// ============================================================================
|
||||
|
||||
interface FeatureCardProps {
|
||||
icon: string;
|
||||
title: string;
|
||||
description: string;
|
||||
stats?: string;
|
||||
delay?: number;
|
||||
badge?: string;
|
||||
href?: string;
|
||||
}
|
||||
|
||||
function FeatureCard({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
stats,
|
||||
delay = 0,
|
||||
badge,
|
||||
href,
|
||||
}: FeatureCardProps): React.ReactElement {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting) {
|
||||
setTimeout(() => setIsVisible(true), delay);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.2 }
|
||||
);
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [delay]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className={`
|
||||
relative p-6 border-2 border-dashed border-border bg-surface
|
||||
transition-all duration-300 ease-out cursor-pointer
|
||||
${isVisible ? 'opacity-100 translate-y-0' : 'opacity-0 translate-y-8'}
|
||||
${isHovered ? 'border-primary bg-primary/5 shadow-[4px_4px_0_0_rgba(166,89,45,0.2)]' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Animated corner accent */}
|
||||
<div
|
||||
className={`
|
||||
absolute top-0 left-0 w-0 h-0 border-t-[3px] border-l-[3px] border-primary
|
||||
transition-all duration-300
|
||||
${isHovered ? 'w-8 h-8' : 'w-0 h-0'}
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
absolute bottom-0 right-0 w-0 h-0 border-b-[3px] border-r-[3px] border-primary
|
||||
transition-all duration-300
|
||||
${isHovered ? 'w-8 h-8' : 'w-0 h-0'}
|
||||
`}
|
||||
/>
|
||||
|
||||
const content = (
|
||||
<div className="group h-full p-6 border border-dashed border-border bg-surface hover:border-primary hover:bg-primary/5 transition-all duration-200">
|
||||
{/* Icon */}
|
||||
<div
|
||||
className={`
|
||||
w-12 h-12 flex items-center justify-center mb-4
|
||||
border-2 border-dashed transition-all duration-300
|
||||
${isHovered ? 'border-primary bg-primary/10' : 'border-border bg-surface-secondary'}
|
||||
`}
|
||||
>
|
||||
<div className="w-12 h-12 flex items-center justify-center mb-4 border border-dashed border-border bg-background group-hover:border-primary group-hover:bg-primary/10 transition-all duration-200">
|
||||
<Icon
|
||||
icon={icon as any}
|
||||
size="md"
|
||||
className={`transition-colors duration-300 ${isHovered ? 'text-primary' : 'text-foreground-secondary'}`}
|
||||
className="text-foreground-secondary group-hover:text-primary transition-colors duration-200"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<h3 className="font-mono text-lg font-semibold mb-2 text-foreground lowercase">{title}</h3>
|
||||
<p className="font-sans text-sm text-foreground-secondary leading-relaxed mb-4">
|
||||
{description}
|
||||
</p>
|
||||
|
||||
{/* Stats badge */}
|
||||
{stats && (
|
||||
<Badge
|
||||
variant={isHovered ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
className="transition-all duration-300"
|
||||
>
|
||||
{stats}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 className="font-mono text-lg font-semibold text-foreground lowercase group-hover:text-primary transition-colors">
|
||||
{title}
|
||||
</h3>
|
||||
{badge && (
|
||||
<Badge variant="outline" size="sm" className="flex-shrink-0">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="font-sans text-sm text-foreground-secondary leading-relaxed">{description}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Animated Counter
|
||||
// ============================================================================
|
||||
|
||||
interface AnimatedCounterProps {
|
||||
end: number;
|
||||
duration?: number;
|
||||
suffix?: string;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
function AnimatedCounter({
|
||||
end,
|
||||
duration = 2000,
|
||||
suffix = '',
|
||||
prefix = '',
|
||||
}: AnimatedCounterProps): React.ReactElement {
|
||||
const [count, setCount] = useState(0);
|
||||
const [hasStarted, setHasStarted] = useState(false);
|
||||
const ref = useRef<HTMLSpanElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry?.isIntersecting && !hasStarted) {
|
||||
setHasStarted(true);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.5 }
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className="block">
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current);
|
||||
}
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [hasStarted]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasStarted) return;
|
||||
|
||||
let startTime: number;
|
||||
let animationFrame: number;
|
||||
|
||||
const animate = (timestamp: number) => {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / duration, 1);
|
||||
|
||||
// Easing function for smooth animation
|
||||
const easeOutQuart = 1 - (1 - progress) ** 4;
|
||||
setCount(Math.floor(easeOutQuart * end));
|
||||
|
||||
if (progress < 1) {
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
}
|
||||
};
|
||||
|
||||
animationFrame = requestAnimationFrame(animate);
|
||||
return () => cancelAnimationFrame(animationFrame);
|
||||
}, [hasStarted, end, duration]);
|
||||
|
||||
return (
|
||||
<span ref={ref} className="tabular-nums">
|
||||
{prefix}
|
||||
{count.toLocaleString()}
|
||||
{suffix}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Flow Diagram Component
|
||||
// ============================================================================
|
||||
|
||||
function FlowDiagram(): React.ReactElement {
|
||||
const [activeStep, setActiveStep] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
setActiveStep((prev) => (prev + 1) % 4);
|
||||
}, 2000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const steps = [
|
||||
{ label: 'npm publish', icon: 'box', desc: 'publish to npm' },
|
||||
{ label: 'auto-discover', icon: 'search', desc: 'indexed in minutes' },
|
||||
{ label: 'validate', icon: 'check', desc: 'health checks run' },
|
||||
{ label: 'available', icon: 'globe', desc: 'ready for agents' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row items-center justify-between gap-4 md:gap-0">
|
||||
{steps.map((step, i) => (
|
||||
<div key={i} className="flex items-center">
|
||||
{/* Step */}
|
||||
<div
|
||||
className={`
|
||||
relative flex flex-col items-center p-4 transition-all duration-500
|
||||
${activeStep === i ? 'scale-110' : 'scale-100 opacity-60'}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
w-16 h-16 flex items-center justify-center border-2 mb-3
|
||||
transition-all duration-500
|
||||
${
|
||||
activeStep === i
|
||||
? 'border-primary bg-primary/10 shadow-[0_0_20px_rgba(166,89,45,0.3)]'
|
||||
: 'border-dashed border-border bg-surface'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Icon
|
||||
icon={step.icon as any}
|
||||
size="lg"
|
||||
className={`transition-colors duration-500 ${activeStep === i ? 'text-primary' : 'text-foreground-tertiary'}`}
|
||||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`font-mono text-sm font-medium transition-colors duration-500 ${activeStep === i ? 'text-primary' : 'text-foreground'}`}
|
||||
>
|
||||
{step.label}
|
||||
</span>
|
||||
<span className="font-mono text-xs text-foreground-tertiary mt-1">{step.desc}</span>
|
||||
|
||||
{/* Pulse ring when active */}
|
||||
{activeStep === i && (
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-4">
|
||||
<div className="w-16 h-16 border-2 border-primary/50 animate-ping" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arrow */}
|
||||
{i < steps.length - 1 && (
|
||||
<div className="hidden md:flex items-center mx-4">
|
||||
<div
|
||||
className={`
|
||||
h-0.5 w-12 transition-all duration-500
|
||||
${activeStep > i ? 'bg-primary' : 'bg-border'}
|
||||
`}
|
||||
/>
|
||||
<div
|
||||
className={`
|
||||
w-0 h-0 border-t-4 border-b-4 border-l-8
|
||||
border-t-transparent border-b-transparent
|
||||
transition-all duration-500
|
||||
${activeStep > i ? 'border-l-primary' : 'border-l-border'}
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Interactive Tool Grid
|
||||
// ============================================================================
|
||||
|
||||
function InteractiveToolGrid(): React.ReactElement {
|
||||
const tools = [
|
||||
{ name: 'web-scraper', category: 'web', color: 'bg-info' },
|
||||
{ name: 'code-executor', category: 'sandbox', color: 'bg-success' },
|
||||
{ name: 'pdf-parser', category: 'data', color: 'bg-warning' },
|
||||
{ name: 'image-gen', category: 'ai', color: 'bg-error' },
|
||||
{ name: 'db-query', category: 'data', color: 'bg-info' },
|
||||
{ name: 'api-caller', category: 'web', color: 'bg-success' },
|
||||
{ name: 'file-convert', category: 'utilities', color: 'bg-warning' },
|
||||
{ name: 'text-analyze', category: 'ai', color: 'bg-error' },
|
||||
{ name: 'email-send', category: 'integration', color: 'bg-info' },
|
||||
];
|
||||
|
||||
const [hoveredTool, setHoveredTool] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{tools.map((tool, i) => (
|
||||
<div
|
||||
key={tool.name}
|
||||
onMouseEnter={() => setHoveredTool(tool.name)}
|
||||
onMouseLeave={() => setHoveredTool(null)}
|
||||
className={`
|
||||
relative p-3 border border-dashed border-border bg-surface
|
||||
transition-all duration-300 cursor-pointer
|
||||
${hoveredTool === tool.name ? 'border-primary scale-105 z-10 shadow-lg' : ''}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${i * 100}ms`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full ${tool.color}`} />
|
||||
<span className="font-mono text-xs truncate">{tool.name}</span>
|
||||
</div>
|
||||
{hoveredTool === tool.name && (
|
||||
<div className="absolute -top-8 left-1/2 -translate-x-1/2 bg-foreground text-background px-2 py-1 text-xs font-mono whitespace-nowrap z-20">
|
||||
{tool.category}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
return content;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Main Features Section
|
||||
// ============================================================================
|
||||
|
||||
export function FeaturesSection(): React.ReactElement {
|
||||
interface FeaturesSectionProps {
|
||||
toolCount?: number;
|
||||
}
|
||||
|
||||
export function FeaturesSection({ toolCount }: FeaturesSectionProps): React.ReactElement {
|
||||
const toolCountLabel = toolCount && toolCount > 0 ? `${toolCount.toLocaleString()}` : '100+';
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: 'search',
|
||||
title: 'tool registry',
|
||||
description: `Browse ${toolCountLabel} AI tools from npm. Auto-discovered within minutes of publication with quality scoring and health monitoring.`,
|
||||
badge: 'auto-sync',
|
||||
href: '/tool/tool-search',
|
||||
},
|
||||
{
|
||||
icon: 'puzzle',
|
||||
title: 'omega agent',
|
||||
description:
|
||||
'Chat with an AI that dynamically discovers and executes tools based on your requests. No configuration needed.',
|
||||
badge: 'live',
|
||||
href: '/omega',
|
||||
},
|
||||
{
|
||||
icon: 'folder',
|
||||
title: 'collections',
|
||||
description:
|
||||
'Curate tool sets for specific use cases. Add test scenarios to validate behavior and generate living documentation.',
|
||||
badge: 'shareable',
|
||||
href: '/collections',
|
||||
},
|
||||
{
|
||||
icon: 'user',
|
||||
title: 'custom agents',
|
||||
description:
|
||||
'Build AI agents with your choice of LLM, custom prompts, and curated tool collections. Share publicly or keep private.',
|
||||
badge: 'unlimited',
|
||||
href: '/agents',
|
||||
},
|
||||
{
|
||||
icon: 'link',
|
||||
title: 'mcp protocol',
|
||||
description:
|
||||
'Works with Claude Desktop, Cursor, Windsurf, and any MCP-compatible client. One URL, instant access to all tools.',
|
||||
badge: 'universal',
|
||||
href: '/integrations',
|
||||
},
|
||||
{
|
||||
icon: 'key',
|
||||
title: 'secure execution',
|
||||
description:
|
||||
'Every tool runs in an isolated sandbox with rate limiting and timeout handling. Your credentials are encrypted at rest.',
|
||||
badge: 'sandboxed',
|
||||
},
|
||||
{
|
||||
icon: 'checkCircle',
|
||||
title: 'test scenarios',
|
||||
description:
|
||||
'AI-generated test scenarios validate tool behavior. Track pass rates, execution times, and quality scores.',
|
||||
badge: 'automated',
|
||||
href: '/scenarios',
|
||||
},
|
||||
{
|
||||
icon: 'message',
|
||||
title: 'living skills',
|
||||
description:
|
||||
'Documentation that evolves from real usage. Skills emerge from question patterns and proven behaviors.',
|
||||
badge: 'new',
|
||||
href: '/docs/skills',
|
||||
},
|
||||
{
|
||||
icon: 'terminal',
|
||||
title: 'developer sdk',
|
||||
description:
|
||||
'Publish tools with one keyword. Full TypeScript support, Vercel AI SDK integration, and automatic schema extraction.',
|
||||
badge: 'npm',
|
||||
href: '/publish',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="py-24 bg-background relative overflow-hidden">
|
||||
{/* Subtle grid background */}
|
||||
<div className="absolute inset-0 opacity-[0.02]">
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
backgroundImage: `
|
||||
linear-gradient(to right, currentColor 1px, transparent 1px),
|
||||
linear-gradient(to bottom, currentColor 1px, transparent 1px)
|
||||
`,
|
||||
backgroundSize: '60px 60px',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Container size="xl" padding="lg" className="relative z-10">
|
||||
<section className="py-20 bg-background border-t border-border">
|
||||
<Container size="xl" padding="lg">
|
||||
{/* Section Header */}
|
||||
<div className="text-center mb-20">
|
||||
<div className="text-center mb-16">
|
||||
<p className="font-mono text-xs text-primary uppercase tracking-widest mb-3">
|
||||
platform capabilities
|
||||
</p>
|
||||
<h2 className="font-mono text-3xl md:text-4xl font-semibold mb-4 text-foreground lowercase">
|
||||
everything you need
|
||||
</p>
|
||||
<h2 className="font-mono text-4xl md:text-5xl font-bold mb-6 text-foreground lowercase tracking-tight">
|
||||
powerful features
|
||||
</h2>
|
||||
<p className="text-lg text-foreground-secondary max-w-2xl mx-auto font-sans">
|
||||
<p className="text-base text-foreground-secondary max-w-2xl mx-auto font-sans">
|
||||
From discovery to execution, TPMJS provides the complete infrastructure for AI tool
|
||||
development.
|
||||
development and deployment.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Row */}
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-6 mb-20">
|
||||
{[
|
||||
{ value: 170, suffix: '+', label: 'tools indexed' },
|
||||
{ value: 15, suffix: ' min', label: 'discovery time' },
|
||||
{ value: 99, suffix: '%', label: 'uptime' },
|
||||
{ value: 4, suffix: '', label: 'mcp clients' },
|
||||
].map((stat, i) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="text-center p-6 border border-dashed border-border bg-surface hover:border-primary transition-colors"
|
||||
>
|
||||
<div className="font-mono text-4xl md:text-5xl font-bold text-primary mb-2">
|
||||
<AnimatedCounter end={stat.value} suffix={stat.suffix} duration={1500 + i * 200} />
|
||||
</div>
|
||||
<div className="font-mono text-sm text-foreground-secondary uppercase tracking-wider">
|
||||
{stat.label}
|
||||
</div>
|
||||
</div>
|
||||
{/* Feature Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-12">
|
||||
{features.map((feature) => (
|
||||
<FeatureCard
|
||||
key={feature.title}
|
||||
icon={feature.icon}
|
||||
title={feature.title}
|
||||
description={feature.description}
|
||||
badge={feature.badge}
|
||||
href={feature.href}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Main Feature Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-12 mb-20">
|
||||
{/* Left: Terminal Demo */}
|
||||
<div>
|
||||
<fieldset className="border border-dashed border-border p-6">
|
||||
<legend className="font-mono text-sm text-foreground-secondary px-3 lowercase">
|
||||
live execution
|
||||
</legend>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-6">
|
||||
Execute any tool directly from your terminal or AI agent. Secure sandboxed execution
|
||||
with real-time streaming output.
|
||||
</p>
|
||||
<AnimatedTerminal />
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
{/* Right: Tool Grid */}
|
||||
<div>
|
||||
<fieldset className="border border-dashed border-border p-6 h-full">
|
||||
<legend className="font-mono text-sm text-foreground-secondary px-3 lowercase">
|
||||
tool registry
|
||||
</legend>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-6">
|
||||
Browse 170+ tools across multiple categories. Each tool is validated, documented,
|
||||
and ready to use.
|
||||
</p>
|
||||
<InteractiveToolGrid />
|
||||
<div className="mt-6 flex justify-center">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button variant="outline" size="sm">
|
||||
Browse All Tools
|
||||
<Icon icon="chevronRight" size="sm" className="ml-2" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Flow Diagram */}
|
||||
<fieldset className="border border-dashed border-border p-8 mb-20">
|
||||
<legend className="font-mono text-sm text-foreground-secondary px-3 lowercase">
|
||||
how it works
|
||||
</legend>
|
||||
<FlowDiagram />
|
||||
</fieldset>
|
||||
|
||||
{/* Interactive Connection Visualization */}
|
||||
<fieldset className="border border-dashed border-border p-8 mb-20 overflow-hidden">
|
||||
<legend className="font-mono text-sm text-foreground-secondary px-3 lowercase">
|
||||
tools → tpmjs → agents
|
||||
</legend>
|
||||
<p className="font-sans text-sm text-foreground-secondary mb-6 text-center max-w-2xl mx-auto">
|
||||
TPMJS acts as the central hub connecting npm packages to AI agents. Watch data flow in
|
||||
real-time as tools serve agent requests.
|
||||
</p>
|
||||
<ToolConnectionViz />
|
||||
</fieldset>
|
||||
|
||||
{/* Feature Cards Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-16">
|
||||
<FeatureCard
|
||||
icon="search"
|
||||
title="instant discovery"
|
||||
description="Tools are automatically discovered from npm within 2-15 minutes. Just add the tpmjs keyword and publish."
|
||||
stats="auto-sync"
|
||||
delay={0}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="key"
|
||||
title="secure execution"
|
||||
description="Every tool runs in an isolated Deno sandbox. Rate limiting, timeout handling, and error recovery built-in."
|
||||
stats="sandboxed"
|
||||
delay={100}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="star"
|
||||
title="quality scoring"
|
||||
description="Automatic scoring based on documentation, downloads, and health status. Find the best tools instantly."
|
||||
stats="0.0 - 1.0"
|
||||
delay={200}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="user"
|
||||
title="ai agents"
|
||||
description="Build custom AI agents with curated tool collections. Share publicly or keep private."
|
||||
stats="unlimited"
|
||||
delay={300}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="folder"
|
||||
title="collections"
|
||||
description="Group related tools into collections. Perfect for specific use cases or team workflows."
|
||||
stats="shareable"
|
||||
delay={400}
|
||||
/>
|
||||
<FeatureCard
|
||||
icon="terminal"
|
||||
title="rest & mcp api"
|
||||
description="Full REST API and MCP protocol support. Works with Claude, Cursor, Windsurf, and any compatible client."
|
||||
stats="json-rpc 2.0"
|
||||
delay={500}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="text-center">
|
||||
<div className="inline-flex flex-col sm:flex-row gap-4">
|
||||
<Link href="/tool/tool-search">
|
||||
<Button size="lg" variant="default" className="min-w-[200px]">
|
||||
Explore Tools
|
||||
<Link href="/omega">
|
||||
<Button size="lg" variant="default">
|
||||
Try Omega Agent
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/publish">
|
||||
<Button size="lg" variant="outline" className="min-w-[200px]">
|
||||
Publish Your Tool
|
||||
<Link href="/docs">
|
||||
<Button size="lg" variant="outline">
|
||||
Read the Docs
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ interface HeroSectionProps {
|
|||
packageCount: number;
|
||||
toolCount: number;
|
||||
categoryCount: number;
|
||||
totalDownloads: number;
|
||||
totalStars: number;
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +81,15 @@ export function HeroSection({ stats }: HeroSectionProps): React.ReactElement {
|
|||
<span className="text-foreground">{formatNumber(stats.toolCount)}</span>
|
||||
<span className="text-foreground-secondary">TOOLS</span>
|
||||
</div>
|
||||
{stats.totalDownloads > 0 && (
|
||||
<>
|
||||
<span className="text-foreground-tertiary">/</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-foreground">{formatNumber(stats.totalDownloads)}</span>
|
||||
<span className="text-foreground-secondary">DOWNLOADS</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Subheading */}
|
||||
|
|
|
|||
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