Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions apps/web/src/app/api/prompt/rewrite/route.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
}
72 changes: 69 additions & 3 deletions apps/web/src/components/AgentConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,16 @@ interface AgentConfigProps {
onExternalTitleChange?: (v: string) => void;
saveRef?: React.MutableRefObject<(() => Promise<void>) | null>;
forceMainInstructionsView?: boolean;
// Expose instructions get/set for external tools (e.g., prompt wand)
instructionsApiRef?: React.MutableRefObject<{
getMarkdown: () => Promise<string>;
setMarkdown: (markdown: string) => Promise<void>;
} | 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";
Expand All @@ -79,6 +89,8 @@ export function AgentConfig({
onExternalTitleChange,
saveRef,
forceMainInstructionsView,
instructionsApiRef,
onRewriteShortcut,
}: AgentConfigProps) {
const { session } = useAuthContext();
const {
Expand Down Expand Up @@ -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 (
<div className="flex h-full items-center justify-center">
Expand Down Expand Up @@ -652,10 +696,31 @@ export function AgentConfig({
<div className="p-6">
<div
key={editorKey}
onKeyDown={(e) => {
// 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;
}
}}
>
Expand All @@ -666,6 +731,7 @@ export function AgentConfig({
theme="light"
data-color-scheme="light"
/>
{/* Formatting toolbar temporarily removed due to version export mismatch */}
</div>
</div>
</div>
Expand Down
206 changes: 206 additions & 0 deletions apps/web/src/features/editor/components/prompt-wand.tsx
Original file line number Diff line number Diff line change
@@ -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<string>;
setMarkdown: (markdown: string) => Promise<void>;
};

type PromptWandApi = {
open: (prefill?: string, anchor?: { x: number; y: number }) => void;
close: () => void;
};

interface PromptWandProps {
instructionsApiRef: React.MutableRefObject<InstructionsApi | null>;
className?: string;
apiRef?: React.MutableRefObject<PromptWandApi | null>;
}

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<HTMLInputElement | null>(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<string | null>(
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 */}
<button
type="button"
aria-label="Rewrite"
onClick={() => {
setAnchor(null);
setSelectionPreview(null);
setOpen((v) => !v);
}}
className={cn(
"fixed right-6 bottom-6 z-40 rounded-full bg-[#2F6868] p-3 text-white shadow-lg transition hover:bg-[#2F6868]/90",
className,
)}
>
{loading ? (
<Loader2 className="h-5 w-5 animate-spin" />
) : (
<Wand2 className="h-5 w-5" />
)}
</button>

{/* Selected text preview (shows above the docked bar when opened via Cmd/Ctrl+K) */}
<AnimatePresence>
{open && selectionPreview && (
<motion.div
key="wand-selection-preview"
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 6 }}
transition={{ type: "spring", stiffness: 260, damping: 24 }}
className="fixed right-20 bottom-20 z-40 max-w-[60vw] truncate rounded-md border border-gray-200 bg-white px-3 py-1 text-xs text-gray-700 shadow"
title={selectionPreview}
>
{selectionPreview.length > 100
? `${selectionPreview.slice(0, 100)}...`
: selectionPreview}
</motion.div>
)}
</AnimatePresence>

{/* Animated compact bar docked near bottom-right */}
<AnimatePresence>
{open && (
<motion.div
key="wand-bar"
initial={{ scaleX: 0, opacity: 0 }}
animate={{ scaleX: 1, opacity: 1 }}
exit={{ scaleX: 0, opacity: 0 }}
transition={{ type: "spring", stiffness: 260, damping: 24 }}
className={cn(
"fixed right-20 bottom-6 z-40 origin-right overflow-hidden rounded-full border border-gray-200 bg-white shadow-xl",
)}
style={{ width: targetWidth, transformOrigin: "right" }}
onAnimationComplete={() => {
// Focus input after opening animation completes
if (open) inputRef.current?.focus();
}}
>
<form
onSubmit={onSubmit}
className="flex items-center gap-2 px-3 py-2"
>
<Wand2 className="h-4 w-4 shrink-0 text-gray-500" />
<Input
ref={inputRef}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rewrite (e.g., concise)"
className="w-full border-0 shadow-none focus-visible:ring-0"
/>
<Button
type="submit"
size="icon"
disabled={loading || !query.trim()}
className="shrink-0 bg-[#2F6868] hover:bg-[#2F6868]/90"
aria-label="Apply"
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="ghost"
size="icon"
aria-label="Close"
onClick={() => setOpen(false)}
className="shrink-0"
>
<X className="h-4 w-4" />
</Button>
</form>
</motion.div>
)}
</AnimatePresence>
</>
);
}
Loading