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
62 changes: 61 additions & 1 deletion apps/mesh/src/api/routes/decopilot/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ import {
import { PersistedRunConfigSchema, toModelsConfig } from "./run-config";
import { StreamRequestSchema } from "./schemas";
import type { ChatMessage, ModelsConfig } from "./types";
import { streamCore } from "./stream-core";
import { streamCore, createLanguageModel } from "./stream-core";
import { RunClaimError } from "./run-reactor";
import { genSuggestions } from "./suggestions-generator";
import { wrapWithSseKeepalive } from "./sse-keepalive";
import type { SqlThreadStorage } from "@/storage/threads";
import { getPodId } from "@/core/pod-identity";
Expand Down Expand Up @@ -595,5 +596,64 @@ export function createDecopilotRoutes(deps: DecopilotDeps) {
}
});

// ============================================================================
// Agent Suggestions Endpoint — generates 3 starter questions for an agent
// ============================================================================

app.post("/:org/decopilot/agent-suggestions", async (c) => {
try {
const ctx = c.get("meshContext");
const organization = ensureOrganization(c);

const { virtualMcpId } = await c.req.json();
if (typeof virtualMcpId !== "string") {
return c.json({ error: "virtualMcpId required" }, 400);
}

const [virtualMcp, models] = await Promise.all([
ctx.storage.virtualMcps.findById(virtualMcpId, organization.id),
resolvePerRequestModels(ctx, undefined).catch(() => null),
]);

if (!virtualMcp || virtualMcp.organization_id !== organization.id) {
return c.json({ error: "Agent not found" }, 404);
}

const agentDescription = [
virtualMcp.metadata?.instructions,
virtualMcp.description,
virtualMcp.title,
]
.filter(Boolean)
.join("\n\n");

if (!models) {
return c.json({ suggestions: [] });
}

const provider = await ctx.aiProviders
.activate(models.credentialId, organization.id)
.catch(() => null);

if (!provider) {
return c.json({ suggestions: [] });
}

const suggestions = await genSuggestions({
abortSignal: c.req.raw.signal,
model: createLanguageModel(provider, models.fast ?? models.thinking),
agentDescription,
});

return c.json({ suggestions: suggestions ?? [] });
} catch (err) {
if (err instanceof HTTPException) {
return c.json({ error: err.message }, err.status);
}
console.error("[decopilot:agent-suggestions] Error", err);
return c.json({ suggestions: [] });
}
});

return app;
}
73 changes: 73 additions & 0 deletions apps/mesh/src/api/routes/decopilot/suggestions-generator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
/**
* Agent Suggestions Generator
*
* Generates 3 starter questions for an agent based on its system prompt,
* using the same LLM approach as title generation.
*/

import type { LanguageModelV3 } from "@ai-sdk/provider";
import { generateText } from "ai";

const SUGGESTIONS_GENERATOR_PROMPT = `Given the following description or instructions for an AI agent, generate 3 short, specific starter questions or requests a user might ask.

Rules:
- Make them specific to this agent's domain, not generic
- Each suggestion under 70 characters
- Use action form: "Can you...?" or direct imperative
- Sentence case only (capitalize first word and proper nouns)
- Return JSON: {"suggestions": ["...", "...", "..."]}

Good example for a Site Diagnostics agent:
{"suggestions": ["Can you audit my website performance?", "Run an SEO analysis on my page", "Check my Core Web Vitals score"]}

Good example for a Code Review agent:
{"suggestions": ["Review my latest pull request", "Can you check for security issues?", "Help me refactor this function"]}`;

