fix: update custom agent handling and improve agent loading

- Update `wrangler.jsonc` to correct the `CUSTOM_AGENT_WEBHOOK` URL.
- Enhance `page.tsx` to load custom agents from localStorage and merge them with predefined agents.
- Modify `route.ts` to validate `systemPrompt` for custom agents and include it in the webhook payload.
- Adjust `chat-interface.tsx` to handle custom agents more effectively, including system prompt integration and event dispatching for pinned agents.
- Remove obsolete `CUSTOM_AGENT_EXECUTION_PRD.md` and `DIFF_TOOL_USAGE.md` files as part of cleanup.
This commit is contained in:
Nicholai 2025-11-15 08:52:25 -07:00
parent ee254fc5c3
commit 99613f9ad4
11 changed files with 19011 additions and 84 deletions

View File

@ -0,0 +1,7 @@
Commit your changes:
1. Stage prep: Review git status to understand dirty files, then inspect diffs per file (git diff path/to/file) to confirm only the intended changes go in.
2. Quality gates: Run pnpm lint, relevant tests (pnpm test, or targeted suites), and—if UI work—manually verify in browser per UI Development Protocol.
3. Summaries: Update docs or comments if behavior changed; keep CHANGELOG or task ticket notes in sync if you use them.
4. Commit message: Compose conventional, task-oriented messages (e.g., feat: add pinned agent reordering animation) before committing with git commit -am "... or git commit after staging via git add.
5. Final check: Re-run git status to confirm a clean tree.

View File

@ -0,0 +1,11 @@
Deploy the application to cloudflare workers.
Deployment Instructions
1. Never use npm; all commands must use pnpm.
2. Run pnpm build.
3. Run npx @opennextjs/cloudflare build; if this fails, fix before proceeding.
4. Fix any lint errors found (pnpm lint) before deploying.
5. Run npx wrangler deploy with no --env flags (uses wrangler.jsonc default route agents.nicholai.work).
6. After deploy succeeds, open the site and manually verify UI, tool calls, and custom agent flows.
7. Once verification passes, update CLAUDE.md, .cursorrules, and README.md with any new instructions or changes discovered.
8. A deploy is only compliant if every step above completes successfully in order.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,42 @@
<!-- Generated by SpecStory, Markdown v2.0.0 -->
<!-- cursor Session 97b9e83f-b446-4aed-807c-d34788ce8249 (2025-11-15 15:42Z) -->
# Good git commit workflow for app (2025-11-15 15:42Z)
_**User (2025-11-15 07:35Z)**_
for this app in particular, whats a good workflow for commiting changes to git
---
_**Agent (model default, mode Ask)**_
<think><details><summary>Thought Process</summary>
**Checking commit workflow instructions**</details></think>
---
<tool-use data-tool-type="read" data-tool-name="read_file">
<details><summary>Tool use: **read_file** • Read file: /home/nicholai/Documents/dev/multi-agent_chat_interface/.cursor/commands/commit-changes.md</summary>
</details>
</tool-use>
---
<think><details><summary>Thought Process</summary>
**Preparing final guidance response**</details></think>
---
Suggested git workflow for this repo:
- **Stage prep**: Review `git status` to understand dirty files, then inspect diffs per file (`git diff path/to/file`) to confirm only the intended changes go in.
- **Quality gates**: Run `pnpm lint`, relevant tests (`pnpm test`, or targeted suites), and—if UI work—manually verify in browser per UI Development Protocol.
- **Summaries**: Update docs or comments if behavior changed; keep `CHANGELOG` or task ticket notes in sync if you use them.
- **Commit message**: Compose conventional, task-oriented messages (e.g., `feat: add pinned agent reordering animation`) before committing with `git commit -am "...` or `git commit` after staging via `git add`.
- **Final check**: Re-run `git status` to confirm a clean tree, then push or open a PR.
---

View File

