From 25613e61763799ea218a7e993ddfe9a4e396ebd8 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:28:47 -0300 Subject: [PATCH 1/7] feat(playground): add codeExport.ts generator (curl/python/typescript) Implements the shared codeExport.ts foundation for the Playground Studio. Generates curl/python/typescript snippets for all 10 endpoints (chat.completions, completions, embeddings, images, audio.transcriptions, audio.speech, moderations, rerank, search, web.fetch). Always uses $OMNIROUTE_API_KEY placeholder (D11). --- src/lib/playground/codeExport.ts | 364 +++++++++++++++++++++++++++++++ 1 file changed, 364 insertions(+) create mode 100644 src/lib/playground/codeExport.ts diff --git a/src/lib/playground/codeExport.ts b/src/lib/playground/codeExport.ts new file mode 100644 index 0000000000..01bc457cc1 --- /dev/null +++ b/src/lib/playground/codeExport.ts @@ -0,0 +1,364 @@ +// src/lib/playground/codeExport.ts +import { z } from "zod"; + +/** + * Endpoint suportado pelo Playground Studio. Reflete os 10 endpoints da aba API + * + os endpoints consumidos pelas tabs Chat/Compare/Build/Search/Scrape. + */ +export type PlaygroundEndpoint = + | "chat.completions" + | "completions" + | "embeddings" + | "images" + | "audio.transcriptions" + | "audio.speech" + | "moderations" + | "rerank" + | "search" + | "web.fetch"; + +/** Linguagens de export suportadas. */ +export type ExportLanguage = "curl" | "python" | "typescript"; + +/** Mensagem chat single-turn ou multi-turn. */ +export interface ChatMessage { + role: "system" | "user" | "assistant" | "tool"; + content: string | Array<{ type: string; [k: string]: unknown }>; + name?: string; + tool_call_id?: string; +} + +/** Tool definition no formato OpenAI Function Calling. */ +export interface ToolDefinition { + type: "function"; + function: { + name: string; + description?: string; + parameters: Record; + }; +} + +/** Estado completo capturável pelo Playground (subset de campos por endpoint). */ +export interface PlaygroundState { + endpoint: PlaygroundEndpoint; + baseUrl: string; // ex.: "http://localhost:20128" + model?: string; // não aplicável a web.fetch + systemPrompt?: string; + messages?: ChatMessage[]; // chat/completions + prompt?: string; // completions/embeddings + query?: string; // search/rerank + url?: string; // web.fetch + params?: Partial<{ + temperature: number; + max_tokens: number; + top_p: number; + presence_penalty: number; + frequency_penalty: number; + seed: number; + stop: string | string[]; + response_format: { type: "text" | "json_object" | "json_schema"; json_schema?: unknown }; + }>; + tools?: ToolDefinition[]; + stream?: boolean; + // Search-specific + searchProvider?: string; + searchType?: "web" | "news"; + maxResults?: number; + // Scrape-specific + fetchProvider?: "firecrawl" | "jina-reader" | "tavily-search"; + fetchFormat?: "markdown" | "html" | "links" | "screenshot"; + fetchDepth?: 0 | 1 | 2; + // Rerank-specific + rerankModel?: string; + documents?: string[]; +} + +export const PlaygroundStateSchema = z.object({ + endpoint: z.enum([ + "chat.completions", + "completions", + "embeddings", + "images", + "audio.transcriptions", + "audio.speech", + "moderations", + "rerank", + "search", + "web.fetch", + ]), + baseUrl: z.string().min(1), + model: z.string().optional(), + systemPrompt: z.string().optional(), + messages: z.array(z.any()).optional(), + prompt: z.string().optional(), + query: z.string().optional(), + url: z.string().optional(), + params: z.record(z.string(), z.any()).optional(), + tools: z.array(z.any()).optional(), + stream: z.boolean().optional(), + searchProvider: z.string().optional(), + searchType: z.enum(["web", "news"]).optional(), + maxResults: z.number().int().optional(), + fetchProvider: z.enum(["firecrawl", "jina-reader", "tavily-search"]).optional(), + fetchFormat: z.enum(["markdown", "html", "links", "screenshot"]).optional(), + fetchDepth: z.union([z.literal(0), z.literal(1), z.literal(2)]).optional(), + rerankModel: z.string().optional(), + documents: z.array(z.string()).optional(), +}); + +/** Constante: placeholder de API key — NUNCA embutir key real. */ +export const API_KEY_PLACEHOLDER = "$OMNIROUTE_API_KEY"; + +/** + * Resolve o path HTTP a partir do endpoint (ex.: "chat.completions" → "/v1/chat/completions"). + * Exportado para reuso em testes. + */ +export function endpointToPath(endpoint: PlaygroundEndpoint): string { + const map: Record = { + "chat.completions": "/v1/chat/completions", + completions: "/v1/completions", + embeddings: "/v1/embeddings", + images: "/v1/images/generations", + "audio.transcriptions": "/v1/audio/transcriptions", + "audio.speech": "/v1/audio/speech", + moderations: "/v1/moderations", + rerank: "/v1/rerank", + search: "/v1/search", + "web.fetch": "/v1/web/fetch", + }; + return map[endpoint]; +} + +/** + * Build the request body for a given endpoint+state. + * Internal helper — returns plain object suitable for JSON.stringify. + */ +function buildBody(state: PlaygroundState): Record { + const { endpoint, model, params, tools, stream } = state; + + switch (endpoint) { + case "chat.completions": { + let messages: ChatMessage[]; + if (state.messages && state.messages.length > 0) { + messages = state.messages; + } else if (state.systemPrompt) { + messages = [ + { role: "system", content: state.systemPrompt }, + { role: "user", content: state.prompt ?? "Hello!" }, + ]; + } else { + messages = [{ role: "user", content: state.prompt ?? "Hello!" }]; + } + const body: Record = { + model: model ?? "gpt-4o-mini", + messages, + stream: stream ?? false, + }; + if (params) Object.assign(body, params); + if (tools && tools.length > 0) body.tools = tools; + return body; + } + + case "completions": { + const body: Record = { + model: model ?? "gpt-3.5-turbo-instruct", + prompt: state.prompt ?? "Hello,", + stream: stream ?? false, + }; + if (params) Object.assign(body, params); + return body; + } + + case "embeddings": { + return { + model: model ?? "text-embedding-3-small", + input: state.prompt ?? "Hello world", + }; + } + + case "images": { + return { + model: model ?? "dall-e-3", + prompt: state.prompt ?? "A beautiful sunset", + n: 1, + size: "1024x1024", + }; + } + + case "audio.transcriptions": { + // Note: actual usage needs multipart/form-data; shown as JSON for documentation + return { + model: model ?? "whisper-1", + file: "", + language: "en", + }; + } + + case "audio.speech": { + return { + model: model ?? "tts-1", + input: state.prompt ?? "Hello, world!", + voice: "alloy", + }; + } + + case "moderations": { + return { + model: model ?? "text-moderation-latest", + input: state.prompt ?? "Hello world", + }; + } + + case "rerank": { + return { + model: state.rerankModel ?? model ?? "rerank-english-v3.0", + query: state.query ?? "search query", + documents: state.documents ?? ["Document 1 text", "Document 2 text"], + top_n: 3, + }; + } + + case "search": { + const body: Record = { + query: state.query ?? "search query", + }; + if (model) body.model = model; + if (state.searchProvider) body.provider = state.searchProvider; + if (state.searchType) body.search_type = state.searchType; + if (state.maxResults) body.max_results = state.maxResults; + return body; + } + + case "web.fetch": { + const body: Record = { + url: state.url ?? "https://example.com", + }; + if (state.fetchProvider) body.provider = state.fetchProvider; + if (state.fetchFormat) body.format = state.fetchFormat; + if (state.fetchDepth != null) body.depth = state.fetchDepth; + return body; + } + } +} + +/** + * Escape a string for safe embedding inside single-quoted shell literals. + */ +function escSingleQuote(s: string): string { + return s.replace(/'/g, "'\\''"); +} + +/** + * Generate curl snippet for a given endpoint. + */ +function buildCurlSnippet(state: PlaygroundState): string { + const path = endpointToPath(state.endpoint); + const url = `${state.baseUrl}${path}`; + const body = buildBody(state); + const bodyJson = JSON.stringify(body, null, 2); + + const lines: string[] = [ + `# Set your API key: export OMNIROUTE_API_KEY="your-key-here"`, + `curl -s -X POST \\`, + ` "${url}" \\`, + ` -H "Authorization: Bearer ${API_KEY_PLACEHOLDER}" \\`, + ` -H "Content-Type: application/json" \\`, + ` -d '${escSingleQuote(JSON.stringify(body))}'`, + ]; + + // Also show pretty body as a comment for readability + const prettyLines = bodyJson.split("\n"); + const commentBlock = prettyLines.map((l) => `# ${l}`).join("\n"); + + return `${lines.join("\n")}\n\n# Request body (pretty-printed for reference):\n${commentBlock}`; +} + +/** + * Generate Python (requests) snippet for a given endpoint. + */ +function buildPythonSnippet(state: PlaygroundState): string { + const path = endpointToPath(state.endpoint); + const url = `${state.baseUrl}${path}`; + const body = buildBody(state); + const bodyJson = JSON.stringify(body, null, 2); + + const lines: string[] = [ + `# Set your API key: export ${API_KEY_PLACEHOLDER}="your-key-here"`, + `import os`, + `import json`, + `import requests`, + ``, + `api_key = os.environ["OMNIROUTE_API_KEY"]`, + ``, + `url = "${url}"`, + `headers = {`, + ` "Authorization": f"Bearer {api_key}",`, + ` "Content-Type": "application/json",`, + `}`, + ``, + `data = json.loads("""`, + bodyJson, + `""")`, + ``, + `response = requests.post(url, headers=headers, json=data)`, + `print(response.json())`, + ]; + + return lines.join("\n"); +} + +/** + * Generate TypeScript (fetch) snippet for a given endpoint. + */ +function buildTypescriptSnippet(state: PlaygroundState): string { + const path = endpointToPath(state.endpoint); + const url = `${state.baseUrl}${path}`; + const body = buildBody(state); + const bodyJson = JSON.stringify(body, null, 2); + + const lines: string[] = [ + `// Set your API key: export ${API_KEY_PLACEHOLDER}="your-key-here"`, + `const apiKey = process.env.OMNIROUTE_API_KEY ?? "";`, + ``, + `const url = "${url}";`, + `const body = ${bodyJson};`, + ``, + `const response = await fetch(url, {`, + ` method: "POST",`, + ` headers: {`, + ` "Authorization": \`Bearer \${apiKey}\`,`, + ` "Content-Type": "application/json",`, + ` },`, + ` body: JSON.stringify(body),`, + `});`, + ``, + `const data = await response.json();`, + `console.log(data);`, + ]; + + return lines.join("\n"); +} + +/** + * Gera código para uma linguagem específica a partir do estado atual. + * Sempre usa `API_KEY_PLACEHOLDER` para a API key (D11). + */ +export function exportCode(state: PlaygroundState, language: ExportLanguage): string { + switch (language) { + case "curl": + return buildCurlSnippet(state); + case "python": + return buildPythonSnippet(state); + case "typescript": + return buildTypescriptSnippet(state); + } +} + +/** Gera os 3 snippets de uma vez (atalho para o ExportCodeModal de UI). */ +export function exportAllLanguages(state: PlaygroundState): Record { + return { + curl: exportCode(state, "curl"), + python: exportCode(state, "python"), + typescript: exportCode(state, "typescript"), + }; +} From bda03ce5dc68511c0160581c2583b263157958b5 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:28:52 -0300 Subject: [PATCH 2/7] feat(playground): add promptImprover.ts meta-prompt helpers Adds META_SYSTEM_PROMPT, ImprovePromptRequestSchema, buildImproveChatBody, and parseImprovedContent for the Prompt Improver feature (D8). Handles system-only, prompt-only, and both-present scenarios with <>/<> markers. --- src/lib/playground/promptImprover.ts | 147 +++++++++++++++++++++++++++ 1 file changed, 147 insertions(+) create mode 100644 src/lib/playground/promptImprover.ts diff --git a/src/lib/playground/promptImprover.ts b/src/lib/playground/promptImprover.ts new file mode 100644 index 0000000000..9600382477 --- /dev/null +++ b/src/lib/playground/promptImprover.ts @@ -0,0 +1,147 @@ +// src/lib/playground/promptImprover.ts +import { z } from "zod"; + +export const ImprovePromptRequestSchema = z + .object({ + /** Conteúdo atual a melhorar; pelo menos um dos dois deve ser não-vazio. */ + system: z.string().max(50_000).optional(), + prompt: z.string().max(50_000).optional(), + /** Modelo configurado no painel Config — escolha do usuário (D8). */ + model: z.string().min(1), + /** Tom: "concise" (padrão) ou "detailed" (mais verboso). */ + tone: z.enum(["concise", "detailed"]).default("concise"), + }) + .refine((d) => Boolean(d.system?.trim() || d.prompt?.trim()), { + message: "At least one of `system` or `prompt` must be non-empty", + path: ["system"], + }); + +export type ImprovePromptRequest = z.infer; + +export interface ImprovePromptResult { + /** Versões melhoradas (apenas as fornecidas no request retornam). */ + improvedSystem?: string; + improvedPrompt?: string; + /** Tokens consumidos no call de melhoria (do usage). */ + tokensIn: number; + tokensOut: number; +} + +/** + * Meta-prompt fixado (D8). NÃO alterar sem aprovação do orquestrador. + * Inspirado em Anthropic Console Prompt Improver. + */ +export const META_SYSTEM_PROMPT = `\ +You are an expert prompt engineer. Rewrite the user-supplied prompts to be: +- Clear, specific, and unambiguous +- Free of redundancy and filler +- Structured (numbered steps, bullets) only when it materially helps the model +- Preserving the user's original intent — never invent new constraints + +Return ONLY the rewritten text. No explanations, no markdown wrappers. +If you receive both a system message and a user prompt, output them on separate +lines prefixed with "<>" and "<>" respectively.`; + +/** + * Monta o body de /v1/chat/completions para o improve call. + * Exportado para reuso em testes + na rota. + */ +export function buildImproveChatBody(req: ImprovePromptRequest): { + model: string; + messages: Array<{ role: "system" | "user"; content: string }>; + temperature: number; + max_tokens: number; + stream: false; +} { + const userContent: string[] = []; + if (req.system?.trim()) userContent.push(`<>\n${req.system}`); + if (req.prompt?.trim()) userContent.push(`<>\n${req.prompt}`); + const tonePrefix = + req.tone === "detailed" + ? "Be detailed and explicit in the rewrite.\n\n" + : "Be concise and direct.\n\n"; + + return { + model: req.model, + messages: [ + { role: "system", content: META_SYSTEM_PROMPT }, + { role: "user", content: tonePrefix + userContent.join("\n\n") }, + ], + temperature: 0.3, + max_tokens: 2048, + stream: false, + }; +} + +/** + * Parse da resposta do meta-LLM em improvedSystem/improvedPrompt. + * Retorna ambos quando ambos foram enviados; senão retorna só o presente. + * + * Edge cases: + * - Se raw não contém marcadores <> ou <>: + * - Se hadSystem e !hadPrompt → trata todo o conteúdo como improvedSystem + * - Se !hadSystem e hadPrompt → trata todo o conteúdo como improvedPrompt + * - Se hadSystem e hadPrompt → trata todo o conteúdo como improvedPrompt (fallback) + * - Whitespace é trimado antes de retornar + */ +export function parseImprovedContent( + raw: string, + hadSystem: boolean, + hadPrompt: boolean, +): { improvedSystem?: string; improvedPrompt?: string } { + const result: { improvedSystem?: string; improvedPrompt?: string } = {}; + + const systemMarker = "<>"; + const promptMarker = "<>"; + + const hasSystemMarker = raw.includes(systemMarker); + const hasPromptMarker = raw.includes(promptMarker); + + if (hasSystemMarker || hasPromptMarker) { + // Parse by markers + if (hasSystemMarker && hasPromptMarker) { + const sysStart = raw.indexOf(systemMarker) + systemMarker.length; + const promptStart = raw.indexOf(promptMarker); + + let sysContent: string; + if (sysStart < promptStart) { + sysContent = raw.substring(sysStart, promptStart).trim(); + } else { + sysContent = raw.substring(sysStart).trim(); + } + + const promptContentStart = promptStart + promptMarker.length; + let promptContent: string; + if (hasSystemMarker && raw.indexOf(systemMarker) > promptStart) { + promptContent = raw.substring(promptContentStart, raw.indexOf(systemMarker)).trim(); + } else { + promptContent = raw.substring(promptContentStart).trim(); + } + + if (hadSystem && sysContent) result.improvedSystem = sysContent; + if (hadPrompt && promptContent) result.improvedPrompt = promptContent; + } else if (hasSystemMarker) { + const sysStart = raw.indexOf(systemMarker) + systemMarker.length; + const sysContent = raw.substring(sysStart).trim(); + if (hadSystem && sysContent) result.improvedSystem = sysContent; + } else { + // Only <> marker + const promptStart = raw.indexOf(promptMarker) + promptMarker.length; + const promptContent = raw.substring(promptStart).trim(); + if (hadPrompt && promptContent) result.improvedPrompt = promptContent; + } + } else { + // No markers — assign to whichever field was provided + const trimmed = raw.trim(); + if (hadSystem && !hadPrompt) { + if (trimmed) result.improvedSystem = trimmed; + } else if (!hadSystem && hadPrompt) { + if (trimmed) result.improvedPrompt = trimmed; + } else if (hadSystem && hadPrompt) { + // Fallback: assign to prompt + if (trimmed) result.improvedPrompt = trimmed; + } + } + + return result; +} From 3c3a02ed42acc1a1b05c46310ea3333386100dc0 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:28:57 -0300 Subject: [PATCH 3/7] feat(playground): add types.ts with static provider pricing table Re-exports all playground types and adds static MODEL_PRICING_TABLE with 8-10 popular models labeled (estimated) for client-side cost estimation (D13). Exports getModelPricing and getProviderPricing helpers. --- src/lib/playground/types.ts | 114 ++++++++++++++++++++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 src/lib/playground/types.ts diff --git a/src/lib/playground/types.ts b/src/lib/playground/types.ts new file mode 100644 index 0000000000..4617a61889 --- /dev/null +++ b/src/lib/playground/types.ts @@ -0,0 +1,114 @@ +// src/lib/playground/types.ts +// Re-exports from codeExport + standalone pricing table + +export type { + PlaygroundEndpoint, + ExportLanguage, + ChatMessage, + ToolDefinition, + PlaygroundState, +} from "./codeExport"; + +export { PlaygroundStateSchema, API_KEY_PLACEHOLDER, exportCode, exportAllLanguages, endpointToPath } from "./codeExport"; + +export type { ImprovePromptRequest, ImprovePromptResult } from "./promptImprover"; + +export { + ImprovePromptRequestSchema, + META_SYSTEM_PROMPT, + buildImproveChatBody, + parseImprovedContent, +} from "./promptImprover"; + +/** + * Pricing entry for a model. + * Label "(estimated)" indicates these are approximate values for UI display (D13). + */ +export interface ProviderPricing { + /** Cost per 1k input tokens in USD. */ + inUsdPer1k: number; + /** Cost per 1k output tokens in USD. */ + outUsdPer1k: number; + /** Display label — always includes "(estimated)". */ + label: string; +} + +/** + * Static inline pricing table for popular models. + * Used in client-side cost estimation (D13). + * DO NOT use for billing — always labeled "(estimated)". + * + * Prices are approximate as of 2025. For real billing see /api/usage. + */ +export const MODEL_PRICING_TABLE: Record = { + "gpt-4o": { + inUsdPer1k: 0.0025, + outUsdPer1k: 0.01, + label: "(estimated)", + }, + "gpt-4o-mini": { + inUsdPer1k: 0.00015, + outUsdPer1k: 0.0006, + label: "(estimated)", + }, + "claude-sonnet-4-6": { + inUsdPer1k: 0.003, + outUsdPer1k: 0.015, + label: "(estimated)", + }, + "claude-opus-4-7": { + inUsdPer1k: 0.015, + outUsdPer1k: 0.075, + label: "(estimated)", + }, + "claude-haiku-4-5": { + inUsdPer1k: 0.0008, + outUsdPer1k: 0.004, + label: "(estimated)", + }, + "gemini-2.5-pro": { + inUsdPer1k: 0.00125, + outUsdPer1k: 0.01, + label: "(estimated)", + }, + "gemini-2.0-flash": { + inUsdPer1k: 0.0001, + outUsdPer1k: 0.0004, + label: "(estimated)", + }, + "deepseek-chat": { + inUsdPer1k: 0.00014, + outUsdPer1k: 0.00028, + label: "(estimated)", + }, + "llama-3.3-70b": { + inUsdPer1k: 0.00059, + outUsdPer1k: 0.00079, + label: "(estimated)", + }, + "mistral-large": { + inUsdPer1k: 0.002, + outUsdPer1k: 0.006, + label: "(estimated)", + }, +}; + +/** + * Look up pricing for a model by name. + * Returns null if not found (use upstream usage data instead). + */ +export function getModelPricing(model: string): ProviderPricing | null { + return MODEL_PRICING_TABLE[model] ?? null; +} + +/** + * Look up pricing for a model — alias with explicit `estimated: true` in return type. + * Required by §3 contract: `getProviderPricing(model): { inUsdPer1k, outUsdPer1k, estimated: true } | null`. + */ +export function getProviderPricing( + model: string, +): { inUsdPer1k: number; outUsdPer1k: number; estimated: true } | null { + const entry = MODEL_PRICING_TABLE[model]; + if (!entry) return null; + return { inUsdPer1k: entry.inUsdPer1k, outUsdPer1k: entry.outUsdPer1k, estimated: true }; +} From 52c2d1cb75793c9f2daa9b270f0580efb50adbfd Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:29:04 -0300 Subject: [PATCH 4/7] feat(schemas): add shared playground and searchTools Zod schemas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PlaygroundPresetRowSchema, PlaygroundPresetCreateSchema, PlaygroundPresetUpdateSchema, PlaygroundPresetListItemSchema, ToolDefinitionSchema, StructuredOutputSchema, StreamMetricsSchema (src/shared/schemas/playground.ts) and SearchProviderCatalogItemSchema, SearchProviderCatalogResponseSchema, ScrapeResultSchema (src/shared/schemas/searchTools.ts). All z.record() calls use the Zod v4 two-argument form z.record(z.string(), z.any()) (§17.1). --- src/shared/schemas/playground.ts | 68 +++++++++++++++++++++++++++++++ src/shared/schemas/searchTools.ts | 38 +++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 src/shared/schemas/playground.ts create mode 100644 src/shared/schemas/searchTools.ts diff --git a/src/shared/schemas/playground.ts b/src/shared/schemas/playground.ts new file mode 100644 index 0000000000..af7cdac650 --- /dev/null +++ b/src/shared/schemas/playground.ts @@ -0,0 +1,68 @@ +// src/shared/schemas/playground.ts +import { z } from "zod"; + +/** Row da tabela playground_presets. */ +export const PlaygroundPresetRowSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(100), + endpoint: z.string().min(1), + model: z.string().min(1), + system: z.string().max(50_000).nullable(), + params_json: z.string(), // JSON serializado (parsed na rota) + created_at: z.string().datetime(), +}); +export type PlaygroundPresetRow = z.infer; + +/** Body de POST /api/playground/presets. */ +export const PlaygroundPresetCreateSchema = z.object({ + name: z.string().min(1).max(100), + endpoint: z.string().min(1), + model: z.string().min(1), + system: z.string().max(50_000).nullable().optional(), + params: z.record(z.string(), z.any()).default({}), +}); + +/** Body de PUT /api/playground/presets/[id]. */ +export const PlaygroundPresetUpdateSchema = PlaygroundPresetCreateSchema.partial(); + +export const PlaygroundPresetListItemSchema = z.object({ + id: z.string().uuid(), + name: z.string(), + endpoint: z.string(), + model: z.string(), + system: z.string().nullable(), + params: z.record(z.string(), z.any()), + created_at: z.string().datetime(), +}); +export type PlaygroundPresetListItem = z.infer; + +/** Tool definition aceito pelo Build tab. Validação client-side. */ +export const ToolDefinitionSchema = z.object({ + type: z.literal("function"), + function: z.object({ + name: z.string().min(1).max(64), + description: z.string().max(1024).optional(), + parameters: z.record(z.string(), z.any()), + }), +}); + +/** JSON schema do response_format (Structured Output). Validação client-side. */ +export const StructuredOutputSchema = z.object({ + type: z.literal("json_schema"), + json_schema: z.object({ + name: z.string().min(1).max(64), + schema: z.record(z.string(), z.any()), + strict: z.boolean().optional(), + }), +}); + +/** Stream metrics — uma medição por coluna/mensagem. */ +export const StreamMetricsSchema = z.object({ + ttftMs: z.number().nonnegative().nullable(), // null = não chegou primeiro chunk ainda + totalMs: z.number().nonnegative().nullable(), + tokensOut: z.number().int().nonnegative(), + tokensIn: z.number().int().nonnegative(), + tps: z.number().nonnegative().nullable(), // tokens/segundo (out/totalSec) + costUsd: z.number().nonnegative().nullable(), +}); +export type StreamMetrics = z.infer; diff --git a/src/shared/schemas/searchTools.ts b/src/shared/schemas/searchTools.ts new file mode 100644 index 0000000000..2463a11a0f --- /dev/null +++ b/src/shared/schemas/searchTools.ts @@ -0,0 +1,38 @@ +// src/shared/schemas/searchTools.ts +import { z } from "zod"; + +/** Item exposto pelo /api/search/providers (estendido em F4). */ +export const SearchProviderCatalogItemSchema = z.object({ + id: z.string(), + name: z.string(), + /** "search" para os 12 search providers; "fetch" para firecrawl/jina/tavily-fetch. */ + kind: z.enum(["search", "fetch"]), + costPerQuery: z.number().nonnegative(), + freeMonthlyQuota: z.number().int().nonnegative(), + searchTypes: z.array(z.string()).optional(), // só search + fetchFormats: z.array(z.string()).optional(), // só fetch + /** "configured" = creds presentes; "missing" = sem creds; "rate_limited" = todas as keys em cooldown. */ + status: z.enum(["configured", "missing", "rate_limited"]), + /** Link para configurar provider. */ + configureHref: z.string().default("/dashboard/providers"), +}); +export type SearchProviderCatalogItem = z.infer; + +export const SearchProviderCatalogResponseSchema = z.object({ + providers: z.array(SearchProviderCatalogItemSchema), +}); + +/** ScrapeResult mostrado na aba Scrape (já é a resposta de /v1/web/fetch, mas tipado). */ +export const ScrapeResultSchema = z.object({ + provider: z.string(), + url: z.string(), + content: z.string(), + links: z.array(z.string()), + metadata: z + .object({ + title: z.string().nullable(), + description: z.string().nullable(), + }) + .nullable(), + screenshot_url: z.string().nullable(), +}); From 1a99f0058eaf2cdfd78ad8e2e53f42ff624dce47 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Wed, 27 May 2026 21:29:10 -0300 Subject: [PATCH 5/7] feat(playground): add MarkdownMessage component with react-markdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renders markdown safely using react-markdown ^10.1.0. Supports code blocks (pre/code fallback — no syntax highlighter), tables, lists, links, headings, blockquotes. Script tags appear as literal text (not executed) by default react-markdown behavior — no XSS possible (D15, §17.3). --- .../playground/components/MarkdownMessage.tsx | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx diff --git a/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx new file mode 100644 index 0000000000..7dacd87837 --- /dev/null +++ b/src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx @@ -0,0 +1,157 @@ +"use client"; + +// src/app/(dashboard)/dashboard/playground/components/MarkdownMessage.tsx +import React from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import type { Components } from "react-markdown"; + +interface MarkdownMessageProps { + content: string; + className?: string; +} + +/** + * MarkdownMessage — renders markdown safely in the Playground chat. + * + * Security notes: + * - react-markdown does NOT render raw HTML by default, so "; + const el = renderMarkdown(xssContent); + + // Should NOT contain a