diff --git a/apps/mesh/src/api/routes/org-scoped.ts b/apps/mesh/src/api/routes/org-scoped.ts index c262a06e30..5a14404c4f 100644 --- a/apps/mesh/src/api/routes/org-scoped.ts +++ b/apps/mesh/src/api/routes/org-scoped.ts @@ -19,6 +19,7 @@ import { createDownstreamTokenRoutes } from "./downstream-token"; import { createFileUploadRoutes } from "./file-uploads"; import { createKVRoutes } from "./kv"; import { createOrgFsRoutes } from "./org-fs"; +import { createPromptExplorerRoutes } from "./prompt-explorer"; import { createOrgScopedWellKnownProtectedResourceRoutes } from "./oauth-proxy"; import { createSsoRoutes } from "./org-sso"; import { createProxyRoutes } from "./proxy"; @@ -96,6 +97,7 @@ export const createOrgScopedApi = (deps: OrgScopedDeps) => { ); // /api/:org/fs/:volume/... app.route("/sandbox", createSandboxRoutes()); // /api/:org/sandbox/:virtualMcpId/:branch/* app.route("/", createHomeNextActionsRoutes()); + app.route("/", createPromptExplorerRoutes()); // /api/:org/prompt-explorer/stream app.route("/deco-sites", createDecoSitesOrgRoutes()); // /api/:org/deco-sites app.route("/sso", createSsoRoutes()); // /api/:org/sso/* (renamed from /api/org-sso) app.route( diff --git a/apps/mesh/src/api/routes/prompt-explorer.ts b/apps/mesh/src/api/routes/prompt-explorer.ts new file mode 100644 index 0000000000..3a69254255 --- /dev/null +++ b/apps/mesh/src/api/routes/prompt-explorer.ts @@ -0,0 +1,140 @@ +/** + * Prompt Explorer Route + * + * Streams an AI-enriched version of a user's draft prompt. The frontend's + * "Explore" modal POSTs a rough draft; a *fast* model rewrites it into a + * richer, more complete prompt with [bracketed] fill-in placeholders, and the + * result (plus any model reasoning) is streamed back as SSE frames. + * + * Route: POST /api/:org/prompt-explorer/stream + * + * Deliberately lightweight: it calls the `ai` SDK `streamText` directly (no + * thread persistence / NATS / StreamBuffer). Mirrors the direct-streamText SSE + * pattern in `openai-compat.ts`. + */ + +import { streamText } from "ai"; +import { Hono } from "hono"; +import { HTTPException } from "hono/http-exception"; +import { streamSSE } from "hono/streaming"; +import { resolveTier } from "@/core/resolve-tier"; +import type { StudioContext } from "@/core/studio-context"; +import { buildPromptExplorerSystem } from "@/lib/prompt-explorer-system"; + +type Variables = { meshContext: StudioContext }; + +/** Cap to keep the model prompt (and our memory) bounded. */ +const MAX_DRAFT_LENGTH = 20_000; + +export const createPromptExplorerRoutes = () => { + const app = new Hono<{ Variables: Variables }>(); + + app.post("/prompt-explorer/stream", async (c) => { + const ctx = c.get("meshContext"); + // Auth + org-membership are already enforced by `resolveOrgFromPath` + // (mounted on every org-scoped route). This isn't a tool, so there's no + // `toolName` for `ctx.access.check()` to authorize against — calling it + // with no resources throws ForbiddenError. The userId/orgId guards below + // mirror the sibling `thread-outputs` route. + const userId = ctx.auth?.user?.id; + if (!userId) { + throw new HTTPException(401, { message: "Unauthorized" }); + } + + const orgId = ctx.organization?.id; + if (!orgId) { + throw new HTTPException(400, { message: "Organization required" }); + } + + const body = await c.req + .json<{ draft?: unknown }>() + .catch(() => ({}) as { draft?: unknown }); + const draft = + typeof body.draft === "string" + ? body.draft.slice(0, MAX_DRAFT_LENGTH) + : ""; + if (draft.trim().length === 0) { + throw new HTTPException(400, { message: "draft is required" }); + } + + // Grow the prompt gradually each iteration: ~3x for short ideas, easing to + // ~2x as it gets longer, so a one-liner doesn't balloon into a wall of + // text. `maxChars` is the soft target (enforced via the system prompt); + // `maxOutputTokens` is the hard ceiling so the model can't run away even if + // it ignores the instruction. + const sourceChars = draft.length; + const factor = sourceChars < 300 ? 3 : sourceChars < 1000 ? 2.5 : 2; + const maxChars = Math.round(sourceChars * factor); + // ~3 chars/token (conservative → a little headroom over the target so the + // model can finish its sentence rather than getting cut mid-word). + const maxOutputTokens = Math.min( + 1200, + Math.max(160, Math.ceil(maxChars / 3)), + ); + + const system = buildPromptExplorerSystem({ + userName: ctx.auth?.user?.name, + userEmail: ctx.auth?.user?.email, + orgName: ctx.organization?.name, + maxChars, + }); + + return streamSSE(c, async (stream) => { + try { + const tier = await resolveTier(ctx, "fast"); + const provider = await ctx.aiProviders.activate( + tier.credentialId, + orgId, + ); + const model = provider.aiSdk.languageModel(tier.modelId); + + const result = streamText({ + model, + system, + // Frame the draft as material to TRANSFORM, not a request to answer — + // otherwise the model "helpfully" replies in the second person + // ("Você quer…", "Describe…") instead of rewriting the user's prompt. + prompt: `Below is the rough draft of MY prompt. Rewrite it as an improved version of MY prompt, in my own voice (same grammatical person and language as the draft). Output only the rewritten prompt.\n\n--- MY DRAFT ---\n${draft}`, + maxOutputTokens, + temperature: 0.4, + abortSignal: c.req.raw.signal, + }); + + for await (const part of result.fullStream) { + if (part.type === "reasoning-delta") { + await stream.writeSSE({ + data: JSON.stringify({ type: "reasoning", text: part.text }), + }); + } else if (part.type === "text-delta") { + await stream.writeSSE({ + data: JSON.stringify({ type: "text", text: part.text }), + }); + } else if (part.type === "error") { + const message = + part.error instanceof Error + ? part.error.message + : String(part.error); + await stream.writeSSE({ + data: JSON.stringify({ type: "error", message }), + }); + } else if (part.type === "finish") { + // Stop as soon as the overall finish arrives rather than waiting + // for the iterator to complete on its own, then close the stream. + break; + } + } + await stream.writeSSE({ data: JSON.stringify({ type: "finish" }) }); + } catch (err) { + // Surface a friendly error frame (e.g. TierUnavailableError when the + // org has no provider connected) rather than dropping the stream. + const message = + err instanceof Error ? err.message : "Failed to enrich prompt"; + await stream.writeSSE({ + data: JSON.stringify({ type: "error", message }), + }); + } + }); + }); + + return app; +}; diff --git a/apps/mesh/src/lib/prompt-explorer-system.test.ts b/apps/mesh/src/lib/prompt-explorer-system.test.ts new file mode 100644 index 0000000000..f9ea5331dd --- /dev/null +++ b/apps/mesh/src/lib/prompt-explorer-system.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "bun:test"; +import { buildPromptExplorerSystem } from "./prompt-explorer-system"; + +describe("buildPromptExplorerSystem", () => { + it("includes the user's name, email, and org", () => { + const system = buildPromptExplorerSystem({ + userName: "Ada Lovelace", + userEmail: "ada@example.com", + orgName: "Analytical Engines", + }); + expect(system).toContain("Ada Lovelace"); + expect(system).toContain("ada@example.com"); + expect(system).toContain('"Analytical Engines"'); + }); + + it("expands into a concrete, ready-to-use prompt with no bracket placeholders", () => { + const system = buildPromptExplorerSystem({ userName: "X" }); + // No fill-in blanks: the model makes concrete choices itself. + expect(system).toMatch(/no placeholders|never use square brackets/i); + expect(system).toMatch(/ready[- ]to[- ]use|as-is|run immediately/i); + // Each iteration must grow, not repeat (fixes "v2 identical to v1"). + expect(system).toMatch(/expand/i); + }); + + it("falls back gracefully when identity fields are missing", () => { + const system = buildPromptExplorerSystem({}); + expect(system).toContain("an unknown user"); + expect(system).not.toContain("in the organization"); + }); + + it("uses email alone when name is absent", () => { + const system = buildPromptExplorerSystem({ userEmail: "only@email.com" }); + expect(system).toContain("only@email.com"); + }); + + it("includes a gradual-growth length budget when maxChars is given", () => { + const system = buildPromptExplorerSystem({ userName: "X", maxChars: 216 }); + expect(system).toContain("216 characters"); + expect(system).toMatch(/gradual/i); + }); + + it("instructs the model to keep the user's voice/person and language", () => { + const system = buildPromptExplorerSystem({ userName: "X" }); + expect(system).toMatch(/first person/i); + expect(system).toMatch(/same language/i); + // Explicitly warns against flipping to second-person address. + expect(system).toMatch(/You want to|second person/i); + }); + + it("omits the length budget when maxChars is absent or zero", () => { + expect(buildPromptExplorerSystem({ userName: "X" })).not.toMatch( + /characters long/, + ); + expect( + buildPromptExplorerSystem({ userName: "X", maxChars: 0 }), + ).not.toMatch(/characters long/); + }); +}); diff --git a/apps/mesh/src/lib/prompt-explorer-system.ts b/apps/mesh/src/lib/prompt-explorer-system.ts new file mode 100644 index 0000000000..b926150ea8 --- /dev/null +++ b/apps/mesh/src/lib/prompt-explorer-system.ts @@ -0,0 +1,55 @@ +/** + * System prompt for the Prompt Explorer feature. + * + * Kept as a pure function (no StudioContext, no I/O) so it can be unit-tested + * directly — the route handler extracts the identity fields from `ctx` and + * passes them in. + */ + +export interface PromptExplorerIdentity { + userName?: string; + userEmail?: string; + orgName?: string; + /** + * Soft length budget: keep the rewrite at most ~this many characters. The + * route derives it from the source length so each iteration grows the prompt + * gradually (~2-3x) instead of ballooning a one-liner into a wall of text. + */ + maxChars?: number; +} + +export function buildPromptExplorerSystem(id: PromptExplorerIdentity): string { + const who = id.userName + ? `${id.userName}${id.userEmail ? ` (${id.userEmail})` : ""}` + : (id.userEmail ?? "an unknown user"); + const org = id.orgName ? ` in the organization "${id.orgName}"` : ""; + + const lines = [ + `You are a prompt-engineering assistant. The user is ${who}${org}.`, + `They are iterating on a prompt for an AI assistant: each pass turns their rough idea into a more complete, concrete, ready-to-use prompt.`, + ``, + `Read their draft, understand its underlying intent, and rewrite it as a richer, clearer, more complete version they could send to an AI assistant AS-IS and get an excellent result.`, + ``, + `CRITICAL — voice & language: The output IS the user's own prompt, written in their voice as if THEY are speaking to an AI assistant. Keep the exact same grammatical person and tone as their draft — if they wrote in the first person ("eu quero…", "I want…"), keep it first person ("eu quero…", "I want…"). NEVER address the user or describe what they want in the second person (no "Você quer…", "You want to…", "Describe…", "Tell me…"). Do not turn their prompt into instructions aimed back at them. Write your entire response in the SAME language as their draft.`, + ``, + `NO placeholders, NO blanks. Never use square brackets, "[...]", "fill in", "TODO", "Para completar", or any leave-it-for-later marker. Instead, make reasonable, concrete choices yourself: invent sensible specifics that fit the intent — concrete aspects, scenarios, examples, criteria — and write them straight into the prompt. The result must read as a finished prompt the user could run immediately, never a template to complete. The user can freely edit anything they'd have chosen differently.`, + ``, + `EXPAND on every iteration. This is one step in a loop — each time the user runs it again, meaningfully grow and deepen the prompt: add concrete detail, specifics, structure, constraints, and examples that weren't there before. Never return essentially the same text; each version must be a clear, richer step up from the previous one.`, + ``, + `Example. Draft: "eu quero testar a feature de prompt exploring, tipo o que ela faz". CORRECT — first person, concrete, ready to use, no brackets:\n"Eu quero explorar e testar a feature de prompt exploring para entender o que ela faz na prática. Vou avaliar a velocidade das respostas, a qualidade e a relevância das sugestões e a facilidade de uso da interface. Para isso, quero testá-la em cenários reais como reescrever um e-mail curto, transformar uma ideia solta num prompt detalhado e refinar um prompt técnico passo a passo, comparando o resultado de cada iteração."\nINCORRECT — do NOT flip to second person ("Você quer testar…", "Descreva…") and do NOT leave blanks or brackets ("entender [quais capacidades]", "Para completar: - …").`, + ]; + + if (id.maxChars && id.maxChars > 0) { + lines.push( + ``, + `Length: grow GRADUALLY — aim for at most about ${id.maxChars} characters this pass (a couple times richer than the input, not the final form). Fill the added room with concrete, useful substance, not padding or repetition; do not balloon a short idea into a wall of text in one step.`, + ); + } + + lines.push( + ``, + `Return ONLY the improved prompt text. No preamble, no explanation, no markdown code fences.`, + ); + + return lines.join("\n"); +} diff --git a/apps/mesh/src/web/components/chat/input.tsx b/apps/mesh/src/web/components/chat/input.tsx index 346b30c8c2..48f7292b7e 100644 --- a/apps/mesh/src/web/components/chat/input.tsx +++ b/apps/mesh/src/web/components/chat/input.tsx @@ -23,6 +23,7 @@ import { Image01, Lock01, Microphone01, + RefreshCw01, Stop, Telescope, Upload01, @@ -56,6 +57,7 @@ import { type TiptapInputHandle, } from "./tiptap/input"; import { isTiptapDocEmpty } from "./tiptap/utils"; +import { PromptExplorerDialog } from "./prompt-explorer-dialog"; import { ToolsPopover } from "./tools-popover"; import { SessionStats } from "./usage-stats"; import { authClient } from "@/web/lib/auth-client.ts"; @@ -72,6 +74,40 @@ import { shouldRenderInlineModeRow } from "./input-mode-row"; // useWindowFileDrop - Reusable hook for window-level file drag & drop // ============================================================================ +/** Flatten a Tiptap doc to plain text for seeding the Prompt Explorer. */ +function tiptapDocToPlainText(doc: Metadata["tiptapDoc"] | undefined): string { + if (!doc?.content) return ""; + return doc.content + .map((node) => { + if (node.type !== "paragraph") return ""; + return (node.content ?? []) + .map((child) => + child.type === "text" + ? (child.text ?? "") + : child.type === "hardBreak" + ? "\n" + : "", + ) + .join(""); + }) + .join("\n") + .trim(); +} + +/** Build a minimal Tiptap doc (one paragraph per line) from plain text. */ +function plainTextToTiptapDoc(text: string): Metadata["tiptapDoc"] { + return { + type: "doc", + content: text + .split("\n") + .map((line) => + line.length > 0 + ? { type: "paragraph", content: [{ type: "text", text: line }] } + : { type: "paragraph" }, + ), + }; +} + function ChatInputDisabledState({ message }: { message: string }) { return (