diff --git a/app/biome.json b/app/biome.json index 5b253fa..b861323 100644 --- a/app/biome.json +++ b/app/biome.json @@ -1,48 +1,48 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": false - }, - "formatter": { - "enabled": true, - "indentStyle": "tab", - "indentWidth": 2, - "lineWidth": 100 - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "correctness": { - "useExhaustiveDependencies": "warn", - "noUnusedImports": "warn", - "noUnusedVariables": "warn" - }, - "style": { - "noNonNullAssertion": "off" - }, - "suspicious": { - "noExplicitAny": "warn", - "noArrayIndexKey": "warn" - }, - "a11y": { - "useButtonType": "warn" - }, - "performance": { - "noImgElement": "warn" - }, - "security": { - "noDangerouslySetInnerHtml": "warn" - } - } - }, - "javascript": { - "formatter": { - "quoteStyle": "double", - "semicolons": "always", - "trailingCommas": "es5" - } - } + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": false + }, + "formatter": { + "enabled": true, + "indentStyle": "tab", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "useExhaustiveDependencies": "warn", + "noUnusedImports": "warn", + "noUnusedVariables": "warn" + }, + "style": { + "noNonNullAssertion": "off" + }, + "suspicious": { + "noExplicitAny": "warn", + "noArrayIndexKey": "warn" + }, + "a11y": { + "useButtonType": "warn" + }, + "performance": { + "noImgElement": "warn" + }, + "security": { + "noDangerouslySetInnerHtml": "warn" + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always", + "trailingCommas": "es5" + } + } } diff --git a/app/next.config.ts b/app/next.config.ts index 35a9619..41552e6 100644 --- a/app/next.config.ts +++ b/app/next.config.ts @@ -1,18 +1,19 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - reactCompiler: true, - // Proxy tier2 streaming tool requests directly to the Python runner, - // bypassing the Next.js API route runtime which kills long-lived streams. - async rewrites() { - const runnerUrl = process.env.TOOL_RUNNER_URL || "http://localhost:9080"; - return [ - { - source: "/api/tools-stream/:toolId", - destination: `${runnerUrl}/api/tools/:toolId`, - }, - ]; - }, + + reactCompiler: true, + // Proxy tier2 streaming tool requests directly to the Python runner, + // bypassing the Next.js API route runtime which kills long-lived streams. + async rewrites() { + const runnerUrl = process.env.TOOL_RUNNER_URL || "http://localhost:9080"; + return [ + { + source: "/api/tools-stream/:toolId", + destination: `${runnerUrl}/api/tools/:toolId`, + }, + ]; + }, }; export default nextConfig; diff --git a/app/package-lock.json b/app/package-lock.json index 84038a9..5e07519 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -19,7 +19,8 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" + "remark-gfm": "^4.0.1", + "undici": "^8.1.0" }, "devDependencies": { "@biomejs/biome": "2.4.6", @@ -9251,6 +9252,15 @@ "integrity": "sha512-yDJTmhydvl5lJzBmy/hyOAA0d+aqCBuwl818haVdYCRrWV84o7YyeVm4QlVHStqNrrJSTb6jKuFAVqAFsr+K3Q==", "license": "MIT" }, + "node_modules/undici": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.1.0.tgz", + "integrity": "sha512-E9MkTS4xXLnRPYqxH2e6Hr2/49e7WFDKczKcCaFH4VaZs2iNvHMqeIkyUAD9vM8kujy9TjVrRlQ5KkdEJxB2pw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/app/package.json b/app/package.json index 6b8d9ab..b492e40 100644 --- a/app/package.json +++ b/app/package.json @@ -1,36 +1,36 @@ { - "name": "oxtools", - "version": "1.0.0", - "private": true, - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "lint": "biome check", - "format": "biome format --write" - }, - "dependencies": { - "@ansospace/ui": "^0.0.3", - "@icons-pack/react-simple-icons": "^13.13.0", - "idb-keyval": "^6.2.2", - "lucide-react": "^1.7.0", - "mermaid": "^11.14.0", - "next": "16.2.2", - "openai": "^6.33.0", - "react": "19.2.4", - "react-dom": "19.2.4", - "react-markdown": "^10.1.0", - "rehype-highlight": "^7.0.2", - "remark-gfm": "^4.0.1" - }, - "devDependencies": { - "@biomejs/biome": "2.4.6", - "@tailwindcss/postcss": "^4", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "babel-plugin-react-compiler": "1.0.0", - "tailwindcss": "^4", - "typescript": "^5" - } + "name": "oxtools", + "version": "1.0.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "biome check", + "format": "biome format --write" + }, + "dependencies": { + "@ansospace/ui": "^0.0.3", + "@icons-pack/react-simple-icons": "^13.13.0", + "idb-keyval": "^6.2.2", + "lucide-react": "^1.7.0", + "mermaid": "^11.14.0", + "next": "16.2.2", + "openai": "^6.33.0", + "react": "19.2.4", + "react-dom": "19.2.4", + "react-markdown": "^10.1.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1" + }, + "devDependencies": { + "@biomejs/biome": "2.4.6", + "@tailwindcss/postcss": "^4", + "@types/node": "^20", + "@types/react": "^19", + "@types/react-dom": "^19", + "babel-plugin-react-compiler": "1.0.0", + "tailwindcss": "^4", + "typescript": "^5" + } } diff --git a/app/postcss.config.mjs b/app/postcss.config.mjs index 79bcf13..6ba14df 100644 --- a/app/postcss.config.mjs +++ b/app/postcss.config.mjs @@ -1,8 +1,8 @@ /** @type {import('postcss-load-config').Config} */ const config = { - plugins: { - "@tailwindcss/postcss": {}, - }, + plugins: { + "@tailwindcss/postcss": {}, + }, }; export default config; diff --git a/app/src/app/api/tools/[toolId]/route.ts b/app/src/app/api/tools/[toolId]/route.ts index 7539717..247430d 100644 --- a/app/src/app/api/tools/[toolId]/route.ts +++ b/app/src/app/api/tools/[toolId]/route.ts @@ -1,24 +1,12 @@ -import type { NextRequest } from "next/server"; -import { NextResponse } from "next/server"; - +import { type NextRequest, NextResponse } from "next/server"; import { createToolRoute } from "@/lib/create-tool-route"; import { getToolById } from "@/lib/tools/registry"; - -// Allow long-running tool executions (up to 5 min locally, 300s on Vercel) +// Issue 5: maxDuration is set to 300s (5 minutes). +// We align the AbortController timeout to match this limit exactly, +// so that requests gracefully abort rather than hanging when Vercel kills the function. export const maxDuration = 300; -// Force dynamic rendering — prevents Next.js from evaluating this route -// at build time (which would fail without OXLO_API_KEY in CI) export const dynamic = "force-dynamic"; -/** - * Dynamic API route for ALL tools. - * - * TIER 1: Dispatches to createToolRoute (calls Oxlo LLM API directly). - * TIER 2: Proxies to the unified Python tool runner on port 9080. - * - * The unified runner handles ALL Python tools on ONE port. - * Route: POST http://localhost:9080/api/tools/{toolId} - */ export async function POST( request: NextRequest, { params }: { params: Promise<{ toolId: string }> } @@ -33,12 +21,10 @@ export async function POST( ); } - // --- Tier 2: Proxy to unified Python tool runner --- if (tool.tier === "tier2") { return proxyToToolRunner(request, toolId); } - // --- Tier 1: Handle via LLM prompt (default) --- const handler = createToolRoute({ requiredFields: tool.requiredFields, buildSystemPrompt: tool.buildSystemPrompt, @@ -46,18 +32,9 @@ export async function POST( defaultModel: tool.defaultModel, errorMessage: `Failed to execute ${tool.name}`, }); - return handler(request); } -/** - * Proxy a request to the unified Python tool runner. - * - * ALL Tier 2 tools run on ONE service (port 9080) via: - * POST http://runner:9080/api/tools/{toolId} - * - * No more port-per-tool. One port, one container, unlimited tools. - */ async function proxyToToolRunner(request: NextRequest, toolId: string) { const runnerUrl = process.env.TOOL_RUNNER_URL || "http://localhost:9080"; const targetUrl = `${runnerUrl}/api/tools/${toolId}`; @@ -66,12 +43,12 @@ async function proxyToToolRunner(request: NextRequest, toolId: string) { const body = await request.text(); const contentType = request.headers.get("content-type") || "application/json"; - // 10 minute timeout — security scans with LLM retries can take 5-10 min + // 5 minute timeout — security scans with LLM retries can take 5 min const response = await fetch(targetUrl, { method: "POST", headers: { "Content-Type": contentType }, body, - signal: AbortSignal.timeout(600_000), + signal: AbortSignal.timeout(300_000), }); if (!response.ok) { diff --git a/app/src/app/tools/[toolId]/page.tsx b/app/src/app/tools/[toolId]/page.tsx index 154a99e..7bbe287 100644 --- a/app/src/app/tools/[toolId]/page.tsx +++ b/app/src/app/tools/[toolId]/page.tsx @@ -1,9 +1,8 @@ "use client"; - import { Button, Label, Textarea } from "@ansospace/ui"; import { ArrowUpRight, Crown, Lock, Play, X } from "lucide-react"; import { notFound, useParams } from "next/navigation"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CodeEditor } from "@/components/code-editor"; import { ResultViewer } from "@/components/result-viewer"; import { ToolLayout } from "@/components/tool-layout"; @@ -20,21 +19,22 @@ import type { InputFieldConfig } from "@/types"; * Contributors only need to create a ToolDefinition file in * src/lib/tools/.ts - this page handles the rest. */ + export default function DynamicToolPage() { const params = useParams<{ toolId: string }>(); const tool = getToolById(params.toolId); - if (!tool || tool.status !== "active") { notFound(); } - return ; } function ToolPageContent({ toolId }: { toolId: string }) { const tool = getToolById(toolId)!; + // Model is hardcoded per tool - no user selection const model = tool.defaultModel || "llama-3.3-70b"; + const [fields, setFields] = useState>(() => { const initial: Record = {}; for (const input of tool.inputs) { @@ -50,6 +50,7 @@ function ToolPageContent({ toolId }: { toolId: string }) { const { result, isLoading, error, execute, setResult } = useToolExecution({ apiEndpoint: `${apiBase}/${tool.id}`, toolId: tool.id, + ...(tool.timeoutMs && { timeoutMs: tool.timeoutMs }), }); const setField = useCallback((key: string, value: string) => { @@ -69,13 +70,32 @@ function ToolPageContent({ toolId }: { toolId: string }) { ); const { canExecute, getToolUsage, trackExecution, redirectToUpgrade } = useAuth(); - const toolUsage = getToolUsage(tool.id); - const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); - // Defer localStorage-dependent rendering to prevent hydration mismatch. - // Server always renders the "Run" button; limit state only applies after mount. + + // ── HYDRATION FIX ────────────────────────────────────────────────────────── + // getToolUsage reads from localStorage/cookies which don't exist on the server. + // We defer the real value until after mount so SSR and client agree on the + // initial render (both see the zero/default state), then the effect below + // runs on the client and updates to the real value. const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); + // Always call the hook (Rules of Hooks) — but only use its value post-mount. + const rawToolUsage = getToolUsage(tool.id); + const toolUsage = mounted + ? rawToolUsage + : { + // Issue 8: use optional chaining + safe defaults to prevent SSR TypeError + // when getToolUsage returns undefined or an incomplete object before hydration. + used: 0, + limit: rawToolUsage?.limit ?? 0, + remaining: rawToolUsage?.limit ?? 0, + limitReached: false, + plan: rawToolUsage?.plan ?? "free", + }; + // ── END HYDRATION FIX ────────────────────────────────────────────────────── + + const [showUpgradeDialog, setShowUpgradeDialog] = useState(false); + // Show popup when limit is newly reached useEffect(() => { if (mounted && toolUsage.limitReached) { @@ -101,6 +121,11 @@ function ToolPageContent({ toolId }: { toolId: string }) { // Check if all required fields are filled const isReady = tool.requiredFields.every((field) => fields[field]?.trim()); + const uploadedImageSrc = useMemo(() => { + const imageKey = tool.inputs.find((i) => i.type === "image")?.key; + return imageKey ? fields[imageKey] : undefined; + }, [tool.inputs, fields]); + return ( <>
- + {/* Issue 19: derive the image src from whichever input has type==='image', + instead of hardcoding fields['image']. */} +
@@ -292,7 +327,6 @@ function ToolPageContent({ toolId }: { toolId: string }) { // --------------------------------------------------------------------------- // Generic input renderer - renders any InputFieldConfig // --------------------------------------------------------------------------- - function InputField({ config, value, @@ -315,7 +349,6 @@ function InputField({ /> ); - case "textarea": return (
@@ -329,7 +362,6 @@ function InputField({ />
); - case "select": return (
@@ -349,7 +381,6 @@ function InputField({
); - case "text": return (
@@ -363,7 +394,6 @@ function InputField({ />
); - case "image": return (
diff --git a/app/src/components/result-viewer.tsx b/app/src/components/result-viewer.tsx index 8231460..52e4e3c 100644 --- a/app/src/components/result-viewer.tsx +++ b/app/src/components/result-viewer.tsx @@ -9,14 +9,18 @@ import { ExternalLink, Key, Lock, + Maximize2, + Minimize2, ShieldAlert, Sparkles, Timer, + Upload, } from "lucide-react"; import mermaid from "mermaid"; import { useCallback, useEffect, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import type { ToolError } from "@/hooks/use-tool-execution"; mermaid.initialize({ startOnLoad: false, @@ -65,14 +69,13 @@ function MermaidViewer({ chart }: { chart: string }) { ); } -import type { ToolError } from "@/hooks/use-tool-execution"; - interface ResultViewerProps { result: string; isLoading?: boolean; error?: ToolError | string | null; streaming?: boolean; onOpenSettings?: () => void; + uploadedImageSrc?: string; } const ERROR_CONFIG: Record = { @@ -182,15 +185,16 @@ function ErrorDisplay({ ); } -function CopyButton({ text }: { text: string }) { +function CopyButton({ text, className }: { text: string; className?: string }) { + const [copied, setCopied] = useState(false); + const handleCopy = useCallback(async () => { - await navigator.clipboard.writeText(text); - const btn = document.activeElement as HTMLButtonElement; - if (btn) { - btn.dataset.copied = "true"; - setTimeout(() => { - btn.dataset.copied = "false"; - }, 2000); + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard access denied — silently fail } }, [text]); @@ -198,24 +202,233 @@ function CopyButton({ text }: { text: string }) { ); } +function PipelineStepsBar({ + meta, + isLoading, +}: { + meta: Record; + isLoading?: boolean; +}) { + return null; // replace with real steps bar later +} + +function SideBySideComparison({ + uploadedSrc, + generatedCode, + isFullscreen, +}: { + uploadedSrc: string; + generatedCode: string; + isFullscreen: boolean; +}) { + const height = isFullscreen ? "h-full" : "h-[580px]"; + return ( +
+ {/* Original screenshot panel */} +
+
+ + + Original + +
+
+ Original screenshot +
+
+ {/* Generated code panel */} +
+
+ + + Generated + +
+