diff --git a/apps/web/package.json b/apps/web/package.json index 5b031c93..1dbeb44f 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -23,6 +23,7 @@ "@langchain/auth": "^0.0.0", "@langchain/core": "^0.3.44", "@langchain/langgraph-sdk": "^0.1.8", + "@langchain/openai": "^0.6.14", "@modelcontextprotocol/sdk": "^1.11.4", "@open-agent-platform/deep-agent-chat": "*", "@radix-ui/react-accordion": "^1.2.12", diff --git a/apps/web/src/app/api/prompt/rewrite/route.ts b/apps/web/src/app/api/prompt/rewrite/route.ts new file mode 100644 index 00000000..a97e92fd --- /dev/null +++ b/apps/web/src/app/api/prompt/rewrite/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { ChatPromptTemplate } from "@langchain/core/prompts"; +import { ChatOpenAI } from "@langchain/openai"; + +const BodySchema = z.object({ + instructions: z.string().min(1), + request: z.string().min(1), +}); + +export async function POST(req: NextRequest) { + try { + const json = await req.json(); + const { instructions, request } = BodySchema.parse(json); + + // Attempt to use OpenAI via LangChain if available + const openaiApiKey = process.env.OPENAI_API_KEY; + if (!openaiApiKey) { + return NextResponse.json( + { + error: + "OPENAI_API_KEY not set. Install @langchain/openai and set OPENAI_API_KEY to enable rewrites.", + }, + { status: 501 }, + ); + } + + const prompt = ChatPromptTemplate.fromMessages([ + [ + "system", + [ + "You are an expert agent-instruction editor.", + "Rewrite the provided instructions to satisfy the user's request.", + "- Preserve core goals and constraints unless the request explicitly changes them.", + "- Keep output as Markdown. Return ONLY the rewritten instructions, no commentary.", + ].join("\n"), + ], + [ + "human", + [ + "User request: {request}", + "---", + "Original instructions:", + "{instructions}", + ].join("\n"), + ], + ]); + + const model = new ChatOpenAI({ + apiKey: openaiApiKey, + model: process.env.OPENAI_MODEL ?? "gpt-4o-mini", + temperature: 0.2, + }); + + const chain = prompt.pipe(model); + const res = await chain.invoke({ request, instructions }); + const rewritten: string = res?.content?.toString?.() ?? ""; + + if (!rewritten.trim()) { + return NextResponse.json( + { error: "Model returned empty output" }, + { status: 500 }, + ); + } + + return NextResponse.json({ rewritten }); + } catch (err) { + console.error("Prompt rewrite error:", err); + const msg = err instanceof Error ? err.message : "Invalid request"; + return NextResponse.json({ error: msg }, { status: 400 }); + } +} diff --git a/apps/web/src/components/AgentConfig.tsx b/apps/web/src/components/AgentConfig.tsx index a855ff0e..5ac5efd5 100644 --- a/apps/web/src/components/AgentConfig.tsx +++ b/apps/web/src/components/AgentConfig.tsx @@ -59,6 +59,16 @@ interface AgentConfigProps { onExternalTitleChange?: (v: string) => void; saveRef?: React.MutableRefObject<(() => Promise) | null>; forceMainInstructionsView?: boolean; + // Expose instructions get/set for external tools (e.g., prompt wand) + instructionsApiRef?: React.MutableRefObject<{ + getMarkdown: () => Promise; + setMarkdown: (markdown: string) => Promise; + } | null>; + // Open the prompt-wand rewrite bar via keyboard shortcut + onRewriteShortcut?: ( + selectedText?: string, + anchor?: { x: number; y: number }, + ) => void; } type ViewType = "instructions" | "tools" | "triggers" | "subagents"; @@ -79,6 +89,8 @@ export function AgentConfig({ onExternalTitleChange, saveRef, forceMainInstructionsView, + instructionsApiRef, + onRewriteShortcut, }: AgentConfigProps) { const { session } = useAuthContext(); const { @@ -524,6 +536,38 @@ export function AgentConfig({ } }, [saveRef, handleSaveChanges]); + // Expose instructions get/set API via ref (for external prompt editing tools) + useEffect(() => { + if (!instructionsApiRef) return; + instructionsApiRef.current = { + getMarkdown: async () => { + try { + const md = await editor.blocksToMarkdownLossy(editor.document); + return md ?? ""; + } catch (err) { + console.error("Failed to serialize editor markdown:", err); + return ""; + } + }, + setMarkdown: async (markdown: string) => { + try { + const processed = (markdown || "").replace( + /^#{1,6}\s+(.+)$/gm, + "**$1**", + ); + const blocks = await editor.tryParseMarkdownToBlocks(processed); + editor.replaceBlocks(editor.document, blocks); + } catch (err) { + console.error("Failed to set editor markdown:", err); + } + }, + }; + // Cleanup on unmount + return () => { + if (instructionsApiRef) instructionsApiRef.current = null; + }; + }, [instructionsApiRef, editor]); + if (!agent) { return (
@@ -652,10 +696,31 @@ export function AgentConfig({
{ - // Prevent keyboard shortcuts from bubbling up to parent components - if (e.ctrlKey || e.metaKey) { + onKeyDownCapture={(e) => { + // Capture phase so we intercept before BlockNote handles it (disables native link editor) + if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") { + e.preventDefault(); e.stopPropagation(); + let selectedText = ""; + let anchor: { x: number; y: number } | undefined = + undefined; + try { + const sel = window.getSelection(); + selectedText = sel?.toString() ?? ""; + if (sel && sel.rangeCount > 0) { + const rect = sel.getRangeAt(0).getBoundingClientRect(); + if (rect) { + anchor = { + x: rect.left + rect.width / 2, + y: rect.top, // viewport top + }; + } + } + } catch { + console.error("Failed to get selection"); + } + onRewriteShortcut?.(selectedText, anchor); + return; } }} > @@ -666,6 +731,7 @@ export function AgentConfig({ theme="light" data-color-scheme="light" /> + {/* Formatting toolbar temporarily removed due to version export mismatch */}
diff --git a/apps/web/src/features/editor/components/prompt-wand.tsx b/apps/web/src/features/editor/components/prompt-wand.tsx new file mode 100644 index 00000000..15d37261 --- /dev/null +++ b/apps/web/src/features/editor/components/prompt-wand.tsx @@ -0,0 +1,206 @@ +"use client"; + +import React from "react"; +import { Wand2, Loader2, X, Send } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; +import { toast } from "sonner"; +import { AnimatePresence, motion } from "framer-motion"; + +type InstructionsApi = { + getMarkdown: () => Promise; + setMarkdown: (markdown: string) => Promise; +}; + +type PromptWandApi = { + open: (prefill?: string, anchor?: { x: number; y: number }) => void; + close: () => void; +}; + +interface PromptWandProps { + instructionsApiRef: React.MutableRefObject; + className?: string; + apiRef?: React.MutableRefObject; +} + +export function PromptWand({ + instructionsApiRef, + className, + apiRef, +}: PromptWandProps) { + const [open, setOpen] = React.useState(false); + const [query, setQuery] = React.useState(""); + const [loading, setLoading] = React.useState(false); + const inputRef = React.useRef(null); + + // Target width for the compact bar (less wide) + const targetWidth = 400; // px + + // Positioning: either docked near wand (bottom-right) or anchored near selection + const [_anchor, setAnchor] = React.useState<{ x: number; y: number } | null>( + null, + ); + // Preview of selected text when opened via Cmd/Ctrl+K + const [selectionPreview, setSelectionPreview] = React.useState( + null, + ); + + const onSubmit = async (e?: React.FormEvent) => { + e?.preventDefault(); + if (!query.trim()) return; + const api = instructionsApiRef.current; + if (!api) { + toast.error("Instructions editor not ready"); + return; + } + try { + setLoading(true); + const current = await api.getMarkdown(); + const res = await fetch("/api/prompt/rewrite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ instructions: current, request: query }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error(data?.error || `Rewrite failed with ${res.status}`); + } + const data: { rewritten: string } = await res.json(); + await api.setMarkdown(data.rewritten || current); + toast.success("Instructions updated"); + setQuery(""); + setOpen(false); + } catch (err) { + console.error(err); + toast.error( + err instanceof Error ? err.message : "Failed to rewrite instructions", + ); + } finally { + setLoading(false); + } + }; + + // Expose open/close to parent via ref + React.useEffect(() => { + if (!apiRef) return; + apiRef.current = { + open: (prefill?: string, nextAnchor?: { x: number; y: number }) => { + // Show the selected text above the bar; keep input empty for user prompt + setSelectionPreview(prefill ?? null); + setQuery(""); + // Keep the bar docked in the corner (ignore selection anchor for position) + setAnchor(null); + setOpen(true); + // focus after a short tick to ensure input is mounted + setTimeout(() => inputRef.current?.focus(), 50); + }, + close: () => setOpen(false), + }; + return () => { + if (apiRef) apiRef.current = null; + }; + }, [apiRef]); + + return ( + <> + {/* Floating wand button */} + + + {/* Selected text preview (shows above the docked bar when opened via Cmd/Ctrl+K) */} + + {open && selectionPreview && ( + + {selectionPreview.length > 100 + ? `${selectionPreview.slice(0, 100)}...` + : selectionPreview} + + )} + + + {/* Animated compact bar docked near bottom-right */} + + {open && ( + { + // Focus input after opening animation completes + if (open) inputRef.current?.focus(); + }} + > +
+ + setQuery(e.target.value)} + placeholder="Rewrite (e.g., concise)" + className="w-full border-0 shadow-none focus-visible:ring-0" + /> + + + +
+ )} +
+ + ); +} diff --git a/apps/web/src/features/editor/index.tsx b/apps/web/src/features/editor/index.tsx index f600eeb4..8bab99db 100644 --- a/apps/web/src/features/editor/index.tsx +++ b/apps/web/src/features/editor/index.tsx @@ -49,6 +49,7 @@ import { DeepAgentChatBreadcrumb } from "@/features/chat/components/breadcrumb"; import { getDeployments } from "@/lib/environment/deployments"; import { SubAgentSheet } from "./components/subagent-sheet"; import { SubagentsList } from "./components/subagents-list"; +import { PromptWand } from "./components/prompt-wand"; export function EditorPageContent(): React.ReactNode { const router = useRouter(); @@ -94,6 +95,16 @@ export function EditorPageContent(): React.ReactNode { const [headerTitle, setHeaderTitle] = useState(""); const saveRef = React.useRef<(() => Promise) | null>(null); const [isSaving, setIsSaving] = useState(false); + // Ref to read/write instructions markdown in AgentConfig + const instructionsApiRef = React.useRef<{ + getMarkdown: () => Promise; + setMarkdown: (markdown: string) => Promise; + } | null>(null); + // Ref to imperatively open/close the prompt wand + const promptWandApiRef = React.useRef<{ + open: (prefill?: string, anchor?: { x: number; y: number }) => void; + close: () => void; + } | null>(null); // Track first visit to editor page for glow effect const [hasVisitedEditor, setHasVisitedEditor] = useLocalStorage( @@ -274,6 +285,11 @@ export function EditorPageContent(): React.ReactNode { return (
+ {/* Floating prompt wand for instruction rewrites */} + {/* Page header with title/description and actions */} {selectedAgent && (
@@ -573,6 +589,13 @@ export function EditorPageContent(): React.ReactNode { triggersFormExternal={triggersForm} view={"instructions"} forceMainInstructionsView + instructionsApiRef={instructionsApiRef} + onRewriteShortcut={(selected, anchor) => + promptWandApiRef.current?.open( + selected && selected.trim() ? selected : undefined, + anchor, + ) + } />
diff --git a/yarn.lock b/yarn.lock index 85c5631a..5a88d57f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1876,6 +1876,19 @@ __metadata: languageName: node linkType: hard +"@langchain/openai@npm:^0.6.14": + version: 0.6.14 + resolution: "@langchain/openai@npm:0.6.14" + dependencies: + js-tiktoken: ^1.0.12 + openai: 5.12.2 + zod: ^3.25.32 + peerDependencies: + "@langchain/core": ">=0.3.68 <0.4.0" + checksum: deb0de1336e5a8160669673e7627673040482bd8e590890e29297c728067cf2751c795cc2c31b1a21d1bea7b42c8f343945c3d9b465732f7413ff624d6d7b11a + languageName: node + linkType: hard + "@leichtgewicht/ip-codec@npm:^2.0.1": version: 2.0.5 resolution: "@leichtgewicht/ip-codec@npm:2.0.5" @@ -2509,6 +2522,7 @@ __metadata: "@langchain/auth": ^0.0.0 "@langchain/core": ^0.3.44 "@langchain/langgraph-sdk": ^0.1.8 + "@langchain/openai": ^0.6.14 "@modelcontextprotocol/sdk": ^1.11.4 "@open-agent-platform/deep-agent-chat": "*" "@radix-ui/react-accordion": ^1.2.12 @@ -13738,6 +13752,23 @@ __metadata: languageName: node linkType: hard +"openai@npm:5.12.2": + version: 5.12.2 + resolution: "openai@npm:5.12.2" + peerDependencies: + ws: ^8.18.0 + zod: ^3.23.8 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + bin: + openai: bin/cli + checksum: b48116979c0666b1438d68a71df2c57833bca8c20c5d43e0c17e4d8594ca72294a1b62f40c7e3b8e5d5be644c2de5ba27fe97b8136f1de36e0490fd49c552a70 + languageName: node + linkType: hard + "openapi-types@npm:^12.0.0": version: 12.1.3 resolution: "openapi-types@npm:12.1.3"