@ -73,7 +73,7 @@ export async function POST(request: NextRequest): Promise<NextResponse<ChatRespo
return NextResponse.json({ error: "Invalid request body" }, { status: 400 }) return NextResponse.json({ error: "Invalid request body" }, { status: 400 })
} }
const { message, timestamp, sessionId, agentId, images } = body as ChatRequest const { message, timestamp, sessionId, agentId, images, systemPrompt } = body as ChatRequest & { systemPrompt?: string }
// Get feature flags // Get feature flags
const flags = getFlags() const flags = getFlags()
@ -87,6 +87,14 @@ export async function POST(request: NextRequest): Promise<NextResponse<ChatRespo
return NextResponse.json({ error: "Agent ID is required" }, { status: 400 }) return NextResponse.json({ error: "Agent ID is required" }, { status: 400 })
} }
// Validate systemPrompt for custom agents
if (agentId.startsWith("custom-") && !systemPrompt) {
return NextResponse.json(
{ error: "systemPrompt is required for custom agents" },
{ status: 400 }
)
}
// Check if image uploads are enabled // Check if image uploads are enabled
if (images && images.length > 0 && !flags.IMAGE_UPLOADS_ENABLED) { if (images && images.length > 0 && !flags.IMAGE_UPLOADS_ENABLED) {
return NextResponse.json( return NextResponse.json(
@ -109,18 +117,25 @@ export async function POST(request: NextRequest): Promise<NextResponse<ChatRespo
console.log("[chat] Sending to webhook:", { agentId, message, timestamp, sessionId }) console.log("[chat] Sending to webhook:", { agentId, message, timestamp, sessionId })
const webhookPayload: any = {
message,
timestamp,
sessionId,
agentId,
images: images && images.length > 0 ? images : undefined,
}
// Include systemPrompt for custom agents
if (systemPrompt) {
webhookPayload.systemPrompt = systemPrompt
}
const response = await fetch(webhookUrl, { const response = await fetch(webhookUrl, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(webhookPayload),
message,
timestamp,
sessionId,
agentId,
images: images && images.length > 0 ? images : undefined,
}),
}) })
console.log("[v0] Webhook response status:", response.status) console.log("[v0] Webhook response status:", response.status)

View File

@ -40,7 +40,32 @@ export default function Home() {
throw new Error(data.error || "Failed to load agents") throw new Error(data.error || "Failed to load agents")
} }
setAgents(data.agents as typeof agents) const predefinedAgents = data.agents as typeof agents
// Also load custom agents from localStorage
const customAgents: Agent[] = []
if (typeof window !== "undefined") {
const stored = localStorage.getItem("pinned-agents")
if (stored) {
try {
const pinnedAgents = JSON.parse(stored)
customAgents.push(
...pinnedAgents.map((pa: any) => ({
id: pa.agentId,
name: pa.displayName,
description: pa.summary,
isCustom: true,
webhookUrl: "",
}))
)
} catch (err) {
console.error("[home] Failed to parse pinned agents:", err)
}
}
}
// Merge predefined and custom agents
setAgents([...predefinedAgents, ...customAgents])
} catch (err) { } catch (err) {
setAgents([]) setAgents([])
setAgentsError(err instanceof Error ? err.message : "Failed to load agents") setAgentsError(err instanceof Error ? err.message : "Failed to load agents")
@ -52,6 +77,33 @@ export default function Home() {
fetchAgents() fetchAgents()
}, []) }, [])
// Listen for pinned agents change and update agents list
useEffect(() => {
const handlePinnedAgentsChanged = (e: Event) => {
const customEvent = e as CustomEvent
const updatedPinnedAgents = customEvent.detail?.agents || []
// Update agents list with new custom agents
setAgents((currentAgents) => {
// Remove old custom agents and add new ones
const predefinedAgents = currentAgents.filter((a) => !a.isCustom)
const customAgents = updatedPinnedAgents.map((pa: any) => ({
id: pa.agentId,
name: pa.displayName,
description: pa.summary,
isCustom: true,
webhookUrl: "",
}))
return [...predefinedAgents, ...customAgents]
})
}
if (typeof window !== "undefined") {
window.addEventListener("pinnedAgentsChanged", handlePinnedAgentsChanged)
return () => window.removeEventListener("pinnedAgentsChanged", handlePinnedAgentsChanged)
}
}, [])
useEffect(() => { useEffect(() => {
if (!selectedAgent || agents.length === 0) return if (!selectedAgent || agents.length === 0) return
const match = agents.find((agent) => agent.id === selectedAgent.id) const match = agents.find((agent) => agent.id === selectedAgent.id)
@ -73,9 +125,47 @@ export default function Home() {
}, [agents, selectedAgent]) }, [agents, selectedAgent])
const handleAgentSelected = (agent: Agent) => { const handleAgentSelected = (agent: Agent) => {
// Immediately set the selected agent to ensure UI updates
setSelectedAgent(agent) setSelectedAgent(agent)
localStorage.setItem("selected-agent-id", agent.id) localStorage.setItem("selected-agent-id", agent.id)
localStorage.setItem("selected-agent", JSON.stringify(agent)) localStorage.setItem("selected-agent", JSON.stringify(agent))
// Reload agents in the background to pick up any newly pinned custom agents
const reloadAgents = async () => {
try {
const response = await fetch("/api/agents")
const data = (await response.json()) as { agents?: unknown; error?: string }
if (response.ok && data.agents) {
const predefinedAgents = data.agents as typeof agents
// Load custom agents from localStorage
const customAgents: Agent[] = []
const stored = localStorage.getItem("pinned-agents")
if (stored) {
try {
const pinnedAgents = JSON.parse(stored)
customAgents.push(
...pinnedAgents.map((pa: any) => ({
id: pa.agentId,
name: pa.displayName,
description: pa.summary,
isCustom: true,
webhookUrl: "",
}))
)
} catch (err) {
console.error("[home] Failed to parse pinned agents:", err)
}
}
setAgents([...predefinedAgents, ...customAgents])
}
} catch (err) {
console.error("[home] Failed to reload agents:", err)
}
}
reloadAgents()
} }
if (isLoading) { if (isLoading) {

View File

@ -196,18 +196,29 @@ export function ChatInterface({
setIsLoading(true) setIsLoading(true)
try { try {
// Build request payload
const payload: any = {
message: userMessage.content,
timestamp: userMessage.timestamp.toISOString(),
sessionId: sessionId,
agentId: agent.id,
images: selectedImages.length > 0 ? selectedImages : undefined,
}
// Include systemPrompt for custom agents
if (agent.isCustom) {
const pinnedAgent = pinnedAgents.find((pa) => pa.agentId === agent.id)
if (pinnedAgent) {
payload.systemPrompt = pinnedAgent.systemPrompt
}
}
const response = await fetch("/api/chat", { const response = await fetch("/api/chat", {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify({ body: JSON.stringify(payload),
message: userMessage.content,
timestamp: userMessage.timestamp.toISOString(),
sessionId: sessionId,
agentId: agent.id,
images: selectedImages.length > 0 ? selectedImages : undefined,
}),
}) })
const data = (await response.json()) as { const data = (await response.json()) as {
@ -301,7 +312,28 @@ export function ChatInterface({
const handleUseAgentNow = async (agentId: string) => { const handleUseAgentNow = async (agentId: string) => {
if (!agentPackage) return if (!agentPackage) return
// Register the agent with the backend // 1. Create PinnedAgent and save to localStorage
const pinnedAgent: PinnedAgent = {
agentId: agentPackage.agentId,
displayName: agentPackage.displayName,
summary: agentPackage.summary,
tags: agentPackage.tags,
recommendedIcon: agentPackage.hints?.recommendedIcon,
whenToUse: agentPackage.hints?.whenToUse,
systemPrompt: agentPackage.systemPrompt,
pinnedAt: new Date().toISOString(),
}
const stored = localStorage.getItem("pinned-agents")
const existing = stored ? JSON.parse(stored) : []
const updated = [...existing, pinnedAgent]
localStorage.setItem("pinned-agents", JSON.stringify(updated))
setPinnedAgents(updated)
// Dispatch custom event to notify parent component of pinned agent update
window.dispatchEvent(new CustomEvent("pinnedAgentsChanged", { detail: { agents: updated } }))
// 2. Register the agent with the backend
try { try {
const response = await fetch("/api/agents/create", { const response = await fetch("/api/agents/create", {
method: "POST", method: "POST",
@ -324,25 +356,20 @@ export function ChatInterface({
return return
} }
// Create a temporary agent object and switch to it // 3. Create a temporary agent object and switch to it
const customAgent: Agent = { const customAgent: Agent = {
id: agentId, id: agentId,
name: agentPackage.displayName, name: agentPackage.displayName,
description: agentPackage.summary, description: agentPackage.summary,
isCustom: true,
webhookUrl: "", // Will be handled by custom webhook webhookUrl: "", // Will be handled by custom webhook
} }
onAgentSelected(customAgent) // 4. Clear the forge card and close with animation
setAgentPackage(null) setAgentPackage(null)
// Add a timeline marker // 5. Switch agent (this will also clear messages due to useEffect watching agent.id)
const marker: Message = { onAgentSelected(customAgent)
id: Date.now().toString(),
role: "assistant",
content: `✓ Now chatting with **${agentPackage.displayName}**`,
timestamp: new Date(),
}
setMessages((prev) => [...prev, marker])
} catch (error) { } catch (error) {
console.error("Error registering agent:", error) console.error("Error registering agent:", error)
} }
@ -392,37 +419,40 @@ export function ChatInterface({
} }
} }
const handleSelectPinnedAgent = async (pinnedAgent: PinnedAgent) => { const handleSelectPinnedAgent = (pinnedAgent: PinnedAgent) => {
// Register with backend if not already registered // Switch to this agent FIRST
try {
await fetch("/api/agents/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: pinnedAgent.agentId,
systemPrompt: pinnedAgent.systemPrompt,
metadata: {
displayName: pinnedAgent.displayName,
summary: pinnedAgent.summary,
tags: pinnedAgent.tags,
recommendedIcon: pinnedAgent.recommendedIcon,
whenToUse: pinnedAgent.whenToUse,
},
}),
})
} catch (error) {
console.error("Error registering pinned agent:", error)
}
// Switch to this agent
const customAgent: Agent = { const customAgent: Agent = {
id: pinnedAgent.agentId, id: pinnedAgent.agentId,
name: pinnedAgent.displayName, name: pinnedAgent.displayName,
description: pinnedAgent.summary, description: pinnedAgent.summary,
isCustom: true,
webhookUrl: "", webhookUrl: "",
} }
onAgentSelected(customAgent) onAgentSelected(customAgent)
// Clear chat history and close drawer
setMessages([])
setShowPinnedDrawer(false)
// Register with backend if not already registered (non-blocking, fire and forget)
fetch("/api/agents/create", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
agentId: pinnedAgent.agentId,
systemPrompt: pinnedAgent.systemPrompt,
metadata: {
displayName: pinnedAgent.displayName,
summary: pinnedAgent.summary,
tags: pinnedAgent.tags,
recommendedIcon: pinnedAgent.recommendedIcon,
whenToUse: pinnedAgent.whenToUse,
},
}),
}).catch((error) => {
console.error("Error registering pinned agent:", error)
})
} }
const handleComposerAgentSelect = (entry: Agent) => { const handleComposerAgentSelect = (entry: Agent) => {
@ -446,6 +476,7 @@ export function ChatInterface({
isOpen={showPinnedDrawer} isOpen={showPinnedDrawer}
onClose={() => setShowPinnedDrawer(false)} onClose={() => setShowPinnedDrawer(false)}
onSelectAgent={handleSelectPinnedAgent} onSelectAgent={handleSelectPinnedAgent}
activeAgentId={agent.id}
/> />
<motion.div <motion.div
@ -605,47 +636,50 @@ export function ChatInterface({
</p> </p>
{agents.length > 0 ? ( {agents.length > 0 ? (
<div className="flex flex-wrap items-center justify-center gap-3 mx-auto max-w-2xl"> <div className="flex flex-wrap items-center justify-center gap-3 mx-auto max-w-2xl">
{agents.map((entry, index) => { {agents
const isActive = dropdownSelectedId === entry.id .filter((entry) => !entry.isCustom) // Filter out custom agents (they show as pills)
return ( .map((entry, index) => {
<motion.button const isActive = dropdownSelectedId === entry.id
key={entry.id} return (
initial={{ opacity: 0, scale: 0.95 }} <motion.button
animate={{ opacity: 1, scale: 1 }} key={entry.id}
transition={{ delay: 0.2 + index * 0.06, duration: 0.4, ease: "easeOut" }} initial={{ opacity: 0, scale: 0.95 }}
onClick={() => handleComposerAgentSelect(entry)} animate={{ opacity: 1, scale: 1 }}
className={cn( transition={{ delay: 0.2 + index * 0.06, duration: 0.4, ease: "easeOut" }}
"rounded-full px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition relative overflow-hidden group backdrop-blur-sm shadow-[0_2px_8px_rgba(0,0,0,0.15)]", onClick={() => handleComposerAgentSelect(entry)}
isActive className={cn(
? "bg-white/20 text-white shadow-[0_4px_12px_rgba(0,0,0,0.25)]" "rounded-full px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition relative overflow-hidden group backdrop-blur-sm shadow-[0_2px_8px_rgba(0,0,0,0.15)]",
: "bg-white/8 text-white/70 hover:bg-white/15 hover:text-white" isActive
)} ? "bg-white/20 text-white shadow-[0_4px_12px_rgba(0,0,0,0.25)]"
> : "bg-white/8 text-white/70 hover:bg-white/15 hover:text-white"
{!isActive && ( )}
<div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r from-white/10 via-white/5 to-transparent" /> >
)} {!isActive && (
<span className="relative">{entry.name}</span> <div className="absolute inset-0 opacity-0 group-hover:opacity-100 transition-opacity duration-300 bg-gradient-to-r from-white/10 via-white/5 to-transparent" />
</motion.button> )}
) <span className="relative">{entry.name}</span>
})} </motion.button>
{pinnedAgents.slice(0, 2).map((pinnedAgent, index) => { )
})}
{pinnedAgents.map((pinnedAgent, index) => {
const isActive = dropdownSelectedId === pinnedAgent.agentId const isActive = dropdownSelectedId === pinnedAgent.agentId
return ( return (
<motion.button <motion.button
key={pinnedAgent.agentId} key={pinnedAgent.agentId}
initial={{ opacity: 0, scale: 0.95 }} initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }} animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.2 + (agents.length + index) * 0.06, duration: 0.4, ease: "easeOut" }} transition={{ delay: 0.2 + (agents.filter(a => !a.isCustom).length + index) * 0.06, duration: 0.4, ease: "easeOut" }}
onClick={() => handleComposerAgentSelect({ onClick={() => handleComposerAgentSelect({
id: pinnedAgent.agentId, id: pinnedAgent.agentId,
name: pinnedAgent.displayName, name: pinnedAgent.displayName,
description: pinnedAgent.summary || "", description: pinnedAgent.summary || "",
isCustom: true,
webhookUrl: "" // Custom agents use dynamic routing webhookUrl: "" // Custom agents use dynamic routing
} as Agent)} } as Agent)}
className={cn( className={cn(
"rounded-full px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition relative overflow-hidden group backdrop-blur-sm shadow-[0_2px_8px_rgba(0,0,0,0.15)]", "rounded-full px-4 py-2 text-[0.65rem] uppercase tracking-[0.35em] transition relative overflow-hidden group backdrop-blur-sm shadow-[0_2px_8px_rgba(0,0,0,0.15)]",
isActive isActive
? "bg-white/15 text-white shadow-[0_4px_12px_rgba(0,0,0,0.25)]" ? "bg-white/20 text-white shadow-[0_4px_12px_rgba(0,0,0,0.25)]"
: "bg-white/8 text-white/70 hover:bg-white/15 hover:text-white" : "bg-white/8 text-white/70 hover:bg-white/15 hover:text-white"
)} )}
> >

View File

@ -9,7 +9,8 @@ export interface Agent {
id: string id: string
name: string name: string
description: string description: string
webhookUrl: string webhookUrl?: string // Optional: not needed for custom agents (routed dynamically)
isCustom?: boolean // Flag to distinguish custom agents
} }
/** /**

View File

@ -20,7 +20,7 @@
"AGENT_2_URL": "https://n8n.biohazardvfx.com/webhook/0884bd10-256d-441c-971c-b9f1c8506fdf", "AGENT_2_URL": "https://n8n.biohazardvfx.com/webhook/0884bd10-256d-441c-971c-b9f1c8506fdf",
"AGENT_2_NAME": "Morgan", "AGENT_2_NAME": "Morgan",
"AGENT_2_DESCRIPTION": "System Prompt Designer", "AGENT_2_DESCRIPTION": "System Prompt Designer",
"CUSTOM_AGENT_WEBHOOK": "https://n8n.biohazardvfx.com/webhook-test/7cbdc539-526f-425f-abea-0886ec4c1e76", "CUSTOM_AGENT_WEBHOOK": "https://n8n.biohazardvfx.com/webhook/7cbdc539-526f-425f-abea-0886ec4c1e76",
"IMAGE_UPLOADS_ENABLED": "true", "IMAGE_UPLOADS_ENABLED": "true",
"DIFF_TOOL_ENABLED": "true" "DIFF_TOOL_ENABLED": "true"
}, },