export async function genSuggestions(config: {
abortSignal: AbortSignal;
model: LanguageModelV3;
agentDescription: string;
}): Promise<string[] | null> {
const { abortSignal, model, agentDescription } = config;

try {
const result = await generateText({
model,
system: SUGGESTIONS_GENERATOR_PROMPT,
messages: [{ role: "user", content: agentDescription }],
maxOutputTokens: 200,
temperature: 0.3,
abortSignal,
});

const cleaned = result.text
.trim()
.replace(/^```(?:json)?\s*\n?/i, "")
.replace(/\n?```\s*$/, "")
.trim();

const parsed = JSON.parse(cleaned);
const suggestions = parsed.suggestions;

if (
!Array.isArray(suggestions) ||
!suggestions.every((s) => typeof s === "string")
) {
return null;
}

return suggestions.slice(0, 3).map((s: string) =>
s
.replace(/^["']|["']$/g, "")
.replace(/[.!?]$/, "")
.slice(0, 80)
.trim(),
);
} catch (error) {
const err = error as Error;
if (err.name !== "AbortError") {
console.error("[decopilot:suggestions] Failed to generate:", err.message);
}
return null;
}
}
10 changes: 10 additions & 0 deletions apps/mesh/src/web/components/chat/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { IceBreakers } from "./ice-breakers";
import { ChatInput } from "./input";
import { MessagePair, useMessagePairs } from "./message/pair.tsx";
import { NoAiProviderEmptyState } from "./no-ai-provider-empty-state";
import { AfterMessageSuggestions } from "./thread-suggestions";
import { CreditsEmptyState } from "./credits-empty-state";
import { CreditsExhaustedBanner } from "./credits-exhausted-banner";
import { CreditsEyebrow, NoCreditsEyebrow } from "./credits-eyebrow";
Expand Down Expand Up @@ -69,6 +70,12 @@ function ChatMessages() {
(p) => "state" in p && p.state === "approval-requested",
);

const showSuggestions =
!isStreaming &&
!hasActiveUserAsk &&
!hasActivePendingApprovals &&
lastMessagePair?.assistant != null;

return (
<div className="w-full min-w-0 max-w-full overflow-y-auto h-full overflow-x-hidden">
<div className="flex flex-col min-w-0 max-w-2xl mx-auto w-full">
Expand All @@ -94,6 +101,9 @@ function ChatMessages() {
isLastPair={true}
status={status}
/>
{showSuggestions && (
<AfterMessageSuggestions className="px-4 pb-6 pt-2" />
)}
</div>
)}
</div>
Expand Down
86 changes: 54 additions & 32 deletions apps/mesh/src/web/components/chat/side-panel-chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,16 @@ import { BranchPicker } from "../thread/github/branch-picker.tsx";

import { useAiProviderKeys } from "@/web/hooks/collections/use-ai-providers";
import { useDecoCredits } from "@/web/hooks/use-deco-credits";
import { ThreadSuggestions } from "./thread-suggestions";
import { useAgentSuggestions } from "@/web/hooks/use-agent-suggestions";
import type { TiptapDoc } from "./types";

// ---------- Default sidebar empty state ----------

function SidebarEmptyState() {
const { org } = useProjectContext();
const { selectedVirtualMcp } = useChatPrefs();
const { sendMessage } = useChatStream();
const { data: session } = authClient.useSession();
const { currentBranch, setCurrentTaskBranch } = useChatTask();

Expand All @@ -35,41 +39,59 @@ function SidebarEmptyState() {
const githubRepo = fullVm?.metadata?.githubRepo ?? null;
const showBranchPicker = !!githubRepo?.connectionId && !!userId;

const suggestions = useAgentSuggestions(displayAgent.id);

const handleSuggestionSelect = async (text: string) => {
const doc: TiptapDoc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
};
await sendMessage(doc);
};

return (
<div className="h-full w-full flex flex-col items-center justify-center gap-6 px-4">
<div className="flex flex-col items-center justify-center gap-2 md:gap-4 text-center">
<IntegrationIcon
icon={displayAgent.icon}
name={displayAgent.title}
size="lg"
fallbackIcon={<Users03 size={32} />}
className="size-10 min-w-10 md:size-[60px]! md:min-w-[60px] rounded-xl md:rounded-[18px]!"
/>
<h3 className="text-base md:text-xl font-medium text-foreground">
{displayAgent.title}
</h3>
<div className="text-muted-foreground text-center text-base max-w-md line-clamp-2">
{displayAgent.description ??
"Ask anything about configuring model providers or using MCP Mesh."}
</div>
{showBranchPicker && (
<div className="mt-2">
<BranchPicker
orgId={org.id}
orgSlug={org.slug}
userId={userId}
connectionId={githubRepo.connectionId!}
owner={githubRepo.owner}
repo={githubRepo.name}
vmMap={fullVm?.metadata?.vmMap}
value={currentBranch ?? undefined}
onChange={setCurrentTaskBranch}
/>
<div className="h-full w-full flex flex-col px-4 max-w-2xl mx-auto">
<div className="flex-1 flex flex-col items-center justify-center gap-6">
<div className="flex flex-col items-center justify-center gap-2 md:gap-4 text-center">
<IntegrationIcon
icon={displayAgent.icon}
name={displayAgent.title}
size="lg"
fallbackIcon={<Users03 size={32} />}
className="size-10 min-w-10 md:size-[60px]! md:min-w-[60px] rounded-xl md:rounded-[18px]!"
/>
<h3 className="text-base md:text-xl font-medium text-foreground">
{displayAgent.title}
</h3>
<div className="text-muted-foreground text-center text-base max-w-md line-clamp-2">
{displayAgent.description ??
"Ask anything about configuring model providers or using MCP Mesh."}
</div>
)}
{showBranchPicker && (
<div className="mt-2">
<BranchPicker
orgId={org.id}
orgSlug={org.slug}
userId={userId}
connectionId={githubRepo.connectionId!}
owner={githubRepo.owner}
repo={githubRepo.name}
vmMap={fullVm?.metadata?.vmMap}
value={currentBranch ?? undefined}
onChange={setCurrentTaskBranch}
/>
</div>
)}
</div>
<div className="w-full">
<Chat.IceBreakers />
</div>
</div>
<div className="w-full max-w-3xl mx-auto">
<Chat.IceBreakers />
<div className="w-full pb-4">
<ThreadSuggestions
suggestions={suggestions}
onSelect={handleSuggestionSelect}
/>
</div>
</div>
);
Expand Down
81 changes: 81 additions & 0 deletions apps/mesh/src/web/components/chat/thread-suggestions.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { CornerDownRight } from "@untitledui/icons";
import { cn } from "@deco/ui/lib/utils.ts";
import { useChatPrefs, useChatStream } from "./context";
import { useAgentSuggestions } from "@/web/hooks/use-agent-suggestions";
import type { TiptapDoc } from "./types";

// ── UI ─────────────────────────────────────────────────────────────────────

interface ThreadSuggestionsProps {
suggestions: string[];
onSelect: (text: string) => void;
disabled?: boolean;
className?: string;
}

export function ThreadSuggestions({
suggestions,
onSelect,
disabled,
className,
}: ThreadSuggestionsProps) {
if (suggestions.length === 0) return null;

return (
<div className={cn("flex flex-col items-start gap-2", className)}>
{suggestions.map((suggestion, index) => (
<button
key={suggestion}
type="button"
disabled={disabled}
onClick={() => onSelect(suggestion)}
style={{ animationDelay: `${index * 60}ms` }}
className={cn(
"flex items-center gap-2 px-4 py-2.5 rounded-full",
"bg-muted text-muted-foreground text-sm font-medium",
"transition-colors hover:bg-muted/70 hover:text-foreground",
"animate-in fade-in-0 slide-in-from-bottom-1 duration-200 ease-out",
"motion-reduce:animate-none",
disabled && "opacity-50 cursor-not-allowed",
)}
>
<CornerDownRight size={14} className="shrink-0" />
<span>{suggestion}</span>
</button>
))}
</div>
);
}

// ── After-message container ────────────────────────────────────────────────

function useSuggestionSend() {
const { sendMessage, isStreaming } = useChatStream();

const send = async (text: string) => {
const doc: TiptapDoc = {
type: "doc",
content: [{ type: "paragraph", content: [{ type: "text", text }] }],
};
await sendMessage(doc);
};

return { send, disabled: isStreaming };
}

export function AfterMessageSuggestions({ className }: { className?: string }) {
const { selectedVirtualMcp } = useChatPrefs();
const { send, disabled } = useSuggestionSend();
const suggestions = useAgentSuggestions(selectedVirtualMcp?.id);

if (suggestions.length === 0) return null;

return (
<ThreadSuggestions
suggestions={suggestions}
onSelect={send}
disabled={disabled}
className={className}
/>
);
}
Loading
Loading