Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ca434e546f | |||
| 43ad5fcd2f | |||
| 13e63db468 | |||
| dfcadcaaf5 | |||
| 76360ea261 | |||
| d6fa0d64ff | |||
| f63b7c67a3 | |||
| 830b830154 | |||
| 0772ab4106 | |||
| 0e204a2a9a | |||
| e209bac792 | |||
| 669d396f7c | |||
| 36628d6bd3 | |||
| f35a328e8a | |||
| 3e42603e03 | |||
| 94af0df33e |
@@ -0,0 +1,16 @@
|
||||
---
|
||||
area: webapp
|
||||
type: fix
|
||||
---
|
||||
|
||||
Fix the dashboard AI assistant (`dashboard-assistant` chat.agent) silently not
|
||||
responding to messages. The session is started via `chat.createStartSessionAction`,
|
||||
which triggers the first run with `trigger: "preload"`, so every chat boots
|
||||
preloaded. The agent only created its `AiChat`/`AiChatSession` rows in
|
||||
`onChatStart`, which early-returns on preloaded runs — so the rows were never
|
||||
created and `onTurnStart`'s `aiChat.update(...)` threw before `run()` streamed.
|
||||
|
||||
Adds an `onPreload` hook that creates the rows (with `onChatStart` kept as the
|
||||
non-preloaded fallback), and declares `tools` on the agent config (function form)
|
||||
read back from the `run()` payload so the SDK re-applies each tool's
|
||||
`toModelOutput` when re-converting history on later turns.
|
||||
@@ -1,549 +1,46 @@
|
||||
import {
|
||||
ArrowPathIcon,
|
||||
ArrowUpIcon,
|
||||
HandThumbDownIcon,
|
||||
HandThumbUpIcon,
|
||||
StopIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { type FeedbackComment, KapaProvider, type QA, useChat } from "@kapaai/react-sdk";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { motion } from "framer-motion";
|
||||
import { marked } from "marked";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTypedRouteLoaderData } from "remix-typedjson";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { useFeatures } from "~/hooks/useFeatures";
|
||||
import { type loader } from "~/root";
|
||||
import { Button } from "./primitives/Buttons";
|
||||
import { Callout } from "./primitives/Callout";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "./primitives/Dialog";
|
||||
import { Header2 } from "./primitives/Headers";
|
||||
import { Paragraph } from "./primitives/Paragraph";
|
||||
import { ShortcutKey } from "./primitives/ShortcutKey";
|
||||
import { Spinner } from "./primitives/Spinner";
|
||||
import {
|
||||
SimpleTooltip,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "./primitives/Tooltip";
|
||||
import { ClientOnly } from "remix-utils/client-only";
|
||||
import { useOptionalAIChat } from "./ai-assistant/AIChatProvider";
|
||||
|
||||
function useKapaWebsiteId() {
|
||||
const routeMatch = useTypedRouteLoaderData<typeof loader>("root");
|
||||
return routeMatch?.kapa.websiteId;
|
||||
}
|
||||
export function AskAI() {
|
||||
const chat = useOptionalAIChat();
|
||||
|
||||
export function AskAI({ isCollapsed = false }: { isCollapsed?: boolean }) {
|
||||
const { isManagedCloud } = useFeatures();
|
||||
const websiteId = useKapaWebsiteId();
|
||||
|
||||
if (!isManagedCloud || !websiteId) {
|
||||
// The provider is only mounted in the project layout. On account/settings
|
||||
// pages there's no assistant, so render nothing. Hide while the drawer is open.
|
||||
if (!chat || chat.isOpen) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ClientOnly
|
||||
fallback={
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
disabled
|
||||
className={isCollapsed ? "w-full justify-center" : ""}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{() => <AskAIProvider websiteId={websiteId} isCollapsed={isCollapsed} />}
|
||||
</ClientOnly>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
onClick={() => chat.toggle()}
|
||||
LeadingIcon={AISparkleIcon}
|
||||
leadingIconClassName="motion-safe:group-hover/button:animate-ai-sparkle-hover motion-reduce:group-hover/button:animate-none"
|
||||
>
|
||||
Ask AI
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={8} className="flex items-center gap-2 text-xs">
|
||||
AI Assistant
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIProviderProps = {
|
||||
websiteId: string;
|
||||
isCollapsed?: boolean;
|
||||
};
|
||||
|
||||
function AskAIProvider({ websiteId, isCollapsed = false }: AskAIProviderProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [initialQuery, setInitialQuery] = useState<string | undefined>();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
const openAskAI = useCallback((question?: string) => {
|
||||
if (question) {
|
||||
setInitialQuery(question);
|
||||
} else {
|
||||
setInitialQuery(undefined);
|
||||
}
|
||||
setIsOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAskAI = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setInitialQuery(undefined);
|
||||
}, []);
|
||||
|
||||
// Handle URL param functionality
|
||||
useEffect(() => {
|
||||
const aiHelp = searchParams.get("aiHelp");
|
||||
if (aiHelp) {
|
||||
// Delay to avoid hCaptcha bot detection
|
||||
window.setTimeout(() => openAskAI(aiHelp), 1000);
|
||||
|
||||
// Clone instead of mutating in place
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete("aiHelp");
|
||||
setSearchParams(next);
|
||||
}
|
||||
}, [searchParams, openAskAI]);
|
||||
|
||||
return (
|
||||
<KapaProvider
|
||||
integrationId={websiteId}
|
||||
callbacks={{
|
||||
askAI: {
|
||||
onQuerySubmit: () => openAskAI(),
|
||||
onAnswerGenerationCompleted: () => openAskAI(),
|
||||
},
|
||||
}}
|
||||
botProtectionMechanism="hcaptcha"
|
||||
>
|
||||
<motion.div layout="position" transition={{ duration: 0.2, ease: "easeInOut" }}>
|
||||
<TooltipProvider disableHoverableContent>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={cn("inline-flex h-8", isCollapsed && "w-full")}>
|
||||
<Button
|
||||
variant="small-menu-item"
|
||||
data-action="ask-ai"
|
||||
shortcut={{ modifiers: ["mod"], key: "i", enabledOnInputElements: true }}
|
||||
hideShortcutKey
|
||||
data-modal-override-open-class-ask-ai="true"
|
||||
onClick={() => openAskAI()}
|
||||
fullWidth={isCollapsed}
|
||||
className={cn("h-full", isCollapsed && "justify-center")}
|
||||
>
|
||||
<AISparkleIcon className="size-5" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
sideOffset={8}
|
||||
className="flex items-center gap-2 text-xs"
|
||||
>
|
||||
Ask AI
|
||||
<span className="flex items-center">
|
||||
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
|
||||
<ShortcutKey shortcut={{ key: "i" }} variant="medium/bright" />
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
</motion.div>
|
||||
<AskAIDialog
|
||||
initialQuery={initialQuery}
|
||||
isOpen={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
closeAskAI={closeAskAI}
|
||||
/>
|
||||
</KapaProvider>
|
||||
);
|
||||
}
|
||||
|
||||
type AskAIDialogProps = {
|
||||
initialQuery?: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
closeAskAI: () => void;
|
||||
};
|
||||
|
||||
function AskAIDialog({ initialQuery, isOpen, onOpenChange, closeAskAI }: AskAIDialogProps) {
|
||||
const handleOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
closeAskAI();
|
||||
} else {
|
||||
onOpenChange(open);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="animated-gradient-glow flex max-h-[90vh] min-h-fit w-full flex-col justify-between gap-0 px-0 pb-0 pt-0 sm:max-w-prose">
|
||||
<DialogHeader className="flex h-[2.75rem] items-start justify-center rounded-t-md bg-background-bright pl-3">
|
||||
<div className="flex items-center gap-1">
|
||||
<AISparkleIcon className="size-5" />
|
||||
<DialogTitle className="text-sm font-medium text-text-bright">Ask AI</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
<ChatInterface initialQuery={initialQuery} />
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatMessages({
|
||||
conversation,
|
||||
isPreparingAnswer,
|
||||
isGeneratingAnswer,
|
||||
onReset,
|
||||
onExampleClick,
|
||||
error,
|
||||
addFeedback,
|
||||
}: {
|
||||
conversation: QA[];
|
||||
isPreparingAnswer: boolean;
|
||||
isGeneratingAnswer: boolean;
|
||||
onReset: () => void;
|
||||
onExampleClick: (question: string) => void;
|
||||
error: string | null;
|
||||
addFeedback: (
|
||||
questionAnswerId: string,
|
||||
reaction: "upvote" | "downvote",
|
||||
comment?: FeedbackComment
|
||||
) => void;
|
||||
}) {
|
||||
const [feedbackGivenForQAs, setFeedbackGivenForQAs] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset feedback state when conversation is reset
|
||||
useEffect(() => {
|
||||
if (conversation.length === 0) {
|
||||
setFeedbackGivenForQAs(new Set());
|
||||
}
|
||||
}, [conversation.length]);
|
||||
|
||||
// Check if feedback has been given for the latest QA
|
||||
const latestQA = conversation[conversation.length - 1];
|
||||
const hasFeedbackForLatestQA = latestQA?.id ? feedbackGivenForQAs.has(latestQA.id) : false;
|
||||
|
||||
const exampleQuestions = [
|
||||
"How do I increase my concurrency limit?",
|
||||
"How do I debug errors in my task?",
|
||||
"How do I deploy my task?",
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto p-4 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600">
|
||||
{conversation.length === 0 ? (
|
||||
<motion.div
|
||||
className="flex flex-col gap-2 pb-2"
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
variants={{
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.1,
|
||||
delayChildren: 0.2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Paragraph className="mb-3 mt-1.5 pl-1">
|
||||
I'm trained on docs, examples, and other content. Ask me anything about Trigger.dev.
|
||||
</Paragraph>
|
||||
{exampleQuestions.map((question, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 transition-colors hover:border-solid hover:border-indigo-500"
|
||||
onClick={() => onExampleClick(question)}
|
||||
variants={{
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
x: 20,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
opacity: {
|
||||
duration: 0.5,
|
||||
ease: "linear",
|
||||
},
|
||||
x: {
|
||||
type: "spring",
|
||||
stiffness: 300,
|
||||
damping: 25,
|
||||
},
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<SparkleListIcon className="size-4 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph variant="small" className="transition group-hover:text-text-bright">
|
||||
{question}
|
||||
</Paragraph>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
) : (
|
||||
conversation.map((qa) => (
|
||||
<div key={qa.id || `temp-${qa.question}`} className="mb-4">
|
||||
<Header2 spacing>{qa.question}</Header2>
|
||||
<div
|
||||
className="prose prose-invert max-w-none text-text-dimmed"
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(marked(qa.answer)) }}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{conversation.length > 0 &&
|
||||
!isPreparingAnswer &&
|
||||
!isGeneratingAnswer &&
|
||||
!error &&
|
||||
!latestQA?.id && (
|
||||
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Answer generation was stopped
|
||||
</Paragraph>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<ArrowPathIcon className="size-4" />}
|
||||
onClick={onReset}
|
||||
className="w-fit pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Reset chat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{conversation.length > 0 &&
|
||||
!isPreparingAnswer &&
|
||||
!isGeneratingAnswer &&
|
||||
!error &&
|
||||
latestQA?.id && (
|
||||
<div className="flex items-center justify-between border-t border-grid-bright pt-3">
|
||||
{hasFeedbackForLatestQA ? (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Thanks for your feedback!
|
||||
</Paragraph>
|
||||
</motion.div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Paragraph variant="small" className="text-text-dimmed">
|
||||
Was this helpful?
|
||||
</Paragraph>
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
const latestQA = conversation[conversation.length - 1];
|
||||
if (latestQA?.id) {
|
||||
addFeedback(latestQA.id, "upvote");
|
||||
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
|
||||
}
|
||||
}}
|
||||
className="size-8 px-1.5"
|
||||
>
|
||||
<HandThumbUpIcon className="size-4 text-text-dimmed transition group-hover/button:text-success" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
onClick={() => {
|
||||
const latestQA = conversation[conversation.length - 1];
|
||||
if (latestQA?.id) {
|
||||
addFeedback(latestQA.id, "downvote");
|
||||
setFeedbackGivenForQAs((prev) => new Set(prev).add(latestQA.id));
|
||||
}
|
||||
}}
|
||||
className="size-8 px-1.5"
|
||||
>
|
||||
<HandThumbDownIcon className="size-4 text-text-dimmed transition group-hover/button:text-error" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="minimal/small"
|
||||
LeadingIcon={<ArrowPathIcon className="size-4" />}
|
||||
onClick={onReset}
|
||||
className="w-fit pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Reset chat
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isPreparingAnswer && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 1)",
|
||||
foreground: "rgba(217, 70, 239, 1)",
|
||||
}}
|
||||
className="size-4"
|
||||
/>
|
||||
<Paragraph className="text-text-dimmed">Preparing answer…</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="flex flex-col">
|
||||
<Callout variant="error" className="mb-4">
|
||||
<Paragraph className="font-semibold text-error">Error generating answer:</Paragraph>
|
||||
<Paragraph className="text-rose-300">
|
||||
{error} If the problem persists after retrying, please contact support.
|
||||
</Paragraph>
|
||||
</Callout>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="secondary/small"
|
||||
LeadingIcon={<ArrowPathIcon className="size-4" />}
|
||||
onClick={onReset}
|
||||
className="w-fit pl-1.5"
|
||||
iconSpacing="gap-x-1.5"
|
||||
>
|
||||
Reset chat
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ChatInterface({ initialQuery }: { initialQuery?: string }) {
|
||||
const [message, setMessage] = useState("");
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const hasSubmittedInitialQuery = useRef(false);
|
||||
const {
|
||||
conversation,
|
||||
submitQuery,
|
||||
isGeneratingAnswer,
|
||||
isPreparingAnswer,
|
||||
resetConversation,
|
||||
stopGeneration,
|
||||
error,
|
||||
addFeedback,
|
||||
} = useChat();
|
||||
|
||||
useEffect(() => {
|
||||
if (initialQuery && !hasSubmittedInitialQuery.current) {
|
||||
hasSubmittedInitialQuery.current = true;
|
||||
setIsExpanded(true);
|
||||
submitQuery(initialQuery);
|
||||
}
|
||||
}, [initialQuery, submitQuery]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (message.trim()) {
|
||||
setIsExpanded(true);
|
||||
submitQuery(message);
|
||||
setMessage("");
|
||||
}
|
||||
};
|
||||
|
||||
const handleExampleClick = (question: string) => {
|
||||
setIsExpanded(true);
|
||||
submitQuery(question);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetConversation();
|
||||
setIsExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
className="flex h-full max-h-[90vh] grow flex-col overflow-y-auto rounded-b-md bg-background-bright"
|
||||
animate={{ height: isExpanded ? "90vh" : "auto" }}
|
||||
transition={{ type: "spring", damping: 25, stiffness: 300 }}
|
||||
initial={{ height: "auto" }}
|
||||
>
|
||||
<ChatMessages
|
||||
conversation={conversation}
|
||||
isPreparingAnswer={isPreparingAnswer}
|
||||
isGeneratingAnswer={isGeneratingAnswer}
|
||||
onReset={handleReset}
|
||||
onExampleClick={handleExampleClick}
|
||||
error={error}
|
||||
addFeedback={addFeedback}
|
||||
/>
|
||||
<form onSubmit={handleSubmit} className="flex-shrink-0 border-t border-grid-bright p-4">
|
||||
<div className="flex gap-3">
|
||||
<input
|
||||
type="text"
|
||||
value={message}
|
||||
onChange={(e) => setMessage(e.target.value)}
|
||||
placeholder="Ask a question..."
|
||||
disabled={isGeneratingAnswer}
|
||||
autoFocus
|
||||
className="flex-1 rounded-md border border-grid-bright bg-background-dimmed px-3 py-2 text-text-bright placeholder:text-text-dimmed focus-visible:focus-custom"
|
||||
/>
|
||||
{isGeneratingAnswer ? (
|
||||
<SimpleTooltip
|
||||
button={
|
||||
<span
|
||||
onClick={() => stopGeneration()}
|
||||
className="group relative z-10 flex size-10 min-w-10 cursor-pointer items-center justify-center"
|
||||
>
|
||||
<StopIcon className="z-10 size-5 text-indigo-500 transition group-hover:text-indigo-400" />
|
||||
<GradientSpinnerBackground
|
||||
className="absolute inset-0 animate-spin"
|
||||
hoverEffect
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
content="Stop generating"
|
||||
/>
|
||||
) : isPreparingAnswer ? (
|
||||
<GradientSpinnerBackground className="flex size-10 min-w-10 items-center justify-center">
|
||||
<Spinner
|
||||
color={{
|
||||
background: "rgba(99, 102, 241, 1)",
|
||||
foreground: "rgba(217, 70, 239, 1)",
|
||||
}}
|
||||
className="size-5"
|
||||
/>
|
||||
</GradientSpinnerBackground>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!message.trim()}
|
||||
LeadingIcon={<ArrowUpIcon className="size-5 text-text-bright" />}
|
||||
variant="primary/large"
|
||||
className="size-10 min-w-10 rounded-full group-disabled/button:border-charcoal-550 group-disabled/button:bg-charcoal-600"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
function GradientSpinnerBackground({
|
||||
children,
|
||||
className,
|
||||
hoverEffect = false,
|
||||
}: {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
hoverEffect?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex rounded-full bg-gradient-to-br from-indigo-500 via-purple-500 to-fuchsia-500 p-px ${className}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-full w-full items-center justify-center rounded-full bg-charcoal-600 ${
|
||||
hoverEffect ? "transition group-hover:bg-charcoal-550" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
interface AIChatContextBannerProps {
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
currentPage: string;
|
||||
}
|
||||
|
||||
export function AIChatContextBanner({
|
||||
projectSlug,
|
||||
environmentSlug,
|
||||
currentPage,
|
||||
}: AIChatContextBannerProps) {
|
||||
if (!projectSlug) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 border-b border-grid-bright bg-charcoal-800/30 px-3 py-1.5 text-xs text-text-dimmed">
|
||||
<span className="font-medium text-text-bright">{projectSlug}</span>
|
||||
<span>/</span>
|
||||
<span>{environmentSlug}</span>
|
||||
<span>/</span>
|
||||
<span className="capitalize">{currentPage}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { PlusIcon, ClockIcon } from "@heroicons/react/20/solid";
|
||||
import { useCallback, useLayoutEffect, useRef, useState } from "react";
|
||||
import { AISparkleIcon } from "~/assets/icons/AISparkleIcon";
|
||||
import { ExitIcon } from "~/assets/icons/ExitIcon";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { useAIChat } from "./AIChatProvider";
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const HOUR = 60 * MINUTE;
|
||||
const DAY = 24 * HOUR;
|
||||
|
||||
function formatRelativeTime(iso: string): string {
|
||||
const then = new Date(iso).getTime();
|
||||
if (Number.isNaN(then)) return "";
|
||||
const diff = Date.now() - then;
|
||||
|
||||
if (diff < MINUTE) return "Just now";
|
||||
if (diff < HOUR) {
|
||||
const mins = Math.floor(diff / MINUTE);
|
||||
return `${mins} minute${mins === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (diff < DAY) {
|
||||
const hours = Math.floor(diff / HOUR);
|
||||
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (diff < 2 * DAY) return "Yesterday";
|
||||
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||
}
|
||||
|
||||
const SCROLL_END_THRESHOLD_PX = 4;
|
||||
|
||||
type ScrollFadeEdge = "top" | "bottom";
|
||||
|
||||
const SCROLL_FADE_HEIGHT = "h-8";
|
||||
|
||||
const scrollFadeBlurLayers: Record<ScrollFadeEdge, { blur: string; mask: string }[]> = {
|
||||
bottom: [
|
||||
{
|
||||
blur: "backdrop-blur-[2px]",
|
||||
mask: "linear-gradient(to top, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.12) 50%, transparent 100%)",
|
||||
},
|
||||
{
|
||||
blur: "backdrop-blur-[6px]",
|
||||
mask: "linear-gradient(to top, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 45%, transparent 100%)",
|
||||
},
|
||||
{
|
||||
blur: "backdrop-blur-[14px]",
|
||||
mask: "linear-gradient(to top, black 0%, rgba(0,0,0,0.35) 40%, transparent 100%)",
|
||||
},
|
||||
],
|
||||
top: [
|
||||
{
|
||||
blur: "backdrop-blur-[2px]",
|
||||
mask: "linear-gradient(to bottom, rgba(0,0,0,0.7) 0%, rgba(0,0,0,0.12) 50%, transparent 100%)",
|
||||
},
|
||||
{
|
||||
blur: "backdrop-blur-[6px]",
|
||||
mask: "linear-gradient(to bottom, rgba(0,0,0,0.85) 0%, rgba(0,0,0,0.2) 45%, transparent 100%)",
|
||||
},
|
||||
{
|
||||
blur: "backdrop-blur-[14px]",
|
||||
mask: "linear-gradient(to bottom, black 0%, rgba(0,0,0,0.35) 40%, transparent 100%)",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function historyListLabel(chat: { title: string | null; updatedAt: string }) {
|
||||
if (chat.title && chat.title !== "New chat") return chat.title;
|
||||
return formatRelativeTime(chat.updatedAt);
|
||||
}
|
||||
|
||||
function ScrollEdgeGradientBlur({ edge, visible }: { edge: ScrollFadeEdge; visible: boolean }) {
|
||||
const tintMask =
|
||||
edge === "bottom"
|
||||
? "linear-gradient(to top, black 0%, transparent 72%)"
|
||||
: "linear-gradient(to bottom, black 0%, transparent 72%)";
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"pointer-events-none absolute inset-x-0 z-10 transition-opacity duration-300",
|
||||
SCROLL_FADE_HEIGHT,
|
||||
edge === "bottom" ? "bottom-0" : "top-0",
|
||||
visible ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
>
|
||||
{scrollFadeBlurLayers[edge].map((layer) => (
|
||||
<div
|
||||
key={layer.blur}
|
||||
className={cn("absolute inset-0", layer.blur)}
|
||||
style={{ WebkitMaskImage: layer.mask, maskImage: layer.mask }}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0",
|
||||
edge === "bottom"
|
||||
? "bg-gradient-to-t from-background-bright/35 via-background-bright/8 to-transparent"
|
||||
: "bg-gradient-to-b from-background-bright/35 via-background-bright/8 to-transparent"
|
||||
)}
|
||||
style={{ WebkitMaskImage: tintMask, maskImage: tintMask }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function measureScrollFades(el: HTMLDivElement) {
|
||||
const canScroll = el.scrollHeight > el.clientHeight + 1;
|
||||
const atTop = el.scrollTop <= SCROLL_END_THRESHOLD_PX;
|
||||
const atBottom =
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight <= SCROLL_END_THRESHOLD_PX;
|
||||
return {
|
||||
showTop: canScroll && !atTop,
|
||||
showBottom: canScroll && !atBottom,
|
||||
};
|
||||
}
|
||||
|
||||
function ChatHistoryList({
|
||||
chats,
|
||||
currentChatId,
|
||||
isOpen,
|
||||
onSelect,
|
||||
}: {
|
||||
chats: { id: string; title: string | null; updatedAt: string }[];
|
||||
currentChatId: string;
|
||||
isOpen: boolean;
|
||||
onSelect: (chatId: string) => void;
|
||||
}) {
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
const [scrollFades, setScrollFades] = useState({ showTop: false, showBottom: false });
|
||||
|
||||
const updateScrollFades = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) {
|
||||
setScrollFades({ showTop: false, showBottom: false });
|
||||
return;
|
||||
}
|
||||
setScrollFades(measureScrollFades(el));
|
||||
}, []);
|
||||
|
||||
const setScrollContainerRef = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
scrollRef.current = node;
|
||||
if (node) {
|
||||
setScrollFades(measureScrollFades(node));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!isOpen) {
|
||||
setScrollFades({ showTop: false, showBottom: false });
|
||||
return;
|
||||
}
|
||||
|
||||
updateScrollFades();
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const observer = new ResizeObserver(updateScrollFades);
|
||||
observer.observe(el);
|
||||
|
||||
const raf = requestAnimationFrame(() => {
|
||||
requestAnimationFrame(updateScrollFades);
|
||||
});
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
cancelAnimationFrame(raf);
|
||||
};
|
||||
}, [isOpen, chats, updateScrollFades]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<ScrollEdgeGradientBlur edge="top" visible={scrollFades.showTop} />
|
||||
<div
|
||||
ref={setScrollContainerRef}
|
||||
onScroll={updateScrollFades}
|
||||
className="max-h-[360px] overflow-y-auto py-1 pb-3 pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
{chats.map((chat) => {
|
||||
const isActive = chat.id === currentChatId;
|
||||
const hasTitle = chat.title && chat.title !== "New chat";
|
||||
return (
|
||||
<button
|
||||
key={chat.id}
|
||||
onClick={() => onSelect(chat.id)}
|
||||
className={cn(
|
||||
"flex w-full flex-col gap-0.5 py-2 text-left transition-colors hover:bg-charcoal-700/50",
|
||||
isActive ? "border-l-2 border-indigo-500 pl-2.5 pr-3" : "px-3"
|
||||
)}
|
||||
>
|
||||
<span className="truncate text-sm text-text-bright">{historyListLabel(chat)}</span>
|
||||
{hasTitle ? (
|
||||
<span className="text-xs text-text-dimmed">{formatRelativeTime(chat.updatedAt)}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<ScrollEdgeGradientBlur edge="bottom" visible={scrollFades.showBottom} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIChatHeader() {
|
||||
const { close, startNewChat, chatHistory, switchChat, currentChatId } = useAIChat();
|
||||
const [historyOpen, setHistoryOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-11 items-center justify-between border-b border-grid-bright px-3">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="inline-flex motion-safe:hover:animate-ai-sparkle-hover motion-reduce:hover:animate-none">
|
||||
<AISparkleIcon className="size-4" />
|
||||
</span>
|
||||
<span className="text-sm font-medium text-text-bright">AI Assistant</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Popover open={historyOpen} onOpenChange={setHistoryOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
className="flex size-7 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="Chat history"
|
||||
>
|
||||
<ClockIcon className="size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
align="end"
|
||||
side="bottom"
|
||||
sideOffset={4}
|
||||
collisionPadding={12}
|
||||
className="w-[min(22.125rem,calc(100vw-1.5rem))] min-w-0 overflow-hidden p-0"
|
||||
style={{ maxHeight: "min(400px, var(--radix-popover-content-available-height))" }}
|
||||
>
|
||||
<div className="border-b border-grid-bright px-3 py-2 text-xs font-medium uppercase tracking-wider text-text-dimmed">
|
||||
Chat History
|
||||
</div>
|
||||
{chatHistory.length === 0 ? (
|
||||
<div className="py-4" />
|
||||
) : (
|
||||
<ChatHistoryList
|
||||
chats={chatHistory}
|
||||
currentChatId={currentChatId}
|
||||
isOpen={historyOpen}
|
||||
onSelect={(chatId) => {
|
||||
switchChat(chatId);
|
||||
setHistoryOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
onClick={startNewChat}
|
||||
className="flex size-7 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-charcoal-700 hover:text-text-bright"
|
||||
title="New chat"
|
||||
>
|
||||
<PlusIcon className="size-4" />
|
||||
</button>
|
||||
|
||||
<Button
|
||||
onClick={close}
|
||||
variant="minimal/small"
|
||||
TrailingIcon={ExitIcon}
|
||||
shortcut={{ key: "esc" }}
|
||||
shortcutPosition="before-trailing-icon"
|
||||
className="pl-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { ArrowUpIcon, StopIcon } from "@heroicons/react/20/solid";
|
||||
import { useLayoutEffect, useRef, useEffect } from "react";
|
||||
import { Button } from "~/components/primitives/Buttons";
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
|
||||
interface AIChatInputProps {
|
||||
input: string;
|
||||
onInputChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
onSubmit: (e: React.FormEvent) => void;
|
||||
onStop: () => void;
|
||||
isLoading: boolean;
|
||||
status: string;
|
||||
// Changes when the user switches/starts a chat — used to re-focus the input.
|
||||
chatId: string;
|
||||
}
|
||||
|
||||
const INDIGO_FUCHSIA = { background: "rgba(99, 102, 241, 1)", foreground: "rgba(217, 70, 239, 1)" };
|
||||
|
||||
export function AIChatInput({
|
||||
input,
|
||||
onInputChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
isLoading,
|
||||
status,
|
||||
chatId,
|
||||
}: AIChatInputProps) {
|
||||
const inputRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
// Focus on mount and whenever the active chat changes (e.g. switching via
|
||||
// history or starting a new chat) so the user can type immediately.
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, [chatId]);
|
||||
|
||||
// Auto-grow the textarea with its content. A single row collapses to the
|
||||
// `min-h-8` (32px = the send-button height) so `items-end` centers them; as
|
||||
// content wraps it grows and the button stays pinned to the bottom. The
|
||||
// `max-h-[70px]` class caps it at 3 rows, after which it scrolls. scrollHeight
|
||||
// excludes the border, so add it back to avoid a phantom scrollbar. Recompute
|
||||
// on every value change and on chat switch.
|
||||
useLayoutEffect(() => {
|
||||
const ta = inputRef.current;
|
||||
if (!ta) return;
|
||||
ta.style.height = "auto";
|
||||
const styles = getComputedStyle(ta);
|
||||
const border = parseFloat(styles.borderTopWidth) + parseFloat(styles.borderBottomWidth);
|
||||
ta.style.height = `${ta.scrollHeight + border}px`;
|
||||
}, [input, chatId]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Enter submits; Shift+Enter inserts a newline.
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
e.currentTarget.form?.requestSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const isStreaming = status === "streaming";
|
||||
const isPreparing = status === "submitted";
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex-shrink-0 border-t border-grid-bright p-3">
|
||||
<div className="flex items-end gap-2">
|
||||
<textarea
|
||||
ref={inputRef}
|
||||
rows={1}
|
||||
value={input}
|
||||
onChange={onInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Ask a question…"
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
className="min-h-8 max-h-[70px] flex-1 resize-none overflow-y-auto rounded-md border border-grid-bright bg-background-dimmed px-3 py-1 text-sm leading-5 text-text-bright placeholder:text-text-dimmed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600 focus-visible:focus-custom disabled:opacity-50"
|
||||
/>
|
||||
{isStreaming ? (
|
||||
// Stop button — only the gradient ring spins (a separate absolutely
|
||||
// positioned layer), so the stop icon itself stays still.
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStop}
|
||||
title="Stop generating"
|
||||
className="group relative flex size-8 min-w-8 items-center justify-center overflow-hidden rounded-full"
|
||||
>
|
||||
<span className="absolute inset-0 animate-spin bg-gradient-to-br from-indigo-500 via-purple-500 to-fuchsia-500 [animation-duration:3s]" />
|
||||
<span className="relative z-10 flex size-[calc(100%-2px)] items-center justify-center rounded-full bg-charcoal-600 transition group-hover:bg-charcoal-550">
|
||||
<StopIcon className="size-4 text-indigo-500 group-hover:text-indigo-400" />
|
||||
</span>
|
||||
</button>
|
||||
) : isPreparing ? (
|
||||
// Preparing — static gradient ring + spinner, not interactive.
|
||||
<div className="size-8 min-w-8 rounded-full bg-gradient-to-br from-indigo-500 via-purple-500 to-fuchsia-500 p-px">
|
||||
<div className="flex size-full items-center justify-center rounded-full bg-charcoal-600">
|
||||
<Spinner className="size-4" color={INDIGO_FUCHSIA} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!input.trim()}
|
||||
LeadingIcon={<ArrowUpIcon className="size-4 text-text-bright" />}
|
||||
variant="primary/small"
|
||||
className="size-8 min-w-8 rounded-full"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ChevronDownIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
HandThumbDownIcon,
|
||||
HandThumbUpIcon,
|
||||
SparklesIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { motion } from "framer-motion";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import DOMPurify from "dompurify";
|
||||
import { marked } from "marked";
|
||||
import type { UIMessage } from "ai";
|
||||
import { useAutoScrollToBottom } from "~/hooks/useAutoScrollToBottom";
|
||||
import { AIChatToolCall } from "./AIChatToolCall";
|
||||
import { FailureSummaryCard, FilterChips, MiniTable } from "./AIChatToolResults";
|
||||
import { toolLabels } from "~/lib/ai-assistant/tool-schemas";
|
||||
import { useAIChat } from "./AIChatProvider";
|
||||
|
||||
interface AIChatMessagesProps {
|
||||
messages: UIMessage[];
|
||||
status: string;
|
||||
error: Error | undefined;
|
||||
onRetry: () => void;
|
||||
onSendMessage?: (text: string) => void;
|
||||
}
|
||||
|
||||
// User has scrolled this many px up from the bottom before the
|
||||
// "scroll to bottom" affordance appears.
|
||||
const SCROLL_BUTTON_THRESHOLD_PX = 100;
|
||||
|
||||
export function AIChatMessages({
|
||||
messages,
|
||||
status,
|
||||
error,
|
||||
onRetry,
|
||||
onSendMessage,
|
||||
}: AIChatMessagesProps) {
|
||||
const navigate = useNavigate();
|
||||
const { requestTestFill } = useAIChat();
|
||||
const autoScrollRef = useAutoScrollToBottom([messages]);
|
||||
const [scrollContainer, setScrollContainer] = useState<HTMLDivElement | null>(null);
|
||||
const [showScrollButton, setShowScrollButton] = useState(false);
|
||||
const navigatedMessagesRef = useRef(new Set<string>());
|
||||
const prevMessagesLengthRef = useRef(0);
|
||||
|
||||
const isStreaming = status === "streaming" || status === "submitted";
|
||||
|
||||
// Auto-navigate only during live chat, not on history load
|
||||
useEffect(() => {
|
||||
// Only check for navigation during active streaming
|
||||
if (!isStreaming) return;
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || lastMessage.role !== "assistant") return;
|
||||
|
||||
lastMessage.parts.forEach((part, idx) => {
|
||||
const toolPart = part as any;
|
||||
if (toolPart.type === "dynamic-tool" || toolPart.type.startsWith("tool-")) {
|
||||
const toolName =
|
||||
toolPart.type === "dynamic-tool"
|
||||
? toolPart.toolName ?? "tool"
|
||||
: toolPart.type.slice("tool-".length);
|
||||
|
||||
if (toolName === "navigateToPage" && toolPart.state === "output-available") {
|
||||
const key = `${lastMessage.id}-${idx}`;
|
||||
if (!navigatedMessagesRef.current.has(key)) {
|
||||
const result = toolPart.output as {
|
||||
found: boolean;
|
||||
url?: string;
|
||||
};
|
||||
if (result?.found && result.url) {
|
||||
navigatedMessagesRef.current.add(key);
|
||||
navigate(result.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fill the Test page editor with a generated payload (the Test page for
|
||||
// the matching task consumes it). Fire-and-forget; not deduped per-key
|
||||
// because re-applying the same payload is harmless.
|
||||
if (toolName === "generateTestPayload" && toolPart.state === "output-available") {
|
||||
const key = `${lastMessage.id}-${idx}`;
|
||||
if (!navigatedMessagesRef.current.has(key)) {
|
||||
const result = toolPart.output as {
|
||||
success?: boolean;
|
||||
taskIdentifier?: string;
|
||||
payload?: string;
|
||||
};
|
||||
if (result?.success && result.taskIdentifier && result.payload) {
|
||||
navigatedMessagesRef.current.add(key);
|
||||
requestTestFill({ taskIdentifier: result.taskIdentifier, payload: result.payload });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Navigate to the run that runTestTask just triggered so the user can watch it.
|
||||
if (toolName === "runTestTask" && toolPart.state === "output-available") {
|
||||
const key = `${lastMessage.id}-${idx}`;
|
||||
if (!navigatedMessagesRef.current.has(key)) {
|
||||
const result = toolPart.output as { success?: boolean; url?: string };
|
||||
if (result?.success && result.url) {
|
||||
navigatedMessagesRef.current.add(key);
|
||||
navigate(result.url);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [messages, status, navigate, requestTestFill, isStreaming]);
|
||||
|
||||
// Feedback bar only attaches to the most recent assistant turn.
|
||||
let lastAssistantIndex = -1;
|
||||
for (let i = messages.length - 1; i >= 0; i--) {
|
||||
if (messages[i].role === "assistant") {
|
||||
lastAssistantIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!scrollContainer) return;
|
||||
const distanceFromBottom =
|
||||
scrollContainer.scrollHeight - scrollContainer.scrollTop - scrollContainer.clientHeight;
|
||||
setShowScrollButton(distanceFromBottom > SCROLL_BUTTON_THRESHOLD_PX);
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
scrollContainer?.scrollTo({ top: scrollContainer.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||
<div
|
||||
ref={setScrollContainer}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto px-3 py-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-charcoal-600"
|
||||
>
|
||||
<div ref={autoScrollRef}>
|
||||
{messages.map((message, msgIndex) => (
|
||||
<div key={message.id} className="mb-4">
|
||||
{message.role === "user" && (
|
||||
<div className="py-2 text-sm font-semibold text-text-bright">
|
||||
{message.parts
|
||||
.filter((p): p is Extract<typeof p, { type: "text" }> => p.type === "text")
|
||||
.map((p, i) => (
|
||||
<span key={i}>{p.text}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" && (
|
||||
<div className="space-y-1">
|
||||
{message.parts.map((part, i) => {
|
||||
if (part.type === "text") {
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="prose prose-invert prose-sm max-w-none break-words text-text-dimmed prose-headings:text-text-bright prose-a:text-indigo-400 prose-code:text-text-bright prose-pre:overflow-x-auto prose-pre:bg-charcoal-800 prose-pre:text-xs"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: DOMPurify.sanitize(marked(part.text) as string),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
// AI SDK v6 tool parts: `tool-${name}` (typed) or "dynamic-tool".
|
||||
if (part.type === "dynamic-tool" || part.type.startsWith("tool-")) {
|
||||
const toolPart = part as {
|
||||
type: string;
|
||||
state: string;
|
||||
toolName?: string;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
};
|
||||
const toolName =
|
||||
part.type === "dynamic-tool"
|
||||
? toolPart.toolName ?? "tool"
|
||||
: part.type.slice("tool-".length);
|
||||
|
||||
// Show spinner while the tool input is being produced / called.
|
||||
if (
|
||||
toolPart.state === "input-streaming" ||
|
||||
toolPart.state === "input-available"
|
||||
) {
|
||||
return (
|
||||
<AIChatToolCall key={i} toolName={toolName} state={toolPart.state} />
|
||||
);
|
||||
}
|
||||
|
||||
// Render navigation results as a clickable link card.
|
||||
if (toolName === "navigateToPage" && toolPart.state === "output-available") {
|
||||
const result = toolPart.output as {
|
||||
found: boolean;
|
||||
url?: string;
|
||||
pageName?: string;
|
||||
description?: string;
|
||||
message?: string;
|
||||
};
|
||||
if (result?.found && result.url) {
|
||||
const url = result.url;
|
||||
return (
|
||||
<div key={i} className="space-y-2">
|
||||
<ToolResultCard toolName={toolName} input={toolPart.input} output={toolPart.output} />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate(url)}
|
||||
className="group w-full flex items-center gap-2.5 rounded-md border border-grid-bright bg-charcoal-800/40 px-3 py-2 text-left transition-colors animate-in fade-in slide-in-from-bottom-1 duration-150 hover:border-indigo-500/50 hover:bg-charcoal-800/60"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="size-4 shrink-0 text-text-dimmed group-hover:text-indigo-400" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm text-text-bright group-hover:text-indigo-400">
|
||||
{result.pageName}
|
||||
</span>
|
||||
{result.description && (
|
||||
<span className="text-xs text-text-dimmed">
|
||||
{result.description}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Render structured outputs as rich cards, falling back
|
||||
// to a collapsible JSON view for everything else.
|
||||
if (toolPart.state === "output-available") {
|
||||
return (
|
||||
<ToolOutput
|
||||
key={i}
|
||||
toolName={toolName}
|
||||
input={toolPart.input}
|
||||
output={toolPart.output}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{message.role === "assistant" && msgIndex === lastAssistantIndex && !isStreaming && (
|
||||
<FeedbackBar key={message.id} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-rose-500/30 bg-rose-500/5 px-3 py-2.5">
|
||||
<ExclamationTriangleIcon className="mt-0.5 size-4 shrink-0 text-rose-400" />
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-rose-300">
|
||||
{error.message || "Something went wrong. Please try again."}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRetry}
|
||||
className="mt-1 w-fit text-xs text-rose-400 underline transition-colors hover:text-rose-300"
|
||||
>
|
||||
Try again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollButton && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToBottom}
|
||||
className="absolute bottom-3 left-1/2 z-10 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-grid-bright bg-charcoal-700 px-3 py-1.5 shadow-md transition-colors animate-in fade-in slide-in-from-bottom-2 duration-150 hover:bg-charcoal-600"
|
||||
>
|
||||
<ChevronDownIcon className="size-3.5 text-text-dimmed" />
|
||||
<span className="text-xs text-text-dimmed">New messages</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Dispatches a completed tool result to its specialized renderer, falling back
|
||||
// to a collapsible JSON card when there's no dedicated view (or the tool errored).
|
||||
function ToolOutput({
|
||||
toolName,
|
||||
input,
|
||||
output,
|
||||
onSendMessage,
|
||||
}: {
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
onSendMessage?: (text: string) => void;
|
||||
}) {
|
||||
const result = output as Record<string, unknown> | undefined;
|
||||
|
||||
if (toolName === "classifyFailure" && result?.category) {
|
||||
return (
|
||||
<FailureSummaryCard
|
||||
result={result as any}
|
||||
runFriendlyId={(input as { runFriendlyId?: string })?.runFriendlyId}
|
||||
onSendMessage={onSendMessage}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (toolName === "applyRunFilters" && result?.success && result.filters) {
|
||||
return <FilterChips filters={result.filters as any} />;
|
||||
}
|
||||
|
||||
if (toolName === "listRuns" && Array.isArray(result?.runs) && result.runs.length > 0) {
|
||||
const runs = result.runs as Array<{ id: string; status?: string; duration?: string }>;
|
||||
const columns = ["Run", "Status", "Duration"];
|
||||
const rows = runs.map((r) => [r.id, friendlyStatus(r.status), r.duration ?? "—"]);
|
||||
return <MiniTable columns={columns} rows={rows} />;
|
||||
}
|
||||
|
||||
if (toolName === "aggregateRuns" && Array.isArray(result?.results) && result.results.length > 0) {
|
||||
const groupBy = String(result.groupBy ?? "Group");
|
||||
const metric = String(result.metric ?? "Value");
|
||||
const columns = [capitalize(groupBy), capitalize(metric)];
|
||||
const rows = (result.results as Array<{ dimension: unknown; value: unknown }>).map((r) => [
|
||||
r.dimension,
|
||||
r.value,
|
||||
]);
|
||||
return <MiniTable columns={columns} rows={rows} />;
|
||||
}
|
||||
|
||||
if (toolName === "queryRuns" && result?.success && Array.isArray(result.results) && result.results.length > 0) {
|
||||
const data = result.results as Array<Record<string, unknown>>;
|
||||
const columns = Object.keys(data[0]);
|
||||
const rows = data.map((row) => columns.map((c) => row[c]));
|
||||
return <MiniTable columns={columns} rows={rows} />;
|
||||
}
|
||||
|
||||
if (toolName === "listTestableTasks" && Array.isArray(result?.tasks) && result.tasks.length > 0) {
|
||||
const tasks = result.tasks as Array<{ taskIdentifier: string; triggerSource?: string }>;
|
||||
const columns = ["Task", "Type"];
|
||||
const rows = tasks.map((t) => [t.taskIdentifier, friendlyStatus(t.triggerSource)]);
|
||||
return <MiniTable columns={columns} rows={rows} />;
|
||||
}
|
||||
|
||||
if (toolName === "generateTestPayload" && result?.success && typeof result.payload === "string") {
|
||||
return <GeneratedPayloadCard payload={result.payload} />;
|
||||
}
|
||||
|
||||
if (toolName === "runTestTask" && result?.success && result.runId) {
|
||||
return (
|
||||
<TestRunCard
|
||||
taskIdentifier={String(result.taskIdentifier ?? "")}
|
||||
runId={String(result.runId)}
|
||||
url={result.url ? String(result.url) : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return <ToolResultCard toolName={toolName} input={input} output={output} />;
|
||||
}
|
||||
|
||||
function capitalize(value: string) {
|
||||
return value.charAt(0).toUpperCase() + value.slice(1);
|
||||
}
|
||||
|
||||
function friendlyStatus(status?: string) {
|
||||
if (!status) return "—";
|
||||
return capitalize(status.replace(/_/g, " ").toLowerCase());
|
||||
}
|
||||
|
||||
function GeneratedPayloadCard({ payload }: { payload: string }) {
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-md border border-grid-bright bg-charcoal-800/40">
|
||||
<div className="flex items-center gap-1.5 border-b border-grid-bright px-3 py-1.5">
|
||||
<SparklesIcon className="size-3.5 shrink-0 text-indigo-400" />
|
||||
<span className="text-xs text-text-bright">Generated test payload</span>
|
||||
<span className="ml-auto text-[10px] text-text-dimmed">filled into the editor</span>
|
||||
</div>
|
||||
<pre className="max-h-56 overflow-auto bg-charcoal-900 p-2.5 text-xs text-text-dimmed">
|
||||
{payload}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TestRunCard({
|
||||
taskIdentifier,
|
||||
runId,
|
||||
url,
|
||||
}: {
|
||||
taskIdentifier: string;
|
||||
runId: string;
|
||||
url?: string;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => url && navigate(url)}
|
||||
disabled={!url}
|
||||
className="group my-1 flex w-full items-center gap-2.5 rounded-md border border-green-500/30 bg-green-500/5 px-3 py-2 text-left transition-colors animate-in fade-in slide-in-from-bottom-1 duration-150 hover:border-green-500/50 enabled:hover:bg-green-500/10"
|
||||
>
|
||||
<ArrowTopRightOnSquareIcon className="size-4 shrink-0 text-green-400" />
|
||||
<span className="flex min-w-0 flex-col">
|
||||
<span className="truncate text-sm text-text-bright group-hover:text-green-300">
|
||||
Test run triggered{taskIdentifier ? ` — ${taskIdentifier}` : ""}
|
||||
</span>
|
||||
<span className="truncate text-xs text-text-dimmed">{runId}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolResultCard({
|
||||
toolName,
|
||||
input,
|
||||
output,
|
||||
}: {
|
||||
toolName: string;
|
||||
input: unknown;
|
||||
output: unknown;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
const label = toolLabels[toolName] || `Running ${toolName}`;
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="group inline-flex items-center gap-1.5 text-xs transition-colors hover:text-indigo-400"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDownIcon className="size-3.5 shrink-0 text-indigo-400" />
|
||||
) : (
|
||||
<ChevronRightIcon className="size-3.5 shrink-0 text-text-dimmed group-hover:text-indigo-400" />
|
||||
)}
|
||||
<SparklesIcon className="size-3 shrink-0 text-indigo-400" />
|
||||
<span className="text-text-bright group-hover:text-indigo-400">{label}</span>
|
||||
</button>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-2 ml-4 space-y-2 text-xs">
|
||||
{input && (
|
||||
<div>
|
||||
<div className="text-text-dimmed mb-1">Input:</div>
|
||||
<pre className="bg-charcoal-900 rounded p-2 overflow-x-auto text-text-dimmed text-xs max-h-48 overflow-y-auto">
|
||||
{JSON.stringify(input, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
{output && (
|
||||
<div>
|
||||
<div className="text-text-dimmed mb-1">Output:</div>
|
||||
<pre className="bg-charcoal-900 rounded p-2 overflow-x-auto text-text-dimmed text-xs max-h-48 overflow-y-auto">
|
||||
{JSON.stringify(output, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeedbackBar() {
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
if (submitted) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
className="pt-1 text-xs text-text-dimmed"
|
||||
>
|
||||
Thanks for the feedback
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmitted(true)}
|
||||
title="Good response"
|
||||
className="rounded p-1 transition-colors hover:bg-charcoal-700"
|
||||
>
|
||||
<HandThumbUpIcon className="size-3.5 text-text-dimmed transition-colors hover:text-green-400" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSubmitted(true)}
|
||||
title="Bad response"
|
||||
className="rounded p-1 transition-colors hover:bg-charcoal-700"
|
||||
>
|
||||
<HandThumbDownIcon className="size-3.5 text-text-dimmed transition-colors hover:text-rose-400" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { useTriggerChatTransport } from "@trigger.dev/sdk/chat/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { dashboardAssistant } from "~/trigger/ai-assistant";
|
||||
import { useAIChat } from "./AIChatProvider";
|
||||
import { AIChatHeader } from "./AIChatHeader";
|
||||
import { AIChatContextBanner } from "./AIChatContextBanner";
|
||||
import { AIChatMessages } from "./AIChatMessages";
|
||||
import { AIChatSuggestedPrompts } from "./AIChatSuggestedPrompts";
|
||||
import { AIChatInput } from "./AIChatInput";
|
||||
|
||||
async function postAssistant(body: Record<string, unknown>): Promise<any> {
|
||||
const res = await fetch("/resources/ai-assistant", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`ai-assistant ${body.intent} failed: ${res.status}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function AIChatPanel() {
|
||||
const {
|
||||
currentChatId,
|
||||
currentChatMessages,
|
||||
pageContext,
|
||||
isOpen,
|
||||
close,
|
||||
pendingQuery,
|
||||
clearPendingQuery,
|
||||
refreshHistory,
|
||||
} = useAIChat();
|
||||
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
|
||||
const transport = useTriggerChatTransport<typeof dashboardAssistant>({
|
||||
task: "dashboard-assistant",
|
||||
// Head Start intentionally disabled: running every turn inside the agent
|
||||
// run is what surfaces the LLM + tool-call spans in the trace (at the cost
|
||||
// of ~750ms first-token).
|
||||
baseURL: typeof window !== "undefined" ? window.location.origin : undefined,
|
||||
clientData: pageContext,
|
||||
// Mint a fresh session-scoped PAT. Fired on first use + 401/403 refresh.
|
||||
accessToken: async ({ chatId }) => {
|
||||
const { publicAccessToken } = await postAssistant({
|
||||
intent: "refreshToken",
|
||||
chatId,
|
||||
clientData: pageContext,
|
||||
});
|
||||
return publicAccessToken;
|
||||
},
|
||||
// Create (or resume) the session + trigger the first run server-side.
|
||||
startSession: async ({ chatId }) => {
|
||||
const { publicAccessToken } = await postAssistant({
|
||||
intent: "createSession",
|
||||
chatId,
|
||||
clientData: pageContext,
|
||||
});
|
||||
return { publicAccessToken };
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
messages,
|
||||
sendMessage,
|
||||
status,
|
||||
stop: aiStop,
|
||||
error,
|
||||
regenerate,
|
||||
} = useChat({
|
||||
id: currentChatId,
|
||||
messages: currentChatMessages,
|
||||
transport,
|
||||
resume: (currentChatMessages?.length ?? 0) > 0,
|
||||
});
|
||||
|
||||
const stop = useCallback(() => {
|
||||
transport.stopGeneration(currentChatId);
|
||||
aiStop();
|
||||
}, [transport, currentChatId, aiStop]);
|
||||
|
||||
// Warm the agent run when the panel opens so it's waiting on `session.in`
|
||||
// by the time the user sends — a cold boot races the first message ahead of
|
||||
// the run's waitpoint and silently drops the turn.
|
||||
useEffect(() => {
|
||||
void transport.preload(currentChatId);
|
||||
}, [transport, currentChatId]);
|
||||
|
||||
const submit = useCallback(
|
||||
(text: string) => {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return;
|
||||
void sendMessage({ text: trimmed });
|
||||
setInput("");
|
||||
},
|
||||
[sendMessage]
|
||||
);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
submit(input);
|
||||
},
|
||||
[submit, input]
|
||||
);
|
||||
|
||||
// Close on Escape, but only when focus is inside the panel — a global
|
||||
// Escape listener would hijack the key from menus/inputs elsewhere.
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
function onKeyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape" && panelRef.current?.contains(document.activeElement)) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
}, [isOpen, close]);
|
||||
|
||||
// A pending query is set when the assistant is opened with an initial
|
||||
// question (e.g. from a "Ask AI about this" affordance). Send it once.
|
||||
const sentPending = useRef(false);
|
||||
useEffect(() => {
|
||||
if (pendingQuery && !sentPending.current) {
|
||||
sentPending.current = true;
|
||||
submit(pendingQuery);
|
||||
clearPendingQuery();
|
||||
}
|
||||
if (!pendingQuery) {
|
||||
sentPending.current = false;
|
||||
}
|
||||
}, [pendingQuery, submit, clearPendingQuery]);
|
||||
|
||||
const prevStatus = useRef(status);
|
||||
useEffect(() => {
|
||||
if (prevStatus.current === "streaming" && status === "ready") {
|
||||
refreshHistory();
|
||||
}
|
||||
prevStatus.current = status;
|
||||
}, [status, refreshHistory]);
|
||||
|
||||
const isLoading = status === "submitted" || status === "streaming";
|
||||
const isEmpty = messages.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="flex h-full w-[380px] flex-col border-l border-grid-bright bg-background-bright animate-in fade-in slide-in-from-right-2 duration-200"
|
||||
>
|
||||
<AIChatHeader />
|
||||
<AIChatContextBanner
|
||||
projectSlug={pageContext.projectSlug}
|
||||
environmentSlug={pageContext.environmentSlug}
|
||||
currentPage={pageContext.currentPage}
|
||||
/>
|
||||
|
||||
{isEmpty ? (
|
||||
<div className="flex-1 overflow-y-auto py-3">
|
||||
<AIChatSuggestedPrompts currentPage={pageContext.currentPage} onSelect={submit} />
|
||||
</div>
|
||||
) : (
|
||||
<AIChatMessages
|
||||
messages={messages}
|
||||
status={status}
|
||||
error={error}
|
||||
onRetry={() => void regenerate()}
|
||||
onSendMessage={submit}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AIChatInput
|
||||
input={input}
|
||||
onInputChange={(e) => setInput(e.target.value)}
|
||||
onSubmit={handleSubmit}
|
||||
onStop={stop}
|
||||
isLoading={isLoading}
|
||||
status={status}
|
||||
chatId={currentChatId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { useParams, useLocation } from "@remix-run/react";
|
||||
import type { UIMessage } from "ai";
|
||||
|
||||
interface PageContext {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
currentPage: string;
|
||||
currentParams: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
id: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
interface SessionState {
|
||||
publicAccessToken: string;
|
||||
lastEventId: string | null;
|
||||
}
|
||||
|
||||
// A payload the assistant generated and wants filled into the Test page editor.
|
||||
// The Test page for the matching task consumes it and clears it.
|
||||
interface PendingTestFill {
|
||||
taskIdentifier: string;
|
||||
payload: string;
|
||||
}
|
||||
|
||||
interface AIChatContextValue {
|
||||
isOpen: boolean;
|
||||
toggle: () => void;
|
||||
open: (initialQuery?: string) => void;
|
||||
close: () => void;
|
||||
currentChatId: string;
|
||||
startNewChat: () => void;
|
||||
switchChat: (chatId: string) => void;
|
||||
chatHistory: ChatHistoryEntry[];
|
||||
refreshHistory: () => void;
|
||||
currentChatMessages: UIMessage[] | undefined;
|
||||
sessionState: SessionState | undefined;
|
||||
pageContext: PageContext;
|
||||
pendingQuery: string | undefined;
|
||||
clearPendingQuery: () => void;
|
||||
pendingTestFill: PendingTestFill | undefined;
|
||||
requestTestFill: (fill: PendingTestFill) => void;
|
||||
clearTestFill: () => void;
|
||||
}
|
||||
|
||||
const AIChatContext = createContext<AIChatContextValue | null>(null);
|
||||
|
||||
export function useAIChat() {
|
||||
const ctx = useContext(AIChatContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAIChat must be used within an AIChatProvider");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
/**
|
||||
* Like useAIChat, but returns null instead of throwing when there is no
|
||||
* provider. The provider is only mounted inside the project layout, so
|
||||
* components rendered on account/org-settings pages (e.g. the global NavBar
|
||||
* AskAI button) use this to no-op when the assistant isn't available.
|
||||
*/
|
||||
export function useOptionalAIChat() {
|
||||
return useContext(AIChatContext);
|
||||
}
|
||||
|
||||
function generateChatId() {
|
||||
return `chat_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
function usePageContext(userId: string): PageContext {
|
||||
const params = useParams();
|
||||
const location = useLocation();
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
const currentPage = segments[segments.length - 1] ?? "overview";
|
||||
|
||||
return {
|
||||
userId,
|
||||
organizationSlug: params.organizationSlug ?? "",
|
||||
projectSlug: params.projectParam ?? "",
|
||||
environmentSlug: params.envParam ?? "",
|
||||
currentPage,
|
||||
currentParams: params as Record<string, string>,
|
||||
};
|
||||
}
|
||||
|
||||
export function AIChatProvider({
|
||||
userId,
|
||||
children,
|
||||
}: {
|
||||
userId: string;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [currentChatId, setCurrentChatId] = useState(() => generateChatId());
|
||||
const [chatHistory, setChatHistory] = useState<ChatHistoryEntry[]>([]);
|
||||
const [currentChatMessages, setCurrentChatMessages] = useState<UIMessage[] | undefined>();
|
||||
const [sessionState, setSessionState] = useState<SessionState | undefined>();
|
||||
const [pendingQuery, setPendingQuery] = useState<string | undefined>();
|
||||
const [pendingTestFill, setPendingTestFill] = useState<PendingTestFill | undefined>();
|
||||
|
||||
const switchRequestRef = useRef<string | undefined>(undefined);
|
||||
|
||||
const pageContext = usePageContext(userId);
|
||||
|
||||
const refreshHistory = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch("/resources/ai-assistant/history");
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { chats?: ChatHistoryEntry[] };
|
||||
setChatHistory(data.chats ?? []);
|
||||
}
|
||||
} catch {
|
||||
// Silently fail — history is non-critical
|
||||
}
|
||||
}, []);
|
||||
|
||||
const open = useCallback(
|
||||
(initialQuery?: string) => {
|
||||
if (initialQuery) {
|
||||
setPendingQuery(initialQuery);
|
||||
}
|
||||
setIsOpen(true);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setIsOpen(false);
|
||||
setPendingQuery(undefined);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setIsOpen((prev) => {
|
||||
if (prev) {
|
||||
setPendingQuery(undefined);
|
||||
}
|
||||
return !prev;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
const newChatId = generateChatId();
|
||||
switchRequestRef.current = newChatId;
|
||||
setCurrentChatId(newChatId);
|
||||
setCurrentChatMessages(undefined);
|
||||
setSessionState(undefined);
|
||||
setPendingQuery(undefined);
|
||||
}, []);
|
||||
|
||||
const switchChat = useCallback(
|
||||
async (chatId: string) => {
|
||||
switchRequestRef.current = chatId;
|
||||
setPendingQuery(undefined);
|
||||
try {
|
||||
const res = await fetch(`/resources/ai-assistant/chat/${chatId}`);
|
||||
if (switchRequestRef.current !== chatId) return;
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as {
|
||||
chat?: { title?: string; messages?: UIMessage[] };
|
||||
session?: SessionState;
|
||||
};
|
||||
if (switchRequestRef.current !== chatId) return;
|
||||
// Messages must be set before the id: useChat reads `messages` only
|
||||
// when `id` changes, so both must land in the same render.
|
||||
setCurrentChatMessages(data.chat?.messages ?? []);
|
||||
setSessionState(data.session ?? undefined);
|
||||
setCurrentChatId(chatId);
|
||||
if (data.chat?.title) {
|
||||
setChatHistory((prev) =>
|
||||
prev.map((c) => (c.id === chatId ? { ...c, title: data.chat!.title! } : c))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setCurrentChatMessages([]);
|
||||
setSessionState(undefined);
|
||||
setCurrentChatId(chatId);
|
||||
}
|
||||
} catch {
|
||||
if (switchRequestRef.current !== chatId) return;
|
||||
setCurrentChatMessages([]);
|
||||
setSessionState(undefined);
|
||||
setCurrentChatId(chatId);
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const clearPendingQuery = useCallback(() => {
|
||||
setPendingQuery(undefined);
|
||||
}, []);
|
||||
|
||||
const requestTestFill = useCallback((fill: PendingTestFill) => {
|
||||
setPendingTestFill(fill);
|
||||
}, []);
|
||||
|
||||
const clearTestFill = useCallback(() => {
|
||||
setPendingTestFill(undefined);
|
||||
}, []);
|
||||
|
||||
// Load history when panel opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
refreshHistory();
|
||||
}
|
||||
}, [isOpen, refreshHistory]);
|
||||
|
||||
return (
|
||||
<AIChatContext.Provider
|
||||
value={{
|
||||
isOpen,
|
||||
toggle,
|
||||
open,
|
||||
close,
|
||||
currentChatId,
|
||||
startNewChat,
|
||||
switchChat,
|
||||
chatHistory,
|
||||
refreshHistory,
|
||||
currentChatMessages,
|
||||
sessionState,
|
||||
pageContext,
|
||||
pendingQuery,
|
||||
clearPendingQuery,
|
||||
pendingTestFill,
|
||||
requestTestFill,
|
||||
clearTestFill,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</AIChatContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { SparkleListIcon } from "~/assets/icons/SparkleListIcon";
|
||||
import { Paragraph } from "~/components/primitives/Paragraph";
|
||||
import { useAIChat } from "./AIChatProvider";
|
||||
import { getPrompts } from "./suggested-prompts";
|
||||
|
||||
interface AIChatSuggestedPromptsProps {
|
||||
currentPage: string;
|
||||
onSelect: (prompt: string) => void;
|
||||
}
|
||||
|
||||
// Stagger the pills in from the right — same motion vocabulary as the legacy
|
||||
// AskAI suggested prompts so the assistant feels consistent across surfaces.
|
||||
const container = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: { staggerChildren: 0.1, delayChildren: 0.2 },
|
||||
},
|
||||
};
|
||||
|
||||
const item = {
|
||||
hidden: { opacity: 0, x: 20 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
x: 0,
|
||||
transition: {
|
||||
opacity: { duration: 0.5, ease: "linear" },
|
||||
x: { type: "spring", stiffness: 300, damping: 25 },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function AIChatSuggestedPrompts({ currentPage, onSelect }: AIChatSuggestedPromptsProps) {
|
||||
const prompts = getPrompts(currentPage);
|
||||
// The panel stays mounted across close/open, so this component never
|
||||
// unmounts. Keying the motion container on `isOpen` remounts it on each
|
||||
// open, replaying the stagger every time rather than only on first mount.
|
||||
const { isOpen } = useAIChat();
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-3 pb-2">
|
||||
<Paragraph className="mb-2 mt-1.5 pl-1 text-text-dimmed">
|
||||
I can help you navigate the dashboard, find documentation, and understand Trigger.dev
|
||||
features. Ask me anything.
|
||||
</Paragraph>
|
||||
<motion.div
|
||||
key={isOpen ? "open" : "closed"}
|
||||
className="flex flex-col gap-2"
|
||||
variants={container}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
>
|
||||
{prompts.map((prompt, index) => (
|
||||
<motion.button
|
||||
key={index}
|
||||
variants={item}
|
||||
className="group flex w-fit items-center gap-2 rounded-full border border-dashed border-charcoal-600 px-4 py-2 text-left transition-colors hover:border-solid hover:border-indigo-500"
|
||||
onClick={() => onSelect(prompt)}
|
||||
>
|
||||
<SparkleListIcon className="size-4 shrink-0 text-text-dimmed transition group-hover:text-indigo-500" />
|
||||
<Paragraph variant="small" className="transition group-hover:text-text-bright">
|
||||
{prompt}
|
||||
</Paragraph>
|
||||
</motion.button>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Spinner } from "~/components/primitives/Spinner";
|
||||
|
||||
const TOOL_LABELS: Record<string, string> = {
|
||||
// V1A - Docs and Navigation
|
||||
searchDocs: "Searching documentation…",
|
||||
navigateToPage: "Finding page…",
|
||||
getCurrentContext: "Checking context…",
|
||||
searchPages: "Searching pages…",
|
||||
|
||||
// V1B - Runs
|
||||
listRuns: "Querying runs…",
|
||||
getRunDetails: "Loading run details…",
|
||||
getRunLogs: "Fetching logs…",
|
||||
getRunGraph: "Building run graph…",
|
||||
applyRunFilters: "Building filters…",
|
||||
queryRuns: "Running analytics query…",
|
||||
|
||||
// V1B - Errors
|
||||
listErrors: "Loading error groups…",
|
||||
getErrorDetails: "Loading error details…",
|
||||
findSimilarErrors: "Searching error history…",
|
||||
classifyFailure: "Classifying failure…",
|
||||
|
||||
// V1B - Analytics
|
||||
summarizeCurrentView: "Analyzing current view…",
|
||||
aggregateRuns: "Computing aggregations…",
|
||||
correlateRunsWithDeploy: "Checking deploy correlation…",
|
||||
};
|
||||
|
||||
interface AIChatToolCallProps {
|
||||
toolName: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
export function AIChatToolCall({ toolName, state }: AIChatToolCallProps) {
|
||||
const label = TOOL_LABELS[toolName] ?? `Running ${toolName}…`;
|
||||
const isRunning = state === "input-streaming" || state === "input-available";
|
||||
|
||||
if (!isRunning) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 py-2 text-xs text-text-dimmed">
|
||||
<Spinner
|
||||
className="size-3.5"
|
||||
color={{ background: "rgba(99, 102, 241, 1)", foreground: "rgba(217, 70, 239, 1)" }}
|
||||
/>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
import { useNavigate } from "@remix-run/react";
|
||||
import { useState } from "react";
|
||||
import type { TaskRunListSearchFilters } from "~/components/runs/v3/RunFilters";
|
||||
import { v3RunPath, v3RunsPath } from "~/utils/pathBuilder";
|
||||
import { useAIChat } from "./AIChatProvider";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Failure summary card — renders the structured output of `classifyFailure`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface FailureClassification {
|
||||
category: string;
|
||||
confidence: string;
|
||||
evidence: string;
|
||||
nextSteps: string[];
|
||||
}
|
||||
|
||||
const CATEGORY_BADGE: Record<string, string> = {
|
||||
Timeout: "bg-amber-500/10 text-amber-400",
|
||||
"OOM / Memory": "bg-rose-500/10 text-rose-400",
|
||||
"Missing env var": "bg-yellow-500/10 text-yellow-400",
|
||||
"Child task failed": "bg-orange-500/10 text-orange-400",
|
||||
"User code exception": "bg-rose-500/10 text-rose-400",
|
||||
"AI provider rate limit": "bg-amber-500/10 text-amber-400",
|
||||
"Deploy regression": "bg-purple-500/10 text-purple-400",
|
||||
"Platform issue": "bg-charcoal-600 text-text-dimmed",
|
||||
Unknown: "bg-charcoal-600 text-text-dimmed",
|
||||
};
|
||||
|
||||
export function FailureSummaryCard({
|
||||
result,
|
||||
runFriendlyId,
|
||||
onSendMessage,
|
||||
}: {
|
||||
result: FailureClassification;
|
||||
runFriendlyId?: string;
|
||||
onSendMessage?: (text: string) => void;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { pageContext } = useAIChat();
|
||||
const badge = CATEGORY_BADGE[result.category] ?? CATEGORY_BADGE.Unknown;
|
||||
|
||||
const openRun = () => {
|
||||
if (!runFriendlyId) return;
|
||||
navigate(
|
||||
v3RunPath(
|
||||
{ slug: pageContext.organizationSlug },
|
||||
{ slug: pageContext.projectSlug },
|
||||
{ slug: pageContext.environmentSlug },
|
||||
{ friendlyId: runFriendlyId }
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-1 flex flex-col gap-2 rounded-md border border-rose-500/20 bg-rose-500/5 p-3 animate-in fade-in slide-in-from-bottom-1 duration-150">
|
||||
<span
|
||||
className={`inline-flex w-fit items-center rounded-full px-2 py-0.5 text-xs font-medium ${badge}`}
|
||||
>
|
||||
{result.category}
|
||||
</span>
|
||||
|
||||
<span className="text-xs text-text-dimmed">{result.confidence} confidence</span>
|
||||
|
||||
{result.evidence && (
|
||||
<div className="text-xs text-text-dimmed">
|
||||
<span className="font-medium text-text-bright">Evidence: </span>
|
||||
{result.evidence}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result.nextSteps?.length > 0 && (
|
||||
<ol className="ml-4 list-decimal space-y-0.5 text-xs text-text-bright">
|
||||
{result.nextSteps.map((step, i) => (
|
||||
<li key={i}>{step}</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{(runFriendlyId || onSendMessage) && (
|
||||
<div className="mt-1 flex items-center gap-2 border-t border-grid-bright pt-1">
|
||||
{runFriendlyId && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={openRun}
|
||||
className="cursor-pointer text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Open run
|
||||
</button>
|
||||
)}
|
||||
{runFriendlyId && onSendMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSendMessage(`Show me the run graph for ${runFriendlyId}`)}
|
||||
className="cursor-pointer text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Show run graph
|
||||
</button>
|
||||
)}
|
||||
{onSendMessage && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onSendMessage(
|
||||
runFriendlyId
|
||||
? `Find errors similar to the failure in run ${runFriendlyId}`
|
||||
: "Find similar errors"
|
||||
)
|
||||
}
|
||||
className="cursor-pointer text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Find similar errors
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Filter chips — renders the structured output of `applyRunFilters`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const FILTER_LABELS: Record<string, string> = {
|
||||
statuses: "Status",
|
||||
tasks: "Task",
|
||||
tags: "Tag",
|
||||
versions: "Version",
|
||||
queues: "Queue",
|
||||
machines: "Machine",
|
||||
sources: "Source",
|
||||
period: "Period",
|
||||
from: "From",
|
||||
to: "To",
|
||||
batchId: "Batch",
|
||||
runId: "Run",
|
||||
scheduleId: "Schedule",
|
||||
};
|
||||
|
||||
function humanizeStatus(status: string) {
|
||||
const lower = status.replace(/_/g, " ").toLowerCase();
|
||||
return lower.charAt(0).toUpperCase() + lower.slice(1);
|
||||
}
|
||||
|
||||
function buildChips(filters: Record<string, unknown>): string[] {
|
||||
const chips: string[] = [];
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null) continue;
|
||||
if (key === "rootOnly" && value === true) {
|
||||
chips.push("Root runs only");
|
||||
continue;
|
||||
}
|
||||
const label = FILTER_LABELS[key];
|
||||
if (!label) continue;
|
||||
const display = (v: unknown) => (key === "statuses" ? humanizeStatus(String(v)) : String(v));
|
||||
if (Array.isArray(value)) {
|
||||
for (const v of value) chips.push(`${label}: ${display(v)}`);
|
||||
} else {
|
||||
chips.push(`${label}: ${display(value)}`);
|
||||
}
|
||||
}
|
||||
return chips;
|
||||
}
|
||||
|
||||
export function FilterChips({ filters }: { filters: TaskRunListSearchFilters }) {
|
||||
const navigate = useNavigate();
|
||||
const { pageContext } = useAIChat();
|
||||
const chips = buildChips(filters as Record<string, unknown>);
|
||||
|
||||
if (chips.length === 0) {
|
||||
return <div className="py-1 text-xs text-text-dimmed">No filters detected.</div>;
|
||||
}
|
||||
|
||||
const applyFilters = () => {
|
||||
navigate(
|
||||
v3RunsPath(
|
||||
{ slug: pageContext.organizationSlug },
|
||||
{ slug: pageContext.projectSlug },
|
||||
{ slug: pageContext.environmentSlug },
|
||||
filters
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="my-1">
|
||||
<div className="flex flex-wrap gap-1.5 py-1">
|
||||
{chips.map((chip, i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="inline-flex items-center gap-1 rounded-full border border-charcoal-600 bg-charcoal-800/40 px-2.5 py-1 text-xs text-text-dimmed"
|
||||
>
|
||||
{chip}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={applyFilters}
|
||||
className="mt-1 cursor-pointer text-xs text-indigo-400 underline hover:text-indigo-300"
|
||||
>
|
||||
Apply these filters →
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Mini table — renders tabular output of `aggregateRuns` / `queryRuns`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MAX_VISIBLE_ROWS = 10;
|
||||
|
||||
function formatCell(value: unknown): string {
|
||||
if (value === null || value === undefined) return "—";
|
||||
if (typeof value === "object") return JSON.stringify(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function MiniTable({
|
||||
columns,
|
||||
rows,
|
||||
}: {
|
||||
columns: string[];
|
||||
rows: unknown[][];
|
||||
}) {
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
const visibleRows = showAll ? rows : rows.slice(0, MAX_VISIBLE_ROWS);
|
||||
const truncated = rows.length > MAX_VISIBLE_ROWS;
|
||||
|
||||
return (
|
||||
<div className="my-1 overflow-hidden rounded-md border border-grid-bright">
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="bg-charcoal-800">
|
||||
<tr>
|
||||
{columns.map((col, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="px-2.5 py-1.5 text-left text-xs font-medium text-text-dimmed"
|
||||
>
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{visibleRows.map((row, ri) => (
|
||||
<tr
|
||||
key={ri}
|
||||
className="animate-in fade-in slide-in-from-bottom-1 duration-100 even:bg-charcoal-800/20"
|
||||
style={{ animationDelay: `${ri * 30}ms` }}
|
||||
>
|
||||
{columns.map((_, ci) => (
|
||||
<td
|
||||
key={ci}
|
||||
className="break-all border-t border-grid-bright px-2.5 py-1.5 text-xs text-text-bright"
|
||||
>
|
||||
{formatCell(row[ci])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{truncated && !showAll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full cursor-pointer border-t border-grid-bright px-2.5 py-1.5 text-left text-xs text-indigo-400 hover:text-indigo-300"
|
||||
>
|
||||
Show all {rows.length} rows
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
// Page → suggested prompts. Imported by frontend components, so keep it free
|
||||
// of server-side dependencies.
|
||||
|
||||
const DEFAULT_PROMPTS = [
|
||||
"How do retries work?",
|
||||
"Where do I configure concurrency?",
|
||||
"How do I deploy my task?",
|
||||
];
|
||||
|
||||
export const SUGGESTED_PROMPTS: Record<string, string[]> = {
|
||||
runs: [
|
||||
"How do I filter runs?",
|
||||
"How do I replay a failed run?",
|
||||
"What do the run statuses mean?",
|
||||
],
|
||||
errors: [
|
||||
"How do I debug task errors?",
|
||||
"How do I set up error alerts?",
|
||||
"What causes SYSTEM_FAILURE?",
|
||||
],
|
||||
deployments: [
|
||||
"How do I set up CI/CD deployments?",
|
||||
"How do preview branches work?",
|
||||
"How do I rollback a deployment?",
|
||||
],
|
||||
schedules: [
|
||||
"How do I create a cron schedule?",
|
||||
"How does timezone handling work?",
|
||||
"Can I pause a schedule?",
|
||||
],
|
||||
"environment-variables": [
|
||||
"How do environment variables work?",
|
||||
"How do I sync env vars from Vercel?",
|
||||
"Can I use different values per environment?",
|
||||
],
|
||||
query: [
|
||||
"How does TRQL work?",
|
||||
"Show me example queries",
|
||||
"How do I query run metrics?",
|
||||
],
|
||||
};
|
||||
|
||||
export function getPrompts(pageId: string): string[] {
|
||||
return SUGGESTED_PROMPTS[pageId] ?? DEFAULT_PROMPTS;
|
||||
}
|
||||
@@ -98,7 +98,6 @@ import {
|
||||
v3WaitpointTokensPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
import { AlphaBadge } from "../AlphaBadge";
|
||||
import { AskAI } from "../AskAI";
|
||||
import { FreePlanUsage } from "../billing/FreePlanUsage";
|
||||
import { ConnectionIcon, DevPresencePanel, useDevPresence } from "../DevPresence";
|
||||
import { ImpersonationBanner } from "../ImpersonationBanner";
|
||||
@@ -1202,7 +1201,6 @@ function HelpAndAI({ isCollapsed, organizationId, projectId }: { isCollapsed: bo
|
||||
>
|
||||
<ShortcutsAutoOpen />
|
||||
<HelpAndFeedback isCollapsed={isCollapsed} organizationId={organizationId} projectId={projectId} />
|
||||
<AskAI isCollapsed={isCollapsed} />
|
||||
</div>
|
||||
</LayoutGroup>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { BreadcrumbIcon } from "./BreadcrumbIcon";
|
||||
import { Header2 } from "./Headers";
|
||||
import { LoadingBarDivider } from "./LoadingBarDivider";
|
||||
import { EnvironmentBanner } from "../navigation/EnvironmentBanner";
|
||||
import { AskAI } from "../AskAI";
|
||||
|
||||
type WithChildren = {
|
||||
children: React.ReactNode;
|
||||
@@ -22,7 +23,10 @@ export function NavBar({ children }: WithChildren) {
|
||||
return (
|
||||
<div>
|
||||
<div className="grid h-10 w-full grid-rows-[auto_1px] bg-background-bright">
|
||||
<div className="flex w-full items-center justify-between pl-3 pr-2">{children}</div>
|
||||
<div className="flex w-full items-center gap-2 pl-3 pr-2">
|
||||
<div className="flex flex-1 items-center justify-between">{children}</div>
|
||||
<AskAI />
|
||||
</div>
|
||||
<LoadingBarDivider isLoading={isLoading} />
|
||||
</div>
|
||||
{showUpgradePrompt.shouldShow && organization ? <UpgradePrompt /> : <EnvironmentBanner />}
|
||||
|
||||
@@ -4,13 +4,13 @@ import {
|
||||
ChevronUpIcon,
|
||||
ClipboardDocumentIcon,
|
||||
CodeBracketSquareIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import { Suspense, useEffect, useState } from "react";
|
||||
import { CodeBlock } from "~/components/code/CodeBlock";
|
||||
import { StreamdownRenderer } from "~/components/code/StreamdownRenderer";
|
||||
import { Button, LinkButton } from "~/components/primitives/Buttons";
|
||||
import { Header3 } from "~/components/primitives/Headers";
|
||||
import tablerSpritePath from "~/components/primitives/tabler-sprite.svg";
|
||||
import type { DisplayItem, ToolUse } from "./types";
|
||||
|
||||
export type PromptLink = {
|
||||
@@ -89,9 +89,7 @@ function SystemSection({
|
||||
{promptLink && (
|
||||
<LinkButton to={promptLink.path} variant="minimal/small">
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="size-3.5 shrink-0 text-text-dimmed">
|
||||
<use xlinkHref={`${tablerSpritePath}#tabler-file-text-ai`} />
|
||||
</svg>
|
||||
<DocumentTextIcon className="size-3.5 shrink-0 text-text-dimmed" />
|
||||
{promptLink.slug}
|
||||
{promptLink.version ? ` v${promptLink.version}` : ""}
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Schema-only tool definitions. Execute functions live in
|
||||
// app/trigger/ai-assistant-tools/ and spread these in, so descriptions are
|
||||
// single-sourced. Keep this file dependency-light (only `ai` and `zod`) — no
|
||||
// SDK runtime, Prisma, or Node built-ins.
|
||||
import { tool } from "ai";
|
||||
import { z } from "zod";
|
||||
|
||||
export const searchDocs = tool({
|
||||
description:
|
||||
"Search Trigger.dev documentation for guides, API reference, configuration, " +
|
||||
"troubleshooting, and help articles. Use when the user asks how a feature works, " +
|
||||
"how to configure something, or needs help with an error.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Search query about Trigger.dev features or APIs"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const navigateToPage = tool({
|
||||
description:
|
||||
"Navigate the user to a specific dashboard page, or deep-link directly to a run " +
|
||||
"(and optionally a specific span/subtrace within it). Use when the user asks " +
|
||||
"'where do I find X', 'take me to Y', 'show me the Z page', 'go to settings', or " +
|
||||
"'open run run_…' / 'take me to that run'. Returns a URL that the frontend renders " +
|
||||
"as a clickable link and auto-navigates to during live chat.",
|
||||
inputSchema: z.object({
|
||||
destination: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"A named section to go to, e.g. 'runs page', 'environment variables', " +
|
||||
"'deployment settings', 'error alerts', 'concurrency configuration'. " +
|
||||
"Omit when deep-linking to a run via runId."
|
||||
),
|
||||
runId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Deep-link to a specific run by its friendly ID (e.g. 'run_cmpy8wwvg0006htra3f5jtr8i'). " +
|
||||
"Opens the run detail / trace view. Takes precedence over destination."
|
||||
),
|
||||
spanId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"When deep-linking to a run, optionally select a specific span (subtrace) in the " +
|
||||
"trace view by its span ID. Requires runId."
|
||||
),
|
||||
testTaskId: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"Deep-link to the Test page for a specific task by its task identifier (e.g. " +
|
||||
"'hello-world'). Opens the test form where a payload can be filled and the task run. " +
|
||||
"Use when the user wants to test/run a specific task. Takes precedence over destination."
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
// V1C Test domain tools
|
||||
export const listTestableTasks = tool({
|
||||
description:
|
||||
"List the tasks available to test in the current environment (the same list shown on the " +
|
||||
"Test page). Use when the user asks what they can test, or to resolve a task before testing it.",
|
||||
inputSchema: z.object({
|
||||
query: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe("Optional substring to filter task identifiers (e.g. 'hello')"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const generateTestPayload = tool({
|
||||
description:
|
||||
"Generate a realistic JSON payload to test a task with — the same AI payload generation used " +
|
||||
"on the Test page. Conforms to the task's payload schema if it has one, otherwise infers the " +
|
||||
"shape from the task's source code. When the user is on (or you have just navigated to) that " +
|
||||
"task's Test page, the generated payload is filled into the editor for them. Call this before " +
|
||||
"runTestTask so the run uses a sensible payload.",
|
||||
inputSchema: z.object({
|
||||
taskIdentifier: z.string().describe("The task identifier to generate a payload for"),
|
||||
instruction: z
|
||||
.string()
|
||||
.optional()
|
||||
.describe(
|
||||
"What kind of payload to generate, e.g. 'a smoke test', 'minimal valid payload', " +
|
||||
"'edge cases', 'a payload with nested objects'. Defaults to a simple valid payload."
|
||||
),
|
||||
}),
|
||||
});
|
||||
|
||||
export const runTestTask = tool({
|
||||
description:
|
||||
"Trigger a test run of a task — the equivalent of filling the payload and clicking 'Run test' " +
|
||||
"on the Test page. Only call this when the user explicitly asks to run/trigger/smoke-test a " +
|
||||
"task. Pass the payload from generateTestPayload. Returns the new run's friendly ID; the UI " +
|
||||
"navigates the user to the run so they can watch it.",
|
||||
inputSchema: z.object({
|
||||
taskIdentifier: z.string().describe("The task identifier to run"),
|
||||
payload: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("The JSON payload object to run the task with (from generateTestPayload). Defaults to {}"),
|
||||
metadata: z
|
||||
.record(z.unknown())
|
||||
.optional()
|
||||
.describe("Optional run metadata object"),
|
||||
tags: z.array(z.string()).optional().describe("Optional run tags (max 10)"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getCurrentContext = tool({
|
||||
description:
|
||||
"Get information about what the user is currently viewing in the dashboard. " +
|
||||
"Returns the current project, environment, page, and any active parameters. " +
|
||||
"Use to ground your answers in the user's current context.",
|
||||
inputSchema: z.object({}),
|
||||
});
|
||||
|
||||
export const searchPages = tool({
|
||||
description:
|
||||
"Search for available dashboard pages by description. Returns matching pages " +
|
||||
"with descriptions and URLs. Use when the user's destination is ambiguous or " +
|
||||
"you want to suggest relevant pages.",
|
||||
inputSchema: z.object({
|
||||
query: z.string().describe("Description of what the user is looking for"),
|
||||
}),
|
||||
});
|
||||
|
||||
// V1B Runs domain tools
|
||||
export const listRuns = tool({
|
||||
description:
|
||||
"List recent runs with optional filters by status, task, time period, and tags. " +
|
||||
"Use to help the user find specific runs or understand run patterns.",
|
||||
inputSchema: z.object({
|
||||
status: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe(
|
||||
"Filter by run status. Valid statuses: PENDING, DELAYED, DEQUEUED, EXECUTING, " +
|
||||
"WAITING_TO_RESUME, COMPLETED_SUCCESSFULLY, COMPLETED_WITH_ERRORS, TIMED_OUT, " +
|
||||
"CRASHED, SYSTEM_FAILURE, CANCELED, EXPIRED. For 'failed' runs pass " +
|
||||
"[COMPLETED_WITH_ERRORS, CRASHED, TIMED_OUT, SYSTEM_FAILURE]; for 'successful' pass " +
|
||||
"[COMPLETED_SUCCESSFULLY]; for 'running' pass [EXECUTING]."
|
||||
),
|
||||
taskIdentifier: z.string().optional().describe("Filter by task identifier"),
|
||||
period: z.string().optional().describe("Time period filter (e.g., '1h', '24h', '7d')"),
|
||||
tags: z.array(z.string()).optional().describe("Filter by tags"),
|
||||
limit: z.number().optional().default(20).describe("Maximum number of runs to return"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getRunDetails = tool({
|
||||
description:
|
||||
"Get detailed information about a specific run, including status, timing, and trace summary. " +
|
||||
"Use when the user wants to investigate a particular run.",
|
||||
inputSchema: z.object({
|
||||
runFriendlyId: z.string().describe("The friendly ID of the run"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getSpanDetails = tool({
|
||||
description:
|
||||
"Get the full detail of a single span (subtrace) within a run's trace, including its " +
|
||||
"error/exception (message + stack trace), log level, span events, properties, and metadata. " +
|
||||
"First call getRunDetails to list the trace's spans, then pass a span's `id` here as `spanId` " +
|
||||
"to drill into the one that failed. Use this to answer 'look at the subtrace and tell me exactly " +
|
||||
"what and why caused the error'. If the span is itself a triggered child run, also returns that " +
|
||||
"run's error and output.",
|
||||
inputSchema: z.object({
|
||||
runFriendlyId: z
|
||||
.string()
|
||||
.describe("The friendly ID of the run that owns the trace (e.g. 'run_…')"),
|
||||
spanId: z
|
||||
.string()
|
||||
.describe("The span ID to inspect — the `id` field from a span in getRunDetails' trace"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getRunLogs = tool({
|
||||
description:
|
||||
"Fetch log lines from a specific run, optionally filtered by log level. " +
|
||||
"Returns formatted log lines with timestamps.",
|
||||
inputSchema: z.object({
|
||||
runFriendlyId: z.string().describe("The friendly ID of the run"),
|
||||
level: z
|
||||
.enum(["debug", "info", "warn", "error"])
|
||||
.optional()
|
||||
.describe("Filter by log level"),
|
||||
limit: z.number().optional().default(50).describe("Maximum number of log lines to return"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getRunGraph = tool({
|
||||
description:
|
||||
"Get the hierarchical structure of a run, including parent and child runs. " +
|
||||
"Useful for understanding task dependencies and call chains.",
|
||||
inputSchema: z.object({
|
||||
runFriendlyId: z.string().describe("The friendly ID of the run"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const applyRunFilters = tool({
|
||||
description:
|
||||
"Convert natural language description into structured filters for the runs list. " +
|
||||
"Returns filters as URL parameters that can be applied to the runs page.",
|
||||
inputSchema: z.object({
|
||||
description: z.string().describe("Natural language description of filters (e.g., 'failed runs in the last 24 hours')"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const queryRuns = tool({
|
||||
description:
|
||||
"Convert natural language questions into SQL queries executed against ClickHouse. " +
|
||||
"Use for analytics and trend analysis. Returns structured query results.",
|
||||
inputSchema: z.object({
|
||||
question: z.string().describe("Natural language question about runs (e.g., 'what is the failure rate for the email task?')"),
|
||||
}),
|
||||
});
|
||||
|
||||
// V1B Errors domain tools
|
||||
export const listErrors = tool({
|
||||
description:
|
||||
"List error groups (unique errors) with their occurrence counts and timing. " +
|
||||
"Use to identify common failure patterns and most frequent errors.",
|
||||
inputSchema: z.object({
|
||||
period: z.string().optional().describe("Time period (e.g., '1h', '24h', '7d')"),
|
||||
taskIdentifier: z.string().optional().describe("Filter by task identifier"),
|
||||
limit: z.number().optional().default(20).describe("Maximum number of error groups to return"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const getErrorDetails = tool({
|
||||
description:
|
||||
"Get detailed information about a specific error group, including stack trace, " +
|
||||
"affected runs sample, and timing. Use to understand a specific error in depth.",
|
||||
inputSchema: z.object({
|
||||
fingerprint: z.string().describe("The error fingerprint identifying the error group"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const findSimilarErrors = tool({
|
||||
description:
|
||||
"Search for error groups with similar messages. Useful for finding patterns or regressions across tasks.",
|
||||
inputSchema: z.object({
|
||||
errorMessage: z.string().describe("The error message to search for similar errors"),
|
||||
limit: z.number().optional().default(10).describe("Maximum number of similar errors to return"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const classifyFailure = tool({
|
||||
description:
|
||||
"Classify the cause of a run failure into categories like timeout, OOM, missing env var, etc. " +
|
||||
"Uses AI analysis of run details and logs to determine the most likely failure reason.",
|
||||
inputSchema: z.object({
|
||||
runFriendlyId: z.string().describe("The friendly ID of the run to classify"),
|
||||
}),
|
||||
});
|
||||
|
||||
// V1B Analytics domain tools
|
||||
export const summarizeCurrentView = tool({
|
||||
description:
|
||||
"Get a summary of the current view: total runs, status distribution, top failing tasks, and error rate trend. " +
|
||||
"Use to understand the overall health of the system.",
|
||||
inputSchema: z.object({
|
||||
period: z.string().optional().describe("Time period for summary (e.g., '1h', '24h', '7d')"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const aggregateRuns = tool({
|
||||
description:
|
||||
"Compute aggregated metrics (count, failure rate, duration) for runs grouped by task, status, version, or queue. " +
|
||||
"Use for performance analysis and bottleneck identification.",
|
||||
inputSchema: z.object({
|
||||
groupBy: z
|
||||
.enum(["task", "status", "version", "queue"])
|
||||
.describe("Dimension to group by"),
|
||||
metric: z
|
||||
.enum(["count", "failureRate", "avgDuration", "p95Duration"])
|
||||
.optional()
|
||||
.describe("Metric to compute"),
|
||||
period: z.string().optional().describe("Time period (e.g., '1h', '24h', '7d')"),
|
||||
}),
|
||||
});
|
||||
|
||||
export const correlateRunsWithDeploy = tool({
|
||||
description:
|
||||
"Analyze failure rates by deployment version to identify deploy regressions. " +
|
||||
"Shows correlation between deployments and failure patterns.",
|
||||
inputSchema: z.object({
|
||||
taskIdentifier: z.string().optional().describe("Filter by task identifier"),
|
||||
period: z.string().optional().describe("Time period (e.g., '1h', '24h', '7d')"),
|
||||
}),
|
||||
});
|
||||
|
||||
// Tool labels for UI display (3-4 words max, action-oriented)
|
||||
export const toolLabels: Record<string, string> = {
|
||||
searchDocs: "Searching documentation",
|
||||
navigateToPage: "Navigating to page",
|
||||
getCurrentContext: "Checking current context",
|
||||
searchPages: "Searching dashboard pages",
|
||||
listRuns: "Querying task runs",
|
||||
getRunDetails: "Loading run details",
|
||||
getSpanDetails: "Inspecting subtrace",
|
||||
getRunLogs: "Fetching run logs",
|
||||
getRunGraph: "Building run hierarchy",
|
||||
applyRunFilters: "Applying run filters",
|
||||
queryRuns: "Running analytics query",
|
||||
listErrors: "Loading error groups",
|
||||
getErrorDetails: "Loading error details",
|
||||
findSimilarErrors: "Finding similar errors",
|
||||
classifyFailure: "Classifying run failure",
|
||||
summarizeCurrentView: "Analyzing current view",
|
||||
aggregateRuns: "Computing aggregations",
|
||||
correlateRunsWithDeploy: "Checking deploy correlation",
|
||||
listTestableTasks: "Listing testable tasks",
|
||||
generateTestPayload: "Generating test payload",
|
||||
runTestTask: "Running test task",
|
||||
};
|
||||
+13
@@ -35,6 +35,7 @@ import { Select, SelectItem } from "~/components/primitives/Select";
|
||||
import { TabButton, TabContainer } from "~/components/primitives/Tabs";
|
||||
import { TextLink } from "~/components/primitives/TextLink";
|
||||
import { TimezoneList } from "~/components/scheduled/timezones";
|
||||
import { useOptionalAIChat } from "~/components/ai-assistant/AIChatProvider";
|
||||
import { useEnvironment } from "~/hooks/useEnvironment";
|
||||
import { useSearchParams } from "~/hooks/useSearchParam";
|
||||
import { useParams, Form, useActionData, useFetcher, useSubmit } from "@remix-run/react";
|
||||
@@ -384,6 +385,18 @@ function StandardTaskForm({
|
||||
|
||||
const currentPayloadJson = useRef<string>(defaultPayloadJson);
|
||||
|
||||
// The AI assistant can generate a payload for this task and ask us to fill the
|
||||
// editor with it (matching what the on-page AI does). Consume it once.
|
||||
const aiChat = useOptionalAIChat();
|
||||
const pendingTestFill = aiChat?.pendingTestFill;
|
||||
const clearTestFill = aiChat?.clearTestFill;
|
||||
useEffect(() => {
|
||||
if (!pendingTestFill || pendingTestFill.taskIdentifier !== task.taskIdentifier) return;
|
||||
setPayload(pendingTestFill.payload);
|
||||
currentPayloadJson.current = pendingTestFill.payload;
|
||||
clearTestFill?.();
|
||||
}, [pendingTestFill, task.taskIdentifier, setPayload, clearTestFill]);
|
||||
|
||||
const [defaultMetadataJson, setDefaultMetadataJson] = useState<string>(
|
||||
lastRun?.seedMetadata ?? startingJson
|
||||
);
|
||||
|
||||
+38
-5
@@ -8,6 +8,39 @@ import { useIsImpersonating, useOrganization, useOrganizations } from "~/hooks/u
|
||||
import { useProject } from "~/hooks/useProject";
|
||||
import { useUser } from "~/hooks/useUser";
|
||||
import { v3ProjectPath } from "~/utils/pathBuilder";
|
||||
import { AIChatProvider, useAIChat } from "~/components/ai-assistant/AIChatProvider";
|
||||
import { AIChatPanel } from "~/components/ai-assistant/AIChatPanel";
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
|
||||
function AIChatLayout({ children }: { children: ReactNode }) {
|
||||
const { isOpen } = useAIChat();
|
||||
|
||||
// Keep the panel mounted after the first open so the conversation persists
|
||||
// across toggles and so the close transition has something to animate. Lazy
|
||||
// mounting avoids the input stealing focus on every page load.
|
||||
const [hasOpened, setHasOpened] = useState(false);
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setHasOpened(true);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="grid overflow-hidden transition-[grid-template-columns] duration-200 ease-in-out"
|
||||
style={{
|
||||
gridTemplateColumns: isOpen ? "auto minmax(0, 1fr) 380px" : "auto minmax(0, 1fr) 0px",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{/* Right-anchored + overflow-hidden so the panel slides in from the right
|
||||
edge as the column grows, rather than being revealed left-to-right. */}
|
||||
<div className="flex h-full justify-end overflow-hidden">
|
||||
{hasOpened && <AIChatPanel />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Project() {
|
||||
const organizations = useOrganizations();
|
||||
@@ -18,8 +51,8 @@ export default function Project() {
|
||||
const isImpersonating = useIsImpersonating();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid grid-cols-[auto_1fr] overflow-hidden">
|
||||
<AIChatProvider userId={user.id}>
|
||||
<AIChatLayout>
|
||||
<DevPresenceProvider enabled={environment.type === "DEVELOPMENT"}>
|
||||
<SideMenu
|
||||
user={{ ...user, isImpersonating }}
|
||||
@@ -32,8 +65,8 @@ export default function Project() {
|
||||
<Outlet />
|
||||
</MainBody>
|
||||
</DevPresenceProvider>
|
||||
</div>
|
||||
</>
|
||||
</AIChatLayout>
|
||||
</AIChatProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,4 +74,4 @@ export function ErrorBoundary() {
|
||||
const org = useOrganization();
|
||||
const project = useProject();
|
||||
return <RouteErrorDisplay button={{ title: project.name, to: v3ProjectPath(org, project) }} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export const loader = async ({ request, params }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
const { chatId } = params;
|
||||
|
||||
if (!chatId) {
|
||||
return json({ error: "Missing chatId" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [chat, session] = await Promise.all([
|
||||
prisma.aiChat.findFirst({
|
||||
where: { id: chatId, userId },
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
messages: true,
|
||||
model: true,
|
||||
},
|
||||
}),
|
||||
prisma.aiChatSession.findFirst({
|
||||
where: { id: chatId },
|
||||
select: {
|
||||
publicAccessToken: true,
|
||||
lastEventId: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!chat) {
|
||||
return json({ error: "Chat not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return json({
|
||||
chat,
|
||||
session: session
|
||||
? {
|
||||
publicAccessToken: session.publicAccessToken,
|
||||
lastEventId: session.lastEventId,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { LoaderFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { prisma } from "~/db.server";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
|
||||
export const loader = async ({ request }: LoaderFunctionArgs) => {
|
||||
const userId = await requireUserId(request);
|
||||
|
||||
const chats = await prisma.aiChat.findMany({
|
||||
where: { userId, NOT: { messages: { equals: [] } } },
|
||||
select: { id: true, title: true, updatedAt: true },
|
||||
orderBy: [{ updatedAt: "desc" }, { id: "desc" }],
|
||||
take: 50,
|
||||
});
|
||||
|
||||
return json({
|
||||
chats: chats.map((c) => ({
|
||||
id: c.id,
|
||||
title: c.title,
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
})),
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { ActionFunctionArgs } from "@remix-run/server-runtime";
|
||||
import { json } from "@remix-run/server-runtime";
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { requireUserId } from "~/services/session.server";
|
||||
import { withAssistantAuth, type AssistantEnvContext } from "~/services/aiAssistant.server";
|
||||
|
||||
const startDashboardAssistant = chat.createStartSessionAction("dashboard-assistant");
|
||||
|
||||
// Auth context from the server-trusted userId + the slugs the browser sends.
|
||||
// The userId is never trusted from the browser — membership is re-checked
|
||||
// against it in `withAssistantAuth`.
|
||||
function envContext(
|
||||
userId: string,
|
||||
clientData: Record<string, unknown> | undefined
|
||||
): AssistantEnvContext {
|
||||
const orgSlug = String(clientData?.organizationSlug ?? "").trim();
|
||||
const projSlug = String(clientData?.projectSlug ?? "").trim();
|
||||
const envSlug = String(clientData?.environmentSlug ?? "").trim();
|
||||
|
||||
if (!orgSlug || !projSlug || !envSlug) {
|
||||
throw new Error("Missing organization, project, or environment slug");
|
||||
}
|
||||
|
||||
return {
|
||||
userId,
|
||||
organizationSlug: orgSlug,
|
||||
projectSlug: projSlug,
|
||||
environmentSlug: envSlug,
|
||||
};
|
||||
}
|
||||
|
||||
export const action = async ({ request }: ActionFunctionArgs) => {
|
||||
try {
|
||||
const userId = await requireUserId(request);
|
||||
const body = (await request.json()) as {
|
||||
intent?: string;
|
||||
chatId?: string;
|
||||
clientData?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
if (!body.chatId) {
|
||||
return json({ error: "Missing chatId" }, { status: 400 });
|
||||
}
|
||||
const chatId = body.chatId;
|
||||
|
||||
if (body.intent === "createSession") {
|
||||
const { clientData } = body;
|
||||
const ctx = envContext(userId, clientData);
|
||||
|
||||
const result = await withAssistantAuth(ctx, () =>
|
||||
startDashboardAssistant({
|
||||
chatId,
|
||||
// Override the browser-claimed userId with the server-trusted one.
|
||||
clientData: {
|
||||
userId,
|
||||
organizationSlug: ctx.organizationSlug,
|
||||
projectSlug: ctx.projectSlug,
|
||||
environmentSlug: ctx.environmentSlug,
|
||||
currentPage: String(clientData?.currentPage ?? ""),
|
||||
currentParams: clientData?.currentParams
|
||||
? (clientData.currentParams as Record<string, string>)
|
||||
: undefined,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return json({
|
||||
sessionId: result.sessionId,
|
||||
publicAccessToken: result.publicAccessToken,
|
||||
});
|
||||
}
|
||||
|
||||
if (body.intent === "refreshToken") {
|
||||
const { chatId, clientData } = body;
|
||||
const ctx = envContext(userId, clientData);
|
||||
|
||||
// Pure mint — no session create, no run trigger. Scoped to this chat.
|
||||
const publicAccessToken = await withAssistantAuth(ctx, () =>
|
||||
auth.createPublicToken({
|
||||
scopes: {
|
||||
read: { sessions: chatId },
|
||||
write: { sessions: chatId },
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
return json({ publicAccessToken });
|
||||
}
|
||||
|
||||
return json({ error: "Unknown intent" }, { status: 400 });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
return json(
|
||||
{ error: `AI assistant error: ${message}` },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import { auth } from "@trigger.dev/sdk";
|
||||
import { env } from "~/env.server";
|
||||
import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
|
||||
import { findProjectBySlug } from "~/models/project.server";
|
||||
|
||||
// The assistant runs as a Trigger.dev task on this same platform. Rather than
|
||||
// stashing a secret, we authenticate SDK calls with the apiKey of the
|
||||
// environment the user is currently viewing, read from the DB and scoped via
|
||||
// `auth.withAuth`. Moving the assistant to a dedicated project would only
|
||||
// change `resolveAssistantApiKey`.
|
||||
|
||||
export type AssistantEnvContext = {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
};
|
||||
|
||||
class AssistantAuthError extends Error {}
|
||||
|
||||
async function resolveAssistantApiKey(ctx: AssistantEnvContext): Promise<string> {
|
||||
const project = await findProjectBySlug(ctx.organizationSlug, ctx.projectSlug, ctx.userId);
|
||||
if (!project) {
|
||||
throw new AssistantAuthError(
|
||||
`AI assistant: no project "${ctx.projectSlug}" in org "${ctx.organizationSlug}" for this user`
|
||||
);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(project.id, ctx.environmentSlug, ctx.userId);
|
||||
if (!environment) {
|
||||
throw new AssistantAuthError(
|
||||
`AI assistant: no environment "${ctx.environmentSlug}" in project "${ctx.projectSlug}"`
|
||||
);
|
||||
}
|
||||
|
||||
return environment.apiKey;
|
||||
}
|
||||
|
||||
// Run `fn` with the SDK API client scoped to the current environment's key and
|
||||
// this instance's own origin, so session/token calls hit the local platform.
|
||||
export async function withAssistantAuth<T>(
|
||||
ctx: AssistantEnvContext,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const apiKey = await resolveAssistantApiKey(ctx);
|
||||
|
||||
return auth.withAuth(
|
||||
{
|
||||
baseURL: env.API_ORIGIN ?? env.APP_ORIGIN,
|
||||
accessToken: apiKey,
|
||||
},
|
||||
fn
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { tool } from "ai";
|
||||
import { aggregateRuns as aggregateRunsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
import {
|
||||
buildTimeRange,
|
||||
formatClickhouseTimestamp,
|
||||
CLICKHOUSE_QUERY_SETTINGS,
|
||||
} from "./clickhouse-queries";
|
||||
|
||||
export function createAggregateRunsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...aggregateRunsSchema,
|
||||
execute: async (params: { groupBy: string; metric?: string; period?: string }) => {
|
||||
try {
|
||||
const { clickhouseClient } = await import("~/v3/clickhouse.server");
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
// Validate groupBy parameter
|
||||
if (!["task", "status", "version", "queue"].includes(params.groupBy)) {
|
||||
return {
|
||||
error: "Invalid groupBy parameter. Must be one of: task, status, version, queue",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get environment IDs
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
error: "Environment not found",
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { from, to } = buildTimeRange(params.period);
|
||||
const metric = params.metric || "count";
|
||||
|
||||
// Map groupBy to column names
|
||||
const columnMap: Record<string, string> = {
|
||||
task: "task_identifier",
|
||||
status: "status",
|
||||
version: "deployment_version",
|
||||
queue: "queue_name",
|
||||
};
|
||||
|
||||
const groupColumn = columnMap[params.groupBy];
|
||||
|
||||
// Build metric aggregate
|
||||
let metricSelect = "COUNT(*) as count";
|
||||
if (metric === "failureRate") {
|
||||
metricSelect = `
|
||||
ROUND(
|
||||
SUM(CASE WHEN status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT') THEN 1 ELSE 0 END) * 100.0 / COUNT(*),
|
||||
2
|
||||
) as failure_rate_percent
|
||||
`;
|
||||
} else if (metric === "avgDuration") {
|
||||
metricSelect = `ROUND(AVG(duration_ms), 0) as avg_duration_ms`;
|
||||
} else if (metric === "p95Duration") {
|
||||
metricSelect = `ROUND(quantile(0.95)(duration_ms), 0) as p95_duration_ms`;
|
||||
}
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
${groupColumn} as dimension,
|
||||
${metricSelect}
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
organization_id = '${environment.organizationId}'
|
||||
AND project_id = '${environment.project.id}'
|
||||
AND environment_id = '${environment.id}'
|
||||
AND engine = 'V2'
|
||||
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
|
||||
AND triggered_at < '${formatClickhouseTimestamp(to)}'
|
||||
GROUP BY ${groupColumn}
|
||||
ORDER BY count DESC
|
||||
LIMIT 50
|
||||
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
|
||||
`;
|
||||
|
||||
const results = await clickhouseClient.query({
|
||||
query,
|
||||
format: "JSONCompact",
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(results.text);
|
||||
const formattedResults = parsed.data?.map((row: unknown[]) => ({
|
||||
dimension: row[0],
|
||||
value: row[1],
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
groupBy: params.groupBy,
|
||||
metric,
|
||||
results: formattedResults,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to aggregate runs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
results: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Shared ClickHouse query helpers for analytics tools
|
||||
// All queries apply the same limits as the query page to prevent resource exhaustion
|
||||
|
||||
const QUERY_CLICKHOUSE_MAX_EXECUTION_TIME = 10; // seconds
|
||||
const QUERY_CLICKHOUSE_MAX_MEMORY_USAGE = 1024 * 1024 * 1024; // 1GB
|
||||
|
||||
export const CLICKHOUSE_QUERY_SETTINGS = {
|
||||
max_execution_time: QUERY_CLICKHOUSE_MAX_EXECUTION_TIME,
|
||||
max_memory_usage: QUERY_CLICKHOUSE_MAX_MEMORY_USAGE,
|
||||
};
|
||||
|
||||
export function formatClickhouseTimestamp(date: Date): string {
|
||||
return date.toISOString().replace("T", " ").split(".")[0];
|
||||
}
|
||||
|
||||
export function buildTimeRange(period: string | undefined): {
|
||||
from: Date;
|
||||
to: Date;
|
||||
} {
|
||||
const to = new Date();
|
||||
const from = new Date();
|
||||
|
||||
const periods: Record<string, () => void> = {
|
||||
"1h": () => from.setHours(from.getHours() - 1),
|
||||
"6h": () => from.setHours(from.getHours() - 6),
|
||||
"24h": () => from.setDate(from.getDate() - 1),
|
||||
"7d": () => from.setDate(from.getDate() - 7),
|
||||
"30d": () => from.setDate(from.getDate() - 30),
|
||||
};
|
||||
|
||||
if (period && period in periods) {
|
||||
periods[period]();
|
||||
} else {
|
||||
// Default to 24h
|
||||
from.setDate(from.getDate() - 1);
|
||||
}
|
||||
|
||||
return { from, to };
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { tool } from "ai";
|
||||
import { correlateRunsWithDeploy as correlateRunsWithDeploySchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
import {
|
||||
buildTimeRange,
|
||||
formatClickhouseTimestamp,
|
||||
CLICKHOUSE_QUERY_SETTINGS,
|
||||
} from "./clickhouse-queries";
|
||||
|
||||
export function createCorrelateRunsWithDeployTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...correlateRunsWithDeploySchema,
|
||||
execute: async (params: { taskIdentifier?: string; period?: string }) => {
|
||||
try {
|
||||
const { clickhouseClient } = await import("~/v3/clickhouse.server");
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
// Get environment IDs
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
error: "Environment not found",
|
||||
deploys: [],
|
||||
correlation: {},
|
||||
};
|
||||
}
|
||||
|
||||
const { from, to } = buildTimeRange(params.period);
|
||||
|
||||
// Query: Failure rates by deployment version
|
||||
const query = `
|
||||
SELECT
|
||||
deployment_version,
|
||||
COUNT(*) as total_runs,
|
||||
SUM(CASE WHEN status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT') THEN 1 ELSE 0 END) as failed_runs,
|
||||
ROUND(
|
||||
SUM(CASE WHEN status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT') THEN 1 ELSE 0 END) * 100.0 / COUNT(*),
|
||||
2
|
||||
) as failure_rate_percent,
|
||||
MIN(triggered_at) as first_run,
|
||||
MAX(triggered_at) as last_run
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
organization_id = '${environment.organizationId}'
|
||||
AND project_id = '${environment.project.id}'
|
||||
AND environment_id = '${environment.id}'
|
||||
AND engine = 'V2'
|
||||
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
|
||||
AND triggered_at < '${formatClickhouseTimestamp(to)}'
|
||||
${params.taskIdentifier ? `AND task_identifier = '${params.taskIdentifier.replace(/'/g, "''")}'` : ""}
|
||||
GROUP BY deployment_version
|
||||
ORDER BY first_run DESC
|
||||
LIMIT 20
|
||||
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
|
||||
`;
|
||||
|
||||
const results = await clickhouseClient.query({
|
||||
query,
|
||||
format: "JSONCompact",
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(results.text);
|
||||
const deploys = parsed.data?.map((row: unknown[]) => ({
|
||||
version: row[0],
|
||||
totalRuns: row[1],
|
||||
failedRuns: row[2],
|
||||
failureRatePercent: row[3],
|
||||
firstRun: row[4],
|
||||
lastRun: row[5],
|
||||
})) || [];
|
||||
|
||||
// Analyze correlation: compare failure rates between versions
|
||||
const correlation: Record<string, unknown> = {};
|
||||
if (deploys.length >= 2) {
|
||||
const current = deploys[0];
|
||||
const previous = deploys[1];
|
||||
|
||||
if (current && previous) {
|
||||
const failureChange = (current.failureRatePercent as number) - (previous.failureRatePercent as number);
|
||||
correlation.currentVersion = current.version;
|
||||
correlation.previousVersion = previous.version;
|
||||
correlation.failureChangePercent = failureChange;
|
||||
correlation.isRegression = failureChange > 5; // Flag as regression if failure rate increased by >5%
|
||||
correlation.recommendation = failureChange > 5
|
||||
? `Possible regression in version ${current.version}: failure rate increased by ${failureChange.toFixed(1)}%`
|
||||
: "No significant regression detected";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
deploys,
|
||||
correlation,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to correlate deploys: ${error instanceof Error ? error.message : String(error)}`,
|
||||
deploys: [],
|
||||
correlation: {},
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { tool } from "ai";
|
||||
import { summarizeCurrentView as summarizeCurrentViewSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
import {
|
||||
buildTimeRange,
|
||||
formatClickhouseTimestamp,
|
||||
CLICKHOUSE_QUERY_SETTINGS,
|
||||
} from "./clickhouse-queries";
|
||||
|
||||
export function createSummarizeCurrentViewTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...summarizeCurrentViewSchema,
|
||||
execute: async (params: { period?: string }) => {
|
||||
try {
|
||||
const { clickhouseClient } = await import("~/v3/clickhouse.server");
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
// Get environment IDs
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
error: "Environment not found",
|
||||
totalRuns: 0,
|
||||
statusDistribution: {},
|
||||
topFailingTasks: [],
|
||||
errorRate: "0%",
|
||||
};
|
||||
}
|
||||
|
||||
const { from, to } = buildTimeRange(params.period);
|
||||
|
||||
// Query 1: Total runs and status distribution
|
||||
const statusQuery = `
|
||||
SELECT
|
||||
status,
|
||||
COUNT(*) as count
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
organization_id = '${environment.organizationId}'
|
||||
AND project_id = '${environment.project.id}'
|
||||
AND environment_id = '${environment.id}'
|
||||
AND engine = 'V2'
|
||||
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
|
||||
AND triggered_at < '${formatClickhouseTimestamp(to)}'
|
||||
GROUP BY status
|
||||
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
|
||||
`;
|
||||
|
||||
const statusResults = await clickhouseClient.query({
|
||||
query: statusQuery,
|
||||
format: "JSONCompact",
|
||||
});
|
||||
|
||||
const statusData = JSON.parse(statusResults.text);
|
||||
const statusDistribution: Record<string, number> = {};
|
||||
let totalRuns = 0;
|
||||
|
||||
statusData.data?.forEach((row: [string, number]) => {
|
||||
statusDistribution[row[0]] = row[1];
|
||||
totalRuns += row[1];
|
||||
});
|
||||
|
||||
// Query 2: Top failing tasks
|
||||
const failingTasksQuery = `
|
||||
SELECT
|
||||
task_identifier,
|
||||
COUNT(*) as failure_count
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
organization_id = '${environment.organizationId}'
|
||||
AND project_id = '${environment.project.id}'
|
||||
AND environment_id = '${environment.id}'
|
||||
AND engine = 'V2'
|
||||
AND triggered_at >= '${formatClickhouseTimestamp(from)}'
|
||||
AND triggered_at < '${formatClickhouseTimestamp(to)}'
|
||||
AND status IN ('COMPLETED_WITH_ERRORS', 'CRASHED', 'TIMED_OUT')
|
||||
GROUP BY task_identifier
|
||||
ORDER BY failure_count DESC
|
||||
LIMIT 5
|
||||
SETTINGS max_execution_time = ${CLICKHOUSE_QUERY_SETTINGS.max_execution_time}
|
||||
`;
|
||||
|
||||
const failingResults = await clickhouseClient.query({
|
||||
query: failingTasksQuery,
|
||||
format: "JSONCompact",
|
||||
});
|
||||
|
||||
const failingData = JSON.parse(failingResults.text);
|
||||
const topFailingTasks = failingData.data?.map((row: [string, number]) => row[0]) || [];
|
||||
|
||||
// Calculate error rate
|
||||
const failureCount =
|
||||
Object.entries(statusDistribution).reduce((sum, [status, count]) => {
|
||||
if (["COMPLETED_WITH_ERRORS", "CRASHED", "TIMED_OUT"].includes(status)) {
|
||||
return sum + count;
|
||||
}
|
||||
return sum;
|
||||
}, 0) || 0;
|
||||
|
||||
const errorRate =
|
||||
totalRuns > 0 ? Math.round((failureCount / totalRuns) * 100) : 0;
|
||||
|
||||
return {
|
||||
totalRuns,
|
||||
statusDistribution,
|
||||
topFailingTasks,
|
||||
errorRate: `${errorRate}%`,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to summarize view: ${error instanceof Error ? error.message : String(error)}`,
|
||||
totalRuns: 0,
|
||||
statusDistribution: {},
|
||||
topFailingTasks: [],
|
||||
errorRate: "0%",
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { tool } from "ai";
|
||||
import { searchDocs as searchDocsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
|
||||
const MINTLIFY_MCP_URL = "https://trigger.dev/docs/mcp";
|
||||
const MCP_PROTOCOL_VERSION = "2025-06-18";
|
||||
|
||||
export function createSearchDocsTool() {
|
||||
return tool({
|
||||
...searchDocsSchema,
|
||||
execute: async ({ query }) => {
|
||||
try {
|
||||
const body = {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "tools/call",
|
||||
params: { name: "search_trigger_dev", arguments: { query } },
|
||||
};
|
||||
|
||||
const response = await fetch(MINTLIFY_MCP_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json, text/event-stream",
|
||||
"MCP-Protocol-Version": MCP_PROTOCOL_VERSION,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const data: any = await parseResponse(response);
|
||||
return { success: true, results: data?.result ?? data };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// --- Mintlify response parsing (handles both SSE and JSON) ---
|
||||
// Adapted from packages/cli-v3/src/mcp/mintlifyClient.ts
|
||||
|
||||
async function parseResponse(response: Response) {
|
||||
if (response.headers.get("content-type")?.includes("text/event-stream")) {
|
||||
return parseSSEResponse(response);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async function parseSSEResponse(response: Response) {
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) throw new Error("No reader found");
|
||||
|
||||
let buffer = "";
|
||||
while (true) {
|
||||
const { value, done } = await reader.read();
|
||||
if (done) throw new Error("SSE stream closed before data arrived");
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const events = buffer.split("\n\n");
|
||||
buffer = events.pop()!;
|
||||
for (const evt of events) {
|
||||
for (const line of evt.split("\n")) {
|
||||
if (line.startsWith("data:")) {
|
||||
return JSON.parse(line.slice(5).trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { tool } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateObject } from "ai";
|
||||
import { z } from "zod";
|
||||
import { classifyFailure as classifyFailureSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
const FailureClassificationSchema = z.object({
|
||||
category: z
|
||||
.enum([
|
||||
"Timeout",
|
||||
"OOM / Memory",
|
||||
"Missing env var",
|
||||
"Child task failed",
|
||||
"User code exception",
|
||||
"AI provider rate limit",
|
||||
"Deploy regression",
|
||||
"Platform issue",
|
||||
"Unknown",
|
||||
])
|
||||
.describe("The category of failure"),
|
||||
confidence: z
|
||||
.enum(["High", "Medium", "Low"])
|
||||
.describe("How confident we are in this classification"),
|
||||
evidence: z.string().describe("The key evidence supporting this classification"),
|
||||
nextSteps: z
|
||||
.array(z.string())
|
||||
.describe("Suggested next steps for investigation or remediation"),
|
||||
});
|
||||
|
||||
export function createClassifyFailureTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...classifyFailureSchema,
|
||||
execute: async (params: { runFriendlyId: string }) => {
|
||||
try {
|
||||
// Lazy import to avoid env validation issues
|
||||
const { getRunForLLM } = await import("../runs/run-presenter-adapter");
|
||||
|
||||
// Fetch run details including logs
|
||||
const runWithTrace = await getRunForLLM(ctx, params.runFriendlyId);
|
||||
|
||||
if (!runWithTrace) {
|
||||
return {
|
||||
category: "Unknown",
|
||||
confidence: "Low",
|
||||
evidence: "Could not fetch run details",
|
||||
nextSteps: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { run, trace } = runWithTrace;
|
||||
|
||||
// Build a summary of the run for classification
|
||||
const runSummary = `
|
||||
Run Status: ${run.status}
|
||||
Duration: ${run.duration || "unknown"}
|
||||
Started: ${run.startedAt || "unknown"}
|
||||
Completed: ${run.completedAt || "unknown"}
|
||||
Trace Summary: ${trace ? `${trace.totalSpans} spans, root status ${trace.rootStatus}` : "No trace data"}
|
||||
`;
|
||||
|
||||
// Use a cheaper model for classification
|
||||
const classification = await generateObject({
|
||||
model: openai("gpt-4o-mini"),
|
||||
schema: FailureClassificationSchema,
|
||||
prompt: `
|
||||
Classify the cause of this task run failure based on the following information:
|
||||
|
||||
${runSummary}
|
||||
|
||||
Consider these categories:
|
||||
- Timeout: Run exceeded max duration
|
||||
- OOM / Memory: Out of memory or memory limit exceeded
|
||||
- Missing env var: Missing required environment variable
|
||||
- Child task failed: Subtask or dependent task failed
|
||||
- User code exception: Exception in user's code
|
||||
- AI provider rate limit: Hit rate limit from external AI service
|
||||
- Deploy regression: Likely caused by a recent deployment
|
||||
- Platform issue: Platform/infrastructure issue
|
||||
- Unknown: Cannot determine the cause
|
||||
|
||||
Provide your classification with supporting evidence and next steps.
|
||||
`,
|
||||
});
|
||||
|
||||
return classification;
|
||||
} catch (error) {
|
||||
return {
|
||||
category: "Unknown",
|
||||
confidence: "Low",
|
||||
evidence: `Error during classification: ${error instanceof Error ? error.message : String(error)}`,
|
||||
nextSteps: ["Check the run logs manually in the dashboard"],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ErrorGroup } from "~/presenters/v3/ErrorsListPresenter.server";
|
||||
import type { ErrorGroupSummary, ErrorDetailsSummary } from "../types";
|
||||
|
||||
const MAX_STACK_TRACE_LENGTH = 1000;
|
||||
const MAX_AFFECTED_RUNS = 5;
|
||||
|
||||
export function summarizeErrorGroup(
|
||||
errorGroup: ErrorGroup
|
||||
): ErrorGroupSummary {
|
||||
return {
|
||||
fingerprint: errorGroup.fingerprint,
|
||||
message: errorGroup.errorMessage,
|
||||
taskIdentifier: errorGroup.taskIdentifier,
|
||||
count: errorGroup.count,
|
||||
firstSeen: errorGroup.firstSeen?.toISOString() ?? new Date().toISOString(),
|
||||
lastSeen: errorGroup.lastSeen?.toISOString() ?? new Date().toISOString(),
|
||||
status: errorGroup.status ?? "UNRESOLVED",
|
||||
};
|
||||
}
|
||||
|
||||
export function truncateStackTrace(stackTrace: string): string {
|
||||
if (stackTrace.length <= MAX_STACK_TRACE_LENGTH) return stackTrace;
|
||||
return stackTrace.slice(0, MAX_STACK_TRACE_LENGTH) + "...[truncated]";
|
||||
}
|
||||
|
||||
export function summarizeErrorDetails(
|
||||
fingerprint: string,
|
||||
message: string,
|
||||
taskIdentifier: string,
|
||||
stackTrace: string | null,
|
||||
count: number,
|
||||
firstSeen: Date | null,
|
||||
lastSeen: Date | null,
|
||||
affectedRuns: Array<{ friendlyId: string; status: string; createdAt: Date }>
|
||||
): ErrorDetailsSummary {
|
||||
return {
|
||||
fingerprint,
|
||||
message,
|
||||
taskIdentifier,
|
||||
stackTrace: stackTrace ? truncateStackTrace(stackTrace) : undefined,
|
||||
count,
|
||||
firstSeen: firstSeen?.toISOString() ?? new Date().toISOString(),
|
||||
lastSeen: lastSeen?.toISOString() ?? new Date().toISOString(),
|
||||
affectedRuns: affectedRuns.slice(0, MAX_AFFECTED_RUNS).map((run) => ({
|
||||
runFriendlyId: run.friendlyId,
|
||||
status: run.status,
|
||||
occurredAt: run.createdAt.toISOString(),
|
||||
})),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { tool } from "ai";
|
||||
import { findSimilarErrors as findSimilarErrorsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext, ErrorGroupSummary } from "../types";
|
||||
|
||||
export function createFindSimilarErrorsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...findSimilarErrorsSchema,
|
||||
execute: async (params: { errorMessage: string; limit?: number }) => {
|
||||
try {
|
||||
const { clickhouseClient } = await import("~/v3/clickhouse.server");
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
// Get the environment
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
errors: [],
|
||||
error: "Environment not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Search for similar error messages using LIKE
|
||||
const limit = params.limit || 10;
|
||||
const searchTerm = params.errorMessage.substring(0, 100); // Use first 100 chars to avoid extremely long searches
|
||||
|
||||
const query = `
|
||||
SELECT
|
||||
error_fingerprint,
|
||||
error_message,
|
||||
task_identifier,
|
||||
COUNT(*) as occurrence_count,
|
||||
MIN(triggered_at) as first_seen,
|
||||
MAX(triggered_at) as last_seen
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
organization_id = '${environment.organizationId}'
|
||||
AND project_id = '${environment.project.id}'
|
||||
AND environment_id = '${environment.id}'
|
||||
AND engine = 'V2'
|
||||
AND error_message ILIKE '%${searchTerm.replace(/'/g, "''")}%'
|
||||
GROUP BY error_fingerprint, error_message, task_identifier
|
||||
ORDER BY occurrence_count DESC
|
||||
LIMIT ${limit}
|
||||
`;
|
||||
|
||||
const results = await clickhouseClient.query({
|
||||
query,
|
||||
format: "JSONCompact",
|
||||
});
|
||||
|
||||
const parsed = JSON.parse(results.text);
|
||||
const errors: ErrorGroupSummary[] = parsed.data?.map((row: any) => ({
|
||||
fingerprint: row[0],
|
||||
message: row[1],
|
||||
taskIdentifier: row[2],
|
||||
count: row[3],
|
||||
firstSeen: new Date(row[4]).toISOString(),
|
||||
lastSeen: new Date(row[5]).toISOString(),
|
||||
status: "UNRESOLVED",
|
||||
})) || [];
|
||||
|
||||
return {
|
||||
errors,
|
||||
total: errors.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [],
|
||||
error: `Failed to find similar errors: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { tool } from "ai";
|
||||
import { getErrorDetails as getErrorDetailsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGetErrorDetailsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...getErrorDetailsSchema,
|
||||
execute: async (params: { fingerprint: string }) => {
|
||||
try {
|
||||
// Lazy import to avoid env validation issues at module load
|
||||
const { ErrorGroupPresenter } = await import("~/presenters/v3/ErrorGroupPresenter.server");
|
||||
const { summarizeErrorDetails } = await import("./error-formatters");
|
||||
const { prisma } = await import("~/db.server");
|
||||
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
|
||||
|
||||
// Get the environment and project IDs
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
error: "Environment not found",
|
||||
fingerprint: params.fingerprint,
|
||||
message: "",
|
||||
taskIdentifier: "",
|
||||
count: 0,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
affectedRuns: [],
|
||||
};
|
||||
}
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
environment.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new ErrorGroupPresenter(prisma, clickhouse, clickhouse);
|
||||
|
||||
const result = await presenter.call(environment.organizationId, environment.id, {
|
||||
projectId: environment.project.id,
|
||||
userId: ctx.clientData.userId,
|
||||
fingerprint: params.fingerprint,
|
||||
runsPageSize: 5,
|
||||
});
|
||||
|
||||
if (!result.errorGroup) {
|
||||
return {
|
||||
error: "Error group not found",
|
||||
fingerprint: params.fingerprint,
|
||||
message: "",
|
||||
taskIdentifier: "",
|
||||
count: 0,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
affectedRuns: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Get affected runs
|
||||
const affectedRuns = result.runList?.runs
|
||||
? result.runList.runs.map((run: any) => ({
|
||||
friendlyId: run.friendlyId,
|
||||
status: run.status,
|
||||
createdAt: new Date(run.createdAt),
|
||||
}))
|
||||
: [];
|
||||
|
||||
return summarizeErrorDetails(
|
||||
result.errorGroup.fingerprint,
|
||||
result.errorGroup.errorMessage,
|
||||
result.errorGroup.taskIdentifier,
|
||||
result.errorGroup.stackTrace || null,
|
||||
result.errorGroup.count,
|
||||
result.errorGroup.firstSeen,
|
||||
result.errorGroup.lastSeen,
|
||||
affectedRuns
|
||||
);
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to get error details: ${error instanceof Error ? error.message : String(error)}`,
|
||||
fingerprint: params.fingerprint,
|
||||
message: "",
|
||||
taskIdentifier: "",
|
||||
count: 0,
|
||||
firstSeen: new Date().toISOString(),
|
||||
lastSeen: new Date().toISOString(),
|
||||
affectedRuns: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { tool } from "ai";
|
||||
import { listErrors as listErrorsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
function parsePeriod(period: string): { from: number; to: number } {
|
||||
const now = Date.now();
|
||||
const units: Record<string, number> = {
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const match = period.match(/^(\d+)([mhd])$/);
|
||||
if (!match) return { from: now - 86400000, to: now }; // default 24h
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
const milliseconds = value * (units[unit] || units.h);
|
||||
|
||||
return { from: now - milliseconds, to: now };
|
||||
}
|
||||
|
||||
export function createListErrorsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...listErrorsSchema,
|
||||
execute: async (params: { period?: string; taskIdentifier?: string; limit?: number }) => {
|
||||
try {
|
||||
// Lazy import to avoid env validation issues at module load
|
||||
const { ErrorsListPresenter } = await import("~/presenters/v3/ErrorsListPresenter.server");
|
||||
const { summarizeErrorGroup } = await import("./error-formatters");
|
||||
const { prisma } = await import("~/db.server");
|
||||
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
|
||||
|
||||
// Get the environment and project IDs
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
errors: [],
|
||||
total: 0,
|
||||
error: "Environment not found",
|
||||
};
|
||||
}
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
environment.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new ErrorsListPresenter(prisma, clickhouse);
|
||||
|
||||
// Convert period to time bounds
|
||||
const timeFilters = params.period
|
||||
? parsePeriod(params.period)
|
||||
: { from: Date.now() - 86400000, to: Date.now() }; // default 24h
|
||||
|
||||
const result = await presenter.call(environment.organizationId, environment.id, {
|
||||
projectId: environment.project.id,
|
||||
userId: ctx.clientData.userId,
|
||||
tasks: params.taskIdentifier ? [params.taskIdentifier] : undefined,
|
||||
from: timeFilters.from,
|
||||
to: timeFilters.to,
|
||||
pageSize: params.limit || 20,
|
||||
});
|
||||
|
||||
// Summarize error groups for LLM
|
||||
const summaries = result.errorGroups.map((eg: any) => summarizeErrorGroup(eg));
|
||||
|
||||
return {
|
||||
errors: summaries,
|
||||
total: result.pagination?.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
errors: [],
|
||||
total: 0,
|
||||
error: `Failed to list errors: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ClientData } from "./types";
|
||||
import { buildToolContext } from "./types";
|
||||
|
||||
// Docs and navigation tools are safe to import at module load (no env.server.ts).
|
||||
import { createSearchDocsTool } from "./docs/search-docs";
|
||||
import { createNavigateToPageTool } from "./navigation/navigate-to-page";
|
||||
import { createSearchPagesTool } from "./navigation/search-pages";
|
||||
import { createGetCurrentContextTool } from "./navigation/get-current-context";
|
||||
|
||||
// Runs, errors, and analytics tools reach into env.server.ts, so they're loaded
|
||||
// lazily to keep that out of the CLI indexing path.
|
||||
async function loadServerTools() {
|
||||
const { createListRunsTool } = await import("./runs/list-runs");
|
||||
const { createGetRunDetailsTool } = await import("./runs/get-run-details");
|
||||
const { createGetSpanDetailsTool } = await import("./runs/get-span-details");
|
||||
const { createGetRunLogsTool } = await import("./runs/get-run-logs");
|
||||
const { createGetRunGraphTool } = await import("./runs/get-run-graph");
|
||||
const { createApplyRunFiltersTool } = await import("./runs/apply-run-filters");
|
||||
const { createQueryRunsTool } = await import("./runs/query-runs");
|
||||
const { createListErrorsTool } = await import("./errors/list-errors");
|
||||
const { createGetErrorDetailsTool } = await import("./errors/get-error-details");
|
||||
const { createFindSimilarErrorsTool } = await import("./errors/find-similar-errors");
|
||||
const { createClassifyFailureTool } = await import("./errors/classify-failure");
|
||||
const { createSummarizeCurrentViewTool } = await import("./analytics/summarize-current-view");
|
||||
const { createAggregateRunsTool } = await import("./analytics/aggregate-runs");
|
||||
const { createCorrelateRunsWithDeployTool } = await import("./analytics/correlate-runs-with-deploy");
|
||||
const { createListTestableTasksTool } = await import("./test/list-testable-tasks");
|
||||
const { createGenerateTestPayloadTool } = await import("./test/generate-test-payload");
|
||||
const { createRunTestTaskTool } = await import("./test/run-test-task");
|
||||
|
||||
return {
|
||||
createListRunsTool,
|
||||
createGetRunDetailsTool,
|
||||
createGetSpanDetailsTool,
|
||||
createGetRunLogsTool,
|
||||
createGetRunGraphTool,
|
||||
createApplyRunFiltersTool,
|
||||
createQueryRunsTool,
|
||||
createListErrorsTool,
|
||||
createGetErrorDetailsTool,
|
||||
createFindSimilarErrorsTool,
|
||||
createClassifyFailureTool,
|
||||
createSummarizeCurrentViewTool,
|
||||
createAggregateRunsTool,
|
||||
createCorrelateRunsWithDeployTool,
|
||||
createListTestableTasksTool,
|
||||
createGenerateTestPayloadTool,
|
||||
createRunTestTaskTool,
|
||||
};
|
||||
}
|
||||
|
||||
// Builds the tool set for a client context. Called from the agent's run() per turn.
|
||||
export async function buildAssistantTools(clientData: ClientData) {
|
||||
const ctx = buildToolContext(clientData);
|
||||
const serverTools = await loadServerTools();
|
||||
|
||||
return {
|
||||
// Docs
|
||||
searchDocs: createSearchDocsTool(),
|
||||
|
||||
// Navigation
|
||||
navigateToPage: createNavigateToPageTool(ctx),
|
||||
searchPages: createSearchPagesTool(ctx),
|
||||
getCurrentContext: createGetCurrentContextTool(ctx),
|
||||
|
||||
// Runs
|
||||
listRuns: serverTools.createListRunsTool(ctx),
|
||||
getRunDetails: serverTools.createGetRunDetailsTool(ctx),
|
||||
getSpanDetails: serverTools.createGetSpanDetailsTool(ctx),
|
||||
getRunLogs: serverTools.createGetRunLogsTool(ctx),
|
||||
getRunGraph: serverTools.createGetRunGraphTool(ctx),
|
||||
applyRunFilters: serverTools.createApplyRunFiltersTool(ctx),
|
||||
queryRuns: serverTools.createQueryRunsTool(ctx),
|
||||
|
||||
// Errors
|
||||
listErrors: serverTools.createListErrorsTool(ctx),
|
||||
getErrorDetails: serverTools.createGetErrorDetailsTool(ctx),
|
||||
findSimilarErrors: serverTools.createFindSimilarErrorsTool(ctx),
|
||||
classifyFailure: serverTools.createClassifyFailureTool(ctx),
|
||||
|
||||
// Analytics
|
||||
summarizeCurrentView: serverTools.createSummarizeCurrentViewTool(ctx),
|
||||
aggregateRuns: serverTools.createAggregateRunsTool(ctx),
|
||||
correlateRunsWithDeploy: serverTools.createCorrelateRunsWithDeployTool(ctx),
|
||||
|
||||
// Test
|
||||
listTestableTasks: serverTools.createListTestableTasksTool(ctx),
|
||||
generateTestPayload: serverTools.createGenerateTestPayloadTool(ctx),
|
||||
runTestTask: serverTools.createRunTestTaskTool(ctx),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { tool } from "ai";
|
||||
import { getCurrentContext as contextSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGetCurrentContextTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...contextSchema,
|
||||
execute: async () => {
|
||||
return {
|
||||
project: ctx.clientData.projectSlug,
|
||||
environment: ctx.clientData.environmentSlug,
|
||||
currentPage: ctx.clientData.currentPage,
|
||||
currentParams: ctx.clientData.currentParams ?? {},
|
||||
description: `The user is viewing the ${ctx.clientData.currentPage} page in project "${ctx.clientData.projectSlug}" (${ctx.clientData.environmentSlug} environment).`,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { tool } from "ai";
|
||||
import { navigateToPage as navigateSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import { v3RunPath, v3RunSpanPath, v3TestTaskPath } from "~/utils/pathBuilder";
|
||||
import type { ToolContext } from "../types";
|
||||
import { findBestMatch } from "./page-matcher";
|
||||
|
||||
export function createNavigateToPageTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...navigateSchema,
|
||||
execute: async ({ destination, runId, spanId, testTaskId }) => {
|
||||
// Deep-link to a task's Test page takes precedence — the user wants to test it.
|
||||
if (testTaskId) {
|
||||
const url = v3TestTaskPath(ctx.org, ctx.project, ctx.env, {
|
||||
taskIdentifier: testTaskId,
|
||||
});
|
||||
return {
|
||||
found: true,
|
||||
pageName: `Test ${testTaskId}`,
|
||||
description: "Test page for the task — fill a payload and run it",
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
// Deep-link to a specific run (optionally a span within it) takes precedence
|
||||
// over a named-section lookup.
|
||||
if (runId) {
|
||||
const run = { friendlyId: runId };
|
||||
const url = spanId
|
||||
? v3RunSpanPath(ctx.org, ctx.project, ctx.env, run, { spanId })
|
||||
: v3RunPath(ctx.org, ctx.project, ctx.env, run);
|
||||
return {
|
||||
found: true,
|
||||
pageName: spanId ? `Span in run ${runId}` : `Run ${runId}`,
|
||||
description: spanId
|
||||
? "Run trace view with the selected span open"
|
||||
: "Run detail and trace view",
|
||||
url,
|
||||
};
|
||||
}
|
||||
|
||||
if (!destination) {
|
||||
return {
|
||||
found: false,
|
||||
message:
|
||||
"I need either a page name or a run ID to navigate to. Try naming a page or a run.",
|
||||
};
|
||||
}
|
||||
|
||||
const match = findBestMatch(destination);
|
||||
if (!match) {
|
||||
return {
|
||||
found: false,
|
||||
message: "I couldn't find that page. Try asking me to search for available pages.",
|
||||
};
|
||||
}
|
||||
return {
|
||||
found: true,
|
||||
pageName: match.id,
|
||||
description: match.description,
|
||||
url: match.pathFn(ctx.org, ctx.project, ctx.env),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { PageEntry } from "./page-registry";
|
||||
import { PAGE_REGISTRY } from "./page-registry";
|
||||
|
||||
/**
|
||||
* Score a page entry against a search query. Higher = better match.
|
||||
* Uses keyword overlap and substring matching — intentionally simple.
|
||||
*/
|
||||
function scoreMatch(entry: PageEntry, query: string): number {
|
||||
const lower = query.toLowerCase();
|
||||
let score = 0;
|
||||
|
||||
// Exact ID match
|
||||
if (lower === entry.id) return 100;
|
||||
|
||||
// ID substring
|
||||
if (lower.includes(entry.id) || entry.id.includes(lower)) score += 10;
|
||||
|
||||
// Keyword matches
|
||||
for (const keyword of entry.keywords) {
|
||||
if (lower.includes(keyword)) score += 5;
|
||||
if (keyword.includes(lower)) score += 3;
|
||||
}
|
||||
|
||||
// Description substring
|
||||
if (entry.description.toLowerCase().includes(lower)) score += 2;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/** Find the best matching page for a destination string */
|
||||
export function findBestMatch(query: string): PageEntry | null {
|
||||
let best: PageEntry | null = null;
|
||||
let bestScore = 0;
|
||||
|
||||
for (const entry of PAGE_REGISTRY) {
|
||||
const score = scoreMatch(entry, query);
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
best = entry;
|
||||
}
|
||||
}
|
||||
|
||||
return bestScore > 0 ? best : null;
|
||||
}
|
||||
|
||||
/** Find top N matching pages for a search query */
|
||||
export function findMatches(query: string, limit = 5): PageEntry[] {
|
||||
return PAGE_REGISTRY.map((entry) => ({ entry, score: scoreMatch(entry, query) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
.slice(0, limit)
|
||||
.map(({ entry }) => entry);
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import {
|
||||
v3RunsPath,
|
||||
v3ErrorsPath,
|
||||
v3DeploymentsPath,
|
||||
v3BatchesPath,
|
||||
v3SchedulesPath,
|
||||
v3EnvironmentVariablesPath,
|
||||
v3ApiKeysPath,
|
||||
v3QueuesPath,
|
||||
v3TestPath,
|
||||
v3LogsPath,
|
||||
v3SessionsPath,
|
||||
v3AgentsPath,
|
||||
v3ModelsPath,
|
||||
v3PromptsPath,
|
||||
v3ProjectAlertsPath,
|
||||
v3ProjectSettingsPath,
|
||||
branchesPath,
|
||||
concurrencyPath,
|
||||
regionsPath,
|
||||
queryPath,
|
||||
} from "~/utils/pathBuilder";
|
||||
|
||||
export interface PageEntry {
|
||||
id: string;
|
||||
keywords: string[];
|
||||
description: string;
|
||||
pathFn: (org: { slug: string }, project: { slug: string }, env: { slug: string }) => string;
|
||||
}
|
||||
|
||||
export const PAGE_REGISTRY: PageEntry[] = [
|
||||
{
|
||||
id: "runs",
|
||||
keywords: ["runs", "task runs", "executions", "jobs", "run list"],
|
||||
description: "Task runs list — view, filter, and manage all task runs",
|
||||
pathFn: (org, project, env) => v3RunsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "errors",
|
||||
keywords: ["errors", "error groups", "failures", "exceptions", "bugs"],
|
||||
description: "Error groups — see grouped errors across tasks with counts and trends",
|
||||
pathFn: (org, project, env) => v3ErrorsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "deployments",
|
||||
keywords: ["deployments", "deploys", "versions", "releases"],
|
||||
description: "Deployments — view deployment history, promote, and rollback versions",
|
||||
pathFn: (org, project, env) => v3DeploymentsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "batches",
|
||||
keywords: ["batches", "batch runs", "batch triggers"],
|
||||
description: "Batches — view and monitor batch trigger operations",
|
||||
pathFn: (org, project, env) => v3BatchesPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "schedules",
|
||||
keywords: ["schedules", "cron", "scheduled tasks", "recurring"],
|
||||
description: "Schedules — create, edit, and manage scheduled task triggers",
|
||||
pathFn: (org, project, env) => v3SchedulesPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "environment-variables",
|
||||
keywords: ["env vars", "environment variables", "secrets", "config", "configuration"],
|
||||
description: "Environment variables — configure secrets and config values per environment",
|
||||
pathFn: (org, project, env) => v3EnvironmentVariablesPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
keywords: ["api keys", "tokens", "authentication", "secret keys"],
|
||||
description: "API keys — manage server and public API keys for each environment",
|
||||
pathFn: (org, project, env) => v3ApiKeysPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "queues",
|
||||
keywords: ["queues", "concurrency", "queue management"],
|
||||
description: "Queues — view queue status, set concurrency limits, pause queues",
|
||||
pathFn: (org, project, env) => v3QueuesPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "test",
|
||||
keywords: ["test", "testing", "test tasks", "trigger test", "playground"],
|
||||
description: "Test — trigger test runs for your tasks with custom payloads",
|
||||
pathFn: (org, project, env) => v3TestPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "logs",
|
||||
keywords: ["logs", "log viewer", "logging", "log lines"],
|
||||
description: "Logs — search and filter log output from task runs",
|
||||
pathFn: (org, project, env) => v3LogsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "sessions",
|
||||
keywords: ["sessions", "chat sessions", "agent sessions"],
|
||||
description: "Sessions — view active and past chat agent sessions",
|
||||
pathFn: (org, project, env) => v3SessionsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "agents",
|
||||
keywords: ["agents", "ai agents", "chat agents"],
|
||||
description: "Agents — view registered chat agents and their status",
|
||||
pathFn: (org, project, env) => v3AgentsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "models",
|
||||
keywords: ["models", "ai models", "llm", "model registry"],
|
||||
description: "Models — view LLM model usage, costs, and performance",
|
||||
pathFn: (org, project, env) => v3ModelsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "prompts",
|
||||
keywords: ["prompts", "prompt management", "prompt versions"],
|
||||
description: "Prompts — manage versioned prompts, create overrides, promote versions",
|
||||
pathFn: (org, project, env) => v3PromptsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "alerts",
|
||||
keywords: ["alerts", "notifications", "alert rules"],
|
||||
description: "Alerts — configure alert rules for task failures and performance",
|
||||
pathFn: (org, project, env) => v3ProjectAlertsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
keywords: ["settings", "project settings", "general settings"],
|
||||
description: "Settings — general project configuration and integrations",
|
||||
pathFn: (org, project, env) => v3ProjectSettingsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "branches",
|
||||
keywords: ["branches", "preview branches", "git branches"],
|
||||
description: "Branches — manage preview branch environments",
|
||||
pathFn: (org, project, env) => branchesPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "concurrency",
|
||||
keywords: ["concurrency", "concurrency limits", "parallel"],
|
||||
description: "Concurrency — view and configure task concurrency limits",
|
||||
pathFn: (org, project, env) => concurrencyPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "regions",
|
||||
keywords: ["regions", "deployment regions", "geography"],
|
||||
description: "Regions — configure deployment regions for task execution",
|
||||
pathFn: (org, project, env) => regionsPath(org, project, env),
|
||||
},
|
||||
{
|
||||
id: "query",
|
||||
keywords: ["query", "trql", "query editor", "search runs"],
|
||||
description: "Query — write and execute TRQL queries against your task data",
|
||||
pathFn: (org, project, env) => queryPath(org, project, env),
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,21 @@
|
||||
import { tool } from "ai";
|
||||
import { searchPages as searchPagesSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
import { findMatches } from "./page-matcher";
|
||||
|
||||
export function createSearchPagesTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...searchPagesSchema,
|
||||
execute: async ({ query }) => {
|
||||
const matches = findMatches(query, 5);
|
||||
return {
|
||||
matches: matches.map((m) => ({
|
||||
pageName: m.id,
|
||||
description: m.description,
|
||||
url: m.pathFn(ctx.org, ctx.project, ctx.env),
|
||||
})),
|
||||
total: matches.length,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { tool } from "ai";
|
||||
import { applyRunFilters as applyRunFiltersSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createApplyRunFiltersTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...applyRunFiltersSchema,
|
||||
execute: async (params: { description: string }) => {
|
||||
try {
|
||||
const { AIRunFilterService, type: QueryTagsType } = await import(
|
||||
"~/v3/services/aiRunFilterService.server"
|
||||
);
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
// Fetch the environment to get its ID
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Environment not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Create query functions that fetch from the database
|
||||
const queryTags = {
|
||||
query: async (search?: string) => {
|
||||
const tags = await prisma.taskRunTag.findMany({
|
||||
where: {
|
||||
taskRun: {
|
||||
runtimeEnvironment: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
...(search && { name: { contains: search } }),
|
||||
},
|
||||
select: { name: true },
|
||||
distinct: ["name"],
|
||||
take: 50,
|
||||
});
|
||||
return { tags: tags.map((t) => t.name) };
|
||||
},
|
||||
};
|
||||
|
||||
const queryVersions = {
|
||||
query: async (versionPrefix?: string, isCurrent?: boolean) => {
|
||||
const versions = await prisma.backgroundWorkerVersion.findMany({
|
||||
where: {
|
||||
runtimeEnvironment: {
|
||||
id: environment.id,
|
||||
},
|
||||
...(versionPrefix && { friendlyId: { contains: versionPrefix } }),
|
||||
...(isCurrent && { isDeployed: true }),
|
||||
},
|
||||
select: { friendlyId: true },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: isCurrent ? 1 : 20,
|
||||
});
|
||||
|
||||
if (isCurrent && versions.length > 0) {
|
||||
return { version: versions[0].friendlyId };
|
||||
}
|
||||
return { versions: versions.map((v) => v.friendlyId) };
|
||||
},
|
||||
};
|
||||
|
||||
const queryQueues = {
|
||||
query: async (search?: string, type?: "task" | "custom") => {
|
||||
const queues = await prisma.taskQueue.findMany({
|
||||
where: {
|
||||
runtimeEnvironment: {
|
||||
id: environment.id,
|
||||
},
|
||||
...(search && { friendlyId: { contains: search } }),
|
||||
...(type === "task" && { name: null }),
|
||||
...(type === "custom" && { name: { not: null } }),
|
||||
},
|
||||
select: { friendlyId: true },
|
||||
take: 50,
|
||||
});
|
||||
return { queues: queues.map((q) => q.friendlyId) };
|
||||
},
|
||||
};
|
||||
|
||||
const queryTasks = {
|
||||
query: async () => {
|
||||
const tasks = await prisma.backgroundWorkerTask.findMany({
|
||||
where: {
|
||||
runtimeEnvironment: {
|
||||
id: environment.id,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
triggerSource: true,
|
||||
},
|
||||
take: 100,
|
||||
});
|
||||
return { tasks };
|
||||
},
|
||||
};
|
||||
|
||||
const service = new AIRunFilterService({
|
||||
queryTags,
|
||||
queryVersions,
|
||||
queryQueues,
|
||||
queryTasks,
|
||||
});
|
||||
|
||||
const result = await service.call(params.description, environment.id);
|
||||
|
||||
if (result.success) {
|
||||
return {
|
||||
success: true,
|
||||
filters: result.filters,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
success: false,
|
||||
error: result.error,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to apply filters: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { tool } from "ai";
|
||||
import { getRunDetails as getRunDetailsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGetRunDetailsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...getRunDetailsSchema,
|
||||
execute: async (params: { runFriendlyId: string }) => {
|
||||
try {
|
||||
const { getRunForLLM } = await import("./run-presenter-adapter");
|
||||
const result = await getRunForLLM(ctx, params.runFriendlyId);
|
||||
if (!result) {
|
||||
return { error: `Run ${params.runFriendlyId} not found` };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to get run details: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { tool } from "ai";
|
||||
import { getRunGraph as getRunGraphSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGetRunGraphTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...getRunGraphSchema,
|
||||
execute: async (params: { runFriendlyId: string }) => {
|
||||
try {
|
||||
const { getRunForLLM } = await import("./run-presenter-adapter");
|
||||
const runWithTrace = await getRunForLLM(ctx, params.runFriendlyId);
|
||||
|
||||
if (!runWithTrace) {
|
||||
return {
|
||||
error: `Run ${params.runFriendlyId} not found`,
|
||||
root: null,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
|
||||
const { run } = runWithTrace;
|
||||
|
||||
// Build a graph structure showing parent/child relationships
|
||||
const graph = {
|
||||
root: run,
|
||||
parent: run.parentRunId ? { id: run.parentRunId } : null,
|
||||
ancestorChain: [] as string[],
|
||||
children: [] as any[],
|
||||
};
|
||||
|
||||
// If we have root run info, that's the top of the chain
|
||||
if (run.rootRunId && run.rootRunId !== run.id) {
|
||||
graph.ancestorChain.push(run.rootRunId);
|
||||
}
|
||||
|
||||
// Fetch child runs if this is the root
|
||||
if (!run.parentRunId) {
|
||||
try {
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
const childRuns = await prisma.taskRun.findMany({
|
||||
where: {
|
||||
parentRunId: run.id,
|
||||
runtimeEnvironment: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
startedAt: true,
|
||||
finishedAt: true,
|
||||
},
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: 20,
|
||||
});
|
||||
|
||||
graph.children = childRuns.map((child: any) => ({
|
||||
id: child.friendlyId,
|
||||
status: child.status,
|
||||
startedAt: child.startedAt ? new Date(child.startedAt).toISOString() : undefined,
|
||||
finishedAt: child.finishedAt ? new Date(child.finishedAt).toISOString() : undefined,
|
||||
}));
|
||||
} catch {
|
||||
// If we can't fetch children, just return the root
|
||||
}
|
||||
}
|
||||
|
||||
return graph;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to get run graph: ${error instanceof Error ? error.message : String(error)}`,
|
||||
root: null,
|
||||
children: [],
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { tool } from "ai";
|
||||
import { getRunLogs as getRunLogsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGetRunLogsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...getRunLogsSchema,
|
||||
execute: async (params: { runFriendlyId: string; level?: string; limit?: number }) => {
|
||||
try {
|
||||
const { prisma } = await import("~/db.server");
|
||||
const { getTaskEventStoreTableForRun, TaskEventStore } = await import("~/v3/taskEventStore.server");
|
||||
const { $replica } = await import("~/db.server");
|
||||
|
||||
// Fetch the run to get its ID and event store table
|
||||
const run = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: params.runFriendlyId,
|
||||
runtimeEnvironment: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
taskEventStore: true,
|
||||
createdAt: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return {
|
||||
error: `Run ${params.runFriendlyId} not found`,
|
||||
logs: [],
|
||||
};
|
||||
}
|
||||
|
||||
const eventStore = new TaskEventStore(prisma, $replica);
|
||||
const table = getTaskEventStoreTableForRun(run);
|
||||
|
||||
// Build the where clause for log filtering
|
||||
const where = {
|
||||
runId: run.id,
|
||||
kind: { in: ["LOG", "TASK"] },
|
||||
...(params.level && { level: params.level.toUpperCase() }),
|
||||
};
|
||||
|
||||
// Fetch log events
|
||||
const logEvents = await eventStore.findMany(
|
||||
table,
|
||||
where,
|
||||
run.createdAt,
|
||||
undefined,
|
||||
{
|
||||
message: true,
|
||||
level: true,
|
||||
startTime: true,
|
||||
},
|
||||
{ startTime: "asc" },
|
||||
{ limit: params.limit || 50 }
|
||||
);
|
||||
|
||||
// Format for LLM
|
||||
const logs = logEvents.map((event: any) => {
|
||||
const timestamp = event.startTime
|
||||
? new Date(Number(event.startTime) / 1000000).toISOString()
|
||||
: new Date().toISOString();
|
||||
const level = event.level || "INFO";
|
||||
return `[${level}] ${timestamp}: ${event.message || "(no message)"}`;
|
||||
});
|
||||
|
||||
return {
|
||||
runFriendlyId: params.runFriendlyId,
|
||||
logs,
|
||||
total: logs.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to get run logs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
logs: [],
|
||||
runFriendlyId: params.runFriendlyId,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { tool } from "ai";
|
||||
import { getSpanDetails as getSpanDetailsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGetSpanDetailsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...getSpanDetailsSchema,
|
||||
execute: async (params: { runFriendlyId: string; spanId: string }) => {
|
||||
try {
|
||||
const { getSpanForLLM } = await import("./span-detail-adapter");
|
||||
const result = await getSpanForLLM(ctx, params.runFriendlyId, params.spanId);
|
||||
if (!result) {
|
||||
return { error: `Span ${params.spanId} not found in run ${params.runFriendlyId}` };
|
||||
}
|
||||
return result;
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to get span details: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { tool } from "ai";
|
||||
import { logger } from "@trigger.dev/sdk";
|
||||
import { isTaskRunStatus, QUEUED_STATUSES, RUNNING_STATUSES } from "~/components/runs/v3/TaskRunStatus";
|
||||
import { FAILED_RUN_STATUSES } from "~/v3/taskStatus";
|
||||
import { listRuns as listRunsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
// The LLM passes colloquial statuses ("failed", "running") that aren't real
|
||||
// TaskRunStatus values, so filtering on them matches nothing. Map those onto
|
||||
// the canonical groupings the /runs status filter uses, so this stays in sync.
|
||||
const STATUS_SYNONYMS: Record<string, readonly string[]> = {
|
||||
FAILED: FAILED_RUN_STATUSES,
|
||||
FAILURE: FAILED_RUN_STATUSES,
|
||||
ERROR: FAILED_RUN_STATUSES,
|
||||
ERRORED: FAILED_RUN_STATUSES,
|
||||
SUCCESS: ["COMPLETED_SUCCESSFULLY"],
|
||||
SUCCESSFUL: ["COMPLETED_SUCCESSFULLY"],
|
||||
SUCCEEDED: ["COMPLETED_SUCCESSFULLY"],
|
||||
COMPLETED: ["COMPLETED_SUCCESSFULLY"],
|
||||
RUNNING: RUNNING_STATUSES,
|
||||
IN_PROGRESS: RUNNING_STATUSES,
|
||||
CANCELLED: ["CANCELED"],
|
||||
TIMEOUT: ["TIMED_OUT"],
|
||||
QUEUED: QUEUED_STATUSES,
|
||||
};
|
||||
|
||||
function normalizeStatuses(input?: string[]): string[] | undefined {
|
||||
if (!input || input.length === 0) return undefined;
|
||||
const out = new Set<string>();
|
||||
const unrecognized: string[] = [];
|
||||
for (const raw of input) {
|
||||
const key = raw.trim().toUpperCase().replace(/\s+/g, "_");
|
||||
if (isTaskRunStatus(key)) {
|
||||
out.add(key);
|
||||
} else if (STATUS_SYNONYMS[key]) {
|
||||
for (const s of STATUS_SYNONYMS[key]) out.add(s);
|
||||
} else {
|
||||
// Drop rather than pass through — a bogus status would silently filter
|
||||
// out everything (ClickHouse returns zero rows, no error).
|
||||
unrecognized.push(raw);
|
||||
}
|
||||
}
|
||||
if (unrecognized.length > 0) {
|
||||
logger.warn("listRuns ignored unrecognized status filter values", { unrecognized });
|
||||
}
|
||||
return out.size > 0 ? Array.from(out) : undefined;
|
||||
}
|
||||
|
||||
function parsePeriod(period: string): { from: number; to: number } {
|
||||
const now = Date.now();
|
||||
const units: Record<string, number> = {
|
||||
m: 60 * 1000,
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
const match = period.match(/^(\d+)([mhd])$/);
|
||||
if (!match) return { from: now - 86400000, to: now }; // default 24h
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
const milliseconds = value * (units[unit] || units.h);
|
||||
|
||||
return { from: now - milliseconds, to: now };
|
||||
}
|
||||
|
||||
export function createListRunsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...listRunsSchema,
|
||||
execute: async (params) => {
|
||||
// All imports inside execute() to avoid env.server.ts at CLI indexing time
|
||||
const dynamicImport = () => import("~/presenters/v3/NextRunListPresenter.server");
|
||||
|
||||
try {
|
||||
const { NextRunListPresenter } = await dynamicImport();
|
||||
const { prisma } = await import("~/db.server");
|
||||
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
|
||||
|
||||
// Get the environment from context
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
project: { slug: ctx.clientData.projectSlug },
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: { select: { id: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
runs: [],
|
||||
total: 0,
|
||||
error: "Environment not found",
|
||||
};
|
||||
}
|
||||
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
environment.organizationId,
|
||||
"standard"
|
||||
);
|
||||
const presenter = new NextRunListPresenter(prisma, clickhouse);
|
||||
|
||||
// Convert period to ClickHouse time bounds
|
||||
const timeFilters = parsePeriod(params.period || "");
|
||||
|
||||
const result = await presenter.call(
|
||||
environment.organizationId,
|
||||
environment.id,
|
||||
{
|
||||
projectId: environment.project.id,
|
||||
tasks: params.taskIdentifier ? [params.taskIdentifier] : undefined,
|
||||
statuses: normalizeStatuses(params.status) as any[] | undefined,
|
||||
tags: params.tags,
|
||||
from: timeFilters.from,
|
||||
to: timeFilters.to,
|
||||
pageSize: params.limit || 20,
|
||||
}
|
||||
);
|
||||
|
||||
// Summarize runs for LLM consumption
|
||||
const summarizedRuns = result.runs.map((run: any) => ({
|
||||
id: run.friendlyId,
|
||||
status: run.status,
|
||||
isFinished: run.hasFinished ?? false,
|
||||
startedAt: run.startedAt ? new Date(run.startedAt).toISOString() : undefined,
|
||||
completedAt: run.finishedAt ? new Date(run.finishedAt).toISOString() : undefined,
|
||||
duration:
|
||||
run.finishedAt && run.startedAt
|
||||
? `${Math.round((new Date(run.finishedAt).getTime() - new Date(run.startedAt).getTime()) / 1000)}s`
|
||||
: undefined,
|
||||
parentRunId: (run as any).parentTaskRun?.friendlyId,
|
||||
rootRunId: (run as any).rootTaskRun?.friendlyId,
|
||||
}));
|
||||
|
||||
return {
|
||||
runs: summarizedRuns,
|
||||
total: result.pagination?.total || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
runs: [],
|
||||
total: 0,
|
||||
error: `Failed to list runs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { tool } from "ai";
|
||||
import { queryRuns as queryRunsSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createQueryRunsTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...queryRunsSchema,
|
||||
execute: async (params: { question: string }) => {
|
||||
try {
|
||||
const { AIQueryService } = await import("~/v3/services/aiQueryService.server");
|
||||
const { runsSchema } = await import("~/v3/querySchemas");
|
||||
const { clickhouseFactory } = await import("~/services/clickhouse/clickhouseFactoryInstance.server");
|
||||
const { prisma } = await import("~/db.server");
|
||||
|
||||
// Fetch environment to validate access
|
||||
const environment = await prisma.runtimeEnvironment.findFirst({
|
||||
where: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: {
|
||||
slug: ctx.clientData.projectSlug,
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
organizationId: true,
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!environment) {
|
||||
return {
|
||||
success: false,
|
||||
error: "Environment not found",
|
||||
};
|
||||
}
|
||||
|
||||
// Create the AI query service with the schema
|
||||
const service = new AIQueryService([runsSchema]);
|
||||
|
||||
// Generate a TSQL query from the natural language question
|
||||
const queryResult = await service.call(params.question);
|
||||
|
||||
if (!queryResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: queryResult.error,
|
||||
};
|
||||
}
|
||||
|
||||
// Get the clickhouse client for this organization
|
||||
const clickhouse = await clickhouseFactory.getClickhouseForOrganization(
|
||||
environment.organizationId,
|
||||
"standard"
|
||||
);
|
||||
|
||||
// Build the final query with tenant filters applied
|
||||
const tenantFiltered = `
|
||||
SELECT *
|
||||
FROM trigger_dev.task_runs_v2
|
||||
WHERE
|
||||
organization_id = '${environment.organizationId}'
|
||||
AND project_id = '${environment.project.id}'
|
||||
AND environment_id = '${environment.id}'
|
||||
AND engine = 'V2'
|
||||
AND (${queryResult.query})
|
||||
LIMIT 100
|
||||
`;
|
||||
|
||||
const results = await clickhouse.query({
|
||||
query: tenantFiltered,
|
||||
format: "JSONCompact",
|
||||
});
|
||||
|
||||
const parsedResults = JSON.parse(results.text) as Record<string, unknown>;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
query: queryResult.query,
|
||||
results: (parsedResults.data as unknown[]) || [],
|
||||
rowCount: (parsedResults.rows as number) || 0,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to query runs: ${error instanceof Error ? error.message : String(error)}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { Run, RunEvent } from "~/presenters/v3/RunPresenter.server";
|
||||
import type { RunSummary, SpanSummary, TraceSummary } from "../types";
|
||||
|
||||
const MAX_ERROR_LENGTH = 500;
|
||||
const MAX_SPANS = 20;
|
||||
const MAX_LOG_LINES = 50;
|
||||
|
||||
export function summarizeRun(run: Run): RunSummary {
|
||||
const duration =
|
||||
run.completedAt && run.startedAt
|
||||
? `${Math.round((run.completedAt.getTime() - run.startedAt.getTime()) / 1000)}s`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
id: run.friendlyId,
|
||||
status: run.status,
|
||||
isFinished: run.isFinished,
|
||||
startedAt: run.startedAt?.toISOString(),
|
||||
completedAt: run.completedAt?.toISOString(),
|
||||
duration,
|
||||
parentRunId: run.parentTaskRun?.friendlyId ?? undefined,
|
||||
rootRunId: run.rootTaskRun?.friendlyId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeSpan(span: RunEvent): SpanSummary {
|
||||
return {
|
||||
id: span.id ?? "",
|
||||
message: span.data?.message ?? "",
|
||||
isError: span.data?.isError ?? false,
|
||||
isPartial: span.data?.isPartial ?? false,
|
||||
duration: span.data?.duration ?? undefined,
|
||||
level: span.data?.level ?? "info",
|
||||
runId: span.runId ?? undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export function summarizeTrace(trace: {
|
||||
rootSpanStatus: string;
|
||||
events: RunEvent[];
|
||||
}): TraceSummary {
|
||||
return {
|
||||
rootStatus: trace.rootSpanStatus,
|
||||
totalSpans: trace.events.length,
|
||||
spans: trace.events.slice(0, MAX_SPANS).map(summarizeSpan),
|
||||
truncated: trace.events.length > MAX_SPANS,
|
||||
};
|
||||
}
|
||||
|
||||
export function truncateError(error: string): string {
|
||||
if (error.length <= MAX_ERROR_LENGTH) return error;
|
||||
return error.slice(0, MAX_ERROR_LENGTH) + "...";
|
||||
}
|
||||
|
||||
export function formatLogLines(
|
||||
logs: Array<{ timestamp?: Date; message: string }>
|
||||
): string[] {
|
||||
return logs.slice(0, MAX_LOG_LINES).map((log) => {
|
||||
const time = log.timestamp?.toISOString() ?? new Date().toISOString();
|
||||
return `${time}: ${log.message}`;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { ToolContext, RunWithTrace } from "../types";
|
||||
import { summarizeRun, summarizeTrace } from "./run-formatters";
|
||||
|
||||
export async function getRunForLLM(
|
||||
ctx: ToolContext,
|
||||
runFriendlyId: string
|
||||
): Promise<RunWithTrace | null> {
|
||||
try {
|
||||
// Dynamic import keeps `~/db.server` / `~/v3/tracer.server` off the module graph
|
||||
// during CLI indexing (index worker already registers its own TracingSDK).
|
||||
const { RunPresenter } = await import("~/presenters/v3/RunPresenter.server");
|
||||
const presenter = new RunPresenter();
|
||||
const result = await presenter.call({
|
||||
userId: ctx.clientData.userId,
|
||||
projectSlug: ctx.clientData.projectSlug,
|
||||
environmentSlug: ctx.clientData.environmentSlug,
|
||||
runFriendlyId,
|
||||
showDeletedLogs: false,
|
||||
showDebug: false,
|
||||
});
|
||||
|
||||
return {
|
||||
run: summarizeRun(result.run),
|
||||
trace: result.trace ? summarizeTrace(result.trace) : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
// Import from the narrow subpaths only — never the `@trigger.dev/core/v3` root
|
||||
// barrel. That barrel re-exports dozens of `*-api.js` global-singleton modules
|
||||
// and pulling it into the webapp's running task worker re-initializes those
|
||||
// globals. `/v3/errors` and `/v3/schemas` are pure zod/data with no side effects.
|
||||
import { createJsonErrorObject } from "@trigger.dev/core/v3/errors";
|
||||
import {
|
||||
isAttemptFailedSpanEvent,
|
||||
isCancellationSpanEvent,
|
||||
isExceptionSpanEvent,
|
||||
TaskRunError,
|
||||
type SpanEvents,
|
||||
} from "@trigger.dev/core/v3/schemas";
|
||||
import type { SpanDetailSummary, SpanException, ToolContext } from "../types";
|
||||
|
||||
const MAX_FIELD_LENGTH = 2000;
|
||||
const MAX_STACK_LENGTH = 4000;
|
||||
const MAX_EXCEPTIONS = 5;
|
||||
|
||||
const FAILED_RUN_STATUSES = new Set([
|
||||
"COMPLETED_WITH_ERRORS",
|
||||
"CRASHED",
|
||||
"SYSTEM_FAILURE",
|
||||
"TIMED_OUT",
|
||||
"EXPIRED",
|
||||
]);
|
||||
|
||||
function truncate(value: string | undefined, max: number): string | undefined {
|
||||
if (value === undefined) return undefined;
|
||||
return value.length <= max ? value : value.slice(0, max) + "\n…(truncated)";
|
||||
}
|
||||
|
||||
function stringifyField(value: unknown): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const s = typeof value === "string" ? value : JSON.stringify(value, null, 2);
|
||||
return truncate(s, MAX_FIELD_LENGTH);
|
||||
}
|
||||
|
||||
// Pull every exception/cancellation out of a span's OTel events.
|
||||
function extractExceptions(events: SpanEvents | undefined): SpanException[] {
|
||||
if (!events?.length) return [];
|
||||
const out: SpanException[] = [];
|
||||
for (const event of events) {
|
||||
if (isExceptionSpanEvent(event) || isAttemptFailedSpanEvent(event)) {
|
||||
const ex = event.properties.exception;
|
||||
out.push({
|
||||
type: ex.type,
|
||||
message: ex.message,
|
||||
stackTrace: truncate(ex.stacktrace, MAX_STACK_LENGTH),
|
||||
});
|
||||
} else if (isCancellationSpanEvent(event)) {
|
||||
out.push({ type: "cancellation", message: event.properties.reason });
|
||||
}
|
||||
if (out.length >= MAX_EXCEPTIONS) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function exceptionFromRunError(error: unknown): SpanException | undefined {
|
||||
const parsed = TaskRunError.safeParse(error);
|
||||
if (parsed.success) {
|
||||
const json = createJsonErrorObject(parsed.data);
|
||||
return {
|
||||
type: json.name,
|
||||
message: json.message,
|
||||
stackTrace: truncate(json.stackTrace, MAX_STACK_LENGTH),
|
||||
};
|
||||
}
|
||||
return { message: truncate(JSON.stringify(error), MAX_STACK_LENGTH) };
|
||||
}
|
||||
|
||||
function formatDuration(nanos: number | null | undefined): string | undefined {
|
||||
if (nanos === null || nanos === undefined) return undefined;
|
||||
return `${Math.round(nanos / 1_000_000)}ms`;
|
||||
}
|
||||
|
||||
export async function getSpanForLLM(
|
||||
ctx: ToolContext,
|
||||
runFriendlyId: string,
|
||||
spanId: string
|
||||
): Promise<SpanDetailSummary | { error: string } | null> {
|
||||
try {
|
||||
// Use the dedicated trigger-task Prisma client and the engine-free event
|
||||
// repository. We deliberately do NOT touch SpanPresenter here: it imports
|
||||
// `~/v3/runEngine.server`, whose `engine.resolveTaskRunContext()` boots the
|
||||
// full RunEngine singleton (Redis, background workers, heartbeats) inside the
|
||||
// task worker and floods the OTel ingest endpoint. The event repository's
|
||||
// `getSpan` returns the same span detail without any of that.
|
||||
const { prisma } = await import("../../db");
|
||||
const { getEventRepositoryForStore } = await import("~/v3/eventRepository/index.server");
|
||||
const { getTaskEventStoreTableForRun } = await import("~/v3/taskEventStore.server");
|
||||
|
||||
const parentRun = await prisma.taskRun.findFirst({
|
||||
where: {
|
||||
friendlyId: runFriendlyId,
|
||||
runtimeEnvironment: {
|
||||
slug: ctx.clientData.environmentSlug,
|
||||
project: { slug: ctx.clientData.projectSlug },
|
||||
},
|
||||
},
|
||||
select: {
|
||||
traceId: true,
|
||||
taskEventStore: true,
|
||||
createdAt: true,
|
||||
completedAt: true,
|
||||
runtimeEnvironmentId: true,
|
||||
runtimeEnvironment: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!parentRun) {
|
||||
return { error: `Run ${runFriendlyId} not found` };
|
||||
}
|
||||
|
||||
const repository = await getEventRepositoryForStore(
|
||||
parentRun.taskEventStore,
|
||||
parentRun.runtimeEnvironment.organizationId
|
||||
);
|
||||
const eventStore = getTaskEventStoreTableForRun(parentRun);
|
||||
|
||||
const span = await repository.getSpan(
|
||||
eventStore,
|
||||
parentRun.runtimeEnvironmentId,
|
||||
spanId,
|
||||
parentRun.traceId,
|
||||
parentRun.createdAt,
|
||||
parentRun.completedAt ?? undefined,
|
||||
{ includeDebugLogs: true }
|
||||
);
|
||||
|
||||
// If this span is itself a triggered run, surface that run's error/output
|
||||
// straight from the TaskRun row (no presenter, no engine).
|
||||
const spanRun = await prisma.taskRun.findFirst({
|
||||
where: { spanId, runtimeEnvironmentId: parentRun.runtimeEnvironmentId },
|
||||
select: {
|
||||
friendlyId: true,
|
||||
status: true,
|
||||
taskIdentifier: true,
|
||||
error: true,
|
||||
output: true,
|
||||
outputType: true,
|
||||
metadata: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!span && !spanRun) {
|
||||
return { error: `Span ${spanId} not found in run ${runFriendlyId}` };
|
||||
}
|
||||
|
||||
const exceptions = extractExceptions(span?.events as SpanEvents | undefined);
|
||||
if (spanRun?.error) {
|
||||
const runException = exceptionFromRunError(spanRun.error);
|
||||
if (runException) exceptions.unshift(runException);
|
||||
}
|
||||
|
||||
const properties = stringifyField(span?.properties);
|
||||
|
||||
if (spanRun) {
|
||||
// Packet references (application/store) are download keys, not data.
|
||||
const output =
|
||||
spanRun.output && spanRun.outputType !== "application/store"
|
||||
? stringifyField(spanRun.output)
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
spanId,
|
||||
kind: "run",
|
||||
message: span?.message ?? spanRun.taskIdentifier,
|
||||
isError: span?.isError ?? FAILED_RUN_STATUSES.has(spanRun.status),
|
||||
isCancelled: span?.isCancelled ?? spanRun.status === "CANCELED",
|
||||
level: span?.level,
|
||||
duration: formatDuration(span?.duration),
|
||||
runFriendlyId: spanRun.friendlyId,
|
||||
taskIdentifier: spanRun.taskIdentifier,
|
||||
status: spanRun.status,
|
||||
exceptions,
|
||||
metadata: stringifyField(spanRun.metadata),
|
||||
properties,
|
||||
output,
|
||||
};
|
||||
}
|
||||
|
||||
// A generic trace span (HTTP call, log group, tool call, etc.).
|
||||
return {
|
||||
spanId: span!.spanId,
|
||||
kind: "span",
|
||||
message: span!.message,
|
||||
isError: span!.isError,
|
||||
isCancelled: span!.isCancelled,
|
||||
level: span!.level,
|
||||
duration: formatDuration(span!.duration),
|
||||
exceptions,
|
||||
properties,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to get span details: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { generateText, stepCountIs, tool } from "ai";
|
||||
import { inflate } from "node:zlib";
|
||||
import { promisify } from "node:util";
|
||||
import { z } from "zod";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
const inflateAsync = promisify(inflate);
|
||||
|
||||
type GeneratePayloadResult =
|
||||
| { success: true; taskIdentifier: string; payload: string; schemaSource: "schema" | "source" }
|
||||
| { success: false; error: string };
|
||||
|
||||
// Mirrors the Test page's AI payload generation
|
||||
// (resources…test.ai-generate-payload.tsx) but returns the payload as a value
|
||||
// so the assistant can fill the editor and/or feed it into runTestTask.
|
||||
export async function generatePayloadForTask(
|
||||
ctx: ToolContext,
|
||||
taskIdentifier: string,
|
||||
instruction?: string
|
||||
): Promise<GeneratePayloadResult> {
|
||||
const { env } = await import("~/env.server");
|
||||
const { $replica } = await import("~/db.server");
|
||||
const { resolveTestEnvironment } = await import("./resolve-environment");
|
||||
|
||||
if (!env.OPENAI_API_KEY) {
|
||||
return { success: false, error: "OpenAI API key is not configured" };
|
||||
}
|
||||
|
||||
const environment = await resolveTestEnvironment(ctx);
|
||||
|
||||
const task = await $replica.backgroundWorkerTask.findFirst({
|
||||
where: { slug: taskIdentifier, runtimeEnvironmentId: environment.id },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { payloadSchema: true },
|
||||
});
|
||||
|
||||
const payloadSchema =
|
||||
task?.payloadSchema != null ? JSON.stringify(task.payloadSchema) : undefined;
|
||||
|
||||
const system = buildSystemPrompt(taskIdentifier, payloadSchema);
|
||||
const prompt =
|
||||
instruction && instruction.trim().length > 0
|
||||
? instruction.trim()
|
||||
: "Generate a simple valid payload to test this task with.";
|
||||
|
||||
const result = await generateText({
|
||||
model: openai(env.AI_RUN_FILTER_MODEL ?? "gpt-5-mini"),
|
||||
temperature: 1,
|
||||
system,
|
||||
prompt,
|
||||
tools: {
|
||||
getTaskSourceCode: tool({
|
||||
description:
|
||||
"Look up the source code of the task to understand the payload shape it expects. Use " +
|
||||
"when there is no JSON Schema available.",
|
||||
inputSchema: z.object({}),
|
||||
execute: async () =>
|
||||
getTaskSourceCode(environment.id, environment.type, taskIdentifier),
|
||||
}),
|
||||
},
|
||||
stopWhen: stepCountIs(3),
|
||||
});
|
||||
|
||||
const payload = extractJsonFromText(result.text);
|
||||
if (!payload) {
|
||||
return { success: false, error: "Could not generate a valid JSON payload" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskIdentifier,
|
||||
payload,
|
||||
schemaSource: payloadSchema ? "schema" : "source",
|
||||
};
|
||||
}
|
||||
|
||||
async function getTaskSourceCode(
|
||||
environmentId: string,
|
||||
environmentType: string,
|
||||
taskIdentifier: string
|
||||
): Promise<string> {
|
||||
try {
|
||||
const { $replica } = await import("~/db.server");
|
||||
|
||||
let fileId: string | null | undefined;
|
||||
if (environmentType !== "DEVELOPMENT") {
|
||||
const { findCurrentWorkerDeployment } = await import(
|
||||
"~/v3/models/workerDeployment.server"
|
||||
);
|
||||
const deployment = await findCurrentWorkerDeployment({ environmentId });
|
||||
fileId = deployment?.worker?.tasks.find((t) => t.slug === taskIdentifier)?.fileId;
|
||||
} else {
|
||||
const task = await $replica.backgroundWorkerTask.findFirst({
|
||||
where: { slug: taskIdentifier, runtimeEnvironmentId: environmentId },
|
||||
orderBy: { createdAt: "desc" },
|
||||
select: { fileId: true },
|
||||
});
|
||||
fileId = task?.fileId;
|
||||
}
|
||||
|
||||
if (!fileId) return "Source code not available for this task.";
|
||||
|
||||
const file = await $replica.backgroundWorkerFile.findUnique({
|
||||
where: { id: fileId },
|
||||
select: { contents: true, filePath: true },
|
||||
});
|
||||
if (!file) return "Source code not available for this task.";
|
||||
|
||||
// File contents are zlib-deflated then base64-encoded by the CLI.
|
||||
const base64 = Buffer.from(file.contents).toString("utf-8");
|
||||
const decompressed = (await inflateAsync(Buffer.from(base64, "base64"))).toString("utf-8");
|
||||
return `File: ${file.filePath}\n\n${decompressed}`;
|
||||
} catch {
|
||||
return "Failed to retrieve task source code.";
|
||||
}
|
||||
}
|
||||
|
||||
function buildSystemPrompt(taskIdentifier: string, payloadSchema?: string): string {
|
||||
let prompt = `You are a JSON payload generator for a Trigger.dev task with id "${taskIdentifier}".
|
||||
|
||||
Your job is to generate a valid JSON payload that can be used to test this task. Return ONLY valid JSON wrapped in a \`\`\`json code block. Do not include any explanation outside the code block.
|
||||
|
||||
Requirements:
|
||||
- Generate realistic, meaningful example data
|
||||
- All string values should be plausible (real-looking names, emails, URLs, etc.)
|
||||
- Number values should be reasonable for their context
|
||||
- The JSON must be valid and parseable`;
|
||||
|
||||
if (payloadSchema) {
|
||||
prompt += `
|
||||
|
||||
The task has the following JSON Schema that the payload must conform to:
|
||||
\`\`\`json
|
||||
${payloadSchema}
|
||||
\`\`\`
|
||||
|
||||
Generate a payload that strictly conforms to this schema, respecting all type constraints, required fields, enums, formats, and validation rules.`;
|
||||
} else {
|
||||
prompt += `
|
||||
|
||||
No JSON Schema is available for this task. Use the getTaskSourceCode tool to look up the task's source code file.
|
||||
|
||||
IMPORTANT instructions for reading the source code:
|
||||
- The file may contain multiple task definitions. Find the one with id "${taskIdentifier}".
|
||||
- Look at the \`run\` function's payload parameter type to determine the expected shape.
|
||||
- If the payload is typed as \`any\`/\`unknown\` or has no annotation, infer the shape from how payload properties are accessed inside the \`run\` body.
|
||||
- If the payload is typed explicitly (e.g. \`{ name: string, count: number }\`), use that exactly.
|
||||
- If the payload is never accessed, the task likely accepts any payload — generate a simple \`{}\` object.
|
||||
- Do NOT invent fields you can't confirm from the type or usage.`;
|
||||
}
|
||||
|
||||
return prompt;
|
||||
}
|
||||
|
||||
function extractJsonFromText(text: string): string | null {
|
||||
const codeBlockMatch = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
||||
if (codeBlockMatch) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(codeBlockMatch[1].trim()), null, 2);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
const jsonMatch = text.match(/(\{[\s\S]*\}|\[[\s\S]*\])/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(jsonMatch[1]), null, 2);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { tool } from "ai";
|
||||
import { generateTestPayload as generateTestPayloadSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
export function createGenerateTestPayloadTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...generateTestPayloadSchema,
|
||||
execute: async (params: { taskIdentifier: string; instruction?: string }) => {
|
||||
try {
|
||||
const { generatePayloadForTask } = await import("./generate-payload-adapter");
|
||||
return await generatePayloadForTask(ctx, params.taskIdentifier, params.instruction);
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to generate test payload: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { tool } from "ai";
|
||||
import { listTestableTasks as listTestableTasksSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
const MAX_TASKS = 50;
|
||||
|
||||
export function createListTestableTasksTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...listTestableTasksSchema,
|
||||
execute: async ({ query }: { query?: string }) => {
|
||||
try {
|
||||
const { resolveTestEnvironment } = await import("./resolve-environment");
|
||||
const { TestPresenter } = await import("~/presenters/v3/TestPresenter.server");
|
||||
|
||||
const environment = await resolveTestEnvironment(ctx);
|
||||
|
||||
const presenter = new TestPresenter();
|
||||
const { tasks } = await presenter.call({
|
||||
userId: ctx.clientData.userId,
|
||||
projectId: environment.projectId,
|
||||
environmentId: environment.id,
|
||||
environmentType: environment.type,
|
||||
});
|
||||
|
||||
const needle = query?.trim().toLowerCase();
|
||||
const filtered = (needle
|
||||
? tasks.filter((t) => t.taskIdentifier.toLowerCase().includes(needle))
|
||||
: tasks
|
||||
).slice(0, MAX_TASKS);
|
||||
|
||||
return {
|
||||
tasks: filtered.map((t) => ({
|
||||
taskIdentifier: t.taskIdentifier,
|
||||
triggerSource: t.triggerSource,
|
||||
filePath: t.filePath,
|
||||
})),
|
||||
total: tasks.length,
|
||||
truncated: tasks.length > filtered.length,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
error: `Failed to list testable tasks: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { AuthenticatedEnvironment } from "~/services/apiAuth.server";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
// Resolves the project + authenticated environment for a tool call from the
|
||||
// client data slugs. Throws a friendly error the tool layer converts to text.
|
||||
export async function resolveTestEnvironment(
|
||||
ctx: ToolContext
|
||||
): Promise<AuthenticatedEnvironment> {
|
||||
const { findProjectBySlug } = await import("~/models/project.server");
|
||||
const { findEnvironmentBySlug } = await import("~/models/runtimeEnvironment.server");
|
||||
|
||||
const project = await findProjectBySlug(
|
||||
ctx.clientData.organizationSlug,
|
||||
ctx.clientData.projectSlug,
|
||||
ctx.clientData.userId
|
||||
);
|
||||
if (!project) {
|
||||
throw new Error(`Project "${ctx.clientData.projectSlug}" not found`);
|
||||
}
|
||||
|
||||
const environment = await findEnvironmentBySlug(
|
||||
project.id,
|
||||
ctx.clientData.environmentSlug,
|
||||
ctx.clientData.userId
|
||||
);
|
||||
if (!environment) {
|
||||
throw new Error(`Environment "${ctx.clientData.environmentSlug}" not found`);
|
||||
}
|
||||
|
||||
return environment;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { tool } from "ai";
|
||||
import { runTestTask as runTestTaskSchema } from "~/lib/ai-assistant/tool-schemas";
|
||||
import type { ToolContext } from "../types";
|
||||
|
||||
type RunTestTaskParams = {
|
||||
taskIdentifier: string;
|
||||
payload?: Record<string, unknown>;
|
||||
metadata?: Record<string, unknown>;
|
||||
tags?: string[];
|
||||
};
|
||||
|
||||
export function createRunTestTaskTool(ctx: ToolContext) {
|
||||
return tool({
|
||||
...runTestTaskSchema,
|
||||
execute: async (params: RunTestTaskParams) => {
|
||||
try {
|
||||
const { resolveTestEnvironment } = await import("./resolve-environment");
|
||||
const { TestTaskService } = await import("~/v3/services/testTask.server");
|
||||
|
||||
const environment = await resolveTestEnvironment(ctx);
|
||||
|
||||
const service = new TestTaskService();
|
||||
// TestTaskData is the *parsed* shape: payload/metadata are objects and
|
||||
// tags is a string[] (the zod transforms have already run).
|
||||
const run = await service.call(environment, {
|
||||
triggerSource: "STANDARD",
|
||||
taskIdentifier: params.taskIdentifier,
|
||||
environmentId: environment.id,
|
||||
payload: params.payload ?? {},
|
||||
metadata: params.metadata ?? {},
|
||||
tags: params.tags && params.tags.length > 0 ? params.tags.slice(0, 10) : undefined,
|
||||
});
|
||||
|
||||
if (!run) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Could not trigger a test run for "${params.taskIdentifier}"`,
|
||||
};
|
||||
}
|
||||
|
||||
const { v3RunPath } = await import("~/utils/pathBuilder");
|
||||
const url = v3RunPath(ctx.org, ctx.project, ctx.env, { friendlyId: run.friendlyId });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
taskIdentifier: params.taskIdentifier,
|
||||
runId: run.friendlyId,
|
||||
url,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: `Failed to run test task: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
};
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Matches the `withClientData` schema on the chat.agent definition.
|
||||
export interface ClientData {
|
||||
userId: string;
|
||||
organizationSlug: string;
|
||||
projectSlug: string;
|
||||
environmentSlug: string;
|
||||
currentPage: string;
|
||||
currentParams?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ToolContext {
|
||||
clientData: ClientData;
|
||||
// Pre-built path objects for pathBuilder functions.
|
||||
org: { slug: string };
|
||||
project: { slug: string };
|
||||
env: { slug: string };
|
||||
}
|
||||
|
||||
export function buildToolContext(clientData: ClientData): ToolContext {
|
||||
if (!clientData?.organizationSlug || !clientData?.projectSlug || !clientData?.environmentSlug) {
|
||||
throw new Error("Invalid clientData: missing organization, project, or environment slug");
|
||||
}
|
||||
return {
|
||||
clientData,
|
||||
org: { slug: clientData.organizationSlug },
|
||||
project: { slug: clientData.projectSlug },
|
||||
env: { slug: clientData.environmentSlug },
|
||||
};
|
||||
}
|
||||
|
||||
// V1B Summaries — LLM-friendly, token-efficient versions of presenter results
|
||||
|
||||
export interface SpanSummary {
|
||||
id: string;
|
||||
message: string;
|
||||
isError: boolean;
|
||||
isPartial: boolean;
|
||||
duration?: number;
|
||||
level: string;
|
||||
// Friendly ID of the run this span triggered, if the span is itself a run.
|
||||
// Lets the agent drill into the child run (getRunDetails / getSpanDetails).
|
||||
runId?: string;
|
||||
}
|
||||
|
||||
// Extracted exception from a span event or run error.
|
||||
export interface SpanException {
|
||||
type?: string;
|
||||
message?: string;
|
||||
stackTrace?: string;
|
||||
}
|
||||
|
||||
// Full detail of a single span (subtrace), tuned for LLM error investigation.
|
||||
export interface SpanDetailSummary {
|
||||
spanId: string;
|
||||
// "span" = generic trace span; "run" = the span is itself a triggered run.
|
||||
kind: "span" | "run";
|
||||
message: string;
|
||||
isError: boolean;
|
||||
isCancelled?: boolean;
|
||||
level?: string;
|
||||
duration?: string;
|
||||
// For run-kind spans:
|
||||
runFriendlyId?: string;
|
||||
taskIdentifier?: string;
|
||||
status?: string;
|
||||
// The thing the agent actually wants when asked "why did this fail":
|
||||
exceptions: SpanException[];
|
||||
metadata?: string;
|
||||
properties?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface TraceSummary {
|
||||
rootStatus: string;
|
||||
totalSpans: number;
|
||||
spans: SpanSummary[];
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export interface RunSummary {
|
||||
id: string;
|
||||
status: string;
|
||||
isFinished: boolean;
|
||||
startedAt?: string;
|
||||
completedAt?: string;
|
||||
duration?: string;
|
||||
parentRunId?: string;
|
||||
rootRunId?: string;
|
||||
}
|
||||
|
||||
export interface RunWithTrace {
|
||||
run: RunSummary;
|
||||
trace?: TraceSummary;
|
||||
}
|
||||
|
||||
export interface ErrorGroupSummary {
|
||||
fingerprint: string;
|
||||
message: string;
|
||||
taskIdentifier: string;
|
||||
count: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ErrorDetailsSummary {
|
||||
fingerprint: string;
|
||||
message: string;
|
||||
taskIdentifier: string;
|
||||
stackTrace?: string;
|
||||
count: number;
|
||||
firstSeen: string;
|
||||
lastSeen: string;
|
||||
affectedRuns: Array<{
|
||||
runFriendlyId: string;
|
||||
status: string;
|
||||
occurredAt: string;
|
||||
}>;
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
import { chat } from "@trigger.dev/sdk/ai";
|
||||
import { logger, prompts, sessions } from "@trigger.dev/sdk";
|
||||
import { streamText, stepCountIs, generateText, generateId } from "ai";
|
||||
import { openai } from "@ai-sdk/openai";
|
||||
import { z } from "zod";
|
||||
import { prisma } from "./db";
|
||||
import { buildAssistantTools } from "./ai-assistant-tools";
|
||||
|
||||
type ChatMessagesForWrite = NonNullable<
|
||||
Parameters<typeof prisma.aiChat.update>[0]["data"]
|
||||
>["messages"];
|
||||
|
||||
const systemPrompt = prompts.define({
|
||||
id: "dashboard-assistant-system",
|
||||
model: "openai:gpt-4.1-mini",
|
||||
config: { temperature: 0.7 },
|
||||
variables: z.object({
|
||||
projectSlug: z.string(),
|
||||
environmentSlug: z.string(),
|
||||
currentPage: z.string(),
|
||||
}),
|
||||
content: `You are the Trigger.dev AI assistant, embedded in the dashboard.
|
||||
|
||||
## Your role
|
||||
Help the user navigate the dashboard, find documentation, understand Trigger.dev features, and investigate runs and errors.
|
||||
|
||||
## Current context
|
||||
The user is viewing: project "{{projectSlug}}" / {{environmentSlug}} environment / {{currentPage}} page.
|
||||
|
||||
## Guidelines
|
||||
- Be concise and friendly. Prefer short, direct answers unless the user asks for detail.
|
||||
- When the user asks how something works, ALWAYS search documentation first.
|
||||
- When the user asks "where do I find X" or "take me to Y", use navigateToPage. To open a specific run, call navigateToPage with that run's friendly ID as runId (and a spanId to deep-link to a specific span/subtrace within its trace).
|
||||
- Use getCurrentContext to ground answers in what the user is viewing. If the user refers to "this run" or "the run I'm looking at" without an ID, check getCurrentContext for a run ID in the current params before asking.
|
||||
- To investigate why a run failed, call getRunDetails first — its trace lists each span with an \`id\` and an \`isError\` flag. Then call getSpanDetails with the failing span's \`id\` to read the exact exception, stack trace, and metadata for that subtrace. Don't guess the cause from the span message alone — drill in.
|
||||
- To test or run a task (e.g. "run a smoke test on hello-world", "test the email task"): (1) if the task isn't named or you're unsure it exists, call listTestableTasks to find it; (2) call navigateToPage with \`testTaskId\` set to the task identifier so the user sees the Test page; (3) call generateTestPayload to create a payload (this fills the editor on that page for the user); (4) ONLY if the user asked to actually run/trigger/smoke-test it, call runTestTask with the payload from step 3 — this triggers the run and navigates them to it. If the user only asked to "fill" or "prepare" a payload, stop after step 3 and do NOT run it.
|
||||
- Use markdown formatting for code blocks and prose. Do NOT format tool data as markdown tables.
|
||||
- If you don't know something, say so — don't make things up.
|
||||
- When you use a tool, briefly explain what you're doing.
|
||||
|
||||
## Rendering tool results
|
||||
Run, error, and analytics tool results (listRuns, listErrors, queryRuns, aggregateRuns, classifyFailure, applyRunFilters, etc.) are rendered for the user as rich UI — tables, cards, and chips — directly from the tool output. Do NOT repeat that data in your reply: never re-list the rows as a bullet list or a hand-written markdown table, even if the user says "show it as a table" (the table is already there). After a data tool runs, just give a one-line summary (e.g. "Here are your 8 most recent failed runs") and let the rendered component show the rows.
|
||||
|
||||
## Completing multi-step requests
|
||||
Many requests have several parts — e.g. "go to the runs page and show me the failed ones" is two steps: (1) navigate there, then (2) inspect and show the results. Carry out EVERY part in the same turn by chaining tool calls: act on the first part, then immediately continue to the next. Do NOT stop after the first step to ask "do you want me to do the next thing?" — the user already asked for it, so just do it. Only pause to ask a question when a step is genuinely ambiguous (you can't tell what the user means) or would change/delete something.
|
||||
|
||||
## What you CAN do
|
||||
- Search and read Trigger.dev documentation
|
||||
- Navigate the user to any dashboard page, or deep-link straight to a run or a span within its trace
|
||||
- Explain Trigger.dev features, configuration, and APIs
|
||||
- Help with common questions about retries, concurrency, deployments, env vars, etc.
|
||||
- Inspect specific runs, errors, and logs — including drilling into an individual span (subtrace) to read its exact exception and metadata
|
||||
- Analyze run failures and trends
|
||||
- Find similar errors and correlate with deployments
|
||||
- List testable tasks, generate test payloads, and run/trigger a test of a task when asked
|
||||
|
||||
## What you CANNOT do yet
|
||||
- Modify settings
|
||||
- Access the user's code (beyond what generateTestPayload reads to infer a payload)
|
||||
|
||||
## When explaining why a run failed, use this format:
|
||||
|
||||
**Summary:** One sentence.
|
||||
**Category:** Timeout / OOM / Missing env var / Child task failed / User code exception / AI provider error / Deploy regression / Platform issue / Unknown
|
||||
**Likely cause:** 1-2 sentences with evidence.
|
||||
**Confidence:** High / Medium / Low
|
||||
**Evidence:**
|
||||
- Error: <message>
|
||||
- Failed span: <name, duration>
|
||||
- Deploy: <version, time since deploy>`,
|
||||
});
|
||||
|
||||
export const dashboardAssistant = chat
|
||||
.withClientData({
|
||||
schema: z.object({
|
||||
userId: z.string(),
|
||||
organizationSlug: z.string(),
|
||||
projectSlug: z.string(),
|
||||
environmentSlug: z.string(),
|
||||
currentPage: z.string(),
|
||||
currentParams: z.record(z.string()).optional(),
|
||||
}),
|
||||
})
|
||||
.agent({
|
||||
id: "dashboard-assistant",
|
||||
idleTimeoutInSeconds: 60,
|
||||
chatAccessTokenTTL: "1h",
|
||||
|
||||
// Declared here (not just on streamText) so the SDK re-applies each tool's
|
||||
// `toModelOutput` when re-converting prior-turn history. run() reads them
|
||||
// back via `tools`.
|
||||
tools: async (event) => buildAssistantTools(event.clientData!),
|
||||
|
||||
uiMessageStreamOptions: {
|
||||
onError: (error: unknown) => {
|
||||
logger.error("Stream error", { error });
|
||||
if (error instanceof Error && error.message.includes("rate limit")) {
|
||||
return "Rate limited — please wait a moment and try again.";
|
||||
}
|
||||
return "Something went wrong. Please try again.";
|
||||
},
|
||||
},
|
||||
|
||||
onBoot: async ({ clientData }) => {
|
||||
if (!clientData) return;
|
||||
const resolved = await systemPrompt.resolve({
|
||||
projectSlug: clientData.projectSlug,
|
||||
environmentSlug: clientData.environmentSlug,
|
||||
currentPage: clientData.currentPage,
|
||||
});
|
||||
chat.prompt.set(resolved);
|
||||
},
|
||||
|
||||
onPreload: async ({ chatId, clientData }) => {
|
||||
if (!clientData) return;
|
||||
// Create session through Trigger platform and local chat record
|
||||
await sessions.start({
|
||||
type: "chat.agent",
|
||||
externalId: chatId,
|
||||
taskIdentifier: "dashboard-assistant",
|
||||
triggerConfig: {
|
||||
basePayload: {
|
||||
userId: clientData.userId,
|
||||
organizationSlug: clientData.organizationSlug,
|
||||
projectSlug: clientData.projectSlug,
|
||||
environmentSlug: clientData.environmentSlug,
|
||||
currentPage: clientData.currentPage,
|
||||
currentParams: clientData.currentParams,
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
`user:${clientData.userId}`,
|
||||
`org:${clientData.organizationSlug}`,
|
||||
`project:${clientData.projectSlug}`,
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
// Fallback for non-preloaded runs; onPreload already created the session.
|
||||
onChatStart: async ({ chatId, clientData, preloaded }) => {
|
||||
if (preloaded) return;
|
||||
if (!clientData) return;
|
||||
// Create session through Trigger platform and local chat record
|
||||
await sessions.start({
|
||||
type: "chat.agent",
|
||||
externalId: chatId,
|
||||
taskIdentifier: "dashboard-assistant",
|
||||
triggerConfig: {
|
||||
basePayload: {
|
||||
userId: clientData.userId,
|
||||
organizationSlug: clientData.organizationSlug,
|
||||
projectSlug: clientData.projectSlug,
|
||||
environmentSlug: clientData.environmentSlug,
|
||||
currentPage: clientData.currentPage,
|
||||
currentParams: clientData.currentParams,
|
||||
},
|
||||
},
|
||||
tags: [
|
||||
`user:${clientData.userId}`,
|
||||
`org:${clientData.organizationSlug}`,
|
||||
`project:${clientData.projectSlug}`,
|
||||
],
|
||||
});
|
||||
},
|
||||
|
||||
// Await the write (not chat.defer): a deferred write loses the user
|
||||
// message on a mid-stream page refresh.
|
||||
onTurnStart: async ({ chatId, uiMessages, clientData }) => {
|
||||
const messages = uiMessages as unknown as ChatMessagesForWrite;
|
||||
await prisma.aiChat.upsert({
|
||||
where: { id: chatId },
|
||||
create: {
|
||||
id: chatId,
|
||||
title: "New chat",
|
||||
userId: clientData?.userId ?? "",
|
||||
model: "gpt-4.1-mini",
|
||||
messages,
|
||||
},
|
||||
update: { messages },
|
||||
});
|
||||
|
||||
if (Array.isArray(uiMessages)) {
|
||||
const firstUser = uiMessages.find((m) => m?.role === "user");
|
||||
const text = firstUser
|
||||
? (firstUser.parts ?? [])
|
||||
.filter((p: { type?: string }) => p?.type === "text")
|
||||
.map((p: { text?: string }) => p.text ?? "")
|
||||
.join(" ")
|
||||
.trim()
|
||||
: "";
|
||||
if (text) {
|
||||
const title = text.length > 60 ? `${text.slice(0, 60).trimEnd()}…` : text;
|
||||
await prisma.aiChat.updateMany({
|
||||
where: { id: chatId, title: "New chat" },
|
||||
data: { title },
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// Persist messages after turn completes
|
||||
onTurnComplete: async ({ chatId, uiMessages }) => {
|
||||
await prisma.aiChat.update({
|
||||
where: { id: chatId },
|
||||
data: { messages: uiMessages as unknown as ChatMessagesForWrite },
|
||||
});
|
||||
},
|
||||
|
||||
compaction: {
|
||||
shouldCompact: ({ totalTokens }) => (totalTokens ?? 0) > 80_000,
|
||||
summarize: async ({ messages }) => {
|
||||
return generateText({
|
||||
model: openai("gpt-4.1-mini"),
|
||||
messages: [
|
||||
...messages,
|
||||
{
|
||||
role: "user",
|
||||
content: "Summarize this conversation concisely. Capture key topics, " +
|
||||
"questions asked, answers given, runs/errors inspected, and any " +
|
||||
"conclusions reached. Keep context needed to continue naturally.",
|
||||
},
|
||||
],
|
||||
}).then((r) => r.text);
|
||||
},
|
||||
compactUIMessages: ({ uiMessages, summary }) => [
|
||||
{
|
||||
id: generateId(),
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: `[Conversation summary]\n\n${summary}` }],
|
||||
},
|
||||
...uiMessages.slice(-2),
|
||||
],
|
||||
},
|
||||
|
||||
pendingMessages: {
|
||||
shouldInject: ({ steps }) => steps.length > 0,
|
||||
prepare: ({ messages }) => {
|
||||
const getMessageText = (m: any) => {
|
||||
const textPart = m?.parts?.find((p: any) => p?.type === "text");
|
||||
return textPart?.text ?? "";
|
||||
};
|
||||
|
||||
if (messages.length === 1) {
|
||||
return [{ role: "user", content: getMessageText(messages[0]) }];
|
||||
}
|
||||
|
||||
const messageList = messages
|
||||
.map((m, i) => `${i + 1}. ${getMessageText(m)}`)
|
||||
.join("\n");
|
||||
return [{
|
||||
role: "user",
|
||||
content: `The user sent ${messages.length} messages while you were working:\n\n${messageList}`,
|
||||
}];
|
||||
},
|
||||
},
|
||||
|
||||
// chat.toStreamTextOptions() must be spread first. `tools` comes from the
|
||||
// run payload so streamText and the history re-converter see the same set.
|
||||
run: async ({ messages, tools, stopSignal }) => {
|
||||
return streamText({
|
||||
...chat.toStreamTextOptions({ tools }),
|
||||
model: openai("gpt-4.1-mini"),
|
||||
messages,
|
||||
abortSignal: stopSignal,
|
||||
stopWhen: stepCountIs(10),
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
import { PrismaClient } from "@trigger.dev/database";
|
||||
|
||||
// Dedicated client for trigger tasks. Importing the webapp's `~/db.server`
|
||||
// pulls in `~/v3/tracer.server`, whose module-load OTel registration collides
|
||||
// with the worker's own ("Attempted duplicate registration of API: trace").
|
||||
export const prisma = new PrismaClient({
|
||||
datasourceUrl: process.env.DATABASE_URL,
|
||||
});
|
||||
@@ -60,9 +60,20 @@ import { prisma } from "~/db.server";
|
||||
import { metricsRegister } from "~/metrics.server";
|
||||
import type { Prisma } from "@trigger.dev/database";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import type { TracerProvider } from "@opentelemetry/api";
|
||||
|
||||
export const SEMINTATTRS_FORCE_RECORDING = "forceRecording";
|
||||
|
||||
/** True when another SDK (e.g. CLI index/run worker `TracingSDK`) already registered OTel globals. */
|
||||
function isOtelGloballyInitialized(): boolean {
|
||||
const provider = trace.getTracerProvider();
|
||||
if (typeof (provider as TracerProvider & { getDelegate?: () => TracerProvider }).getDelegate === "function") {
|
||||
const delegate = (provider as TracerProvider & { getDelegate: () => TracerProvider }).getDelegate();
|
||||
return delegate.constructor.name !== "NoopTracerProvider";
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export const DATASOURCE_CONTEXT_KEY = createContextKey("trigger.db.datasource");
|
||||
|
||||
class DatasourceAttributeSpanProcessor implements SpanProcessor {
|
||||
@@ -210,6 +221,17 @@ function getResource() {
|
||||
}
|
||||
|
||||
function setupTelemetry() {
|
||||
if (isOtelGloballyInitialized()) {
|
||||
console.log(`🔦 Tracer: reusing existing global OpenTelemetry provider`);
|
||||
|
||||
return {
|
||||
tracer: trace.getTracer("trigger.dev", "3.3.12"),
|
||||
logger: logs.getLogger("trigger.dev", "3.3.12"),
|
||||
provider: trace.getTracerProvider() as NodeTracerProvider,
|
||||
meter: setupMetrics(),
|
||||
};
|
||||
}
|
||||
|
||||
if (env.INTERNAL_OTEL_TRACE_DISABLED === "1") {
|
||||
console.log(`🔦 Tracer disabled, returning a noop tracer`);
|
||||
|
||||
@@ -331,7 +353,7 @@ function setupTelemetry() {
|
||||
}
|
||||
|
||||
function setupMetrics() {
|
||||
if (env.INTERNAL_OTEL_METRIC_EXPORTER_ENABLED === "0") {
|
||||
if (env.INTERNAL_OTEL_METRIC_EXPORTER_ENABLED === "0" || isOtelGloballyInitialized()) {
|
||||
return metrics.getMeter("trigger.dev", "3.3.12");
|
||||
}
|
||||
|
||||
|
||||
@@ -56,7 +56,6 @@
|
||||
"@electric-sql/react": "^0.3.5",
|
||||
"@headlessui/react": "^1.7.8",
|
||||
"@heroicons/react": "^2.0.12",
|
||||
"@jsonhero/schema-infer": "^0.1.5",
|
||||
"@internal/cache": "workspace:*",
|
||||
"@internal/compute": "workspace:*",
|
||||
"@internal/llm-model-catalog": "workspace:*",
|
||||
@@ -67,6 +66,7 @@
|
||||
"@internal/tsql": "workspace:*",
|
||||
"@internal/zod-worker": "workspace:*",
|
||||
"@internationalized/date": "^3.5.1",
|
||||
"@jsonhero/schema-infer": "^0.1.5",
|
||||
"@kapaai/react-sdk": "^0.1.3",
|
||||
"@lezer/highlight": "^1.1.6",
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
@@ -116,6 +116,7 @@
|
||||
"@sentry/remix": "9.46.0",
|
||||
"@slack/web-api": "7.16.0",
|
||||
"@socket.io/redis-adapter": "^8.3.0",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"@tabler/icons-react": "^3.36.1",
|
||||
"@tailwindcss/container-queries": "^0.1.1",
|
||||
"@tanstack/match-sorter-utils": "^8.19.4",
|
||||
@@ -125,9 +126,9 @@
|
||||
"@trigger.dev/companyicons": "^1.5.35",
|
||||
"@trigger.dev/core": "workspace:*",
|
||||
"@trigger.dev/database": "workspace:*",
|
||||
"@trigger.dev/rbac": "workspace:*",
|
||||
"@trigger.dev/otlp-importer": "workspace:*",
|
||||
"@trigger.dev/platform": "1.0.27",
|
||||
"@trigger.dev/rbac": "workspace:*",
|
||||
"@trigger.dev/redis-worker": "workspace:*",
|
||||
"@trigger.dev/sdk": "workspace:*",
|
||||
"@types/pg": "8.6.6",
|
||||
@@ -220,7 +221,6 @@
|
||||
"sonner": "^1.0.3",
|
||||
"sql-formatter": "^15.4.10",
|
||||
"sqs-consumer": "^7.4.0",
|
||||
"@streamdown/code": "^1.1.1",
|
||||
"streamdown": "^2.5.0",
|
||||
"superjson": "^2.2.1",
|
||||
"tailwind-merge": "^1.12.0",
|
||||
@@ -249,6 +249,7 @@
|
||||
"@tailwindcss/forms": "^0.5.3",
|
||||
"@tailwindcss/typography": "^0.5.9",
|
||||
"@total-typescript/ts-reset": "^0.4.2",
|
||||
"@trigger.dev/build": "4.4.6",
|
||||
"@types/bcryptjs": "^2.4.2",
|
||||
"@types/compression": "^1.7.2",
|
||||
"@types/cookie": "^0.6.0",
|
||||
|
||||
@@ -321,12 +321,19 @@ module.exports = {
|
||||
"0%": { "background-position": "-1px" },
|
||||
"100%": { "background-position": "7px" },
|
||||
},
|
||||
"ai-sparkle-hover": {
|
||||
"0%": { transform: "scale(1) rotate(0deg)" },
|
||||
"45%": { transform: "scale(1.1) rotate(-6deg)" },
|
||||
"75%": { transform: "scale(1.06) rotate(4deg)" },
|
||||
"100%": { transform: "scale(1) rotate(0deg)" },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
"accordion-down": "accordion-down 0.2s ease-out",
|
||||
"accordion-up": "accordion-up 0.2s ease-out",
|
||||
"tile-scroll": "tile-move 0.5s infinite linear",
|
||||
"tile-scroll-offset": "tile-move-offset 0.5s infinite linear",
|
||||
"ai-sparkle-hover": "ai-sparkle-hover 0.45s ease-out 1",
|
||||
},
|
||||
backgroundImage: {
|
||||
"gradient-radial": "radial-gradient(closest-side, var(--tw-gradient-stops))",
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "@trigger.dev/sdk";
|
||||
|
||||
export default defineConfig({
|
||||
project: process.env.TRIGGER_PROJECT_REF!,
|
||||
dirs: ["./app/trigger"],
|
||||
maxDuration: 3600,
|
||||
runtime: "node-22",
|
||||
});
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"exclude": ["./cypress", "./cypress.config.ts"],
|
||||
"include": ["remix.env.d.ts", "global.d.ts", "**/*.ts", "**/*.tsx"],
|
||||
"include": [
|
||||
"remix.env.d.ts",
|
||||
"global.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
"trigger.config.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"types": ["vitest/globals", "node"],
|
||||
"lib": ["DOM", "DOM.Iterable", "DOM.AsyncIterable", "ES2020"],
|
||||
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."AiChat" (
|
||||
"id" TEXT NOT NULL,
|
||||
"userId" TEXT NOT NULL,
|
||||
"title" TEXT NOT NULL DEFAULT 'New chat',
|
||||
"messages" JSONB NOT NULL DEFAULT '[]',
|
||||
"model" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AiChat_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "public"."AiChatSession" (
|
||||
"id" TEXT NOT NULL,
|
||||
"publicAccessToken" TEXT NOT NULL,
|
||||
"lastEventId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "AiChatSession_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AiChat_userId_updatedAt_idx" ON "public"."AiChat"("userId", "updatedAt" DESC);
|
||||
@@ -3172,3 +3172,31 @@ model OrganizationDataStore {
|
||||
|
||||
@@index([kind])
|
||||
}
|
||||
|
||||
// ====================================================
|
||||
// AI Assistant Chat Persistence
|
||||
// ====================================================
|
||||
|
||||
/// Stores chat conversations for the dashboard AI assistant.
|
||||
/// The id is the same as the chatId / session externalId.
|
||||
model AiChat {
|
||||
id String @id
|
||||
userId String
|
||||
title String @default("New chat")
|
||||
messages Json @default("[]")
|
||||
model String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([userId, updatedAt(sort: Desc)])
|
||||
}
|
||||
|
||||
/// Stores session state for AI assistant chat sessions.
|
||||
/// Used for SSE resume and token refresh.
|
||||
model AiChatSession {
|
||||
id String @id
|
||||
publicAccessToken String
|
||||
lastEventId String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
+2
-1
@@ -119,7 +119,8 @@
|
||||
"postcss@>=8 <8.5.10": "^8.5.10",
|
||||
"yaml@>=2 <2.8.3": "^2.8.3",
|
||||
"semver@>=5 <5.7.2": "^5.7.2",
|
||||
"defu@>=6 <6.1.5": "^6.1.5"
|
||||
"defu@>=6 <6.1.5": "^6.1.5",
|
||||
"ai": "6.0.116"
|
||||
},
|
||||
"onlyBuiltDependencies": [
|
||||
"@depot/cli",
|
||||
|
||||
@@ -92,6 +92,7 @@
|
||||
"@types/ws": "^8.5.3",
|
||||
"ai": "^6.0.116",
|
||||
"encoding": "^0.1.13",
|
||||
"react": "18.2.0",
|
||||
"rimraf": "^6.0.1",
|
||||
"tshy": "^3.0.2",
|
||||
"tsx": "4.17.0",
|
||||
|
||||
Generated
+115
-341
@@ -37,6 +37,7 @@ overrides:
|
||||
yaml@>=2 <2.8.3: ^2.8.3
|
||||
semver@>=5 <5.7.2: ^5.7.2
|
||||
defu@>=6 <6.1.5: ^6.1.5
|
||||
ai: 6.0.116
|
||||
|
||||
patchedDependencies:
|
||||
'@changesets/assemble-release-plan@5.2.4':
|
||||
@@ -578,8 +579,8 @@ importers:
|
||||
specifier: 1.1.3
|
||||
version: 1.1.3(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
|
||||
ai:
|
||||
specifier: ^6.0.116
|
||||
version: 6.0.168(zod@3.25.76)
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
assert-never:
|
||||
specifier: ^1.2.1
|
||||
version: 1.2.1
|
||||
@@ -902,6 +903,9 @@ importers:
|
||||
'@total-typescript/ts-reset':
|
||||
specifier: ^0.4.2
|
||||
version: 0.4.2
|
||||
'@trigger.dev/build':
|
||||
specifier: 4.4.6
|
||||
version: 4.4.6(bufferutil@4.0.9)(typescript@5.5.4)
|
||||
'@types/bcryptjs':
|
||||
specifier: ^2.4.2
|
||||
version: 2.4.2
|
||||
@@ -1015,7 +1019,7 @@ importers:
|
||||
version: 2.0.5(eslint@8.31.0)
|
||||
evalite:
|
||||
specifier: 1.0.0-beta.16
|
||||
version: 1.0.0-beta.16(ai@6.0.168(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9)
|
||||
version: 1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9)
|
||||
npm-run-all:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5
|
||||
@@ -1894,8 +1898,8 @@ importers:
|
||||
specifier: ^4.0.14
|
||||
version: 4.0.14
|
||||
ai:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.3(zod@3.25.76)
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
defu:
|
||||
specifier: ^6.1.5
|
||||
version: 6.1.7
|
||||
@@ -2166,9 +2170,6 @@ importers:
|
||||
evt:
|
||||
specifier: ^2.4.13
|
||||
version: 2.4.13
|
||||
react:
|
||||
specifier: ^18.0 || ^19.0
|
||||
version: 18.3.1
|
||||
slug:
|
||||
specifier: ^6.0.0
|
||||
version: 6.1.0
|
||||
@@ -2201,11 +2202,14 @@ importers:
|
||||
specifier: ^8.5.3
|
||||
version: 8.5.4
|
||||
ai:
|
||||
specifier: ^6.0.116
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
encoding:
|
||||
specifier: ^0.1.13
|
||||
version: 0.1.13
|
||||
react:
|
||||
specifier: 18.2.0
|
||||
version: 18.2.0
|
||||
rimraf:
|
||||
specifier: ^6.0.1
|
||||
version: 6.0.1
|
||||
@@ -2259,7 +2263,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/trigger-sdk
|
||||
ai:
|
||||
specifier: ^6.0.0
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
next:
|
||||
specifier: 15.3.3
|
||||
@@ -2393,8 +2397,8 @@ importers:
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
ai:
|
||||
specifier: 5.0.14
|
||||
version: 5.0.14(zod@3.25.76)
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -2484,8 +2488,8 @@ importers:
|
||||
specifier: ^0.10.0
|
||||
version: 0.10.0
|
||||
ai:
|
||||
specifier: 4.2.5
|
||||
version: 4.2.5(react@19.0.0)(zod@3.25.76)
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@@ -2582,7 +2586,7 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/trigger-sdk
|
||||
ai:
|
||||
specifier: ^6.0.116
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
arktype:
|
||||
specifier: ^2.0.0
|
||||
@@ -2699,8 +2703,8 @@ importers:
|
||||
specifier: ^7.0.3
|
||||
version: 7.0.3(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(react@18.3.1)(uploadthing@7.1.0(express@5.2.1)(fastify@5.8.5)(next@14.2.21(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@18.2.0(react@18.3.1))(react@18.3.1))(tailwindcss@3.4.1))
|
||||
ai:
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(react@18.3.1)(zod@3.25.76)
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.0
|
||||
version: 0.7.0
|
||||
@@ -2966,8 +2970,8 @@ importers:
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/trigger-sdk
|
||||
ai:
|
||||
specifier: ^5.0.76
|
||||
version: 5.0.76(zod@3.25.76)
|
||||
specifier: 6.0.116
|
||||
version: 6.0.116(zod@3.25.76)
|
||||
next:
|
||||
specifier: 15.5.6
|
||||
version: 15.5.6(@opentelemetry/api@1.9.0)(@playwright/test@1.37.0)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
|
||||
@@ -3114,30 +3118,6 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@1.0.6':
|
||||
resolution: {integrity: sha512-JuSj1MtTr4vw2VBBth4wlbciQnQIV0o1YV9qGLFA+r85nR5H+cJp3jaYE0nprqfzC9rYG8w9c6XGHB3SDKgcgA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/gateway@2.0.0':
|
||||
resolution: {integrity: sha512-Gj0PuawK7NkZuyYgO/h5kDK/l6hFOjhLdTq3/Lli1FTl47iGmwhH1IZQpAL3Z09BeFYWakcwUmn02ovIm2wy9g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@3.0.104':
|
||||
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@3.0.2':
|
||||
resolution: {integrity: sha512-giJEg9ob45htbu3iautK+2kvplY2JnTj7ir4wZzYSQWvqGatWfBBfDuNCU5wSJt9BCGjymM5ZS9ziD42JGCZBw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/gateway@3.0.66':
|
||||
resolution: {integrity: sha512-SIQ0YY0iMuv+07HLsZ+bB990zUJ6S4ujORAh+Jv1V2KGNn73qQKnGO0JBk+w+Res8YqOFSycwDoWcFlQrVxS4A==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3210,12 +3190,6 @@ packages:
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.1':
|
||||
resolution: {integrity: sha512-de2v8gH9zj47tRI38oSxhQIewmNc+OZjYIOOaMoVWKL65ERSav2PYYZHPSPCrfOeLMkv+Dyh8Y0QGwkO29wMWQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.19':
|
||||
resolution: {integrity: sha512-3eG55CrSWCu2SXlqq2QCsFjo3+E7+Gmg7i/oRVoSZzIodTuDSfLb3MRje67xE9RFea73Zao7Lm4mADIfUETKGg==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3244,57 +3218,16 @@ packages:
|
||||
resolution: {integrity: sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/provider@3.0.0':
|
||||
resolution: {integrity: sha512-m9ka3ptkPQbaHHZHqDXDF9C9B5/Mav0KTdky1k2HZ3/nrW2t1AgObxIVPyGDWQNS9FXT/FS6PIoSjpcP/No8rQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/provider@3.0.8':
|
||||
resolution: {integrity: sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@ai-sdk/react@1.0.0':
|
||||
resolution: {integrity: sha512-BDrZqQA07Btg64JCuhFvBgYV+tt2B8cXINzEqWknGoxqcwgdE8wSLG2gkXoLzyC2Rnj7oj0HHpOhLUxDCmoKZg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
zod: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/react@1.2.2':
|
||||
resolution: {integrity: sha512-rxyNTFjUd3IilVOJFuUJV5ytZBYAIyRi50kFS2gNmSEiG4NHMBBm31ddrxI/i86VpY8gzZVp1/igtljnWBihUA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/react@3.0.170':
|
||||
resolution: {integrity: sha512-YUDn+mK0c8iUz14rCBf1A0zg6SV5b5aSVUz+azF1bdBd1SFXVI19dKYR+PQSpZY+0+z+zs252AAsacUqiO98Kw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ~19.0.1 || ~19.1.2 || ^19.2.1
|
||||
|
||||
'@ai-sdk/ui-utils@1.0.0':
|
||||
resolution: {integrity: sha512-oXBDIM/0niWeTWyw77RVl505dNxBUDLLple7bTsqo2d3i1UKwGlzBUX8XqZsh7GbY7I6V05nlG0Y8iGlWxv1Aw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@ai-sdk/ui-utils@1.2.1':
|
||||
resolution: {integrity: sha512-BzvMbYm7LHBlbWuLlcG1jQh4eu14MGpz7L+wrGO1+F4oQ+O0fAjgUSNwPWGlZpKmg4NrcVq/QLmxiVJrx2R4Ew==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.23.8
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -9332,6 +9265,9 @@ packages:
|
||||
'@s2-dev/streamstore@0.22.10':
|
||||
resolution: {integrity: sha512-dtm+oFHVE8szINwOUoNQdx9xpGSJOrcAEvsxspPFvomjYKGnmhIRmU4OX8o6kxcPoiK76S1tPeU0smjZdmOngA==}
|
||||
|
||||
'@s2-dev/streamstore@0.22.5':
|
||||
resolution: {integrity: sha512-GqdOKIbIoIxT+40fnKzHbrsHB6gBqKdECmFe7D3Ojk4FoN1Hu0LhFzZv6ZmVMjoHHU+55debS1xSWjZwQmbIyQ==}
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1':
|
||||
resolution: {integrity: sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg==}
|
||||
|
||||
@@ -10429,12 +10365,20 @@ packages:
|
||||
'@total-typescript/ts-reset@0.4.2':
|
||||
resolution: {integrity: sha512-vqd7ZUDSrXFVT1n8b2kc3LnklncDQFPvR58yUS1kEP23/nHPAO9l1lMjUfnPrXYYk4Hj54rrLKMW5ipwk7k09A==}
|
||||
|
||||
'@trigger.dev/build@4.4.6':
|
||||
resolution: {integrity: sha512-eHPPaeuFe9GZDndQzP4QUlxocyIJWYSx0FMx1GEiAnEVKwXWUqiW72DRFH7cr9v7IQnI9YbAWRuWvyMPHSVLwg==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@trigger.dev/companyicons@1.5.35':
|
||||
resolution: {integrity: sha512-AhY7yshwh0onlgB6EGiyjyLSzl38Cuxo4tpUJVHxs5im8gDA+fuUq7o6Vz1WetFeNXwjMqh3f+bPW7bfqR4epg==}
|
||||
peerDependencies:
|
||||
react: ^18.2.0
|
||||
react-dom: 18.2.0
|
||||
|
||||
'@trigger.dev/core@4.4.6':
|
||||
resolution: {integrity: sha512-oXAjxBNVMiKXUKj1EnHUlO2ULujc4Dy8ad+H/59DYZGY1barkGVzrQAQIpBZo/kDygu7dKGT+F5yBkbIkYjAUw==}
|
||||
engines: {node: '>=18.20.0'}
|
||||
|
||||
'@trigger.dev/platform@1.0.27':
|
||||
resolution: {integrity: sha512-gYfYDBnp2RJpgL4V2oOeqzV20flMZg+oGALhxHmmNJdJM01MW4aJSLsFFjoj+dikpL+4/3gocJcoyZk1G02UNg==}
|
||||
|
||||
@@ -10598,9 +10542,6 @@ packages:
|
||||
'@types/deep-eql@4.0.2':
|
||||
resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==}
|
||||
|
||||
'@types/diff-match-patch@1.0.36':
|
||||
resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==}
|
||||
|
||||
'@types/docker-modem@3.0.6':
|
||||
resolution: {integrity: sha512-yKpAGEuKRSS8wwx0joknWxsmLha78wNMe9R2S3UNsVOkZded8UqOrV8KoeDXoXsjndxwyF3eIhyClGbO1SEhEg==}
|
||||
|
||||
@@ -11058,22 +10999,10 @@ packages:
|
||||
'@vanilla-extract/private@1.0.3':
|
||||
resolution: {integrity: sha512-17kVyLq3ePTKOkveHxXuIJZtGYs+cSoev7BlP+Lf4916qfDhk/HBjvlYDe8egrea7LNPHKwSZJK/bzZC+Q6AwQ==}
|
||||
|
||||
'@vercel/oidc@3.0.3':
|
||||
resolution: {integrity: sha512-yNEQvPcVrK9sIe637+I0jD6leluPxzwJKx/Haw6F4H77CdDsszUn5V3o96LPziXkSNE2B83+Z3mjqGKBK/R6Gg==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/oidc@3.0.5':
|
||||
resolution: {integrity: sha512-fnYhv671l+eTTp48gB4zEsTW/YtRgRPnkI2nT7x6qw5rkI1Lq2hTmQIpHPgyThI0znLK+vX2n9XxKdXZ7BUbbw==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/oidc@3.1.0':
|
||||
resolution: {integrity: sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/oidc@3.2.0':
|
||||
resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==}
|
||||
engines: {node: '>= 20'}
|
||||
|
||||
'@vercel/otel@1.13.0':
|
||||
resolution: {integrity: sha512-esRkt470Y2jRK1B1g7S1vkt4Csu44gp83Zpu8rIyPoqy2BKgk4z7ik1uSMswzi45UogLHFl6yR5TauDurBQi4Q==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -11369,58 +11298,12 @@ packages:
|
||||
ahocorasick@1.0.2:
|
||||
resolution: {integrity: sha512-hCOfMzbFx5IDutmWLAt6MZwOUjIfSM9G9FyVxytmE4Rs/5YDPWQrD/+IR1w+FweD9H2oOZEnv36TmkjhNURBVA==}
|
||||
|
||||
ai@4.0.0:
|
||||
resolution: {integrity: sha512-cqf2GCaXnOPhUU+Ccq6i+5I0jDjnFkzfq7t6mc0SUSibSa1wDPn5J4p8+Joh2fDGDYZOJ44rpTW9hSs40rXNAw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
zod: ^3.0.0
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
ai@4.2.5:
|
||||
resolution: {integrity: sha512-URJEslI3cgF/atdTJHtz+Sj0W1JTmiGmD3znw9KensL3qV605odktDim+GTazNJFPR4QaIu1lUio5b8RymvOjA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: ^18 || ^19 || ^19.0.0-rc
|
||||
zod: ^3.23.8
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
|
||||
ai@5.0.14:
|
||||
resolution: {integrity: sha512-xiujFa879skB7YxGzbeHAxepsr6AEaWcHPXrc5a9MRM6p4WdVAwn6mGwVZkBnhqGfZtXFr4LUnU2ayvcjWp5ig==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4
|
||||
|
||||
ai@5.0.76:
|
||||
resolution: {integrity: sha512-ZCxi1vrpyCUnDbtYrO/W8GLvyacV9689f00yshTIQ3mFFphbD7eIv40a2AOZBv3GGRA7SSRYIDnr56wcS/gyQg==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ai@6.0.116:
|
||||
resolution: {integrity: sha512-7yM+cTmyRLeNIXwt4Vj+mrrJgVQ9RMIW5WO0ydoLoYkewIvsMcvUmqS4j2RJTUXaF1HphwmSKUMQ/HypNRGOmA==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ai@6.0.168:
|
||||
resolution: {integrity: sha512-2HqCJuO+1V2aV7vfYs5LFEUfxbkGX+5oa54q/gCCTL7KLTdbxcCu5D7TdLA5kwsrs3Szgjah9q6D9tpjHM3hUQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ai@6.0.3:
|
||||
resolution: {integrity: sha512-OOo+/C+sEyscoLnbY3w42vjQDICioVNyS+F+ogwq6O5RJL/vgWGuiLzFwuP7oHTeni/MkmX8tIge48GTdaV7QQ==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
zod: ^3.25.76 || ^4.1.8
|
||||
|
||||
ajv-formats@2.1.1:
|
||||
resolution: {integrity: sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==}
|
||||
peerDependencies:
|
||||
@@ -12953,9 +12836,6 @@ packages:
|
||||
didyoumean@1.2.2:
|
||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||
|
||||
diff-match-patch@1.0.5:
|
||||
resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==}
|
||||
|
||||
diff@5.1.0:
|
||||
resolution: {integrity: sha512-D+mk+qE8VC/PAUrlAU34N+VfXev0ghe5ywmpqrawphmVZc1bEfn56uo9qpyGp1p4xpzOHkSW4ztBd6L7Xx4ACw==}
|
||||
engines: {node: '>=0.3.1'}
|
||||
@@ -13663,7 +13543,7 @@ packages:
|
||||
resolution: {integrity: sha512-14G+Y1Rqi9xOJck1vykPwaRSPSPpSvaklMS9WjJA04KWIOUwrT3jHUyA/6GkMC0twi1vdkssGS5FstNtVqVCWQ==}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
ai: ^6
|
||||
ai: 6.0.116
|
||||
better-sqlite3: ^11.6.0
|
||||
peerDependenciesMeta:
|
||||
ai:
|
||||
@@ -14987,9 +14867,6 @@ packages:
|
||||
jose@5.4.0:
|
||||
resolution: {integrity: sha512-6rpxTHPAQyWMb9A35BroFl1Sp0ST3DpPcm5EVIxZxdH+e0Hv9fwhyB3XLKFUcHNpdSDnETmBfuPPTTlYz5+USw==}
|
||||
|
||||
jose@6.0.8:
|
||||
resolution: {integrity: sha512-EyUPtOKyTYq+iMOszO42eobQllaIjJnwkZ2U93aJzNyPibCy7CEvT9UQnaCVB51IAd49gbNdCew1c0LcLTCB2g==}
|
||||
|
||||
jose@6.1.3:
|
||||
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
|
||||
|
||||
@@ -15100,11 +14977,6 @@ packages:
|
||||
jsonc-parser@3.2.1:
|
||||
resolution: {integrity: sha512-AilxAyFOAcK5wA1+LeaySVBrHsGQvUFCDWXKpZjzaL0PqW+xfBOttn8GNtWKFWqneyMZj41MWF9Kl6iPWLwgOA==}
|
||||
|
||||
jsondiffpatch@0.6.0:
|
||||
resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==}
|
||||
engines: {node: ^18.0.0 || >=20.0.0}
|
||||
hasBin: true
|
||||
|
||||
jsonfile@4.0.0:
|
||||
resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
|
||||
|
||||
@@ -19193,9 +19065,6 @@ packages:
|
||||
tinyexec@0.3.2:
|
||||
resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==}
|
||||
|
||||
tinyexec@1.0.1:
|
||||
resolution: {integrity: sha512-5uC6DDlmeqiOwCPmK9jMSdOuZTh8bU39Ys6yidB+UTt5hfZUPGAypSgFRiEp+jbi9qH40BLDvy85jIU88wKSqw==}
|
||||
|
||||
tinyexec@1.2.3:
|
||||
resolution: {integrity: sha512-g62dB+w1/OEFnPvmX0yd/HnetYITOL+1nJW7kitOycOeAvmbWC/nu0fwmmQ/kupNojqExzyC/T++pST/jRJ2mQ==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -20392,33 +20261,6 @@ snapshots:
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@1.0.6(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.3(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@2.0.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.12(zod@3.25.76)
|
||||
'@vercel/oidc': 3.0.3
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@3.0.104(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
'@vercel/oidc': 3.2.0
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@3.0.2(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
|
||||
'@vercel/oidc': 3.0.5
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/gateway@3.0.66(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
@@ -20496,13 +20338,6 @@ snapshots:
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.24.6(zod@3.25.76)
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.1(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@standard-schema/spec': 1.1.0
|
||||
eventsource-parser: 3.0.6
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/provider-utils@4.0.19(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
@@ -20533,38 +20368,14 @@ snapshots:
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/provider@3.0.0':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/provider@3.0.8':
|
||||
dependencies:
|
||||
json-schema: 0.4.0
|
||||
|
||||
'@ai-sdk/react@1.0.0(react@18.3.1)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
|
||||
'@ai-sdk/ui-utils': 1.0.0(zod@3.25.76)
|
||||
swr: 2.2.5(react@18.3.1)
|
||||
throttleit: 2.1.0
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/react@1.2.2(react@19.0.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
|
||||
'@ai-sdk/ui-utils': 1.2.1(zod@3.25.76)
|
||||
react: 19.0.0
|
||||
swr: 2.2.5(react@19.0.0)
|
||||
throttleit: 2.1.0
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/react@3.0.170(react@18.2.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
ai: 6.0.168(zod@3.25.76)
|
||||
ai: 6.0.116(zod@3.25.76)
|
||||
react: 18.2.0
|
||||
swr: 2.2.5(react@18.2.0)
|
||||
throttleit: 2.1.0
|
||||
@@ -20574,28 +20385,13 @@ snapshots:
|
||||
'@ai-sdk/react@3.0.170(react@19.1.0)(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
ai: 6.0.168(zod@3.25.76)
|
||||
ai: 6.0.116(zod@3.25.76)
|
||||
react: 19.1.0
|
||||
swr: 2.2.5(react@19.1.0)
|
||||
throttleit: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- zod
|
||||
|
||||
'@ai-sdk/ui-utils@1.0.0(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.0
|
||||
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
|
||||
zod-to-json-schema: 3.24.6(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@ai-sdk/ui-utils@1.2.1(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.0
|
||||
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
|
||||
zod: 3.25.76
|
||||
zod-to-json-schema: 3.24.6(zod@3.25.76)
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
@@ -20608,7 +20404,7 @@ snapshots:
|
||||
'@antfu/install-pkg@1.1.0':
|
||||
dependencies:
|
||||
package-manager-detector: 1.4.1
|
||||
tinyexec: 1.0.1
|
||||
tinyexec: 1.2.3
|
||||
|
||||
'@antfu/utils@9.3.0': {}
|
||||
|
||||
@@ -28170,8 +27966,8 @@ snapshots:
|
||||
dependencies:
|
||||
html-to-text: 9.0.5
|
||||
js-beautify: 1.15.1
|
||||
react: 18.3.1
|
||||
react-dom: 18.2.0(react@18.3.1)
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
'@react-email/row@0.0.7(react@18.3.1)':
|
||||
dependencies:
|
||||
@@ -28868,6 +28664,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@s2-dev/streamstore@0.22.5':
|
||||
dependencies:
|
||||
'@protobuf-ts/runtime': 2.11.1
|
||||
debug: 4.4.3(supports-color@10.0.0)
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@sec-ant/readable-stream@0.4.1': {}
|
||||
|
||||
'@selderee/plugin-htmlparser2@0.11.0':
|
||||
@@ -30350,11 +30153,69 @@ snapshots:
|
||||
|
||||
'@total-typescript/ts-reset@0.4.2': {}
|
||||
|
||||
'@trigger.dev/build@4.4.6(bufferutil@4.0.9)(typescript@5.5.4)':
|
||||
dependencies:
|
||||
'@prisma/config': 6.19.0(magicast@0.3.5)
|
||||
'@trigger.dev/core': 4.4.6(bufferutil@4.0.9)
|
||||
mlly: 1.7.4
|
||||
pkg-types: 1.1.3
|
||||
resolve: 1.22.8
|
||||
tinyglobby: 0.2.16
|
||||
tsconfck: 3.1.3(typescript@5.5.4)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- magicast
|
||||
- supports-color
|
||||
- typescript
|
||||
- utf-8-validate
|
||||
|
||||
'@trigger.dev/companyicons@1.5.35(react-dom@18.2.0(react@18.2.0))(react@18.2.0)':
|
||||
dependencies:
|
||||
react: 18.2.0
|
||||
react-dom: 18.2.0(react@18.2.0)
|
||||
|
||||
'@trigger.dev/core@4.4.6(bufferutil@4.0.9)':
|
||||
dependencies:
|
||||
'@bugsnag/cuid': 3.1.1
|
||||
'@electric-sql/client': 1.0.14
|
||||
'@google-cloud/precise-date': 4.0.0
|
||||
'@jsonhero/path': 1.0.21
|
||||
'@opentelemetry/api': 1.9.0
|
||||
'@opentelemetry/api-logs': 0.203.0
|
||||
'@opentelemetry/core': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-logs-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-metrics-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/exporter-trace-otlp-http': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/host-metrics': 0.37.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/instrumentation': 0.203.0(@opentelemetry/api@1.9.0)(supports-color@10.0.0)
|
||||
'@opentelemetry/resources': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-logs': 0.203.0(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-metrics': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-base': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/sdk-trace-node': 2.0.1(@opentelemetry/api@1.9.0)
|
||||
'@opentelemetry/semantic-conventions': 1.36.0
|
||||
'@s2-dev/streamstore': 0.22.5
|
||||
dequal: 2.0.3
|
||||
eventsource: 3.0.5
|
||||
eventsource-parser: 3.0.6
|
||||
execa: 8.0.1
|
||||
humanize-duration: 3.27.3
|
||||
jose: 5.4.0
|
||||
nanoid: 3.3.8
|
||||
prom-client: 15.1.0
|
||||
socket.io: 4.7.4(bufferutil@4.0.9)
|
||||
socket.io-client: 4.7.5(bufferutil@4.0.9)(supports-color@10.0.0)
|
||||
std-env: 3.10.0
|
||||
tinyexec: 0.3.2
|
||||
uncrypto: 0.1.3
|
||||
zod: 3.25.76
|
||||
zod-error: 1.5.0
|
||||
zod-validation-error: 1.5.0(zod@3.25.76)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@trigger.dev/platform@1.0.27':
|
||||
dependencies:
|
||||
zod: 3.23.8
|
||||
@@ -30548,8 +30409,6 @@ snapshots:
|
||||
|
||||
'@types/deep-eql@4.0.2': {}
|
||||
|
||||
'@types/diff-match-patch@1.0.36': {}
|
||||
|
||||
'@types/docker-modem@3.0.6':
|
||||
dependencies:
|
||||
'@types/node': 20.14.14
|
||||
@@ -31154,14 +31013,8 @@ snapshots:
|
||||
|
||||
'@vanilla-extract/private@1.0.3': {}
|
||||
|
||||
'@vercel/oidc@3.0.3': {}
|
||||
|
||||
'@vercel/oidc@3.0.5': {}
|
||||
|
||||
'@vercel/oidc@3.1.0': {}
|
||||
|
||||
'@vercel/oidc@3.2.0': {}
|
||||
|
||||
'@vercel/otel@1.13.0(@opentelemetry/api-logs@0.203.0)(@opentelemetry/api@1.9.0)(@opentelemetry/instrumentation@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.203.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.0.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.0.1(@opentelemetry/api@1.9.0))':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
@@ -31529,47 +31382,6 @@ snapshots:
|
||||
|
||||
ahocorasick@1.0.2: {}
|
||||
|
||||
ai@4.0.0(react@18.3.1)(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.0.0
|
||||
'@ai-sdk/provider-utils': 2.0.0(zod@3.25.76)
|
||||
'@ai-sdk/react': 1.0.0(react@18.3.1)(zod@3.25.76)
|
||||
'@ai-sdk/ui-utils': 1.0.0(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
jsondiffpatch: 0.6.0
|
||||
zod-to-json-schema: 3.24.6(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
react: 18.3.1
|
||||
zod: 3.25.76
|
||||
|
||||
ai@4.2.5(react@19.0.0)(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/provider': 1.1.0
|
||||
'@ai-sdk/provider-utils': 2.2.1(zod@3.25.76)
|
||||
'@ai-sdk/react': 1.2.2(react@19.0.0)(zod@3.25.76)
|
||||
'@ai-sdk/ui-utils': 1.2.1(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
jsondiffpatch: 0.6.0
|
||||
zod: 3.25.76
|
||||
optionalDependencies:
|
||||
react: 19.0.0
|
||||
|
||||
ai@5.0.14(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 1.0.6(zod@3.25.76)
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.3(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ai@5.0.76(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 2.0.0(zod@3.25.76)
|
||||
'@ai-sdk/provider': 2.0.0
|
||||
'@ai-sdk/provider-utils': 3.0.12(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ai@6.0.116(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.66(zod@3.25.76)
|
||||
@@ -31578,22 +31390,6 @@ snapshots:
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ai@6.0.168(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.104(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.8
|
||||
'@ai-sdk/provider-utils': 4.0.23(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ai@6.0.3(zod@3.25.76):
|
||||
dependencies:
|
||||
'@ai-sdk/gateway': 3.0.2(zod@3.25.76)
|
||||
'@ai-sdk/provider': 3.0.0
|
||||
'@ai-sdk/provider-utils': 4.0.1(zod@3.25.76)
|
||||
'@opentelemetry/api': 1.9.0
|
||||
zod: 3.25.76
|
||||
|
||||
ajv-formats@2.1.1(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
@@ -33201,8 +32997,6 @@ snapshots:
|
||||
|
||||
didyoumean@1.2.2: {}
|
||||
|
||||
diff-match-patch@1.0.5: {}
|
||||
|
||||
diff@5.1.0: {}
|
||||
|
||||
dir-glob@3.0.1:
|
||||
@@ -34217,7 +34011,7 @@ snapshots:
|
||||
dependencies:
|
||||
require-like: 0.1.2
|
||||
|
||||
evalite@1.0.0-beta.16(ai@6.0.168(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9):
|
||||
evalite@1.0.0-beta.16(ai@6.0.116(zod@3.25.76))(better-sqlite3@11.10.0)(bufferutil@4.0.9):
|
||||
dependencies:
|
||||
'@fastify/static': 8.2.0
|
||||
'@fastify/websocket': 11.2.0(bufferutil@4.0.9)
|
||||
@@ -34234,7 +34028,7 @@ snapshots:
|
||||
table: 6.9.0
|
||||
tinyrainbow: 3.1.0
|
||||
optionalDependencies:
|
||||
ai: 6.0.168(zod@3.25.76)
|
||||
ai: 6.0.116(zod@3.25.76)
|
||||
better-sqlite3: 11.10.0
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -35784,8 +35578,6 @@ snapshots:
|
||||
|
||||
jose@5.4.0: {}
|
||||
|
||||
jose@6.0.8: {}
|
||||
|
||||
jose@6.1.3: {}
|
||||
|
||||
joycon@3.1.1: {}
|
||||
@@ -35869,12 +35661,6 @@ snapshots:
|
||||
|
||||
jsonc-parser@3.2.1: {}
|
||||
|
||||
jsondiffpatch@0.6.0:
|
||||
dependencies:
|
||||
'@types/diff-match-patch': 1.0.36
|
||||
chalk: 5.3.0
|
||||
diff-match-patch: 1.0.5
|
||||
|
||||
jsonfile@4.0.0:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -36278,8 +36064,8 @@ snapshots:
|
||||
|
||||
magicast@0.3.5:
|
||||
dependencies:
|
||||
'@babel/parser': 7.27.5
|
||||
'@babel/types': 7.27.3
|
||||
'@babel/parser': 7.29.7
|
||||
'@babel/types': 7.29.7
|
||||
source-map-js: 1.2.1
|
||||
optional: true
|
||||
|
||||
@@ -37695,7 +37481,7 @@ snapshots:
|
||||
consola: 3.4.2
|
||||
pathe: 2.0.3
|
||||
pkg-types: 2.3.0
|
||||
tinyexec: 1.0.1
|
||||
tinyexec: 1.2.3
|
||||
|
||||
oauth-sign@0.9.0: {}
|
||||
|
||||
@@ -37909,7 +37695,7 @@ snapshots:
|
||||
|
||||
openid-client@6.3.3:
|
||||
dependencies:
|
||||
jose: 6.0.8
|
||||
jose: 6.1.3
|
||||
oauth4webapi: 3.3.0
|
||||
|
||||
optionator@0.9.1:
|
||||
@@ -40805,12 +40591,6 @@ snapshots:
|
||||
react: 18.3.1
|
||||
use-sync-external-store: 1.2.2(react@18.3.1)
|
||||
|
||||
swr@2.2.5(react@19.0.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
react: 19.0.0
|
||||
use-sync-external-store: 1.2.2(react@19.0.0)
|
||||
|
||||
swr@2.2.5(react@19.1.0):
|
||||
dependencies:
|
||||
client-only: 0.0.1
|
||||
@@ -41140,8 +40920,6 @@ snapshots:
|
||||
|
||||
tinyexec@0.3.2: {}
|
||||
|
||||
tinyexec@1.0.1: {}
|
||||
|
||||
tinyexec@1.2.3: {}
|
||||
|
||||
tinyglobby@0.2.10:
|
||||
@@ -41795,10 +41573,6 @@ snapshots:
|
||||
dependencies:
|
||||
react: 18.3.1
|
||||
|
||||
use-sync-external-store@1.2.2(react@19.0.0):
|
||||
dependencies:
|
||||
react: 19.0.0
|
||||
|
||||
use-sync-external-store@1.2.2(react@19.1.0):
|
||||
dependencies:
|
||||
react: 19.1.0
|
||||
@@ -41988,11 +41762,11 @@ snapshots:
|
||||
vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@3.12.2)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.1
|
||||
fdir: 6.4.4(picomatch@4.0.4)
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.10
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.13
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 20.14.14
|
||||
fsevents: 2.3.3
|
||||
@@ -42005,11 +41779,11 @@ snapshots:
|
||||
vite@6.4.2(@types/node@20.14.14)(jiti@2.6.1)(lightningcss@1.29.2)(terser@5.44.1)(tsx@4.20.6)(yaml@2.8.3):
|
||||
dependencies:
|
||||
esbuild: 0.25.1
|
||||
fdir: 6.4.4(picomatch@4.0.4)
|
||||
fdir: 6.5.0(picomatch@4.0.4)
|
||||
picomatch: 4.0.4
|
||||
postcss: 8.5.10
|
||||
rollup: 4.60.1
|
||||
tinyglobby: 0.2.13
|
||||
tinyglobby: 0.2.16
|
||||
optionalDependencies:
|
||||
'@types/node': 20.14.14
|
||||
fsevents: 2.3.3
|
||||
|
||||
Reference in New Issue
Block a user