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 (
@@ -276,6 +312,7 @@ export function ChatInput({ const fullVm = useVirtualMCP(selectedVirtualMcp?.id ?? decopilotId); const playSwitchSound = useSound(question004Sound); const [connectionsOpen, setConnectionsOpen] = useState(false); + const [exploreOpen, setExploreOpen] = useState(false); const { unsupportedFile, onUnsupportedFile, clearUnsupportedFile } = useUnsupportedFileDialog(); @@ -440,6 +477,26 @@ export function ChatInput({ } }; + // Send the optimized prompt produced by the Prompt Explorer modal, reusing + // the normal submit path (active task → stream, else home composer). + const handleExploreSend = (text: string) => { + const doc = plainTextToTiptapDoc(text); + if (isStreaming || isTiptapDocEmpty(doc)) return; + track("chat_prompt_explorer_sent", { + thread_id: taskId || null, + mode: chatMode, + model_id: selectedModel?.modelId ?? null, + virtual_mcp_id: selectedVirtualMcp?.id ?? null, + }); + if (stream) { + void stream.sendMessage(doc); + } else { + homeSubmit({ tiptapDoc: doc, virtualMcp: selectedVirtualMcp }); + } + clearChatDraft(sessionStorage, locator, draftKey); + setTiptapDoc(undefined); + }; + if (userId && task?.created_by && task.created_by !== userId) { return ( @@ -661,6 +718,25 @@ export function ChatInput({ currentBranch={taskCtx?.currentBranch ?? null} /> )} + + {/* Microphone button — kept mounted (and disabled) @@ -760,6 +836,13 @@ export function ChatInput({ info={unsupportedFile} onClose={clearUnsupportedFile} /> + + ); } diff --git a/apps/mesh/src/web/components/chat/prompt-explorer-dialog.tsx b/apps/mesh/src/web/components/chat/prompt-explorer-dialog.tsx new file mode 100644 index 0000000000..b7ec92e8a0 --- /dev/null +++ b/apps/mesh/src/web/components/chat/prompt-explorer-dialog.tsx @@ -0,0 +1,292 @@ +import { Button } from "@deco/ui/components/button.tsx"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { cn } from "@deco/ui/lib/utils.ts"; +import { ArrowUp, Loading01, RefreshCw01 } from "@untitledui/icons"; +import { type KeyboardEvent, useEffect, useRef, useState } from "react"; +import { toast } from "sonner"; +import { + PROMPT_EXPLORER_MIN_CHARS, + usePromptEnricher, +} from "@/web/hooks/use-prompt-enricher.ts"; + +interface PromptExplorerDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + /** Seed the editor with the user's current composer draft. */ + initialText?: string; + /** Called with the current prompt when the user clicks Send. */ + onSend: (text: string) => void; +} + +interface Version { + id: number; + text: string; +} + +export function PromptExplorerDialog({ + open, + onOpenChange, + initialText, + onSend, +}: PromptExplorerDialogProps) { + return ( + + + + + + Improve prompt + + + Improve a rough idea into a richer, more detailed, ready-to-use + prompt, then send it to the chat. + + + {/* Radix unmounts content on close, so the body remounts fresh each + open — local state (versions, selection) resets naturally. */} + {open && ( + { + onSend(text); + onOpenChange(false); + }} + onClose={() => onOpenChange(false)} + /> + )} + + + ); +} + +function PromptExplorerBody({ + initialText, + onSend, + onClose, +}: { + initialText?: string; + onSend: (text: string) => void; + onClose: () => void; +}) { + const [versions, setVersions] = useState(() => [ + { id: 0, text: initialText ?? "" }, + ]); + const [selectedId, setSelectedId] = useState(0); + const [streamingId, setStreamingId] = useState(null); + const nextId = useRef(1); + const enricher = usePromptEnricher(); + + const selected = + versions.find((v) => v.id === selectedId) ?? versions[versions.length - 1]!; + const isStreaming = enricher.status === "streaming"; + const isStreamingSelected = isStreaming && streamingId === selectedId; + // While the selected version is streaming, show the live text; otherwise the + // committed version text (which the user can edit directly). + const mainValue = isStreamingSelected ? enricher.text : selected.text; + const trimmedLen = selected.text.trim().length; + const canImprove = !isStreaming && trimmedLen >= PROMPT_EXPLORER_MIN_CHARS; + const canSend = !isStreaming && trimmedLen > 0; + + const updateSelected = (text: string) => { + setVersions((vs) => + vs.map((v) => (v.id === selectedId ? { ...v, text } : v)), + ); + }; + + const handleImprove = async () => { + if (!canImprove) return; + const source = selected.text; + const id = nextId.current++; + // The new (soon-to-be-improved) version becomes the latest and is selected + // immediately; the source version stays in the sidebar. + setVersions((vs) => [...vs, { id, text: "" }]); + setSelectedId(id); + setStreamingId(id); + try { + const result = await enricher.enrich(source); + setVersions((vs) => + vs.map((v) => (v.id === id ? { ...v, text: result.text } : v)), + ); + } catch (e) { + toast.error( + e instanceof Error ? e.message : "Failed to improve the prompt", + ); + // Drop the empty placeholder version and return to the source. + setVersions((vs) => vs.filter((v) => v.id !== id)); + setSelectedId(selected.id); + } finally { + setStreamingId(null); + } + }; + + const handleSend = () => { + if (!canSend) return; + onSend(selected.text); + }; + + // Enter → Improve, Cmd/Ctrl+Enter → Send, Shift+Enter → newline. + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Enter" || e.shiftKey) return; + e.preventDefault(); + if (e.metaKey || e.ctrlKey) { + handleSend(); + } else { + void handleImprove(); + } + }; + + const handleClose = () => { + enricher.cancel(); + onClose(); + }; + + // Auto-run the first improvement on open when the composer draft already has + // enough text — opening Improve "just starts improving". A mount-scoped + // effect is the natural fit here (mirrors useSubtaskStream in this codebase); + // the ref guards against StrictMode's double-invoke. + const didAutoRun = useRef(false); + /* oxlint-disable ban-use-effect/ban-use-effect */ + /* oxlint-disable react-hooks/exhaustive-deps */ + useEffect(() => { + if (didAutoRun.current) return; + didAutoRun.current = true; + if ((initialText ?? "").trim().length >= PROMPT_EXPLORER_MIN_CHARS) { + void handleImprove(); + } + }, []); + /* oxlint-enable react-hooks/exhaustive-deps */ + /* oxlint-enable ban-use-effect/ban-use-effect */ + + const placeholder = + isStreamingSelected && !enricher.text + ? "Thinking…" + : versions.length === 1 + ? "Write a rough idea for your prompt, then click Improve to expand it." + : "Edit this version, Improve again to expand it further, or Send."; + + return ( +
+
+ {/* Versions sidebar */} + + + {/* Main editor */} +
+ {enricher.reasoning && isStreamingSelected && ( +
+ {enricher.reasoning} +
+ )} +