diff --git a/package-lock.json b/package-lock.json index 556bc7c..cdc92b9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,9 +15,11 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^6.2.1", "@tanstack/react-query": "^5.64.2", + "@types/js-yaml": "^4.0.9", "@xyflow/react": "^12.11.3", "clsx": "^2.1.1", "html-to-image": "^1.11.13", + "js-yaml": "^5.3.0", "jspdf": "^4.2.1", "lucide-react": "^0.474.0", "next": "^15.1.6", @@ -764,6 +766,29 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/@eslint/eslintrc/node_modules/js-yaml": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", + "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, "node_modules/@eslint/eslintrc/node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -2611,6 +2636,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -3702,7 +3733,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -7397,10 +7427,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", - "dev": true, + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.3.0.tgz", + "integrity": "sha512-muutsYr+e2+d3rTgUGslq5rxbBlUy3cJ61IsHag2QNDQV+7zXWjkUpmALIajhrlLlrgRUiymj6U3zUr/TMK84Q==", "funding": [ { "type": "github", @@ -7416,7 +7445,7 @@ "argparse": "^2.0.1" }, "bin": { - "js-yaml": "bin/js-yaml.js" + "js-yaml": "bin/js-yaml.mjs" } }, "node_modules/json-buffer": { diff --git a/package.json b/package.json index 5b80c66..53083a1 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,11 @@ "@modelcontextprotocol/sdk": "^1.30.0", "@prisma/client": "^6.2.1", "@tanstack/react-query": "^5.64.2", + "@types/js-yaml": "^4.0.9", "@xyflow/react": "^12.11.3", "clsx": "^2.1.1", "html-to-image": "^1.11.13", + "js-yaml": "^5.3.0", "jspdf": "^4.2.1", "lucide-react": "^0.474.0", "next": "^15.1.6", diff --git a/src/app/api/approvals/[id]/cancel/route.ts b/src/app/api/approvals/[id]/cancel/route.ts index 8640330..0f0506c 100644 --- a/src/app/api/approvals/[id]/cancel/route.ts +++ b/src/app/api/approvals/[id]/cancel/route.ts @@ -1,20 +1,14 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { ApprovalEngine } from "@/modules/approval"; import { unauthorized, forbidden, badRequest, serverError, notFound } from "@/lib/api/handlers"; +import { apiServices } from "@/lib/api/services"; const cancelSchema = z.object({ idempotencyKey: z.string().min(1, "Idempotency key is required"), }); -const approvalRepo = new ApprovalRepository(); -const historyRepo = new ApprovalHistoryRepository(); -const executionRepo = new ExecutionRepository(); -const approvalEngine = new ApprovalEngine(approvalRepo, historyRepo, executionRepo); +const { approvalEngine } = apiServices(); export async function POST( request: Request, diff --git a/src/app/api/approvals/route.ts b/src/app/api/approvals/route.ts index 0241a32..07a5aa9 100644 --- a/src/app/api/approvals/route.ts +++ b/src/app/api/approvals/route.ts @@ -2,25 +2,21 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; import { respondApprovalSchema } from "@/validators/approvalSchema"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { ApprovalEngine } from "@/modules/approval"; import { unauthorized, forbidden, notFound, badRequest, serverError } from "@/lib/api/handlers"; import { rateLimit } from "@/lib/api/rateLimit"; +import { apiServices } from "@/lib/api/services"; -const approvalRepo = new ApprovalRepository(); -const _auditRepo = new AuditLogRepository(); -const historyRepo = new ApprovalHistoryRepository(); -const executionRepo = new ExecutionRepository(); -const approvalEngine = new ApprovalEngine(approvalRepo, historyRepo, executionRepo); +const { approvalRepo, approvalEngine } = apiServices(); export async function GET(_request: Request) { const { userId } = await auth(); if (!userId) return unauthorized(); try { + // Eventually-consistent escalation sweep: expire stale PENDING requests + // whose in-process escalateAfterMin timer was lost to a deploy/freeze. + await approvalRepo.expireStaleForUser(userId).catch(() => 0); + // Return ALL approvals for the user (not just pending) — the review UI needs // both the pending queue and history. Single query, ownership-scoped. const requests = await approvalRepo.findByUserId(userId); diff --git a/src/app/api/canvas/copilot/route.ts b/src/app/api/canvas/copilot/route.ts index d866a19..83cd520 100644 --- a/src/app/api/canvas/copilot/route.ts +++ b/src/app/api/canvas/copilot/route.ts @@ -1,6 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; -import { getLLMProvider } from "@/providers/llm"; +import { auth } from "@clerk/nextjs/server"; +import { getProviderForModel } from "@/providers/llm"; import type { AgentGraphDefinition } from "@/types/graph"; +import { unauthorized, serverError } from "@/lib/api/handlers"; +import { rateLimit } from "@/lib/api/rateLimit"; const COPILOT_SYSTEM_PROMPT = `You are a graph architect for Agent Studio. Given a natural language description of a multi-agent system, generate a complete AgentGraphDefinition JSON. @@ -55,9 +58,15 @@ Make sure: 5. Return ONLY valid JSON, no markdown fences, no explanation`; export async function POST(request: NextRequest) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const limited = rateLimit(`canvas:copilot:${userId}`); + if (limited) return limited; + try { const body = await request.json(); - const { prompt } = body; + const { prompt, model } = body; if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) { return NextResponse.json( @@ -66,7 +75,7 @@ export async function POST(request: NextRequest) { ); } - const llm = getLLMProvider(); + const llm = getProviderForModel(model); const llmResponse = await llm.complete( [ { role: "system", content: COPILOT_SYSTEM_PROMPT }, @@ -123,13 +132,6 @@ export async function POST(request: NextRequest) { return NextResponse.json({ success: true, graph }); } catch (error) { - console.error("[Canvas Copilot]", error); - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : "Graph generation failed", - }, - { status: 500 } - ); + return serverError(error); } } diff --git a/src/app/api/canvas/optimize-prompt/route.ts b/src/app/api/canvas/optimize-prompt/route.ts index 41c34c4..0549464 100644 --- a/src/app/api/canvas/optimize-prompt/route.ts +++ b/src/app/api/canvas/optimize-prompt/route.ts @@ -1,5 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; import { getLLMProvider } from "@/providers/llm"; +import { unauthorized, serverError } from "@/lib/api/handlers"; +import { rateLimit } from "@/lib/api/rateLimit"; const OPTIMIZER_SYSTEM_PROMPT = `You are a prompt engineering expert. Given an agent's current system prompt and its role context, produce an optimized version that: @@ -18,6 +21,12 @@ Rules: - Return ONLY the optimized prompt text, no explanations or meta-commentary`; export async function POST(request: NextRequest) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const limited = rateLimit(`canvas:optimize:${userId}`); + if (limited) return limited; + try { const body = await request.json(); const { prompt, nodeType, label, condition, toolName } = body; @@ -65,13 +74,6 @@ export async function POST(request: NextRequest) { }, }); } catch (error) { - console.error("[Prompt Optimizer]", error); - return NextResponse.json( - { - success: false, - error: error instanceof Error ? error.message : "Optimization failed", - }, - { status: 500 } - ); + return serverError(error); } } diff --git a/src/app/api/canvas/preview/[id]/stream/route.ts b/src/app/api/canvas/preview/[id]/stream/route.ts index b30619b..02358ee 100644 --- a/src/app/api/canvas/preview/[id]/stream/route.ts +++ b/src/app/api/canvas/preview/[id]/stream/route.ts @@ -2,20 +2,15 @@ import { auth } from "@clerk/nextjs/server"; import { executionEventBus, ExecutionEvent } from "@/modules/graph/eventBus"; import { GraphInterpreter } from "@/modules/graph/graphInterpreter"; import { previewStore } from "@/modules/graph/previewStore"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ExecutionLogRepository } from "@/repositories/ExecutionLogRepository"; import { createToolRegistry } from "@/modules/tools"; import { PermissionChecker } from "@/modules/execution/tool-registry/permissionChecker"; import { getLLMProvider } from "@/providers/llm"; import { AgentGraphDefinition } from "@/types/graph"; import { logger } from "@/lib/logger"; +import { apiServices } from "@/lib/api/services"; export const dynamic = "force-dynamic"; -const skillRepo = new SkillRepository(); - const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache, no-transform", @@ -112,11 +107,6 @@ export async function GET( return new Response(stream, { headers: SSE_HEADERS }); } -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; - /** Run the graph interpreter in dry-run mode against the preview session. */ async function runPreview( previewId: string, @@ -125,14 +115,16 @@ async function runPreview( graph: AgentGraphDefinition, inputData: Record ): Promise { + const { skillRepo, mcpService, openApiService, executionRepo, approvalRepo, logRepo } = apiServices(); + const version = await skillRepo.findVersionById(skillVersionId); if (!version) throw new Error("Skill version not found"); const skill = await skillRepo.findByIdForUser(version.skillId, userId); if (!skill) throw new Error("Skill not found"); + // Ghost previews run on an ISOLATED registry (never the shared execution + // registries) so a preview can never mutate live tool state mid-run. const toolRegistry = createToolRegistry(); - const mcpService = new McpClientService(new McpServerRepository()); - const openApiService = new OpenApiService(new OpenApiRepository()); await Promise.all([ mcpService.registerUserMcpTools(userId, toolRegistry).catch((err) => { @@ -147,9 +139,9 @@ async function runPreview( llm: getLLMProvider(), toolRegistry, permissionChecker: new PermissionChecker(), - executionRepo: new ExecutionRepository(), - approvalRepo: new ApprovalRepository(), - logRepo: new ExecutionLogRepository(), + executionRepo, + approvalRepo, + logRepo, }); await interpreter.run({ diff --git a/src/app/api/executions/[id]/cancel/route.ts b/src/app/api/executions/[id]/cancel/route.ts index 4ce1364..468cca4 100644 --- a/src/app/api/executions/[id]/cancel/route.ts +++ b/src/app/api/executions/[id]/cancel/route.ts @@ -1,15 +1,9 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; import { unauthorized, serverError, notFound } from "@/lib/api/handlers"; +import { apiServices } from "@/lib/api/services"; -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo); +const { executionService } = apiServices(); export async function POST( _request: Request, diff --git a/src/app/api/executions/[id]/detail/route.ts b/src/app/api/executions/[id]/detail/route.ts index 3da5083..bf9db5e 100644 --- a/src/app/api/executions/[id]/detail/route.ts +++ b/src/app/api/executions/[id]/detail/route.ts @@ -1,28 +1,9 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionHistoryService } from "@/modules/history"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ExecutionLogRepository } from "@/repositories/ExecutionLogRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; import { unauthorized, serverError, notFound } from "@/lib/api/handlers"; +import { apiServices } from "@/lib/api/services"; -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo); -const historyService = new ExecutionHistoryService( - executionRepo, - skillRepo, - auditRepo, - executionService, - new ExecutionLogRepository(), - new ApprovalRepository(), - new ApprovalHistoryRepository() -); +const { historyService } = apiServices(); /** Full execution detail: trace data, structured logs, timeline, and approval events. */ export async function GET( diff --git a/src/app/api/executions/[id]/export/route.ts b/src/app/api/executions/[id]/export/route.ts index 28b17c3..422e652 100644 --- a/src/app/api/executions/[id]/export/route.ts +++ b/src/app/api/executions/[id]/export/route.ts @@ -1,28 +1,9 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionHistoryService } from "@/modules/history"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ExecutionLogRepository } from "@/repositories/ExecutionLogRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; import { unauthorized, serverError, notFound } from "@/lib/api/handlers"; +import { apiServices } from "@/lib/api/services"; -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo); -const historyService = new ExecutionHistoryService( - executionRepo, - skillRepo, - auditRepo, - executionService, - new ExecutionLogRepository(), - new ApprovalRepository(), - new ApprovalHistoryRepository() -); +const { historyService } = apiServices(); /** * JSON export of a full execution report: trace data, structured logs, diff --git a/src/app/api/executions/[id]/replay/route.ts b/src/app/api/executions/[id]/replay/route.ts index 7990de1..2f81b18 100644 --- a/src/app/api/executions/[id]/replay/route.ts +++ b/src/app/api/executions/[id]/replay/route.ts @@ -1,43 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionHistoryService } from "@/modules/history"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ExecutionLogRepository } from "@/repositories/ExecutionLogRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; import { unauthorized, serverError, notFound, forbidden } from "@/lib/api/handlers"; import { rateLimit } from "@/lib/api/rateLimit"; +import { apiServices } from "@/lib/api/services"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; - -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const approvalRepo = new ApprovalRepository(); -const historyRepo = new ApprovalHistoryRepository(); -const mcpService = new McpClientService(new McpServerRepository()); -const openApiService = new OpenApiService(new OpenApiRepository()); - -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo, { - mcpService, - openApiService, - approvalRepo, -}); -const historyService = new ExecutionHistoryService( - executionRepo, - skillRepo, - auditRepo, - executionService, - new ExecutionLogRepository(), - approvalRepo, - historyRepo -); +const { historyService } = apiServices(); /** * Replay a previous execution. Reuses its skill version + input, creates a NEW diff --git a/src/app/api/executions/[id]/resume/route.ts b/src/app/api/executions/[id]/resume/route.ts index 5aee17f..506268b 100644 --- a/src/app/api/executions/[id]/resume/route.ts +++ b/src/app/api/executions/[id]/resume/route.ts @@ -1,40 +1,16 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; -import { ApprovalEngine } from "@/modules/approval"; import { unauthorized, forbidden, badRequest, serverError, notFound } from "@/lib/api/handlers"; import { rateLimit } from "@/lib/api/rateLimit"; +import { apiServices } from "@/lib/api/services"; const resumeSchema = z.object({ approvalId: z.string().min(1, "Approval ID is required"), idempotencyKey: z.string().min(1, "Idempotency key is required"), }); -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; - -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const approvalRepo = new ApprovalRepository(); -const historyRepo = new ApprovalHistoryRepository(); -const mcpService = new McpClientService(new McpServerRepository()); -const openApiService = new OpenApiService(new OpenApiRepository()); - -const approvalEngine = new ApprovalEngine(approvalRepo, historyRepo, executionRepo); -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo, { - mcpService, - openApiService, - approvalRepo, -}); +const { auditRepo, approvalRepo, approvalEngine, executionService } = apiServices(); export async function POST( request: Request, diff --git a/src/app/api/executions/[id]/retry/route.ts b/src/app/api/executions/[id]/retry/route.ts index a137870..e3468d4 100644 --- a/src/app/api/executions/[id]/retry/route.ts +++ b/src/app/api/executions/[id]/retry/route.ts @@ -1,30 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; import { unauthorized, forbidden, notFound, serverError, badRequest } from "@/lib/api/handlers"; import { rateLimit } from "@/lib/api/rateLimit"; +import { apiServices } from "@/lib/api/services"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; - -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const approvalRepo = new ApprovalRepository(); -const mcpService = new McpClientService(new McpServerRepository()); -const openApiService = new OpenApiService(new OpenApiRepository()); - -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo, { - mcpService, - openApiService, - approvalRepo, -}); +const { executionService } = apiServices(); /** * Step-Level Safe Recovery Endpoint: @@ -42,7 +22,7 @@ export async function POST( try { const { id } = await params; - const execution = await executionRepo.findByIdForUser(id, userId); + const execution = await executionService.getExecutionForUser(id, userId); if (!execution) { return notFound("Execution not found or you do not have access to it"); } diff --git a/src/app/api/executions/[id]/route.ts b/src/app/api/executions/[id]/route.ts index 799a067..ff12fad 100644 --- a/src/app/api/executions/[id]/route.ts +++ b/src/app/api/executions/[id]/route.ts @@ -1,15 +1,9 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; import { unauthorized, serverError, notFound } from "@/lib/api/handlers"; +import { apiServices } from "@/lib/api/services"; -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo); +const { executionService } = apiServices(); export async function GET( _request: Request, diff --git a/src/app/api/executions/route.ts b/src/app/api/executions/route.ts index 1f1b14e..9c31021 100644 --- a/src/app/api/executions/route.ts +++ b/src/app/api/executions/route.ts @@ -1,18 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { startExecutionSchema } from "@/validators/executionSchema"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionHistoryService } from "@/modules/history"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { ExecutionLogRepository } from "@/repositories/ExecutionLogRepository"; -import { ApprovalRepository } from "@/repositories/ApprovalRepository"; -import { ApprovalHistoryRepository } from "@/repositories/ApprovalHistoryRepository"; import { unauthorized, badRequest, serverError, isValidIsoDate } from "@/lib/api/handlers"; import { rateLimit } from "@/lib/api/rateLimit"; import { ExecutionError } from "@/modules/execution/executor/errors"; import { ExecutionQuery, ExecutionStatus } from "@/types/execution"; +import { apiServices } from "@/lib/api/services"; const VALID_STATUSES = new Set([ "PENDING", @@ -27,32 +20,7 @@ const VALID_STATUSES = new Set([ const VALID_SORT_BY = new Set(["startedAt", "durationMs", "status"]); const VALID_SORT_ORDER = new Set(["asc", "desc"]); -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; - -const executionRepo = new ExecutionRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); -const approvalRepo = new ApprovalRepository(); -const mcpService = new McpClientService(new McpServerRepository()); -const openApiService = new OpenApiService(new OpenApiRepository()); - -const executionService = new ExecutionService(executionRepo, skillRepo, auditRepo, { - mcpService, - openApiService, - approvalRepo, -}); -const historyService = new ExecutionHistoryService( - executionRepo, - skillRepo, - auditRepo, - executionService, - new ExecutionLogRepository(), - approvalRepo, - new ApprovalHistoryRepository() -); +const { executionService, historyService } = apiServices(); export async function GET(request: Request) { const { userId } = await auth(); diff --git a/src/app/api/mcp/messages/route.ts b/src/app/api/mcp/messages/route.ts index e9d608f..819b1c3 100644 --- a/src/app/api/mcp/messages/route.ts +++ b/src/app/api/mcp/messages/route.ts @@ -1,12 +1,7 @@ import { auth } from "@clerk/nextjs/server"; import { env } from "@/lib/config/env"; import { AgentStudioMcpServer, isMcpRequestAuthorized } from "@/modules/mcp/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -24,15 +19,8 @@ export async function POST(request: Request) { }); } - const mcpServer = new AgentStudioMcpServer({ - executionService: new ExecutionService( - new ExecutionRepository(), - new SkillRepository(), - new AuditLogRepository(), - { mcpService: new McpClientService(new McpServerRepository()) } - ), - skillRepo: new SkillRepository(), - }); + const { executionService, skillRepo } = apiServices(); + const mcpServer = new AgentStudioMcpServer({ executionService, skillRepo }); return mcpServer.handleMessageRequest(request); } diff --git a/src/app/api/mcp/quality/route.ts b/src/app/api/mcp/quality/route.ts index 3877a42..a1305bc 100644 --- a/src/app/api/mcp/quality/route.ts +++ b/src/app/api/mcp/quality/route.ts @@ -190,10 +190,10 @@ export async function POST(req: Request) { // Determine badges const badges: ServerQualityScore["badges"] = []; - if (schema.score >= 90) badges.push({ id: "schema-star", label: "Schema Star", icon: "⭐", description: "Excellent tool schema quality", earnedAt: new Date().toISOString() }); - if (community.score >= 80) badges.push({ id: "community-favorite", label: "Community Favorite", icon: "❤️", description: "Highly rated by the community", earnedAt: new Date().toISOString() }); - if (server.isVerified) badges.push({ id: "verified", label: "Verified", icon: "🛡️", description: "Officially verified server", earnedAt: new Date().toISOString() }); - if (uptime.score >= 85) badges.push({ id: "high-uptime", label: "High Uptime", icon: "🟢", description: "Consistently available", earnedAt: new Date().toISOString() }); + if (schema.score >= 90) badges.push({ id: "schema-star", label: "Schema Star", icon: "Star", description: "Excellent tool schema quality", earnedAt: new Date().toISOString() }); + if (community.score >= 80) badges.push({ id: "community-favorite", label: "Community Favorite", icon: "Heart", description: "Highly rated by the community", earnedAt: new Date().toISOString() }); + if (server.isVerified) badges.push({ id: "verified", label: "Verified", icon: "ShieldCheck", description: "Officially verified server", earnedAt: new Date().toISOString() }); + if (uptime.score >= 85) badges.push({ id: "high-uptime", label: "High Uptime", icon: "Activity", description: "Consistently available", earnedAt: new Date().toISOString() }); return { serverId: server.id, diff --git a/src/app/api/mcp/servers/[id]/connect/route.ts b/src/app/api/mcp/servers/[id]/connect/route.ts index afd0763..3c95337 100644 --- a/src/app/api/mcp/servers/[id]/connect/route.ts +++ b/src/app/api/mcp/servers/[id]/connect/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, badRequest, notFound } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { userId } = await auth(); diff --git a/src/app/api/mcp/servers/[id]/disconnect/route.ts b/src/app/api/mcp/servers/[id]/disconnect/route.ts index 4dee0fd..6b06910 100644 --- a/src/app/api/mcp/servers/[id]/disconnect/route.ts +++ b/src/app/api/mcp/servers/[id]/disconnect/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, serverError, notFound } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { userId } = await auth(); diff --git a/src/app/api/mcp/servers/[id]/discover/route.ts b/src/app/api/mcp/servers/[id]/discover/route.ts index dd55edb..832be27 100644 --- a/src/app/api/mcp/servers/[id]/discover/route.ts +++ b/src/app/api/mcp/servers/[id]/discover/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, badRequest, notFound } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); export async function POST(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { userId } = await auth(); diff --git a/src/app/api/mcp/servers/[id]/health/route.ts b/src/app/api/mcp/servers/[id]/health/route.ts index 1f92568..6c8d85f 100644 --- a/src/app/api/mcp/servers/[id]/health/route.ts +++ b/src/app/api/mcp/servers/[id]/health/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, serverError, notFound } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { userId } = await auth(); diff --git a/src/app/api/mcp/servers/[id]/metrics/route.ts b/src/app/api/mcp/servers/[id]/metrics/route.ts index f398f49..80d9c86 100644 --- a/src/app/api/mcp/servers/[id]/metrics/route.ts +++ b/src/app/api/mcp/servers/[id]/metrics/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, notFound, forbidden, serverError } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); /** * GET /api/mcp/servers/:id/metrics diff --git a/src/app/api/mcp/servers/[id]/progress/route.ts b/src/app/api/mcp/servers/[id]/progress/route.ts index f39615c..9473ecd 100644 --- a/src/app/api/mcp/servers/[id]/progress/route.ts +++ b/src/app/api/mcp/servers/[id]/progress/route.ts @@ -1,8 +1,8 @@ import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; -const mcpService = new McpClientService(new McpServerRepository()); + +const { mcpService } = apiServices(); /** * GET /api/mcp/servers/:id/progress @@ -23,37 +23,52 @@ export async function GET(request: Request, { params }: { params: Promise<{ id: const stream = new ReadableStream({ start(controller) { + let closed = false; + let unsubscribe: (() => void) | null = () => {}; + const safeUnsubscribe = () => { + closed = true; + try { unsubscribe?.(); } catch { /* noop */ } + unsubscribe = null; + }; + // Send initial connection event controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: "connected", serverId: id, timestamp: Date.now() })}\n\n`) ); - // Subscribe to progress events from the MCP connection - const unsubscribe = mcpService.onProgress(id, userId, (event) => { + // Subscribe to progress events from the MCP connection. + // Async: the service verifies OWNERSHIP before attaching the listener — + // a non-owner (or unconnected server) resolves to a no-op subscription. + void mcpService.onProgress(id, userId, (event) => { + if (closed) return; try { controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); } catch { // Stream may be closed by the client - unsubscribe(); + safeUnsubscribe(); } + }).then((unsub) => { + if (closed) { try { unsub(); } catch { /* noop */ } return; } + unsubscribe = unsub; }); // Heartbeat every 30s to keep connection alive const heartbeat = setInterval(() => { + if (closed) { clearInterval(heartbeat); return; } try { controller.enqueue( encoder.encode(`data: ${JSON.stringify({ type: "heartbeat", timestamp: Date.now() })}\n\n`) ); } catch { clearInterval(heartbeat); - unsubscribe(); + safeUnsubscribe(); } }, 30_000); // Clean up when client disconnects request.signal.addEventListener("abort", () => { clearInterval(heartbeat); - unsubscribe(); + safeUnsubscribe(); try { controller.close(); } catch { diff --git a/src/app/api/mcp/servers/[id]/prompts/route.ts b/src/app/api/mcp/servers/[id]/prompts/route.ts index aca769c..aef9628 100644 --- a/src/app/api/mcp/servers/[id]/prompts/route.ts +++ b/src/app/api/mcp/servers/[id]/prompts/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, notFound, forbidden, serverError, badRequest } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); /** * GET /api/mcp/servers/:id/prompts diff --git a/src/app/api/mcp/servers/[id]/resources/read/route.ts b/src/app/api/mcp/servers/[id]/resources/read/route.ts index ec3f39f..6fb82c9 100644 --- a/src/app/api/mcp/servers/[id]/resources/read/route.ts +++ b/src/app/api/mcp/servers/[id]/resources/read/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, notFound, forbidden, badRequest, serverError } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); const readResourceSchema = z.object({ uri: z.string().min(1, "URI is required"), diff --git a/src/app/api/mcp/servers/[id]/resources/route.ts b/src/app/api/mcp/servers/[id]/resources/route.ts index 3053858..8e45eb0 100644 --- a/src/app/api/mcp/servers/[id]/resources/route.ts +++ b/src/app/api/mcp/servers/[id]/resources/route.ts @@ -1,10 +1,10 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, notFound, forbidden, serverError } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); /** * GET /api/mcp/servers/:id/resources diff --git a/src/app/api/mcp/servers/[id]/route.ts b/src/app/api/mcp/servers/[id]/route.ts index 0646ffa..d871f96 100644 --- a/src/app/api/mcp/servers/[id]/route.ts +++ b/src/app/api/mcp/servers/[id]/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { updateMcpServerSchema } from "@/validators/mcpSchema"; import { unauthorized, badRequest, serverError, notFound, forbidden } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { userId } = await auth(); diff --git a/src/app/api/mcp/servers/[id]/sampling/route.ts b/src/app/api/mcp/servers/[id]/sampling/route.ts index 4ef9a43..31d3c48 100644 --- a/src/app/api/mcp/servers/[id]/sampling/route.ts +++ b/src/app/api/mcp/servers/[id]/sampling/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, badRequest, serverError, notFound, forbidden } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); const samplingSchema = z.object({ messages: z.array( diff --git a/src/app/api/mcp/servers/[id]/test/route.ts b/src/app/api/mcp/servers/[id]/test/route.ts index 717192c..6605f9c 100644 --- a/src/app/api/mcp/servers/[id]/test/route.ts +++ b/src/app/api/mcp/servers/[id]/test/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { mcpTestToolSchema } from "@/validators/mcpSchema"; import { unauthorized, badRequest, serverError, notFound } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const { userId } = await auth(); diff --git a/src/app/api/mcp/servers/batch/route.ts b/src/app/api/mcp/servers/batch/route.ts index 6a0b53d..cc31e9d 100644 --- a/src/app/api/mcp/servers/batch/route.ts +++ b/src/app/api/mcp/servers/batch/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); const batchActionSchema = z.object({ action: z.enum(["connect", "disconnect", "health"]), diff --git a/src/app/api/mcp/servers/export/route.ts b/src/app/api/mcp/servers/export/route.ts index abe39a5..bd640fe 100644 --- a/src/app/api/mcp/servers/export/route.ts +++ b/src/app/api/mcp/servers/export/route.ts @@ -1,9 +1,9 @@ import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, serverError } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); /** * GET /api/mcp/servers/export @@ -18,12 +18,15 @@ export async function GET() { const exportBundle = { version: "1.0", exportedAt: new Date().toISOString(), + // SECURITY: headers carry upstream credentials — they are NEVER + // exported. The bundle stays re-importable; users re-enter secrets + // after import (header names are preserved as a convenience). servers: servers.map((s) => ({ name: s.name, transport: s.transport, endpointUrl: s.endpointUrl, command: s.command, - headers: s.headers, + ...(s.headers ? { headerNames: Object.keys(s.headers) } : {}), })), }; diff --git a/src/app/api/mcp/servers/import/route.ts b/src/app/api/mcp/servers/import/route.ts index 72d32f2..83363fb 100644 --- a/src/app/api/mcp/servers/import/route.ts +++ b/src/app/api/mcp/servers/import/route.ts @@ -1,11 +1,11 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { z } from "zod"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); const importSchema = z.object({ version: z.string().optional(), @@ -45,7 +45,10 @@ export async function POST(request: Request) { endpointUrl: serverInput.endpointUrl ?? undefined, command: serverInput.command ?? undefined, headers: serverInput.headers ?? undefined, - connectOnCreate: serverInput.connectOnCreate ?? false, + // Imported bundles NEVER auto-connect: a STDIO entry would mean + // executing an arbitrary command from a JSON file the moment it is + // imported. The user must explicitly hit Connect. + connectOnCreate: false, }); imported.push(created); } catch (err) { diff --git a/src/app/api/mcp/servers/route.ts b/src/app/api/mcp/servers/route.ts index 1d15ea3..3c1018b 100644 --- a/src/app/api/mcp/servers/route.ts +++ b/src/app/api/mcp/servers/route.ts @@ -1,20 +1,23 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; + import { createMcpServerSchema } from "@/validators/mcpSchema"; import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; import { ZodError } from "zod"; -const mcpService = new McpClientService(new McpServerRepository()); +const { mcpService } = apiServices(); -export async function GET() { +export async function GET(request: Request) { const { userId } = await auth(); if (!userId) return unauthorized(); try { - const servers = await mcpService.listServers(userId); + // Optional ?limit= (1–200, default uncapped for the hub's own use). + const limitRaw = new URL(request.url).searchParams.get("limit"); + const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.min(Number(limitRaw), 200) : undefined; + const servers = await mcpService.listServers(userId, limit); return NextResponse.json({ success: true, data: servers }); } catch (error) { return serverError(error); diff --git a/src/app/api/mcp/skills-feed/route.ts b/src/app/api/mcp/skills-feed/route.ts index 9560a08..1a47190 100644 --- a/src/app/api/mcp/skills-feed/route.ts +++ b/src/app/api/mcp/skills-feed/route.ts @@ -48,9 +48,9 @@ function serverToSkill(server: AwesomeMcpServer): AgentSkill { requiredServers: [server.name], requiredTools: [], tags: Array.from(new Set([server.source, ...(server.tags || [])])).slice(0, 5), - stars: server.isVerified ? 180 : 45, - installs: server.isVerified ? 2100 : 380, - rating: server.isVerified ? 4.9 : 4.6, + // Real metrics only — fabricated stars/installs/ratings were previously + // hardcoded per "verified" flag, which misinformed purchase decisions. + ...(typeof server.stars === "number" ? { stars: server.stars } : {}), difficulty: server.requiresAuthToken ? "INTERMEDIATE" : "BEGINNER", estimatedTime: "3 min", steps: [ @@ -88,9 +88,6 @@ function composioToolkitToSkill(tk: ComposioToolkit): AgentSkill { requiredServers: [`${tk.name} (Composio)`], requiredTools: [], tags: [...tk.categories.map((c) => c.name.toLowerCase()), "composio", "managed-auth", tk.slug].slice(0, 5), - stars: tk.toolsCount, - installs: tk.toolsCount * 10, - rating: tk.managedAuth.length > 0 ? 4.9 : 4.5, difficulty: tk.noAuth ? "BEGINNER" : "INTERMEDIATE", estimatedTime: "2 min", steps: [ @@ -118,9 +115,6 @@ function composioToolToSkill(tool: ComposioTool): AgentSkill { requiredServers: [`${tool.toolkitName} (Composio)`], requiredTools: [tool.slug], tags: ["composio", tool.toolkitSlug, tool.toolkitName.toLowerCase(), ...inputProps.slice(0, 2)].slice(0, 5), - stars: 0, - installs: 0, - rating: 4.7, difficulty: inputProps.length > 4 ? "ADVANCED" : inputProps.length > 2 ? "INTERMEDIATE" : "BEGINNER", estimatedTime: "1 min", steps: [ @@ -162,9 +156,13 @@ function smitheryItemToSkill(s: Record): AgentSkill { requiredServers: servers, requiredTools: [], tags: ["smithery", ...categories, ...servers].slice(0, 5), - stars: typeof s.externalStars === "number" ? s.externalStars : 0, - installs: typeof s.totalActivations === "number" ? s.totalActivations : 0, - rating: Math.min(5, Math.max(1, Math.round(qualityScore * 5))), + // Real Smithery metrics when present; undefined (not zero/fabricated) + // otherwise so the UI hides the badge entirely. + ...(typeof s.externalStars === "number" && s.externalStars > 0 ? { stars: s.externalStars } : {}), + ...(typeof s.totalActivations === "number" && s.totalActivations > 0 ? { installs: s.totalActivations } : {}), + ...(typeof s.qualityScore === "number" + ? { rating: Math.min(5, Math.max(1, Math.round(s.qualityScore * 5))) } + : {}), difficulty: (qualityScore > 0.9 ? "ADVANCED" : qualityScore > 0.7 ? "INTERMEDIATE" : "BEGINNER") as AgentSkill["difficulty"], estimatedTime: "5 min", steps: [ diff --git a/src/app/api/mcp/sse/route.ts b/src/app/api/mcp/sse/route.ts index 6235e64..d524f7a 100644 --- a/src/app/api/mcp/sse/route.ts +++ b/src/app/api/mcp/sse/route.ts @@ -1,12 +1,7 @@ import { auth } from "@clerk/nextjs/server"; import { env } from "@/lib/config/env"; import { AgentStudioMcpServer, isMcpRequestAuthorized } from "@/modules/mcp/server"; -import { ExecutionService } from "@/services/ExecutionService"; -import { ExecutionRepository } from "@/repositories/ExecutionRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; -import { McpClientService } from "@/services/McpClientService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; +import { apiServices } from "@/lib/api/services"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -25,15 +20,8 @@ export async function GET(request: Request) { return unauthorizedResponse(); } - const mcpServer = new AgentStudioMcpServer({ - executionService: new ExecutionService( - new ExecutionRepository(), - new SkillRepository(), - new AuditLogRepository(), - { mcpService: new McpClientService(new McpServerRepository()) } - ), - skillRepo: new SkillRepository(), - }); + const { executionService, skillRepo } = apiServices(); + const mcpServer = new AgentStudioMcpServer({ executionService, skillRepo }); return mcpServer.handleSseRequest(request); } @@ -44,15 +32,8 @@ export async function POST(request: Request) { return unauthorizedResponse(); } - const mcpServer = new AgentStudioMcpServer({ - executionService: new ExecutionService( - new ExecutionRepository(), - new SkillRepository(), - new AuditLogRepository(), - { mcpService: new McpClientService(new McpServerRepository()) } - ), - skillRepo: new SkillRepository(), - }); + const { executionService, skillRepo } = apiServices(); + const mcpServer = new AgentStudioMcpServer({ executionService, skillRepo }); return mcpServer.handleMessageRequest(request); } diff --git a/src/app/api/mcp/updates/route.ts b/src/app/api/mcp/updates/route.ts index def2c9e..f238b2b 100644 --- a/src/app/api/mcp/updates/route.ts +++ b/src/app/api/mcp/updates/route.ts @@ -1,20 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { McpClientService } from "@/services/McpClientService"; +import { apiServices } from "@/lib/api/services"; import { SkillService } from "@/services/SkillService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; + import { ApplyToolUpdateInput, McpToolUpdate } from "@/types/mcp"; import { applyToolUpdates } from "@/modules/mcp/toolDiff"; import { logger } from "@/lib/logger"; export const dynamic = "force-dynamic"; -const mcpRepo = new McpServerRepository(); -const mcpService = new McpClientService(mcpRepo); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); +const { mcpService, skillRepo, auditRepo } = apiServices(); const skillService = new SkillService(skillRepo, auditRepo); /** @@ -30,7 +25,7 @@ export async function GET(_request: Request) { const updates = mcpService.getAllPendingUpdates(); // Filter to only updates for servers owned by this user - const servers = await mcpRepo.findByUserId(userId); + const servers = await mcpService.listServers(userId); const serverIds = new Set(servers.map((s) => s.id)); const userUpdates = updates.filter((u) => serverIds.has(u.serverId)); @@ -86,7 +81,7 @@ export async function POST(request: NextRequest) { } // Verify ownership - const server = await mcpRepo.findByIdForUser(update.serverId, userId); + const server = await mcpService.getServer(update.serverId, userId); if (!server) { return NextResponse.json({ error: "Server not found or access denied" }, { status: 403 }); } diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts new file mode 100644 index 0000000..807f07e --- /dev/null +++ b/src/app/api/models/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from "next/server"; +import { + ALL_FALLBACK_MODELS, + GROQ_FREE_MODELS, + OPENROUTER_FREE_MODELS, + getFreeModelsByCategory, +} from "@/providers/llm"; + +/** + * GET /api/models + * Returns the catalog of free LLM models available on Groq and OpenRouter. + */ +export async function GET() { + const byCategory = getFreeModelsByCategory(); + + return NextResponse.json({ + success: true, + totalCount: ALL_FALLBACK_MODELS.length, + groqCount: GROQ_FREE_MODELS.length, + openRouterCount: OPENROUTER_FREE_MODELS.length, + models: ALL_FALLBACK_MODELS, + categories: byCategory, + }); +} diff --git a/src/app/api/models/test/route.ts b/src/app/api/models/test/route.ts new file mode 100644 index 0000000..aec4359 --- /dev/null +++ b/src/app/api/models/test/route.ts @@ -0,0 +1,50 @@ +import { NextResponse } from "next/server"; +import { getProviderForModel } from "@/providers/llm"; + +/** + * POST /api/models/test + * Tests a custom model endpoint, custom API key, or built-in model connectivity. + * Returns response status, latency in milliseconds, and model banner output. + */ +export async function POST(req: Request) { + try { + const body = await req.json(); + const { model, apiKey, apiBaseUrl, provider } = body; + + const started = Date.now(); + const testProvider = getProviderForModel(model || "gpt-4o", { + customApiKey: apiKey, + customApiBaseUrl: apiBaseUrl, + customApiProvider: provider, + }); + + const completion = await testProvider.complete( + [ + { role: "system", content: "You are a connectivity test agent. Reply with the single word: OK" }, + { role: "user", content: "ping" }, + ], + { temperature: 0.1, maxTokens: 10, timeoutMs: 15_000 } + ); + + const latencyMs = Date.now() - started; + + return NextResponse.json({ + success: true, + connected: true, + latencyMs, + model: model || testProvider.model, + provider: testProvider.name, + reply: completion.content.trim(), + }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + return NextResponse.json( + { + success: false, + connected: false, + error: message, + }, + { status: 400 } + ); + } +} diff --git a/src/app/api/openapi/integrations/[id]/route.ts b/src/app/api/openapi/integrations/[id]/route.ts index c6d561f..95f5e0c 100644 --- a/src/app/api/openapi/integrations/[id]/route.ts +++ b/src/app/api/openapi/integrations/[id]/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; +import { apiServices } from "@/lib/api/services"; + import { updateOpenApiIntegrationSchema } from "@/validators/openApiSchema"; import { unauthorized, notFound, badRequest, serverError } from "@/lib/api/handlers"; import { ZodError } from "zod"; -const openApiService = new OpenApiService(new OpenApiRepository()); +const { openApiService } = apiServices(); export async function GET( _request: Request, diff --git a/src/app/api/openapi/integrations/[id]/test/route.ts b/src/app/api/openapi/integrations/[id]/test/route.ts index 81f70ee..9d7dc26 100644 --- a/src/app/api/openapi/integrations/[id]/test/route.ts +++ b/src/app/api/openapi/integrations/[id]/test/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; +import { apiServices } from "@/lib/api/services"; + import { testEndpointRequestSchema } from "@/validators/openApiSchema"; import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; import { ZodError } from "zod"; -const openApiService = new OpenApiService(new OpenApiRepository()); +const { openApiService } = apiServices(); export async function POST( request: Request, diff --git a/src/app/api/openapi/integrations/route.ts b/src/app/api/openapi/integrations/route.ts index f8486ca..8d2625a 100644 --- a/src/app/api/openapi/integrations/route.ts +++ b/src/app/api/openapi/integrations/route.ts @@ -1,19 +1,22 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; +import { apiServices } from "@/lib/api/services"; + import { createOpenApiIntegrationSchema } from "@/validators/openApiSchema"; import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; import { ZodError } from "zod"; -const openApiService = new OpenApiService(new OpenApiRepository()); +const { openApiService } = apiServices(); -export async function GET() { +export async function GET(request: Request) { const { userId } = await auth(); if (!userId) return unauthorized(); try { - const integrations = await openApiService.listIntegrations(userId); + // Optional ?limit= (1–200, default uncapped for the hub's own use). + const limitRaw = new URL(request.url).searchParams.get("limit"); + const limit = limitRaw && /^\d+$/.test(limitRaw) ? Math.min(Number(limitRaw), 200) : undefined; + const integrations = await openApiService.listIntegrations(userId, limit); return NextResponse.json({ success: true, data: integrations }); } catch (error) { return serverError(error); diff --git a/src/app/api/openapi/parse/route.ts b/src/app/api/openapi/parse/route.ts index ee02b34..27e20c4 100644 --- a/src/app/api/openapi/parse/route.ts +++ b/src/app/api/openapi/parse/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; +import { apiServices } from "@/lib/api/services"; + import { parseSpecRequestSchema } from "@/validators/openApiSchema"; import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; import { ZodError } from "zod"; -const openApiService = new OpenApiService(new OpenApiRepository()); +const { openApiService } = apiServices(); export async function POST(request: Request) { const { userId } = await auth(); diff --git a/src/app/api/openapi/test-raw/route.ts b/src/app/api/openapi/test-raw/route.ts index 58194e9..ef6ef67 100644 --- a/src/app/api/openapi/test-raw/route.ts +++ b/src/app/api/openapi/test-raw/route.ts @@ -1,12 +1,12 @@ import { NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { OpenApiService } from "@/services/OpenApiService"; -import { OpenApiRepository } from "@/repositories/OpenApiRepository"; +import { apiServices } from "@/lib/api/services"; + import { testRawEndpointRequestSchema } from "@/validators/openApiSchema"; import { unauthorized, badRequest, serverError } from "@/lib/api/handlers"; import { ZodError } from "zod"; -const openApiService = new OpenApiService(new OpenApiRepository()); +const { openApiService } = apiServices(); export async function POST(request: Request) { const { userId } = await auth(); diff --git a/src/app/api/settings/providers/route.ts b/src/app/api/settings/providers/route.ts index 72d4ec1..82b06aa 100644 --- a/src/app/api/settings/providers/route.ts +++ b/src/app/api/settings/providers/route.ts @@ -3,9 +3,8 @@ import { auth } from "@clerk/nextjs/server"; import { env } from "@/lib/config/env"; import { unauthorized } from "@/lib/api/handlers"; import { - GROQ_FREE_MODELS, - OPENROUTER_FREE_MODELS, - ALL_FALLBACK_MODELS, + GROQ_ALL_MODELS, + ALL_MODELS_CATALOG, ModelEntry, } from "@/providers/llm"; import { ProviderStatus } from "@/types/settings"; @@ -25,22 +24,33 @@ export async function GET() { const hasOpenRouter = Boolean(env.OPENROUTER_API_KEY); const formatModels = (entries: ModelEntry[]) => - entries.map((e) => ({ label: e.label, model: e.model })); + entries.map((e) => ({ + label: e.label, + model: e.model, + category: e.category || "general", + contextLength: e.contextLength, + latency: e.latency, + throughput: e.throughput, + inputPrice: e.inputPrice ?? 0, + outputPrice: e.outputPrice ?? 0, + })); + + const openRouterCatalog = ALL_MODELS_CATALOG.filter((m) => m.provider === "openrouter"); const status: ProviderStatus = { groqConfigured: hasGroq, openRouterConfigured: hasOpenRouter, - groqModels: hasGroq ? GROQ_FREE_MODELS.length : 0, - openRouterModels: hasOpenRouter ? OPENROUTER_FREE_MODELS.length : 0, - totalModels: hasGroq || hasOpenRouter ? ALL_FALLBACK_MODELS.length : 0, + groqModels: hasGroq ? GROQ_ALL_MODELS.length : 0, + openRouterModels: hasOpenRouter ? openRouterCatalog.length : 0, + totalModels: hasGroq || hasOpenRouter ? (hasGroq ? GROQ_ALL_MODELS.length : 0) + (hasOpenRouter ? openRouterCatalog.length : 0) : 0, runtimeReady: hasGroq || hasOpenRouter, roster: { - groq: hasGroq ? formatModels(GROQ_FREE_MODELS) : [], - openRouter: hasOpenRouter ? formatModels(OPENROUTER_FREE_MODELS) : [], + groq: hasGroq ? formatModels(GROQ_ALL_MODELS) : [], + openRouter: hasOpenRouter ? formatModels(openRouterCatalog) : [], }, availableModels: { - groq: formatModels(GROQ_FREE_MODELS), - openRouter: formatModels(OPENROUTER_FREE_MODELS), + groq: formatModels(GROQ_ALL_MODELS), + openRouter: formatModels(openRouterCatalog), }, }; diff --git a/src/app/api/skills/packs/route.ts b/src/app/api/skills/packs/route.ts index ea985e2..d67a630 100644 --- a/src/app/api/skills/packs/route.ts +++ b/src/app/api/skills/packs/route.ts @@ -1,22 +1,17 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; import { SkillService } from "@/services/SkillService"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; + import { getPackById, SKILL_PACKS } from "@/data/skillPacks"; import { InstallPackInput, PackInstallationState } from "@/types/skillPacks"; import { createSkillSchema } from "@/validators/skillSchema"; -import { McpClientService } from "@/services/McpClientService"; +import { apiServices } from "@/lib/api/services"; import { logger } from "@/lib/logger"; export const dynamic = "force-dynamic"; -const skillRepo = new SkillRepository(); -const mcpRepo = new McpServerRepository(); -const auditRepo = new AuditLogRepository(); +const { skillRepo, auditRepo, mcpService } = apiServices(); const skillService = new SkillService(skillRepo, auditRepo); -const mcpService = new McpClientService(mcpRepo); /** * GET /api/skills/packs — List all available skill packs @@ -29,7 +24,7 @@ export async function GET() { try { // Get existing servers to check which are already connected - const existingServers = await mcpRepo.findByUserId(userId); + const existingServers = await mcpService.listServers(userId); const serverNames = new Set(existingServers.map((s) => s.name.toLowerCase())); // Enrich packs with installation status @@ -90,7 +85,7 @@ export async function POST(request: NextRequest) { }; // ── Phase 1: Mount MCP Servers ── - const existingServers = await mcpRepo.findByUserId(userId); + const existingServers = await mcpService.listServers(userId); const serverIdMap = new Map(); // pack server index → DB server ID const serverIndices = body.serverIndices ?? pack.servers.map((_, i) => i); diff --git a/src/app/api/skills/synthesize/route.ts b/src/app/api/skills/synthesize/route.ts index 98e388b..ad188bc 100644 --- a/src/app/api/skills/synthesize/route.ts +++ b/src/app/api/skills/synthesize/route.ts @@ -1,19 +1,15 @@ import { NextRequest, NextResponse } from "next/server"; import { auth } from "@clerk/nextjs/server"; -import { getLLMProvider } from "@/providers/llm"; -import { McpServerRepository } from "@/repositories/McpServerRepository"; -import { SkillRepository } from "@/repositories/SkillRepository"; -import { AuditLogRepository } from "@/repositories/AuditLogRepository"; +import { getProviderForModel } from "@/providers/llm"; import { SkillService } from "@/services/SkillService"; import { createSkillSchema } from "@/validators/skillSchema"; import type { AgentGraphDefinition } from "@/types/graph"; import { logger } from "@/lib/logger"; +import { apiServices } from "@/lib/api/services"; export const dynamic = "force-dynamic"; -const mcpRepo = new McpServerRepository(); -const skillRepo = new SkillRepository(); -const auditRepo = new AuditLogRepository(); +const { skillRepo, auditRepo, mcpService } = apiServices(); const skillService = new SkillService(skillRepo, auditRepo); // ────────────── LLM Prompt for Graph + Server Discovery ────────────── @@ -160,7 +156,7 @@ export async function POST(request: NextRequest) { try { const body = await request.json(); - const { prompt, autoMount = false } = body; + const { prompt, autoMount = false, model } = body; if (!prompt || typeof prompt !== "string" || prompt.trim().length === 0) { return NextResponse.json( @@ -169,10 +165,10 @@ export async function POST(request: NextRequest) { ); } - logger.info({ userId, promptLength: prompt.length }, "Skill synthesis started"); + logger.info({ userId, promptLength: prompt.length, model }, "Skill synthesis started"); // ── Phase 1: LLM generates graph + server analysis ── - const llm = getLLMProvider(); + const llm = getProviderForModel(model); const llmResponse = await llm.complete( [ { role: "system", content: SYNTHESIZER_SYSTEM_PROMPT }, @@ -300,7 +296,7 @@ export async function POST(request: NextRequest) { const mountedServerIds: string[] = []; if (autoMount) { - const existingRes = await mcpRepo.findByUserId(userId); + const existingRes = await mcpService.listServers(userId); const existingServers = existingRes; for (const server of matchedServers) { diff --git a/src/app/api/workflows/dify/[id]/route.ts b/src/app/api/workflows/dify/[id]/route.ts new file mode 100644 index 0000000..7773e52 --- /dev/null +++ b/src/app/api/workflows/dify/[id]/route.ts @@ -0,0 +1,138 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { unauthorized, notFound } from "@/lib/api/handlers"; +import { fetchWithRetry } from "@/lib/fetch-utils"; +import { + convertDifyToAgentGraph, + convertDifyToWorkflowTemplate, + parseDifyDslYaml, +} from "@/lib/converters/dify-converter"; + +export const revalidate = 600; + +interface CacheEntry { + data: any; + timestamp: number; +} + +const difyDetailCache = new Map(); +const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const { id } = await params; + if (!id) return notFound("Template ID required"); + + const cached = difyDetailCache.get(id); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return NextResponse.json({ + success: true, + data: cached.data, + cached: true, + }); + } + + try { + // 1. Fetch template metadata + const metaRes = await fetchWithRetry(`https://marketplace.dify.ai/api/v1/templates/${id}`, { + timeoutMs: 12000, + retries: 2, + }); + + if (!metaRes.ok) { + if (metaRes.status === 404) return notFound(`Dify template #${id} not found`); + throw new Error(`Dify API responded with status ${metaRes.status}`); + } + + const metaJson = await metaRes.json(); + const templateData = metaJson.data || metaJson; + + // 2. Fetch raw DSL YAML + let rawDsl = ""; + let dslParsed: any = {}; + try { + const dslRes = await fetchWithRetry(`https://marketplace.dify.ai/api/v1/templates/${id}/dsl`, { + timeoutMs: 15000, + retries: 2, + }); + if (dslRes.ok) { + rawDsl = await dslRes.text(); + dslParsed = parseDifyDslYaml(rawDsl); + } + } catch (dslErr) { + console.warn(`[Dify DSL fetch warning for #${id}]:`, dslErr); + } + + // 3. Convert Dify workflow DSL to Agent Studio Graph + const convertedGraph = convertDifyToAgentGraph({ + id: templateData.id, + name: templateData.template_name || templateData.name, + description: templateData.overview || templateData.description, + workflow: dslParsed.workflow || dslParsed, + app: dslParsed.app, + dependencies: dslParsed.dependencies, + }); + + const convertedTemplate = convertDifyToWorkflowTemplate({ + ...templateData, + dsl: dslParsed, + }); + + const plugins = Array.isArray(templateData.deps_plugins) ? templateData.deps_plugins : []; + const pluginTags = plugins.map((p: string) => { + const parts = p.split("/"); + return parts[parts.length - 1] || p; + }); + + const payload = { + id: templateData.id, + name: templateData.template_name || templateData.name || "Dify Workflow", + description: templateData.overview || templateData.description || "", + readme: templateData.readme || "", + categories: Array.isArray(templateData.categories) ? templateData.categories : ["operations"], + depsPlugins: plugins, + pluginTags, + preferredLanguages: templateData.preferred_languages || ["en"], + icon: templateData.icon || null, + iconBackground: templateData.icon_background || "#EFF1F5", + iconFileKey: templateData.icon_file_key || null, + author: templateData.publisher_unique_handle || (templateData.publisher_type === "organization" ? "Dify Team" : "Community"), + publisherType: templateData.publisher_type || "individual", + usageCount: typeof templateData.usage_count === "number" ? templateData.usage_count : 0, + version: templateData.version || "1.0.0", + badges: Array.isArray(templateData.badges) ? templateData.badges : [], + createdAt: templateData.created_at, + updatedAt: templateData.updated_at, + source: "dify", + url: `https://marketplace.dify.ai/templates/${id}`, + rawDsl, + convertedGraph, + convertedTemplate, + nodeCount: convertedGraph.nodes.length, + }; + + difyDetailCache.set(id, { + data: payload, + timestamp: Date.now(), + }); + + return NextResponse.json({ + success: true, + data: payload, + }); + } catch (error: any) { + console.error(`[Dify template detail API error for #${id}]:`, error); + return NextResponse.json( + { + success: false, + error: error?.message || `Failed to fetch Dify template #${id}`, + }, + { status: 502 } + ); + } +} diff --git a/src/app/api/workflows/dify/search/route.ts b/src/app/api/workflows/dify/search/route.ts new file mode 100644 index 0000000..62441ad --- /dev/null +++ b/src/app/api/workflows/dify/search/route.ts @@ -0,0 +1,187 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { unauthorized } from "@/lib/api/handlers"; +import { fetchWithRetry } from "@/lib/fetch-utils"; + +export const revalidate = 300; + +interface CacheEntry { + data: any; + timestamp: number; +} + +const difySearchCache = new Map(); +const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes + +async function fetchDifyRawPage( + page: number, + perPage: number, + q: string, + category: string +) { + const targetUrl = new URL("https://marketplace.dify.ai/api/v1/templates"); + targetUrl.searchParams.set("page", String(page)); + targetUrl.searchParams.set("page_size", String(perPage)); + targetUrl.searchParams.set("limit", String(perPage)); + + if (q) { + targetUrl.searchParams.set("search", q); + targetUrl.searchParams.set("q", q); + targetUrl.searchParams.set("keyword", q); + } + + if (category && category !== "ALL") { + targetUrl.searchParams.set("category", category.toLowerCase()); + } + + const res = await fetchWithRetry(targetUrl.toString(), { + timeoutMs: 12000, + retries: 2, + }); + + if (!res.ok) { + throw new Error(`Dify marketplace API responded with status ${res.status}`); + } + + const json = await res.json(); + const rawTemplates: any[] = json.data?.templates || json.templates || []; + const total = json.data?.total ?? json.total ?? rawTemplates.length; + + return { total, rawTemplates }; +} + +async function fetchAndCacheDifySearch( + page: number, + perPage: number, + q: string, + category: string +) { + const cacheKey = `dify-search:${page}:${perPage}:${q}:${category}`; + const cached = difySearchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + + const { total, rawTemplates: initialTemplates } = await fetchDifyRawPage(page, perPage, q, category); + let rawTemplates = initialTemplates; + + // Client-side search filtering fallback if upstream API did not filter by keyword + if (q && rawTemplates.length > 0) { + const qLower = q.toLowerCase().trim(); + const filtered = rawTemplates.filter((t) => { + const name = (t.template_name || t.name || "").toLowerCase(); + const overview = (t.overview || t.description || "").toLowerCase(); + const tags = (t.categories || []).map((c: string) => c.toLowerCase()); + const plugins = (t.deps_plugins || []).map((p: string) => p.toLowerCase()); + return ( + name.includes(qLower) || + overview.includes(qLower) || + tags.some((c: string) => c.includes(qLower)) || + plugins.some((p: string) => p.includes(qLower)) + ); + }); + if (filtered.length > 0) { + rawTemplates = filtered; + } + } + + // Client-side category filtering fallback if upstream did not filter + if (category && category !== "ALL" && rawTemplates.length > 0) { + const catLower = category.toLowerCase().trim(); + const catFiltered = rawTemplates.filter((t) => { + const cats = (t.categories || []).map((c: string) => c.toLowerCase()); + return cats.includes(catLower); + }); + if (catFiltered.length > 0) { + rawTemplates = catFiltered; + } + } + + const normalizedWorkflows = rawTemplates.map((t: any) => { + const plugins = Array.isArray(t.deps_plugins) ? t.deps_plugins : []; + const pluginTags = plugins.map((p: string) => { + const parts = p.split("/"); + return parts[parts.length - 1] || p; + }); + + return { + id: t.id, + name: t.template_name || t.name || "Dify Workflow", + description: t.overview || t.description || "", + readme: t.readme || "", + categories: Array.isArray(t.categories) ? t.categories : ["operations"], + depsPlugins: plugins, + pluginTags, + preferredLanguages: t.preferred_languages || ["en"], + icon: t.icon || null, + iconBackground: t.icon_background || "#EFF1F5", + iconFileKey: t.icon_file_key || null, + author: t.publisher_unique_handle || (t.publisher_type === "organization" ? "Dify Team" : "Community"), + publisherType: t.publisher_type || "individual", + usageCount: typeof t.usage_count === "number" ? t.usage_count : 0, + version: t.version || "1.0.0", + badges: Array.isArray(t.badges) ? t.badges : [], + createdAt: t.created_at, + updatedAt: t.updated_at, + source: "dify", + url: `https://marketplace.dify.ai/templates/${t.id}`, + }; + }); + + const responsePayload = { + workflows: normalizedWorkflows, + pagination: { + page, + perPage, + totalWorkflows: total, + totalPages: Math.max(1, Math.ceil(total / perPage)), + }, + }; + + difySearchCache.set(cacheKey, { + data: responsePayload, + timestamp: Date.now(), + }); + + return responsePayload; +} + +export async function GET(request: Request) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") || "1", 10); + const perPage = Math.min(50, parseInt(url.searchParams.get("perPage") || "18", 10)); + const q = url.searchParams.get("q")?.trim() || ""; + const category = url.searchParams.get("category")?.trim() || ""; + + try { + const responsePayload = await fetchAndCacheDifySearch(page, perPage, q, category); + + // Background prefetch next page into cache if more pages exist + if (page < responsePayload.pagination.totalPages) { + const nextPage = page + 1; + const nextCacheKey = `dify-search:${nextPage}:${perPage}:${q}:${category}`; + if (!difySearchCache.has(nextCacheKey)) { + fetchAndCacheDifySearch(nextPage, perPage, q, category).catch(() => {}); + } + } + + return NextResponse.json({ + success: true, + ...responsePayload, + }); + } catch (error: any) { + console.error("[Dify templates search API error]:", error); + return NextResponse.json( + { + success: false, + error: error?.message || "Failed to fetch Dify workflow templates", + workflows: [], + pagination: { page, perPage, totalWorkflows: 0, totalPages: 0 }, + }, + { status: 502 } + ); + } +} diff --git a/src/app/api/workflows/n8n/[id]/route.ts b/src/app/api/workflows/n8n/[id]/route.ts new file mode 100644 index 0000000..a08a367 --- /dev/null +++ b/src/app/api/workflows/n8n/[id]/route.ts @@ -0,0 +1,106 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { unauthorized, notFound } from "@/lib/api/handlers"; +import { fetchWithRetry } from "@/lib/fetch-utils"; +import { convertN8nToAgentGraph, convertN8nToWorkflowTemplate } from "@/lib/converters/n8n-converter"; + +export const revalidate = 600; + +interface CacheEntry { + data: any; + timestamp: number; +} + +const workflowCache = new Map(); +const CACHE_TTL_MS = 30 * 60 * 1000; // 30 minutes + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> } +) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const { id } = await params; + if (!id) return notFound("Workflow ID required"); + + const cached = workflowCache.get(id); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return NextResponse.json({ + success: true, + data: cached.data, + cached: true, + }); + } + + try { + const res = await fetchWithRetry(`https://api.n8n.io/templates/workflows/${id}`, { + timeoutMs: 15000, + retries: 2, + }); + + if (!res.ok) { + if (res.status === 404) return notFound(`n8n workflow #${id} not found`); + throw new Error(`n8n API responded with status ${res.status}`); + } + + const json = await res.json(); + const rawWorkflow = json.workflow || json; + + if (!rawWorkflow) { + return notFound(`n8n workflow #${id} structure not found`); + } + + const innerWorkflowData = rawWorkflow.workflow || rawWorkflow; + const nodes = innerWorkflowData.nodes || []; + const connections = innerWorkflowData.connections || {}; + + const convertedGraph = convertN8nToAgentGraph({ + id: rawWorkflow.id, + name: rawWorkflow.name, + description: rawWorkflow.description, + nodes, + connections, + }); + + const convertedTemplate = convertN8nToWorkflowTemplate({ + id: rawWorkflow.id, + name: rawWorkflow.name, + description: rawWorkflow.description, + nodes, + }); + + const payload = { + id: rawWorkflow.id, + name: rawWorkflow.name, + description: rawWorkflow.description, + views: rawWorkflow.views || rawWorkflow.totalViews || 0, + createdAt: rawWorkflow.createdAt, + user: rawWorkflow.user, + rawWorkflowJson: innerWorkflowData, + nodeCount: nodes.length, + convertedGraph, + convertedTemplate, + url: `https://n8n.io/workflows/${id}`, + }; + + workflowCache.set(id, { + data: payload, + timestamp: Date.now(), + }); + + return NextResponse.json({ + success: true, + data: payload, + }); + } catch (error: any) { + console.error(`[n8n workflow detail API error for #${id}]:`, error); + return NextResponse.json( + { + success: false, + error: error?.message || `Failed to fetch n8n workflow #${id}`, + }, + { status: 502 } + ); + } +} diff --git a/src/app/api/workflows/n8n/search/route.ts b/src/app/api/workflows/n8n/search/route.ts new file mode 100644 index 0000000..c416224 --- /dev/null +++ b/src/app/api/workflows/n8n/search/route.ts @@ -0,0 +1,194 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { unauthorized } from "@/lib/api/handlers"; +import { fetchWithRetry } from "@/lib/fetch-utils"; + +export const revalidate = 300; + +interface CacheEntry { + data: any; + timestamp: number; +} + +const searchCache = new Map(); +const CACHE_TTL_MS = 15 * 60 * 1000; // 15 minutes + +const CATEGORY_MAP: Record = { + "AI": "25", + "Langchain": "48", + "RAG": "48", + "Communication": "13", + "Support": "13", + "Data & Storage": "30", + "Development": "16", + "DevOps": "16", + "Productivity": "11", + "Sales": "2", + "CRM": "39", + "Marketing": "27", +}; + +async function fetchN8nRawPage( + n8nPage: number, + q: string, + category: string, + collection: string, + rowsLimit: number = 18 +) { + const targetUrl = new URL("https://api.n8n.io/templates/search"); + targetUrl.searchParams.set("page", String(n8nPage)); + targetUrl.searchParams.set("rows", String(rowsLimit)); + targetUrl.searchParams.set("limit", String(rowsLimit)); + targetUrl.searchParams.set("perPage", String(rowsLimit)); + targetUrl.searchParams.set("pageSize", String(rowsLimit)); + if (q) { + targetUrl.searchParams.set("search", q); + targetUrl.searchParams.set("q", q); + } + + if (category && category !== "ALL") { + const mappedId = CATEGORY_MAP[category] || category; + targetUrl.searchParams.set("category", mappedId); + targetUrl.searchParams.set("categories", mappedId); + } + if (collection) targetUrl.searchParams.set("collection", collection); + + const res = await fetchWithRetry(targetUrl.toString(), { + timeoutMs: 12000, + retries: 2, + }); + + if (!res.ok) { + throw new Error(`n8n API responded with status ${res.status}`); + } + + const json = await res.json(); + const totalWorkflows = json.totalWorkflows || json.total || (json.workflows || []).length; + const rawWorkflows: any[] = json.workflows || json.templates || json.data || []; + return { totalWorkflows, rawWorkflows }; +} + +async function fetchAndCacheSearch( + page: number, + perPage: number, + q: string, + category: string, + collection: string +) { + const cacheKey = `n8n-search:${page}:${perPage}:${q}:${category}:${collection}`; + const cached = searchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return cached.data; + } + + // 1. Fetch initial batch + const { totalWorkflows, rawWorkflows: initialWorkflows } = await fetchN8nRawPage(page, q, category, collection, perPage); + let rawWorkflows = initialWorkflows; + + // 2. If upstream n8n API hard-caps at 10 items per page, stitch upstream pages to provide exact 18 items + const startIndex = (page - 1) * perPage; + if (rawWorkflows.length < perPage && rawWorkflows.length > 0 && startIndex + rawWorkflows.length < totalWorkflows) { + const upstreamPageSize = rawWorkflows.length; // usually 10 + const u1 = Math.floor(startIndex / upstreamPageSize) + 1; + const u2 = Math.floor((startIndex + perPage - 1) / upstreamPageSize) + 1; + const offset = startIndex % upstreamPageSize; + + const fetches: Promise<{ rawWorkflows: any[] }>[] = []; + for (let u = u1; u <= u2; u++) { + fetches.push(fetchN8nRawPage(u, q, category, collection, upstreamPageSize)); + } + + try { + const results = await Promise.all(fetches); + const combined = results.flatMap((r) => r.rawWorkflows); + rawWorkflows = combined.slice(offset, offset + perPage); + } catch { + // If stitching fails, fall back to initial batch + } + } + + const normalizedWorkflows = rawWorkflows.map((wf: any) => { + const nodes = Array.isArray(wf.nodes) ? wf.nodes : []; + const nodeIcons = nodes.slice(0, 6).map((n: any) => ({ + name: n.displayName || n.name || "Node", + icon: n.iconData?.fileBuffer || null, + type: n.name || n.type, + })); + + return { + id: wf.id, + name: wf.name, + description: wf.description || "", + totalViews: wf.totalViews || wf.views || 0, + createdAt: wf.createdAt, + user: { + name: wf.user?.name || "Community", + username: wf.user?.username || "n8n", + avatar: wf.user?.avatar || null, + verified: Boolean(wf.user?.verified), + }, + nodeCount: nodes.length, + nodeIcons, + nodeTypes: Array.from(new Set(nodes.map((n: any) => n.name || n.displayName))).slice(0, 8), + url: `https://n8n.io/workflows/${wf.id}`, + }; + }); + + const responsePayload = { + workflows: normalizedWorkflows, + pagination: { + page, + perPage, + totalWorkflows, + totalPages: Math.ceil(totalWorkflows / perPage), + }, + }; + + searchCache.set(cacheKey, { + data: responsePayload, + timestamp: Date.now(), + }); + + return responsePayload; +} + +export async function GET(request: Request) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const url = new URL(request.url); + const page = parseInt(url.searchParams.get("page") || "1", 10); + const perPage = Math.min(50, parseInt(url.searchParams.get("perPage") || "18", 10)); + const q = url.searchParams.get("q")?.trim() || ""; + const category = url.searchParams.get("category")?.trim() || ""; + const collection = url.searchParams.get("collection")?.trim() || ""; + + try { + const responsePayload = await fetchAndCacheSearch(page, perPage, q, category, collection); + + // Background prefetch next page into cache if more pages exist + if (page < responsePayload.pagination.totalPages) { + const nextPage = page + 1; + const nextCacheKey = `n8n-search:${nextPage}:${perPage}:${q}:${category}:${collection}`; + if (!searchCache.has(nextCacheKey)) { + fetchAndCacheSearch(nextPage, perPage, q, category, collection).catch(() => {}); + } + } + + return NextResponse.json({ + success: true, + ...responsePayload, + }); + } catch (error: any) { + console.error("[n8n search API error]:", error); + return NextResponse.json( + { + success: false, + error: error?.message || "Failed to fetch n8n workflows", + workflows: [], + pagination: { page, perPage, totalWorkflows: 0, totalPages: 0 }, + }, + { status: 502 } + ); + } +} diff --git a/src/app/api/workflows/search/route.ts b/src/app/api/workflows/search/route.ts new file mode 100644 index 0000000..260e0fc --- /dev/null +++ b/src/app/api/workflows/search/route.ts @@ -0,0 +1,572 @@ +import { NextResponse } from "next/server"; +import { auth } from "@clerk/nextjs/server"; +import { unauthorized } from "@/lib/api/handlers"; +import { fetchWithRetry } from "@/lib/fetch-utils"; +import { CANVAS_TEMPLATES } from "@/components/canvas/AgentGraphTemplates"; +import { WORKFLOW_TEMPLATES } from "@/components/workflows/WorkflowTemplates"; + +export const revalidate = 300; + +interface CacheEntry { + data: Record; + timestamp: number; +} + +const unifiedSearchCache = new Map(); +const CACHE_TTL_MS = 15 * 60 * 1000; + +// Studio Built-in Templates normalized +function mapUnifiedCategoryToN8n(cat: string): string { + const map: Record = { + ai: "AI", + marketing: "Marketing", + sales: "Sales", + support: "Customer Support", + operations: "Operations", + it: "Development", + knowledge: "Langchain", + finance: "Finance & Accounting", + }; + return map[cat.toLowerCase()] || ""; +} + +function mapUnifiedCategoryToDify(cat: string): string { + const map: Record = { + ai: "ai", + marketing: "marketing", + sales: "sales", + support: "support", + operations: "operations", + it: "it", + knowledge: "knowledge", + finance: "finance", + }; + return map[cat.toLowerCase()] || ""; +} + +// Studio Built-in Templates normalized +function getStudioTemplates(q: string, category: string, tag: string = "") { + const allStudio = [ + ...CANVAS_TEMPLATES.map((t) => { + const isZeroKey = + t.category.includes("ZERO-KEY") || + t.badge.includes("ZERO-KEY") || + t.id.includes("trending") || + t.id.includes("arxiv") || + t.id.includes("weather") || + t.id.includes("subreddit") || + t.id.includes("wiki") || + t.id.includes("jina"); + + const isOpenSource = + t.category.includes("OPEN SOURCE") || + t.category.includes("SELF-HOSTED") || + t.badge.includes("OPEN SOURCE") || + t.badge.includes("LOCAL AI") || + t.badge.includes("SELF-HOSTED") || + t.id.includes("deep_research") || + t.id.includes("docling") || + t.id.includes("whisper") || + t.id.includes("qdrant") || + t.id.includes("windmill"); + + const icon = isOpenSource + ? "Layers" + : isZeroKey + ? "Globe" + : t.category.includes("SECURITY") + ? "Shield" + : t.category.includes("FINANCE") + ? "Coins" + : "Workflow"; + + const categories = [ + t.category.toLowerCase(), + "multi-agent", + "orchestration", + ...(isZeroKey ? ["zero-key", "free-api", "open-api", "no-auth"] : []), + ...(isOpenSource ? ["open-source", "self-hosted", "local-ai", "privacy", "free-api", "zero-key"] : []), + ...(t.category.includes("SECURITY") ? ["security", "devops", "cve"] : []), + ...(t.category.includes("FINANCE") ? ["finance", "fintech", "crypto"] : []), + ]; + + return { + id: `canvas-${t.id}`, + provider: "studio" as const, + providerName: "Agent Studio", + name: t.name, + description: t.description, + readme: `### Built-in Agent Studio Blueprint\n\n**Category:** ${t.category}\n**Badge:** ${t.badge}\n\n${t.description}\n\nClick **OPEN IN CANVAS** to customize and run.`, + author: "Agent Studio", + authorUrl: "/dashboard/canvas", + icon, + iconBackground: isOpenSource ? "#6366F1" : isZeroKey ? "#059669" : "#4338CA", + categories, + primaryCategory: isOpenSource ? "open-source" : isZeroKey ? "zero-key" : t.category.toLowerCase(), + tags: [ + "multi-agent", + "graph", + ...(isOpenSource ? ["Open-Source", "Self-Hosted", "Local AI", "Privacy"] : []), + ...(isZeroKey ? ["Zero-Key", "Free API", "No Key Required"] : []), + ...t.graph.nodes.map((n) => n.type), + ], + pluginTags: [ + ...(isOpenSource ? ["Open-Source", "Self-Hosted"] : isZeroKey ? ["Zero-Key", "Free Public API"] : ["Agent Graph"]), + t.badge, + ], + nodeCount: t.graph.nodes.length, + usageCount: isOpenSource ? 3200 : isZeroKey ? 2840 : 1420, + viewsCount: isOpenSource ? 9800 : isZeroKey ? 8900 : 5200, + version: "1.0.0", + badges: isOpenSource ? ["official", "blueprint", "open-source", "self-hosted"] : isZeroKey ? ["official", "blueprint", "zero-key", "no-key-required"] : ["official", "blueprint"], + sourceUrl: `/dashboard/canvas/new?template=${t.id}`, + canvasUrl: `/dashboard/canvas/new?template=${t.id}`, + createdAt: new Date().toISOString(), + }; + }), + ...WORKFLOW_TEMPLATES.map((t) => { + const isZeroKey = t.category.includes("ZERO-KEY") || t.badge.includes("ZERO-KEY"); + const isOpenSource = t.category.includes("OPEN SOURCE") || t.badge.includes("OPEN SOURCE") || t.badge.includes("LOCAL AI"); + return { + id: `workflow-${t.id}`, + provider: "studio" as const, + providerName: "Agent Studio", + name: t.name, + description: t.purpose, + readme: `### Enterprise Workflow Starter\n\n**Instructions:**\n${t.instructions}\n\n**Steps:**\n${t.stepsSummary.join(" → ")}\n\n**Allowed Tools:**\n${t.allowedTools.join(", ")}`, + author: "Enterprise Blueprints", + authorUrl: "/dashboard/skills", + icon: isOpenSource ? "Layers" : isZeroKey ? "Globe" : "Shield", + iconBackground: isOpenSource ? "#4F46E5" : isZeroKey ? "#0D9488" : "#312E81", + categories: [ + t.category.toLowerCase(), + ...(isOpenSource ? ["open-source", "self-hosted", "local-ai", "zero-key"] : []), + ...(isZeroKey ? ["zero-key", "free-api", "open-api"] : ["hitl", "enterprise"]), + ], + primaryCategory: isOpenSource ? "open-source" : isZeroKey ? "zero-key" : t.category.toLowerCase(), + tags: [...t.allowedTools, ...(isOpenSource ? ["Open-Source", "Self-Hosted"] : []), ...(isZeroKey ? ["Zero-Key", "Free API"] : [])], + pluginTags: t.allowedTools, + nodeCount: t.stepsSummary.length, + usageCount: 980, + viewsCount: 3900, + version: "1.0.0", + badges: isOpenSource ? ["open-source", "self-hosted"] : isZeroKey ? ["zero-key", "free-api", "enterprise"] : ["hitl", "enterprise"], + sourceUrl: `/dashboard/skills/new`, + canvasUrl: `/dashboard/canvas/new`, + createdAt: new Date().toISOString(), + }; + }), + ]; + + let filtered = allStudio; + const qLower = (q || "").toLowerCase().trim(); + const tagLower = (tag || "").toLowerCase().trim(); + + if (qLower) { + filtered = filtered.filter( + (s) => + s.name.toLowerCase().includes(qLower) || + s.description.toLowerCase().includes(qLower) || + s.tags.some((t) => t.toLowerCase().includes(qLower)) || + s.pluginTags.some((t) => t.toLowerCase().includes(qLower)) || + s.badges.some((b) => b.toLowerCase().includes(qLower)) || + s.categories.some((c) => c.toLowerCase().includes(qLower)) + ); + } + + if (tagLower) { + filtered = filtered.filter( + (s) => + s.tags.some((t) => t.toLowerCase().includes(tagLower)) || + s.pluginTags.some((t) => t.toLowerCase().includes(tagLower)) || + s.name.toLowerCase().includes(tagLower) || + s.description.toLowerCase().includes(tagLower) || + s.badges.some((b) => b.toLowerCase().includes(tagLower)) + ); + } + + if (category && category !== "ALL") { + const catLower = category.toLowerCase(); + filtered = filtered.filter( + (s) => + s.categories.some((c) => c.toLowerCase().includes(catLower)) || + s.primaryCategory.toLowerCase() === catLower + ); + } + + return filtered; +} + +export async function GET(request: Request) { + const { userId } = await auth(); + if (!userId) return unauthorized(); + + const url = new URL(request.url); + const provider = url.searchParams.get("provider") || "all"; + const page = parseInt(url.searchParams.get("page") || "1", 10); + const perPage = Math.min(50, parseInt(url.searchParams.get("perPage") || "18", 10)); + const q = url.searchParams.get("q")?.trim() || ""; + const category = url.searchParams.get("category")?.trim() || ""; + const tag = url.searchParams.get("tag")?.trim() || ""; + + const cacheKey = `unified-search:${provider}:${page}:${perPage}:${q}:${category}:${tag}`; + const cached = unifiedSearchCache.get(cacheKey); + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { + return NextResponse.json({ + success: true, + ...cached.data, + cached: true, + }); + } + + const effectiveQuery = tag ? `${q} ${tag}`.trim() : q; + const totalStudioTemplates = CANVAS_TEMPLATES.length + WORKFLOW_TEMPLATES.length; + + try { + if (provider === "studio") { + const studioItems = getStudioTemplates(q, category, tag); + const total = studioItems.length; + const offset = (page - 1) * perPage; + const paginated = studioItems.slice(offset, offset + perPage); + + const payload = { + provider: "studio", + workflows: paginated, + stats: { + total: 11950 + totalStudioTemplates, + n8n: 11620, + dify: 292, + studio: totalStudioTemplates, + }, + pagination: { + page, + perPage, + totalWorkflows: total, + totalPages: Math.max(1, Math.ceil(total / perPage)), + }, + }; + + unifiedSearchCache.set(cacheKey, { data: payload, timestamp: Date.now() }); + return NextResponse.json({ success: true, ...payload }); + } + + if (provider === "n8n") { + const targetUrl = new URL("https://api.n8n.io/templates/search"); + targetUrl.searchParams.set("page", String(page)); + targetUrl.searchParams.set("rows", String(perPage)); + targetUrl.searchParams.set("perPage", String(perPage)); + + const n8nCategory = mapUnifiedCategoryToN8n(category); + if (n8nCategory) { + targetUrl.searchParams.set("categories", n8nCategory); + } + + if (category.toLowerCase() === "zero-key" && !effectiveQuery) { + targetUrl.searchParams.set("search", "API"); + } else if (effectiveQuery) { + targetUrl.searchParams.set("search", effectiveQuery); + } + + const res = await fetchWithRetry(targetUrl.toString(), { timeoutMs: 12000, retries: 2 }); + if (!res.ok) throw new Error(`n8n API error ${res.status}`); + const json = await res.json(); + + const rawWorkflows: any[] = json.workflows || []; + const total = json.totalWorkflows ?? json.total ?? rawWorkflows.length; + + const normalized = rawWorkflows.map((w: any) => ({ + id: w.id, + provider: "n8n" as const, + providerName: "n8n", + name: w.name || `n8n Workflow #${w.id}`, + description: w.description || "", + readme: w.description || "", + author: w.user?.username || w.user?.name || "n8n Community", + authorUrl: `https://n8n.io/workflows/${w.id}`, + icon: "Workflow", + iconBackground: "#F0506E", + categories: (w.categories || []).map((c: any) => (typeof c === "string" ? c : c.name || "Automation")), + primaryCategory: (w.categories?.[0]?.name || w.categories?.[0] || "automation").toLowerCase(), + tags: (w.nodes || []).map((n: any) => (typeof n === "string" ? n : n.name || n.type)), + pluginTags: (w.nodes || []).map((n: any) => { + const typeStr = typeof n === "string" ? n : n.type || n.name || ""; + return typeStr.replace(/^n8n-nodes-base\./, ""); + }).slice(0, 4), + nodeCount: Array.isArray(w.nodes) ? w.nodes.length : 0, + usageCount: 0, + viewsCount: w.views || w.totalViews || 0, + version: "1.0.0", + badges: ["community"], + sourceUrl: `https://n8n.io/workflows/${w.id}`, + canvasUrl: `/dashboard/canvas/new?n8nId=${w.id}`, + createdAt: w.createdAt, + })); + + const payload = { + provider: "n8n", + workflows: normalized, + stats: { + total: (total || 11620) + 292 + totalStudioTemplates, + n8n: total || 11620, + dify: 292, + studio: totalStudioTemplates, + }, + pagination: { + page, + perPage, + totalWorkflows: total, + totalPages: Math.max(1, Math.ceil(total / perPage)), + }, + }; + + unifiedSearchCache.set(cacheKey, { data: payload, timestamp: Date.now() }); + return NextResponse.json({ success: true, ...payload }); + } + + if (provider === "dify") { + const targetUrl = new URL("https://marketplace.dify.ai/api/v1/templates"); + targetUrl.searchParams.set("page", String(page)); + targetUrl.searchParams.set("page_size", String(perPage)); + targetUrl.searchParams.set("limit", String(perPage)); + + const difyCategory = mapUnifiedCategoryToDify(category); + if (difyCategory) { + targetUrl.searchParams.set("category", difyCategory); + } + + if (category.toLowerCase() === "zero-key" && !effectiveQuery) { + targetUrl.searchParams.set("search", "api"); + } else if (effectiveQuery) { + targetUrl.searchParams.set("search", effectiveQuery); + } + + const res = await fetchWithRetry(targetUrl.toString(), { timeoutMs: 12000, retries: 2 }); + if (!res.ok) throw new Error(`Dify API error ${res.status}`); + const json = await res.json(); + + const rawTemplates: any[] = json.data?.templates || json.templates || []; + const total = json.data?.total ?? json.total ?? rawTemplates.length; + + const normalized = rawTemplates.map((t: any) => { + const plugins = Array.isArray(t.deps_plugins) ? t.deps_plugins : []; + const pluginTags = plugins.map((p: string) => { + const parts = p.split("/"); + return parts[parts.length - 1] || p; + }); + + return { + id: t.id, + provider: "dify" as const, + providerName: "Dify.ai", + name: t.template_name || t.name || "Dify Workflow", + description: t.overview || t.description || "", + readme: t.readme || "", + author: t.publisher_unique_handle || (t.publisher_type === "organization" ? "Dify Team" : "Community"), + authorUrl: `https://marketplace.dify.ai/templates/${t.id}`, + icon: t.icon || "Layers", + iconBackground: t.icon_background || "#1C64F2", + categories: Array.isArray(t.categories) ? t.categories : ["operations"], + primaryCategory: (t.categories?.[0] || "operations").toLowerCase(), + tags: pluginTags, + pluginTags, + nodeCount: 0, + usageCount: typeof t.usage_count === "number" ? t.usage_count : 0, + viewsCount: 0, + version: t.version || "1.0.0", + badges: Array.isArray(t.badges) ? t.badges : [], + sourceUrl: `https://marketplace.dify.ai/templates/${t.id}`, + canvasUrl: `/dashboard/canvas/new?difyId=${t.id}`, + createdAt: t.created_at, + }; + }); + + const payload = { + provider: "dify", + workflows: normalized, + stats: { + total: 11620 + (total || 292) + totalStudioTemplates, + n8n: 11620, + dify: total || 292, + studio: totalStudioTemplates, + }, + pagination: { + page, + perPage, + totalWorkflows: total, + totalPages: Math.max(1, Math.ceil(total / perPage)), + }, + }; + + unifiedSearchCache.set(cacheKey, { data: payload, timestamp: Date.now() }); + return NextResponse.json({ success: true, ...payload }); + } + + // Default: ALL PROVIDERS (Aggregated multi-source) + const isZeroKeyCategory = category.toLowerCase() === "zero-key"; + const n8nCategory = mapUnifiedCategoryToN8n(category); + const difyCategory = mapUnifiedCategoryToDify(category); + + const [n8nRes, difyRes] = await Promise.allSettled([ + (async () => { + const n8nUrl = new URL("https://api.n8n.io/templates/search"); + n8nUrl.searchParams.set("page", String(page)); + n8nUrl.searchParams.set("rows", String(Math.ceil(perPage * 0.7))); // 12 items + n8nUrl.searchParams.set("perPage", String(Math.ceil(perPage * 0.7))); + if (effectiveQuery) n8nUrl.searchParams.set("search", effectiveQuery); + else if (isZeroKeyCategory) n8nUrl.searchParams.set("search", "API"); + if (n8nCategory) n8nUrl.searchParams.set("categories", n8nCategory); + + const res = await fetchWithRetry(n8nUrl.toString(), { timeoutMs: 12000, retries: 1 }); + if (!res.ok) return { total: 11620, workflows: [] }; + const json = await res.json(); + return { + total: json.totalWorkflows ?? json.total ?? 11620, + workflows: json.workflows || [], + }; + })(), + (async () => { + const difyUrl = new URL("https://marketplace.dify.ai/api/v1/templates"); + difyUrl.searchParams.set("page", String(page)); + difyUrl.searchParams.set("page_size", String(Math.ceil(perPage * 0.3))); // 6 items + difyUrl.searchParams.set("limit", String(Math.ceil(perPage * 0.3))); + if (effectiveQuery) difyUrl.searchParams.set("search", effectiveQuery); + else if (isZeroKeyCategory) difyUrl.searchParams.set("search", "api"); + if (difyCategory) difyUrl.searchParams.set("category", difyCategory); + + const res = await fetchWithRetry(difyUrl.toString(), { timeoutMs: 12000, retries: 1 }); + if (!res.ok) return { total: 292, workflows: [] }; + const json = await res.json(); + return { + total: json.data?.total ?? json.total ?? 292, + workflows: json.data?.templates || json.templates || [], + }; + })(), + ]); + + const n8nData = n8nRes.status === "fulfilled" ? n8nRes.value : { total: 11620, workflows: [] }; + const difyData = difyRes.status === "fulfilled" ? difyRes.value : { total: 292, workflows: [] }; + const studioAll = getStudioTemplates(q, category, tag); + const studioData = isZeroKeyCategory + ? studioAll.slice((page - 1) * perPage, page * perPage) + : page === 1 + ? studioAll.slice(0, 4) + : []; + + const normalizedN8n = (n8nData.workflows || []).map((w: any) => ({ + id: w.id, + provider: "n8n" as const, + providerName: "n8n", + name: w.name || `n8n Workflow #${w.id}`, + description: w.description || "", + readme: w.description || "", + author: w.user?.username || w.user?.name || "n8n Community", + authorUrl: `https://n8n.io/workflows/${w.id}`, + icon: "Workflow", + iconBackground: "#F0506E", + categories: (w.categories || []).map((c: any) => (typeof c === "string" ? c : c.name || "Automation")), + primaryCategory: (w.categories?.[0]?.name || w.categories?.[0] || "automation").toLowerCase(), + tags: (w.nodes || []).map((n: any) => (typeof n === "string" ? n : n.name || n.type)), + pluginTags: (w.nodes || []).map((n: any) => { + const typeStr = typeof n === "string" ? n : n.type || n.name || ""; + return typeStr.replace(/^n8n-nodes-base\./, ""); + }).slice(0, 4), + nodeCount: Array.isArray(w.nodes) ? w.nodes.length : 0, + usageCount: 0, + viewsCount: w.views || w.totalViews || 0, + version: "1.0.0", + badges: ["community"], + sourceUrl: `https://n8n.io/workflows/${w.id}`, + canvasUrl: `/dashboard/canvas/new?n8nId=${w.id}`, + createdAt: w.createdAt, + })); + + const normalizedDify = (difyData.workflows || []).map((t: any) => { + const plugins = Array.isArray(t.deps_plugins) ? t.deps_plugins : []; + const pluginTags = plugins.map((p: string) => { + const parts = p.split("/"); + return parts[parts.length - 1] || p; + }); + + return { + id: t.id, + provider: "dify" as const, + providerName: "Dify.ai", + name: t.template_name || t.name || "Dify Workflow", + description: t.overview || t.description || "", + readme: t.readme || "", + author: t.publisher_unique_handle || (t.publisher_type === "organization" ? "Dify Team" : "Community"), + authorUrl: `https://marketplace.dify.ai/templates/${t.id}`, + icon: t.icon || "Layers", + iconBackground: t.icon_background || "#1C64F2", + categories: Array.isArray(t.categories) ? t.categories : ["operations"], + primaryCategory: (t.categories?.[0] || "operations").toLowerCase(), + tags: pluginTags, + pluginTags, + nodeCount: 0, + usageCount: typeof t.usage_count === "number" ? t.usage_count : 0, + viewsCount: 0, + version: t.version || "1.0.0", + badges: Array.isArray(t.badges) ? t.badges : [], + sourceUrl: `https://marketplace.dify.ai/templates/${t.id}`, + canvasUrl: `/dashboard/canvas/new?difyId=${t.id}`, + createdAt: t.created_at, + }; + }); + + // Interleave Studio, Dify, and n8n items for rich multi-provider presentation + const combinedWorkflows = []; + let i = 0, j = 0, k = 0; + + // If zero-key category, put Studio zero-key templates first + if (isZeroKeyCategory) { + while (k < studioData.length) { + combinedWorkflows.push(studioData[k++]); + } + } + + while ( + (i < normalizedN8n.length || j < normalizedDify.length || k < studioData.length) && + combinedWorkflows.length < perPage + ) { + if (k < studioData.length) combinedWorkflows.push(studioData[k++]); + if (j < normalizedDify.length && combinedWorkflows.length < perPage) combinedWorkflows.push(normalizedDify[j++]); + if (i < normalizedN8n.length && combinedWorkflows.length < perPage) combinedWorkflows.push(normalizedN8n[i++]); + if (i < normalizedN8n.length && combinedWorkflows.length < perPage) combinedWorkflows.push(normalizedN8n[i++]); + } + + const totalWorkflows = + (n8nData.total || 11620) + (difyData.total || 292) + (studioAll.length || totalStudioTemplates); + + const payload = { + provider: "all", + workflows: combinedWorkflows, + stats: { + total: totalWorkflows, + n8n: n8nData.total || 11620, + dify: difyData.total || 292, + studio: totalStudioTemplates, + }, + pagination: { + page, + perPage, + totalWorkflows: isZeroKeyCategory ? studioAll.length : totalWorkflows, + totalPages: Math.max(1, Math.ceil((isZeroKeyCategory ? studioAll.length : totalWorkflows) / perPage)), + }, + }; + + unifiedSearchCache.set(cacheKey, { data: payload, timestamp: Date.now() }); + return NextResponse.json({ success: true, ...payload }); + } catch (error: any) { + console.error("[Unified workflows search API error]:", error); + return NextResponse.json( + { + success: false, + error: error?.message || "Failed to search workflows", + workflows: [], + stats: { total: 11950, n8n: 11620, dify: 292, studio: totalStudioTemplates }, + pagination: { page, perPage, totalWorkflows: 0, totalPages: 0 }, + }, + { status: 502 } + ); + } +} diff --git a/src/app/dashboard/canvas/[id]/page.tsx b/src/app/dashboard/canvas/[id]/page.tsx index e9088ce..f8369de 100644 --- a/src/app/dashboard/canvas/[id]/page.tsx +++ b/src/app/dashboard/canvas/[id]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { use, useEffect, useMemo, useState } from "react"; +import React, { use, useEffect, useMemo, useState, useCallback } from "react"; import Link from "next/link"; import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; import { @@ -17,6 +17,7 @@ import { Share2, Activity, GitBranch, + Check, } from "lucide-react"; import { skillsApi } from "@/lib/api/skills"; import { executionsApi } from "@/lib/api/executions"; @@ -29,6 +30,9 @@ import { EmptyState } from "@/components/feedback/EmptyState"; import { toast } from "@/stores/toastStore"; import { AgentGraphDefinition } from "@/types/graph"; import { clsx } from "clsx"; +import { getPrefilledExecutionInput } from "@/lib/execution/inputHelper"; +import { JsonEditorModal } from "@/components/common/JsonEditorModal"; +import { Maximize2, ChevronDown, ChevronUp } from "lucide-react"; export default function CanvasEditorPage({ params }: { params: Promise<{ id: string }> }) { const { id } = use(params); @@ -84,10 +88,14 @@ export default function CanvasEditorPage({ params }: { params: Promise<{ id: str const [graph, setGraph] = useState(null); const [runInput, setRunInput] = useState("{\n \n}"); const [runInputError, setRunInputError] = useState(null); + const [isJsonModalOpen, setIsJsonModalOpen] = useState(false); + const [showInputBox, setShowInputBox] = useState(false); const [activeExecutionId, setActiveExecutionId] = useState(null); const [activePreviewId, setActivePreviewId] = useState(null); const [showDiff, setShowDiff] = useState(false); const [editingSubgraph, setEditingSubgraph] = useState(false); + const [syncStatus, setSyncStatus] = useState<"synced" | "saving" | "unsaved">("synced"); + const [isDirty, setIsDirty] = useState(false); // Version diff: working graph vs the last published version. const publishedGraph = skill?.publishedVersion?.graphDefinition ?? null; @@ -96,28 +104,58 @@ export default function CanvasEditorPage({ params }: { params: Promise<{ id: str [graph, publishedGraph] ); - // Initialize graph state from the draft once it loads. + // Initialize graph state and preloaded input from the draft once it loads. const [initialized, setInitialized] = useState(false); useEffect(() => { if (draft && !initialized) { setGraph(draft.graphDefinition ?? null); + setRunInput(getPrefilledExecutionInput(draft)); setInitialized(true); } }, [draft, initialized]); + const handleGraphChange = useCallback((newGraph: AgentGraphDefinition) => { + setGraph(newGraph); + setIsDirty(true); + setSyncStatus("unsaved"); + }, []); + + // Debounced Auto-Sync effect + useEffect(() => { + if (!isDirty || !graph || !draft) return; + const timer = setTimeout(async () => { + setSyncStatus("saving"); + try { + await skillsApi.update(id, { graphDefinition: graph, instructions: "Visual multi-agent graph." }); + setIsDirty(false); + setSyncStatus("synced"); + queryClient.invalidateQueries({ queryKey: ["skill", id] }); + } catch { + setSyncStatus("unsaved"); + } + }, 1200); + return () => clearTimeout(timer); + }, [graph, isDirty, draft, id, queryClient]); + const hasGraph = graph !== null && graph.nodes.length > 0; const saveMutation = useMutation({ mutationFn: () => { if (!graph) throw new Error("Nothing to save"); + setSyncStatus("saving"); return skillsApi.update(id, { graphDefinition: graph, instructions: "Visual multi-agent graph." }); }, onSuccess: () => { - toast.success("Graph saved", "Draft updated on the canvas."); + setIsDirty(false); + setSyncStatus("synced"); + toast.success("Graph synced", "All changes saved to draft."); queryClient.invalidateQueries({ queryKey: ["skill", id] }); queryClient.invalidateQueries({ queryKey: ["skills"] }); }, - onError: (e) => toast.error("Save failed", e instanceof Error ? e.message : undefined), + onError: (e) => { + setSyncStatus("unsaved"); + toast.error("Save failed", e instanceof Error ? e.message : undefined); + }, }); const runMutation = useMutation({ @@ -183,12 +221,20 @@ export default function CanvasEditorPage({ params }: { params: Promise<{ id: str } const inputData = parseInput(); if (!inputData) return; - // Persist the graph first so the executed version matches the canvas. - saveMutation.mutateAsync().then(() => { - runMutation.mutate({ versionId: draft.id, inputData }); - }).catch(() => { - // Save failure already toasted. - }); + + // Flush dirty changes quietly in background so latest graph runs + if (isDirty && graph) { + skillsApi.update(id, { graphDefinition: graph, instructions: "Visual multi-agent graph." }) + .then(() => { + setIsDirty(false); + setSyncStatus("synced"); + queryClient.invalidateQueries({ queryKey: ["skill", id] }); + }) + .catch(() => {}); + } + + // Directly trigger execution without blocking on manual save + runMutation.mutate({ versionId: draft.id, inputData }); }; const handlePreview = () => { @@ -241,6 +287,21 @@ export default function CanvasEditorPage({ params }: { params: Promise<{ id: str {skill.name} + {syncStatus === "saving" && ( + + SAVING… + + )} + {syncStatus === "unsaved" && ( + + UNSAVED + + )} + {syncStatus === "synced" && ( + + AUTO-SYNCED + + )} {hasGraph && ( GRAPH @@ -345,12 +406,12 @@ export default function CanvasEditorPage({ params }: { params: Promise<{ id: str Analytics runs {analytics.runs} - success{' '} + success{" "} = 70 ? "text-emerald-600 dark:text-emerald-400" : "text-amber-600 dark:text-amber-400")}> {analytics.successRate === null ? "—" : `${analytics.successRate}%`} - avg{' '} + avg{" "} {analytics.avgDurationMs >= 1000 ? `${(analytics.avgDurationMs / 1000).toFixed(1)}s` : `${Math.round(analytics.avgDurationMs)}ms`} @@ -359,30 +420,74 @@ export default function CanvasEditorPage({ params }: { params: Promise<{ id: str )} - {/* Input editor (collapsed when tracing) */} + {/* Sleek Execution Input Action Bar */} {!tracing && ( -
-
-
- Execution Input (JSON) +
+
+
+ + INPUT: + +
setIsJsonModalOpen(true)} + className="flex-1 min-w-0 px-3 py-1 rounded bg-slate-100 dark:bg-black/50 border border-slate-200 dark:border-indigo-950/60 text-[11px] text-slate-700 dark:text-slate-300 truncate cursor-pointer hover:border-indigo-400 transition-colors" + title="Click to expand full JSON editor" + > + {runInput.replace(/\s+/g, " ").slice(0, 120) || "{ }"} +
+
+ +
+ + +
- Sent as user input to the graph
-