Sync suggestion branch with base branch

This commit is contained in:
promptless[bot] 2026-04-13 18:41:43 +00:00
commit 00dec8ad81
11 changed files with 1784 additions and 537 deletions

View File

@ -0,0 +1,29 @@
-- CreateTable
CREATE TABLE "AiConversation" (
"id" UUID NOT NULL,
"projectUserId" UUID NOT NULL,
"projectId" TEXT NOT NULL REFERENCES "Project"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"title" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "AiConversation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "AiMessage" (
"id" UUID NOT NULL,
"conversationId" UUID NOT NULL REFERENCES "AiConversation"("id") ON DELETE CASCADE ON UPDATE CASCADE,
"position" INTEGER NOT NULL,
"role" TEXT NOT NULL,
"content" JSONB NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AiMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "AiConversation_projectUserId_projectId_updatedAt_idx" ON "AiConversation"("projectUserId", "projectId", "updatedAt" DESC);
-- CreateIndex
CREATE INDEX "AiMessage_conversationId_position_idx" ON "AiMessage"("conversationId", "position" ASC);

View File

@ -43,6 +43,7 @@ model Project {
branchConfigOverrides BranchConfigOverride[]
environmentConfigOverrides EnvironmentConfigOverride[]
localEmulatorProject LocalEmulatorProject?
aiConversations AiConversation[]
@@index([ownerTeamId], map: "Project_ownerTeamId_idx")
}
@ -1143,6 +1144,31 @@ model ThreadMessage {
@@id([tenancyId, id])
}
model AiConversation {
id String @id @default(uuid()) @db.Uuid
projectUserId String @db.Uuid
projectId String
project Project @relation(fields: [projectId], references: [id], onDelete: Cascade)
title String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
messages AiMessage[]
@@index([projectUserId, projectId, updatedAt(sort: Desc)])
}
model AiMessage {
id String @id @default(uuid()) @db.Uuid
conversationId String @db.Uuid
position Int
role String
content Json
createdAt DateTime @default(now())
conversation AiConversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
@@index([conversationId, position])
}
enum CustomerType {
USER
TEAM

View File

@ -0,0 +1,67 @@
import { globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getOwnedConversation } from "../../utils";
export const PUT = createSmartRouteHandler({
metadata: {
summary: "Replace conversation messages",
description: "Replace all messages in a conversation",
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
user: adaptSchema.defined(),
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
params: yupObject({
conversationId: yupString().defined(),
}),
body: yupObject({
messages: yupArray(
yupObject({
role: yupString().oneOf(["user", "assistant"]).defined(),
content: yupMixed().defined(),
})
).defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({}).defined(),
}),
handler: async ({ auth, params, body }) => {
await getOwnedConversation(params.conversationId, auth.user.id);
await globalPrismaClient.$executeRaw`
WITH input AS (
SELECT
(ord - 1)::int AS position,
(elem->>'role')::text AS role,
elem->'content' AS content
FROM jsonb_array_elements(${JSON.stringify(body.messages)}::jsonb)
WITH ORDINALITY AS t(elem, ord)
),
deleted AS (
DELETE FROM "AiMessage" WHERE "conversationId" = ${params.conversationId}::uuid
),
inserted AS (
INSERT INTO "AiMessage" ("id", "conversationId", "position", "role", "content")
SELECT gen_random_uuid(), ${params.conversationId}::uuid, position, role, content
FROM input
)
UPDATE "AiConversation"
SET "updatedAt" = NOW()
WHERE "id" = ${params.conversationId}::uuid
`;
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {},
};
},
});

View File

@ -0,0 +1,140 @@
import { globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { getOwnedConversation } from "../utils";
export const GET = createSmartRouteHandler({
metadata: {
summary: "Get AI conversation",
description: "Fetch a single AI conversation with all its messages",
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
user: adaptSchema.defined(),
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
params: yupObject({
conversationId: yupString().defined(),
}),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
title: yupString().defined(),
projectId: yupString().defined(),
messages: yupArray(yupObject({
id: yupString().defined(),
role: yupString().defined(),
content: yupMixed().defined(),
}).noUnknown(false)).defined(),
}).defined(),
}),
handler: async ({ auth, params }) => {
const conversation = await getOwnedConversation(params.conversationId, auth.user.id);
const messages = await globalPrismaClient.aiMessage.findMany({
where: { conversationId: conversation.id },
orderBy: { position: "asc" },
select: {
id: true,
role: true,
content: true,
},
});
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {
id: conversation.id,
title: conversation.title,
projectId: conversation.projectId,
messages: messages.map(m => ({ ...m, content: m.content as object })),
},
};
},
});
export const PATCH = createSmartRouteHandler({
metadata: {
summary: "Update AI conversation",
description: "Update the title of an AI conversation",
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
user: adaptSchema.defined(),
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
params: yupObject({
conversationId: yupString().defined(),
}),
body: yupObject({
title: yupString().defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({}).defined(),
}),
handler: async ({ auth, params, body }) => {
await getOwnedConversation(params.conversationId, auth.user.id);
await globalPrismaClient.aiConversation.update({
where: { id: params.conversationId },
data: { title: body.title },
});
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {},
};
},
});
export const DELETE = createSmartRouteHandler({
metadata: {
summary: "Delete AI conversation",
description: "Delete an AI conversation and all its messages",
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
user: adaptSchema.defined(),
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
params: yupObject({
conversationId: yupString().defined(),
}),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({}).defined(),
}),
handler: async ({ auth, params }) => {
await getOwnedConversation(params.conversationId, auth.user.id);
await globalPrismaClient.aiConversation.delete({
where: { id: params.conversationId },
});
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {},
};
},
});

View File

@ -0,0 +1,127 @@
import { globalPrismaClient, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
export const GET = createSmartRouteHandler({
metadata: {
summary: "List AI conversations",
description: "List AI conversations for the current user filtered by project",
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
user: adaptSchema.defined(),
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
query: yupObject({
projectId: yupString().defined(),
}),
method: yupString().oneOf(["GET"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
conversations: yupArray(yupObject({
id: yupString().defined(),
title: yupString().defined(),
projectId: yupString().defined(),
updatedAt: yupString().defined(),
}).noUnknown(false)).defined(),
}).defined(),
}),
handler: async ({ auth, query }) => {
const conversations = await globalPrismaClient.aiConversation.findMany({
where: {
projectUserId: auth.user.id,
projectId: query.projectId,
},
orderBy: { updatedAt: "desc" },
take: 50,
select: {
id: true,
title: true,
projectId: true,
updatedAt: true,
},
});
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: {
conversations: conversations.map(c => ({
...c,
updatedAt: c.updatedAt.toISOString(),
})),
},
};
},
});
export const POST = createSmartRouteHandler({
metadata: {
summary: "Create AI conversation",
description: "Create a new AI conversation with optional initial messages",
},
request: yupObject({
auth: yupObject({
type: adaptSchema,
user: adaptSchema.defined(),
project: yupObject({
id: yupString().oneOf(["internal"]).defined(),
}).defined(),
}).defined(),
body: yupObject({
title: yupString().defined(),
projectId: yupString().defined(),
messages: yupArray(
yupObject({
role: yupString().oneOf(["user", "assistant"]).defined(),
content: yupMixed().defined(),
})
).defined(),
}),
method: yupString().oneOf(["POST"]).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
id: yupString().defined(),
title: yupString().defined(),
}).defined(),
}),
handler: async ({ auth, body }) => {
const conversation = await retryTransaction(globalPrismaClient, async (tx) => {
const conv = await tx.aiConversation.create({
data: {
projectUserId: auth.user.id,
title: body.title,
projectId: body.projectId,
},
});
if (body.messages.length > 0) {
await tx.aiMessage.createMany({
data: body.messages.map((msg, index) => ({
conversationId: conv.id,
position: index,
role: msg.role,
content: msg.content as object,
})),
});
}
return conv;
});
return {
statusCode: 200 as const,
bodyType: "json" as const,
body: { id: conversation.id, title: conversation.title },
};
},
});

View File

@ -0,0 +1,12 @@
import { globalPrismaClient } from "@/prisma-client";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
export async function getOwnedConversation(conversationId: string, userId: string) {
const conversation = await globalPrismaClient.aiConversation.findUnique({
where: { id: conversationId },
});
if (!conversation || conversation.projectUserId !== userId) {
throw new StatusError(StatusError.NotFound, "Conversation not found");
}
return conversation;
}

View File

@ -0,0 +1,534 @@
import { cn } from "@/components/ui";
import { buildStackAuthHeaders, type CurrentUser } from "@/lib/api-headers";
import { getPublicEnvVar } from "@/lib/env";
import type { UIMessage } from "@ai-sdk/react";
import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { convertToModelMessages, DefaultChatTransport } from "ai";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
export function createAskAiTransport({
currentUser,
projectId,
}: {
currentUser: CurrentUser | null,
projectId: string | undefined,
}): DefaultChatTransport<UIMessage> {
const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set");
return new DefaultChatTransport<UIMessage>({
api: `${backendBaseUrl}/api/latest/ai/query/stream`,
headers: () => buildStackAuthHeaders(currentUser),
prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => {
const modelMessages = await convertToModelMessages(uiMessages);
return {
body: {
systemPrompt: "command-center-ask-ai",
tools: ["docs", "sql-query"],
quality: "smart",
speed: "slow",
projectId,
messages: modelMessages.map(m => ({
role: m.role,
content: m.content,
})),
},
headers,
};
},
});
}
// Memoized copy button for performance
export const CopyButton = memo(function CopyButton({ text, className, size = "sm" }: {
text: string,
className?: string,
size?: "sm" | "xs",
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [text]);
const iconSize = size === "xs" ? "h-2.5 w-2.5" : "h-3 w-3";
return (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
runAsynchronously(handleCopy());
}}
className={cn(
"shrink-0 rounded transition-colors hover:transition-none",
size === "xs" ? "p-0.5" : "p-1",
copied
? "text-green-400"
: "text-muted-foreground/50 hover:text-muted-foreground hover:bg-foreground/[0.08]",
className
)}
title={copied ? "Copied!" : "Copy"}
type="button"
>
{copied ? <CheckIcon className={iconSize} /> : <CopyIcon className={iconSize} />}
</button>
);
});
// Truncate URL for display while keeping full URL for copy
export function truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
const match = url.match(/^https?:\/\/([^/?#]+)(\/[^?#]*)?(\?[^#]*)?(#.*)?$/);
if (match) {
const host = match[1];
const path = (match[2] || "") + (match[3] || "");
if (path.length > 30) {
return host + path.slice(0, 20) + "..." + path.slice(-10);
}
}
return url.slice(0, maxLength - 3) + "...";
}
// Inline code with smart copy button
export const InlineCode = memo(function InlineCode({ children }: { children?: React.ReactNode }) {
const text = String(children || "");
const isUrl = /^https?:\/\//.test(text);
const isCommand = /^(npm|npx|pnpm|yarn|curl|git|docker|cd|mkdir|ls|brew|apt|pip)/.test(text);
const isPath = /^[./~]/.test(text) && text.includes("/");
const showCopy = isUrl || isCommand || isPath || text.length > 15;
// For very long URLs, show truncated version
const displayText = isUrl && text.length > 60 ? truncateUrl(text, 55) : text;
return (
<code className={cn(
"inline-flex items-center gap-1 max-w-full rounded px-1.5 py-0.5",
"bg-foreground/[0.06] text-[11px] font-mono leading-relaxed",
"break-all"
)}>
<span className={cn(
"min-w-0",
isUrl ? "text-blue-400" : "text-foreground/90"
)}>
{displayText}
</span>
{showCopy && <CopyButton text={text} size="xs" />}
</code>
);
});
// Code block with language label and copy
export const CodeBlock = memo(function CodeBlock({ children, className }: {
children?: React.ReactNode,
className?: string,
}) {
const text = String(children || "").replace(/\n$/, "");
const language = className?.replace("language-", "").toUpperCase() ?? "";
return (
<div className="relative group my-2.5 rounded-lg bg-foreground/[0.04] ring-1 ring-foreground/[0.06] overflow-hidden">
{/* Header with language and copy */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-foreground/[0.06] bg-foreground/[0.02]">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
{language || "CODE"}
</span>
<CopyButton text={text} size="xs" />
</div>
{/* Code content */}
<div className="overflow-x-auto">
<pre className="p-3 text-[11px] font-mono leading-relaxed">
<code className="text-foreground/90">{children}</code>
</pre>
</div>
</div>
);
});
// Smart link component with copy and external icon
export const SmartLink = memo(function SmartLink({ href, children }: {
href?: string,
children?: React.ReactNode,
}) {
const displayText = String(children || href || "");
const isFullUrl = href?.startsWith("http");
const isDocsLink = href?.includes("docs.stack-auth.com");
// Truncate long URLs for display
const truncatedDisplay = displayText.length > 55 ? truncateUrl(displayText, 50) : displayText;
return (
<span className="inline-flex items-center gap-1 max-w-full">
<a
href={href}
className={cn(
"inline-flex items-center gap-1 text-blue-400 hover:text-blue-300",
"hover:underline underline-offset-2 transition-colors hover:transition-none",
"break-all"
)}
target="_blank"
rel="noopener noreferrer"
>
<span className="min-w-0">{truncatedDisplay}</span>
{isFullUrl && !isDocsLink && (
<ArrowSquareOutIcon className="shrink-0 h-2.5 w-2.5 opacity-60" />
)}
</a>
{isFullUrl && href && <CopyButton text={href} size="xs" />}
</span>
);
});
export type ToolInvocationPart = Extract<UIMessage["parts"][number], { type: `tool-${string}` }>;
// Expandable tool invocation card
export const ToolInvocationCard = memo(function ToolInvocationCard({
invocation,
}: {
invocation: ToolInvocationPart,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const isLoading = invocation.state === "input-streaming" || invocation.state === "input-available";
const hasResult = invocation.state === "output-available";
const hasError = invocation.state === "output-error";
// Extract tool name from type (e.g., "tool-queryAnalytics" → "queryAnalytics")
const toolName = invocation.type.replace(/^tool-/, "");
// Format the tool name for display
const getToolDisplay = () => {
if (toolName === "queryAnalytics") {
return { label: "Analytics Query", icon: DatabaseIcon };
}
return { label: toolName, icon: DatabaseIcon };
};
const { label, icon: Icon } = getToolDisplay();
const input = invocation.input as { query?: string };
const queryArg = input.query;
const result = invocation.output as { success?: boolean, result?: unknown[], error?: string, rowCount?: number };
return (
<div
className={cn(
"my-2 rounded-lg overflow-hidden transition-all duration-200 ease-out",
"bg-foreground/[0.03] ring-1 ring-foreground/[0.08]",
isExpanded && "ring-purple-500/20"
)}
>
{/* Header - always visible */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left",
"hover:bg-foreground/[0.02] transition-colors"
)}
>
<Icon className="h-3.5 w-3.5 text-purple-400 shrink-0" />
<span className="text-[12px] font-medium text-foreground/80 flex-1">
{label}
</span>
{isLoading ? (
<SpinnerGapIcon className="h-3 w-3 text-purple-400 animate-spin shrink-0" />
) : hasError ? (
<span className="text-[10px] text-red-400/80 shrink-0">Error</span>
) : hasResult && result.success ? (
<span className="text-[10px] text-green-400/80 shrink-0">
{result.rowCount} {result.rowCount === 1 ? "row" : "rows"}
</span>
) : hasResult && !result.success ? (
<span className="text-[10px] text-red-400/80 shrink-0">Error</span>
) : null}
<div className={cn(
"transition-transform duration-200",
isExpanded && "rotate-0",
!isExpanded && "-rotate-90"
)}>
<CaretDownIcon className="h-3 w-3 text-muted-foreground/50" />
</div>
</button>
{/* Expandable content */}
<div
className={cn(
"grid transition-all duration-200 ease-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
>
<div className="overflow-hidden">
<div className="px-3 pb-3 pt-1 space-y-2 border-t border-foreground/[0.06]">
{/* Query */}
{queryArg && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
Query
</span>
<CopyButton text={queryArg} size="xs" />
</div>
<pre className="text-[10px] font-mono text-foreground/70 bg-foreground/[0.03] rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{queryArg}
</pre>
</div>
)}
{/* Result */}
{hasResult && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
{result.success ? "Result" : "Error"}
</span>
{result.success && result.result && (
<CopyButton text={JSON.stringify(result.result, null, 2)} size="xs" />
)}
</div>
{result.success ? (
<pre className="text-[10px] font-mono text-foreground/70 bg-foreground/[0.03] rounded px-2 py-1.5 overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(result.result, null, 2)}
</pre>
) : (
<div className="text-[11px] text-red-400/90 bg-red-500/[0.08] rounded px-2 py-1.5">
{result.error || "Query failed"}
</div>
)}
</div>
)}
{/* Error state from SDK */}
{hasError && invocation.errorText && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
Error
</span>
</div>
<div className="text-[11px] text-red-400/90 bg-red-500/[0.08] rounded px-2 py-1.5">
{invocation.errorText}
</div>
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/60 py-1">
<SpinnerGapIcon className="h-3 w-3 animate-spin" />
<span>Running query...</span>
</div>
)}
</div>
</div>
</div>
</div>
);
});
// Memoized markdown components for consistent rendering
export const markdownComponents = {
p: ({ children }: { children?: React.ReactNode }) => (
<p className="text-[13px] text-foreground/90 mb-2.5 last:mb-0 leading-relaxed">
{children}
</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="text-[13px] text-foreground/90 mb-2.5 pl-4 space-y-1 list-disc marker:text-muted-foreground/40">
{children}
</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="text-[13px] text-foreground/90 mb-2.5 pl-4 space-y-1.5 list-decimal marker:text-muted-foreground/60">
{children}
</ol>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-relaxed pl-0.5">{children}</li>
),
code: ({ children, className }: { children?: React.ReactNode, className?: string }) => {
if (className) {
return <CodeBlock className={className}>{children}</CodeBlock>;
}
return <InlineCode>{children}</InlineCode>;
},
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => (
<em className="italic text-foreground/80">{children}</em>
),
a: SmartLink,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-2.5 rounded-lg ring-1 ring-foreground/[0.08]">
<table className="w-full text-[11px]">{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className="bg-foreground/[0.04] border-b border-foreground/[0.08]">{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => (
<tbody className="divide-y divide-foreground/[0.04]">{children}</tbody>
),
tr: ({ children }: { children?: React.ReactNode }) => <tr>{children}</tr>,
th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-2.5 py-1.5 text-left font-semibold text-foreground/90 whitespace-nowrap">
{children}
</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-2.5 py-1.5 text-foreground/70">{children}</td>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="text-base font-semibold text-foreground mt-3 mb-2 first:mt-0">{children}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="text-[13px] font-semibold text-foreground mt-3 mb-1.5 first:mt-0">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-[13px] font-semibold text-foreground mt-2.5 mb-1 first:mt-0">{children}</h3>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-2 border-purple-500/40 pl-3 my-2 text-foreground/70 italic">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-foreground/[0.06]" />,
};
// Helper to count words in a string
export function countWords(text: string): number {
return text.split(/\s+/).filter(Boolean).length;
}
// Helper to get first N words from text
export function getFirstNWords(text: string, n: number): string {
const words = text.split(/(\s+)/); // Split but keep whitespace
let wordCount = 0;
let result = "";
for (const part of words) {
if (part.trim()) {
wordCount++;
if (wordCount > n) break;
}
result += part;
}
return result;
}
// Helper to extract text content from UIMessage parts
export function getMessageContent(message: UIMessage): string {
return message.parts
.filter((part): part is { type: "text", text: string } => part.type === "text")
.map(part => part.text)
.join("");
}
// Helper to extract tool invocations from UIMessage parts
export function getToolInvocations(message: UIMessage): ToolInvocationPart[] {
return message.parts.filter(
(part): part is ToolInvocationPart => part.type.startsWith("tool-")
);
}
// Memoized user message component
export const UserMessage = memo(function UserMessage({ content }: { content: string }) {
return (
<div className="flex gap-2.5 justify-end">
<div className="min-w-0 rounded-xl px-3.5 py-2 max-w-[80%] bg-blue-500/10 text-foreground">
<p className="text-[13px] leading-relaxed break-words">{content}</p>
</div>
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-blue-500/10 flex items-center justify-center">
<UserIcon className="h-3 w-3 text-blue-400" />
</div>
</div>
);
});
// Memoized assistant message component
export const AssistantMessage = memo(function AssistantMessage({
content,
toolInvocations,
}: {
content: string,
toolInvocations?: ToolInvocationPart[],
}) {
const hasToolInvocations = toolInvocations && toolInvocations.length > 0;
const hasContent = content.trim().length > 0;
return (
<div className="flex gap-2.5 justify-start">
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-purple-500/10 flex items-center justify-center">
<SparkleIcon className="h-3 w-3 text-purple-400" />
</div>
<div className="min-w-0 max-w-[calc(100%-2rem)] flex flex-col gap-1">
{/* Tool invocations */}
{hasToolInvocations && (
<div className="space-y-1">
{toolInvocations.map((invocation) => (
<ToolInvocationCard
key={invocation.toolCallId}
invocation={invocation}
/>
))}
</div>
)}
{/* Text content */}
{hasContent && (
<div className="rounded-xl px-3.5 py-2 bg-foreground/[0.02]">
<div className="min-w-0 overflow-hidden">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
</div>
)}
</div>
</div>
);
});
// Word streaming hook - handles the progressive word reveal animation
export function useWordStreaming(content: string) {
const [displayedWordCount, setDisplayedWordCount] = useState(0);
const targetWordCount = content ? countWords(content) : 0;
const previousContentRef = useRef("");
useEffect(() => {
if (!content) {
setDisplayedWordCount(0);
previousContentRef.current = "";
return;
}
if (!content.startsWith(previousContentRef.current)) {
setDisplayedWordCount(0);
}
previousContentRef.current = content;
}, [content]);
useEffect(() => {
if (targetWordCount === 0 || displayedWordCount >= targetWordCount) {
return;
}
const timeoutId = setTimeout(() => {
setDisplayedWordCount(prev => Math.min(prev + 1, targetWordCount));
}, 15);
return () => clearTimeout(timeoutId);
}, [displayedWordCount, targetWordCount]);
return {
displayedWordCount,
targetWordCount,
getDisplayContent: (text: string) => getFirstNWords(text, displayedWordCount),
isRevealing: displayedWordCount < targetWordCount,
};
}

View File

@ -1,520 +1,22 @@
import { cn } from "@/components/ui";
import { useDebouncedAction } from "@/hooks/use-debounced-action";
import { buildStackAuthHeaders } from "@/lib/api-headers";
import { getPublicEnvVar } from "@/lib/env";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { ArrowSquareOutIcon, CaretDownIcon, CheckIcon, CopyIcon, DatabaseIcon, PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon, UserIcon } from "@phosphor-icons/react";
import { PaperPlaneTiltIcon, SparkleIcon, SpinnerGapIcon } from "@phosphor-icons/react";
import { useUser } from "@stackframe/stack";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";
import { convertToModelMessages, DefaultChatTransport } from "ai";
import { usePathname } from "next/navigation";
import { memo, useCallback, useEffect, useRef, useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { CmdKPreviewProps } from "../cmdk-commands";
import {
AssistantMessage,
createAskAiTransport,
getMessageContent,
getToolInvocations,
UserMessage,
useWordStreaming,
} from "./ai-chat-shared";
// Memoized copy button for performance
const CopyButton = memo(function CopyButton({ text, className, size = "sm" }: {
text: string,
className?: string,
size?: "sm" | "xs",
}) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [text]);
const iconSize = size === "xs" ? "h-2.5 w-2.5" : "h-3 w-3";
return (
<button
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
runAsynchronously(handleCopy());
}}
className={cn(
"shrink-0 rounded transition-colors hover:transition-none",
size === "xs" ? "p-0.5" : "p-1",
copied
? "text-green-400"
: "text-muted-foreground/50 hover:text-muted-foreground hover:bg-foreground/[0.08]",
className
)}
title={copied ? "Copied!" : "Copy"}
type="button"
>
{copied ? <CheckIcon className={iconSize} /> : <CopyIcon className={iconSize} />}
</button>
);
});
// Truncate URL for display while keeping full URL for copy
function truncateUrl(url: string, maxLength = 50): string {
if (url.length <= maxLength) return url;
try {
const urlObj = new URL(url);
const path = urlObj.pathname + urlObj.search;
if (path.length > 30) {
return urlObj.host + path.slice(0, 20) + "..." + path.slice(-10);
}
return url.slice(0, maxLength - 3) + "...";
} catch {
return url.slice(0, maxLength - 3) + "...";
}
}
// Inline code with smart copy button
const InlineCode = memo(function InlineCode({ children }: { children?: React.ReactNode }) {
const text = String(children || "");
const isUrl = /^https?:\/\//.test(text);
const isCommand = /^(npm|npx|pnpm|yarn|curl|git|docker|cd|mkdir|ls|brew|apt|pip)/.test(text);
const isPath = /^[./~]/.test(text) && text.includes("/");
const showCopy = isUrl || isCommand || isPath || text.length > 15;
// For very long URLs, show truncated version
const displayText = isUrl && text.length > 60 ? truncateUrl(text, 55) : text;
return (
<code className={cn(
"inline-flex items-center gap-1 max-w-full rounded px-1.5 py-0.5",
"bg-foreground/[0.06] text-[11px] font-mono leading-relaxed",
"break-all"
)}>
<span className={cn(
"min-w-0",
isUrl ? "text-blue-400" : "text-foreground/90"
)}>
{displayText}
</span>
{showCopy && <CopyButton text={text} size="xs" />}
</code>
);
});
// Code block with language label and copy
const CodeBlock = memo(function CodeBlock({ children, className }: {
children?: React.ReactNode,
className?: string,
}) {
const text = String(children || "").replace(/\n$/, "");
const language = className?.replace("language-", "").toUpperCase() ?? "";
return (
<div className="relative group my-2.5 rounded-lg bg-foreground/[0.04] ring-1 ring-foreground/[0.06] overflow-hidden">
{/* Header with language and copy */}
<div className="flex items-center justify-between px-3 py-1.5 border-b border-foreground/[0.06] bg-foreground/[0.02]">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
{language || "CODE"}
</span>
<CopyButton text={text} size="xs" />
</div>
{/* Code content */}
<div className="overflow-x-auto">
<pre className="p-3 text-[11px] font-mono leading-relaxed">
<code className="text-foreground/90">{children}</code>
</pre>
</div>
</div>
);
});
// Smart link component with copy and external icon
const SmartLink = memo(function SmartLink({ href, children }: {
href?: string,
children?: React.ReactNode,
}) {
const displayText = String(children || href || "");
const isFullUrl = href?.startsWith("http");
const isDocsLink = href?.includes("docs.stack-auth.com");
// Truncate long URLs for display
const truncatedDisplay = displayText.length > 55 ? truncateUrl(displayText, 50) : displayText;
return (
<span className="inline-flex items-center gap-1 max-w-full">
<a
href={href}
className={cn(
"inline-flex items-center gap-1 text-blue-400 hover:text-blue-300",
"hover:underline underline-offset-2 transition-colors hover:transition-none",
"break-all"
)}
target="_blank"
rel="noopener noreferrer"
>
<span className="min-w-0">{truncatedDisplay}</span>
{isFullUrl && !isDocsLink && (
<ArrowSquareOutIcon className="shrink-0 h-2.5 w-2.5 opacity-60" />
)}
</a>
{isFullUrl && href && <CopyButton text={href} size="xs" />}
</span>
);
});
// Tool invocation type from AI SDK (matches the actual UIMessage part structure)
type ToolInvocationPart = {
type: `tool-${string}`,
toolCallId: string,
state: "input-streaming" | "input-available" | "output-available" | "output-error" | "approval-requested" | "approval-responded" | "output-denied",
input: unknown,
output?: unknown,
errorText?: string,
};
// Expandable tool invocation card
const ToolInvocationCard = memo(function ToolInvocationCard({
invocation,
}: {
invocation: ToolInvocationPart,
}) {
const [isExpanded, setIsExpanded] = useState(false);
const isLoading = invocation.state === "input-streaming" || invocation.state === "input-available";
const hasResult = invocation.state === "output-available";
const hasError = invocation.state === "output-error";
// Extract tool name from type (e.g., "tool-queryAnalytics" → "queryAnalytics")
const toolName = invocation.type.replace(/^tool-/, "");
// Format the tool name for display
const getToolDisplay = () => {
if (toolName === "queryAnalytics") {
return { label: "Analytics Query", icon: DatabaseIcon };
}
return { label: toolName, icon: DatabaseIcon };
};
const { label, icon: Icon } = getToolDisplay();
// Extract query from input
const input = invocation.input as { query?: string } | undefined;
const queryArg = input?.query;
const result = invocation.output as { success?: boolean, result?: unknown[], error?: string, rowCount?: number } | undefined;
return (
<div
className={cn(
"my-2 rounded-lg overflow-hidden transition-all duration-200 ease-out",
"bg-foreground/[0.03] ring-1 ring-foreground/[0.08]",
isExpanded && "ring-purple-500/20"
)}
>
{/* Header - always visible */}
<button
type="button"
onClick={() => setIsExpanded(!isExpanded)}
className={cn(
"w-full flex items-center gap-2 px-3 py-2 text-left",
"hover:bg-foreground/[0.02] transition-colors"
)}
>
<Icon className="h-3.5 w-3.5 text-purple-400 shrink-0" />
<span className="text-[12px] font-medium text-foreground/80 flex-1">
{label}
</span>
{isLoading ? (
<SpinnerGapIcon className="h-3 w-3 text-purple-400 animate-spin shrink-0" />
) : hasError ? (
<span className="text-[10px] text-red-400/80 shrink-0">Error</span>
) : hasResult && result?.success ? (
<span className="text-[10px] text-green-400/80 shrink-0">
{result.rowCount} {result.rowCount === 1 ? "row" : "rows"}
</span>
) : hasResult && !result?.success ? (
<span className="text-[10px] text-red-400/80 shrink-0">Error</span>
) : null}
<div className={cn(
"transition-transform duration-200",
isExpanded && "rotate-0",
!isExpanded && "-rotate-90"
)}>
<CaretDownIcon className="h-3 w-3 text-muted-foreground/50" />
</div>
</button>
{/* Expandable content */}
<div
className={cn(
"grid transition-all duration-200 ease-out",
isExpanded ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
)}
>
<div className="overflow-hidden">
<div className="px-3 pb-3 pt-1 space-y-2 border-t border-foreground/[0.06]">
{/* Query */}
{queryArg && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
Query
</span>
<CopyButton text={queryArg} size="xs" />
</div>
<pre className="text-[10px] font-mono text-foreground/70 bg-foreground/[0.03] rounded px-2 py-1.5 overflow-x-auto whitespace-pre-wrap break-all">
{queryArg}
</pre>
</div>
)}
{/* Result */}
{hasResult && result && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
{result.success ? "Result" : "Error"}
</span>
{result.success && result.result && (
<CopyButton text={JSON.stringify(result.result, null, 2)} size="xs" />
)}
</div>
{result.success ? (
<pre className="text-[10px] font-mono text-foreground/70 bg-foreground/[0.03] rounded px-2 py-1.5 overflow-x-auto max-h-[200px] overflow-y-auto whitespace-pre-wrap break-all">
{JSON.stringify(result.result, null, 2)}
</pre>
) : (
<div className="text-[11px] text-red-400/90 bg-red-500/[0.08] rounded px-2 py-1.5">
{result.error || "Query failed"}
</div>
)}
</div>
)}
{/* Error state from SDK */}
{hasError && invocation.errorText && (
<div>
<div className="flex items-center justify-between mb-1">
<span className="text-[9px] font-medium text-muted-foreground/60 uppercase tracking-wider">
Error
</span>
</div>
<div className="text-[11px] text-red-400/90 bg-red-500/[0.08] rounded px-2 py-1.5">
{invocation.errorText}
</div>
</div>
)}
{/* Loading state */}
{isLoading && (
<div className="flex items-center gap-2 text-[11px] text-muted-foreground/60 py-1">
<SpinnerGapIcon className="h-3 w-3 animate-spin" />
<span>Running query...</span>
</div>
)}
</div>
</div>
</div>
</div>
);
});
// Memoized markdown components for consistent rendering
const markdownComponents = {
p: ({ children }: { children?: React.ReactNode }) => (
<p className="text-[13px] text-foreground/90 mb-2.5 last:mb-0 leading-relaxed">
{children}
</p>
),
ul: ({ children }: { children?: React.ReactNode }) => (
<ul className="text-[13px] text-foreground/90 mb-2.5 pl-4 space-y-1 list-disc marker:text-muted-foreground/40">
{children}
</ul>
),
ol: ({ children }: { children?: React.ReactNode }) => (
<ol className="text-[13px] text-foreground/90 mb-2.5 pl-4 space-y-1.5 list-decimal marker:text-muted-foreground/60">
{children}
</ol>
),
li: ({ children }: { children?: React.ReactNode }) => (
<li className="leading-relaxed pl-0.5">{children}</li>
),
code: ({ children, className }: { children?: React.ReactNode, className?: string }) => {
if (className) {
return <CodeBlock className={className}>{children}</CodeBlock>;
}
return <InlineCode>{children}</InlineCode>;
},
pre: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
strong: ({ children }: { children?: React.ReactNode }) => (
<strong className="font-semibold text-foreground">{children}</strong>
),
em: ({ children }: { children?: React.ReactNode }) => (
<em className="italic text-foreground/80">{children}</em>
),
a: SmartLink,
table: ({ children }: { children?: React.ReactNode }) => (
<div className="overflow-x-auto my-2.5 rounded-lg ring-1 ring-foreground/[0.08]">
<table className="w-full text-[11px]">{children}</table>
</div>
),
thead: ({ children }: { children?: React.ReactNode }) => (
<thead className="bg-foreground/[0.04] border-b border-foreground/[0.08]">{children}</thead>
),
tbody: ({ children }: { children?: React.ReactNode }) => (
<tbody className="divide-y divide-foreground/[0.04]">{children}</tbody>
),
tr: ({ children }: { children?: React.ReactNode }) => <tr>{children}</tr>,
th: ({ children }: { children?: React.ReactNode }) => (
<th className="px-2.5 py-1.5 text-left font-semibold text-foreground/90 whitespace-nowrap">
{children}
</th>
),
td: ({ children }: { children?: React.ReactNode }) => (
<td className="px-2.5 py-1.5 text-foreground/70">{children}</td>
),
h1: ({ children }: { children?: React.ReactNode }) => (
<h1 className="text-base font-semibold text-foreground mt-3 mb-2 first:mt-0">{children}</h1>
),
h2: ({ children }: { children?: React.ReactNode }) => (
<h2 className="text-[13px] font-semibold text-foreground mt-3 mb-1.5 first:mt-0">{children}</h2>
),
h3: ({ children }: { children?: React.ReactNode }) => (
<h3 className="text-[13px] font-semibold text-foreground mt-2.5 mb-1 first:mt-0">{children}</h3>
),
blockquote: ({ children }: { children?: React.ReactNode }) => (
<blockquote className="border-l-2 border-purple-500/40 pl-3 my-2 text-foreground/70 italic">
{children}
</blockquote>
),
hr: () => <hr className="my-3 border-foreground/[0.06]" />,
};
// Helper to count words in a string
function countWords(text: string): number {
return text.split(/\s+/).filter(Boolean).length;
}
// Helper to get first N words from text
function getFirstNWords(text: string, n: number): string {
const words = text.split(/(\s+)/); // Split but keep whitespace
let wordCount = 0;
let result = "";
for (const part of words) {
if (part.trim()) {
wordCount++;
if (wordCount > n) break;
}
result += part;
}
return result;
}
// Memoized user message component
const UserMessage = memo(function UserMessage({ content }: { content: string }) {
return (
<div className="flex gap-2.5 justify-end">
<div className="min-w-0 rounded-xl px-3.5 py-2 max-w-[80%] bg-blue-500/10 text-foreground">
<p className="text-[13px] leading-relaxed break-words">{content}</p>
</div>
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-blue-500/10 flex items-center justify-center">
<UserIcon className="h-3 w-3 text-blue-400" />
</div>
</div>
);
});
// Memoized assistant message component
const AssistantMessage = memo(function AssistantMessage({
content,
toolInvocations,
}: {
content: string,
toolInvocations?: ToolInvocationPart[],
}) {
const hasToolInvocations = toolInvocations && toolInvocations.length > 0;
const hasContent = content.trim().length > 0;
return (
<div className="flex gap-2.5 justify-start">
<div className="shrink-0 w-6 h-6 mt-0.5 rounded-full bg-purple-500/10 flex items-center justify-center">
<SparkleIcon className="h-3 w-3 text-purple-400" />
</div>
<div className="min-w-0 max-w-[calc(100%-2rem)] flex flex-col gap-1">
{/* Tool invocations */}
{hasToolInvocations && (
<div className="space-y-1">
{toolInvocations.map((invocation) => (
<ToolInvocationCard
key={invocation.toolCallId}
invocation={invocation}
/>
))}
</div>
)}
{/* Text content */}
{hasContent && (
<div className="rounded-xl px-3.5 py-2 bg-foreground/[0.02]">
<div className="min-w-0 overflow-hidden">
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</div>
</div>
)}
</div>
</div>
);
});
// Helper to extract text content from UIMessage parts
function getMessageContent(message: UIMessage): string {
return message.parts
.filter((part): part is { type: "text", text: string } => part.type === "text")
.map(part => part.text)
.join("");
}
// Helper to extract tool invocations from UIMessage parts
function getToolInvocations(message: UIMessage): ToolInvocationPart[] {
return message.parts
.filter((part) => part.type.startsWith("tool-"))
.map((part) => part as unknown as ToolInvocationPart);
}
// Word streaming hook - handles the progressive word reveal animation
function useWordStreaming(content: string) {
const [displayedWordCount, setDisplayedWordCount] = useState(0);
const targetWordCount = content ? countWords(content) : 0;
const targetWordCountRef = useRef(targetWordCount);
targetWordCountRef.current = targetWordCount;
// Reset when content is cleared
const hasContent = Boolean(content);
useEffect(() => {
if (!hasContent) {
setDisplayedWordCount(0);
return;
}
const intervalId = setInterval(() => {
setDisplayedWordCount(prev => {
if (prev < targetWordCountRef.current) {
return prev + 1;
}
return prev;
});
}, 15);
return () => clearInterval(intervalId);
}, [hasContent]);
return {
displayedWordCount,
targetWordCount,
getDisplayContent: (text: string) => getFirstNWords(text, displayedWordCount),
isRevealing: displayedWordCount < targetWordCount,
};
}
/**
* AI Chat Preview Component
*
@ -542,7 +44,6 @@ const AIChatPreviewInner = memo(function AIChatPreview({
const projectId = pathname.startsWith("/projects/") ? pathname.split("/")[2] : undefined;
const trimmedQuery = query.trim();
const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set");
const {
messages,
@ -550,27 +51,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({
sendMessage,
error: aiError,
} = useChat({
transport: new DefaultChatTransport({
api: `${backendBaseUrl}/api/latest/ai/query/stream`,
headers: () => buildStackAuthHeaders(currentUser),
prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => {
const modelMessages = await convertToModelMessages(uiMessages);
return {
body: {
systemPrompt: "command-center-ask-ai",
tools: ["docs", "sql-query"],
quality: "smart",
speed: "slow",
projectId,
messages: modelMessages.map(m => ({
role: m.role,
content: m.content,
})),
},
headers,
};
},
}),
transport: createAskAiTransport({ currentUser, projectId }),
});
const aiLoading = status === "submitted" || status === "streaming";
@ -585,9 +66,9 @@ const AIChatPreviewInner = memo(function AIChatPreview({
});
// Word streaming for the last assistant message
const lastAssistantMessage = messages.slice(1).findLast((m: UIMessage) => m.role === "assistant");
const lastAssistantMessage = messages.slice(1).reverse().find((m: UIMessage) => m.role === "assistant");
const lastAssistantContent = lastAssistantMessage ? getMessageContent(lastAssistantMessage) : "";
const { displayedWordCount, targetWordCount, getDisplayContent, isRevealing } = useWordStreaming(lastAssistantContent);
const { displayedWordCount, getDisplayContent, isRevealing } = useWordStreaming(lastAssistantContent);
const isStreaming = aiLoading && lastAssistantMessage;
// Focus handler registration
@ -626,9 +107,11 @@ const AIChatPreviewInner = memo(function AIChatPreview({
// Handle follow-up questions
const handleFollowUp = useCallback(() => {
if (!followUpInput.trim() || aiLoading) return;
const input = followUpInput;
const input = followUpInput.trim();
if (!input || aiLoading) return;
setFollowUpInput("");
// runAsynchronously intentionally used instead of runAsynchronouslyWithAlert:
// sendMessage errors are already surfaced to the user via the aiError state below.
runAsynchronously(sendMessage({ text: input }));
requestAnimationFrame(() => {
followUpInputRef.current?.focus();
@ -638,6 +121,7 @@ const AIChatPreviewInner = memo(function AIChatPreview({
// Handle follow-up input keyboard
const handleFollowUpKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
@ -717,8 +201,8 @@ const AIChatPreviewInner = memo(function AIChatPreview({
</div>
)}
{/* Streaming indicator - show when still loading or still revealing words */}
{(isStreaming || isRevealing) && displayedWordCount > 0 && (
{/* Streaming indicator */}
{isStreaming && displayedWordCount > 0 && (
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50 pl-8">
<span className="inline-flex gap-0.5">
<span className="w-1 h-1 rounded-full bg-purple-400/60 animate-pulse" />

View File

@ -5,11 +5,12 @@ import { ChangelogEntry } from '@/lib/changelog';
import { getPublicEnvVar } from '@/lib/env';
import { cn } from '@/lib/utils';
import { checkVersion, VersionCheckResult } from '@/lib/version-check';
import { BookOpenIcon, ClockClockwiseIcon, LightbulbIcon, QuestionIcon, XIcon } from '@phosphor-icons/react';
import { BookOpenIcon, ClockClockwiseIcon, LightbulbIcon, QuestionIcon, SparkleIcon, XIcon } from '@phosphor-icons/react';
import { runAsynchronously } from '@stackframe/stack-shared/dist/utils/promises';
import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
import packageJson from '../../package.json';
import { FeedbackForm } from './feedback-form';
import { AIChatWidget } from './stack-companion/ai-chat-widget';
import { ChangelogWidget } from './stack-companion/changelog-widget';
import { FeatureRequestBoard } from './stack-companion/feature-request-board';
import { UnifiedDocsWidget } from './stack-companion/unified-docs-widget';
@ -58,6 +59,13 @@ type SidebarItem = {
};
const sidebarItems: SidebarItem[] = [
{
id: 'ask-ai',
label: 'Ask AI',
icon: SparkleIcon,
color: 'text-purple-600 dark:text-purple-400',
hoverBg: 'hover:bg-purple-500/10',
},
{
id: 'docs',
label: 'Docs',
@ -421,7 +429,11 @@ export function StackCompanion({ className, glassBg = false }: { className?: str
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto p-5 overflow-x-hidden no-drag cursor-auto">
<div className={cn(
"flex-1 overflow-x-hidden no-drag cursor-auto",
activeItem === 'ask-ai' ? "overflow-hidden p-0 flex flex-col" : "overflow-y-auto p-5"
)}>
{activeItem === 'ask-ai' && <AIChatWidget />}
{activeItem === 'docs' && <UnifiedDocsWidget isActive={true} />}
{activeItem === 'feedback' && <FeatureRequestBoard isActive={true} />}
{activeItem === 'changelog' && <ChangelogWidget isActive={true} initialData={changelogData} />}

View File

@ -0,0 +1,712 @@
'use client';
import { cn } from "@/components/ui";
import {
createConversation,
deleteConversation,
getConversation,
listConversations,
replaceConversationMessages,
type ConversationSummary,
} from "@/lib/ai-conversations";
import { buildStackAuthHeaders } from "@/lib/api-headers";
import { getPublicEnvVar } from "@/lib/env";
import { useChat, type UIMessage } from "@ai-sdk/react";
import { ArrowCounterClockwiseIcon, ArrowLeftIcon, ChatCircleDotsIcon, PaperPlaneTiltIcon, PlusIcon, SparkleIcon, SpinnerGapIcon, TrashIcon } from "@phosphor-icons/react";
import { useUser } from "@stackframe/stack";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
import { runAsynchronously, runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises";
import { convertToModelMessages, DefaultChatTransport } from "ai";
import { usePathname } from "next/navigation";
import { useCallback, useEffect, useRef, useState } from "react";
import {
AssistantMessage,
getMessageContent,
getToolInvocations,
UserMessage,
useWordStreaming,
} from "../commands/ai-chat-shared";
type ViewMode =
| { view: 'list' }
| { view: 'chat', conversationId: string | null, initialMessages: UIMessage[] };
function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
function ConversationList({
projectId,
onSelectConversation,
onNewChat,
}: {
projectId: string | undefined,
onSelectConversation: (id: string) => void,
onNewChat: () => void,
}) {
const currentUser = useUser();
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
const [loading, setLoading] = useState(true);
const [deletingId, setDeletingId] = useState<string | null>(null);
useEffect(() => {
if (!projectId || !currentUser) {
setLoading(false);
return;
}
runAsynchronouslyWithAlert(async () => {
try {
const result = await listConversations(currentUser, projectId);
setConversations(result);
} finally {
setLoading(false);
}
});
}, [currentUser, projectId]);
const handleDelete = useCallback((e: React.MouseEvent, id: string) => {
e.stopPropagation();
setDeletingId(id);
runAsynchronouslyWithAlert(async () => {
try {
await deleteConversation(currentUser, id);
setConversations(prev => prev.filter(c => c.id !== id));
} finally {
setDeletingId(null);
}
});
}, [currentUser]);
if (loading) {
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-foreground/[0.05]">
<span className="text-xs font-medium text-muted-foreground">Chat History</span>
<button
onClick={onNewChat}
className="flex items-center gap-1 text-[11px] text-purple-400 hover:text-purple-300 transition-colors"
type="button"
>
<PlusIcon className="h-3 w-3" />
<span>New Chat</span>
</button>
</div>
<div className="flex-1 px-3 py-2 space-y-2">
{[...Array(3)].map((_, i) => (
<div key={i} className="h-14 rounded-lg bg-foreground/[0.03] animate-pulse" />
))}
</div>
</div>
);
}
return (
<div className="flex flex-col h-full">
<div className="flex items-center justify-between px-4 py-3 border-b border-foreground/[0.05]">
<span className="text-xs font-medium text-muted-foreground">Chat History</span>
<button
onClick={onNewChat}
className="flex items-center gap-1 text-[11px] text-purple-400 hover:text-purple-300 transition-colors"
type="button"
>
<PlusIcon className="h-3 w-3" />
<span>New Chat</span>
</button>
</div>
<div className="flex-1 overflow-y-auto px-2 py-2 space-y-1">
{conversations.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full gap-3 text-center px-6">
<ChatCircleDotsIcon className="h-8 w-8 text-muted-foreground/30" />
<div className="space-y-1">
<p className="text-xs text-muted-foreground/60">No conversations yet</p>
<button
onClick={onNewChat}
className="text-xs text-purple-400 hover:text-purple-300 transition-colors"
type="button"
>
Start a new chat
</button>
</div>
</div>
) : (
conversations.map(conv => (
<div
key={conv.id}
onClick={() => onSelectConversation(conv.id)}
className="w-full text-left px-3 py-2.5 rounded-lg hover:bg-foreground/[0.04] transition-colors group flex items-start gap-2 cursor-pointer"
>
<SparkleIcon className="h-3.5 w-3.5 text-purple-400/60 mt-0.5 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-[13px] text-foreground truncate">
{conv.title.length > 40 ? `${conv.title.slice(0, 40)}...` : conv.title}
</p>
<p className="text-[10px] text-muted-foreground/50 mt-0.5">
{formatRelativeTime(conv.updatedAt)}
</p>
</div>
<button
onClick={(e) => handleDelete(e, conv.id)}
disabled={deletingId === conv.id}
className="opacity-0 group-hover:opacity-100 p-1 text-muted-foreground/40 hover:text-red-400 transition-all shrink-0"
type="button"
aria-label="Delete conversation"
title="Delete conversation"
>
{deletingId === conv.id ? (
<SpinnerGapIcon className="h-3 w-3 animate-spin" />
) : (
<TrashIcon className="h-3 w-3" />
)}
</button>
</div>
))
)}
</div>
</div>
);
}
export function AIChatWidget() {
const currentUser = useUser();
const pathname = usePathname();
const projectId = pathname.startsWith("/projects/") ? pathname.split("/")[2] : undefined;
const [viewMode, setViewMode] = useState<ViewMode>({ view: 'chat', conversationId: null, initialMessages: [] });
const [conversationKey, setConversationKey] = useState(0);
const [initialLoading, setInitialLoading] = useState(true);
const didLoadRef = useRef(false);
useEffect(() => {
if (didLoadRef.current) return;
didLoadRef.current = true;
if (!projectId) {
setInitialLoading(false);
return;
}
runAsynchronouslyWithAlert(async () => {
try {
const conversations = await listConversations(currentUser, projectId);
if (conversations.length > 0) {
const conv = await getConversation(currentUser, conversations[0].id);
const initialMessages: UIMessage[] = conv.messages.map((msg) => ({
id: msg.id,
role: msg.role,
parts: msg.content as UIMessage["parts"],
}));
setViewMode({ view: 'chat', conversationId: conversations[0].id, initialMessages });
setConversationKey(prev => prev + 1);
}
} finally {
setInitialLoading(false);
}
});
// eslint-disable-next-line react-hooks/exhaustive-deps -- only load on mount
}, []);
const handleSelectConversation = useCallback(async (id: string) => {
const conv = await getConversation(currentUser, id);
const initialMessages: UIMessage[] = conv.messages.map((msg) => ({
id: msg.id,
role: msg.role,
parts: msg.content as UIMessage["parts"],
}));
setConversationKey(prev => prev + 1);
setViewMode({ view: 'chat', conversationId: id, initialMessages });
}, [currentUser]);
const handleNewChat = useCallback(() => {
setConversationKey(prev => prev + 1);
setViewMode({ view: 'chat', conversationId: null, initialMessages: [] });
}, []);
const handleBackToList = useCallback(() => {
setViewMode({ view: 'list' });
}, []);
const handleConversationCreated = useCallback((id: string) => {
setViewMode(prev => {
if (prev.view === 'chat') {
return { ...prev, conversationId: id };
}
return prev;
});
}, []);
if (initialLoading) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3">
<SpinnerGapIcon className="h-5 w-5 text-purple-400 animate-spin" />
<span className="text-xs text-muted-foreground/60">Loading conversations...</span>
</div>
);
}
if (viewMode.view === 'list') {
return (
<ConversationList
projectId={projectId}
onSelectConversation={(id) => runAsynchronouslyWithAlert(handleSelectConversation(id))}
onNewChat={handleNewChat}
/>
);
}
return (
<AIChatWidgetInner
key={conversationKey}
projectId={projectId}
conversationId={viewMode.conversationId}
initialMessages={viewMode.initialMessages}
onConversationCreated={handleConversationCreated}
onBackToList={handleBackToList}
onNewChat={handleNewChat}
/>
);
}
function AIChatWidgetInner({
projectId,
conversationId: initialConversationId,
initialMessages,
onConversationCreated,
onBackToList,
onNewChat,
}: {
projectId: string | undefined,
conversationId: string | null,
initialMessages: UIMessage[],
onConversationCreated: (id: string) => void,
onBackToList: () => void,
onNewChat: () => void,
}) {
const [followUpInput, setFollowUpInput] = useState("");
const messagesContainerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const followUpInputRef = useRef<HTMLInputElement>(null);
const lastMessageCountRef = useRef(0);
const isNearBottomRef = useRef(true);
const currentUser = useUser();
const conversationIdRef = useRef(initialConversationId);
const prevStatusRef = useRef<string>("");
const isSavingRef = useRef(false);
const pendingMessagesRef = useRef<{ messages: Array<{ role: string; content: unknown }>; title: string } | null>(null);
const backendBaseUrl = getPublicEnvVar("NEXT_PUBLIC_BROWSER_STACK_API_URL") ?? getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_BROWSER_STACK_API_URL is not set");
const hasInitialMessages = initialMessages.length > 0;
const [input, setInput] = useState("");
const [conversationStarted, setConversationStarted] = useState(hasInitialMessages);
const {
messages,
status,
sendMessage,
error: aiError,
} = useChat({
messages: hasInitialMessages ? initialMessages : undefined,
transport: new DefaultChatTransport({
api: `${backendBaseUrl}/api/latest/ai/query/stream`,
headers: () => buildStackAuthHeaders(currentUser),
prepareSendMessagesRequest: async ({ messages: uiMessages, headers }) => {
const modelMessages = await convertToModelMessages(uiMessages);
return {
body: {
systemPrompt: "command-center-ask-ai",
tools: ["docs", "sql-query"],
quality: "smart",
speed: "slow",
projectId,
messages: modelMessages.map(m => ({
role: m.role,
content: m.content,
})),
},
headers,
};
},
}),
});
const aiLoading = status === "submitted" || status === "streaming";
const doSave = useCallback(async (messagesToSave: Array<{ role: string; content: unknown }>, title: string) => {
isSavingRef.current = true;
try {
if (conversationIdRef.current) {
await replaceConversationMessages(currentUser, conversationIdRef.current, messagesToSave);
} else if (projectId) {
const result = await createConversation(currentUser, {
title,
projectId,
messages: messagesToSave,
});
conversationIdRef.current = result.id;
onConversationCreated(result.id);
}
} finally {
isSavingRef.current = false;
const pending = pendingMessagesRef.current;
pendingMessagesRef.current = null;
if (pending) {
await doSave(pending.messages, pending.title);
}
}
}, [currentUser, projectId, onConversationCreated]);
// Save conversation when streaming completes
useEffect(() => {
const prevStatus = prevStatusRef.current;
prevStatusRef.current = status;
const completedOk = (prevStatus === "streaming" || prevStatus === "submitted") && status === "ready";
const completedWithError = (prevStatus === "streaming" || prevStatus === "submitted") && status === "error";
if (
(completedOk || completedWithError) &&
messages.length > 0
) {
// On error, only save user messages (strip any partial/failed assistant turn)
const safeMessages = completedWithError
? messages.filter(m => m.role === "user")
: messages;
if (safeMessages.length === 0) return;
const messagesToSave = safeMessages.map(m => ({
role: m.role,
content: m.parts,
}));
const firstUserMessage = messages.find(m => m.role === "user");
const title = firstUserMessage
? getMessageContent(firstUserMessage).slice(0, 50) || "New conversation"
: "New conversation";
if (isSavingRef.current) {
pendingMessagesRef.current = { messages: messagesToSave, title };
return;
}
runAsynchronouslyWithAlert(doSave(messagesToSave, title));
}
}, [status, messages, doSave]);
// Word streaming for the last assistant message
const lastAssistantMessage = messages.slice().reverse().find((m: UIMessage) => m.role === "assistant");
const lastAssistantContent = lastAssistantMessage ? getMessageContent(lastAssistantMessage) : "";
const { displayedWordCount, getDisplayContent, isRevealing } = useWordStreaming(lastAssistantContent);
const isStreaming = aiLoading && lastAssistantMessage;
// Auto-focus input on mount
useEffect(() => {
if (!conversationStarted) {
inputRef.current?.focus();
} else {
followUpInputRef.current?.focus();
}
}, [conversationStarted]);
// Track if user is near the bottom of the scroll container
const handleScroll = useCallback(() => {
if (!messagesContainerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = messagesContainerRef.current;
isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
}, []);
// Auto-scroll when new messages are added or when already at bottom
useEffect(() => {
if (!messagesContainerRef.current) return;
const container = messagesContainerRef.current;
const messageCount = messages.length;
if (messageCount > lastMessageCountRef.current) {
container.scrollTop = container.scrollHeight;
isNearBottomRef.current = true;
} else if (aiLoading && isNearBottomRef.current) {
container.scrollTop = container.scrollHeight;
}
lastMessageCountRef.current = messageCount;
}, [messages, aiLoading]);
// Handle initial question submit
const handleSubmit = useCallback(() => {
if (!input.trim() || aiLoading) return;
setConversationStarted(true);
runAsynchronously(sendMessage({ text: input.trim() }));
setInput("");
requestAnimationFrame(() => {
followUpInputRef.current?.focus();
});
}, [input, aiLoading, sendMessage]);
// Handle initial input keyboard
const handleInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit();
}
},
[handleSubmit]
);
// Handle follow-up questions
const handleFollowUp = useCallback(() => {
const text = followUpInput.trim();
if (!text || aiLoading) return;
setFollowUpInput("");
runAsynchronously(sendMessage({ text }));
requestAnimationFrame(() => {
followUpInputRef.current?.focus();
});
}, [followUpInput, sendMessage, aiLoading]);
// Handle follow-up input keyboard
const handleFollowUpKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.nativeEvent.isComposing) return;
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
e.stopPropagation();
handleFollowUp();
} else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
e.preventDefault();
e.stopPropagation();
const container = messagesContainerRef.current;
if (container) {
const scrollAmount = e.key === "ArrowUp" ? -100 : 100;
container.scrollBy({ top: scrollAmount, behavior: "smooth" });
}
}
},
[handleFollowUp]
);
// Determine what to show in the loading state
const showLoadingIndicator = conversationStarted && (messages.length === 0 || (aiLoading && !messages.some((m: UIMessage) => m.role === "assistant" && getMessageContent(m))));
// Initial state - show input
if (!conversationStarted) {
return (
<div className="flex flex-col h-full">
{/* Back button */}
<div className="px-3 py-2 border-b border-foreground/[0.05]">
<button
onClick={onBackToList}
className="flex items-center gap-1 text-[11px] text-muted-foreground/60 hover:text-muted-foreground transition-colors"
type="button"
>
<ArrowLeftIcon className="h-3 w-3" />
<span>Back to history</span>
</button>
</div>
<div className="flex-1 flex flex-col items-center justify-center px-6 gap-4">
<div className="w-10 h-10 rounded-full bg-purple-500/10 flex items-center justify-center">
<SparkleIcon className="h-5 w-5 text-purple-400" />
</div>
<div className="text-center space-y-1.5">
<h3 className="text-sm font-semibold text-foreground">Ask AI</h3>
<p className="text-xs text-muted-foreground/70 max-w-[240px]">
Get AI-powered answers about Stack Auth, your project, and analytics
</p>
</div>
</div>
<div className="shrink-0 border-t border-foreground/[0.05] px-3.5 py-2.5">
<div className="flex items-center gap-2 rounded-lg bg-foreground/[0.03] px-3 py-1.5 ring-1 ring-foreground/[0.05] focus-within:ring-purple-500/25 transition-shadow">
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleInputKeyDown}
aria-label="Initial prompt"
placeholder="Ask a question..."
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<button
onClick={handleSubmit}
disabled={!input.trim() || aiLoading}
aria-label="Send message"
title="Send message"
className={cn(
"p-1 rounded transition-colors hover:transition-none",
input.trim() && !aiLoading
? "text-purple-400 hover:text-purple-300 hover:bg-purple-500/10"
: "text-muted-foreground/25 cursor-not-allowed"
)}
type="button"
>
<PaperPlaneTiltIcon className="h-3.5 w-3.5" />
</button>
</div>
<p className="text-[9px] text-muted-foreground/40 mt-1.5 text-center">
Enter to send
</p>
</div>
</div>
);
}
// Conversation view
return (
<div className="flex flex-col h-full">
{/* Back button */}
<div className="px-3 py-2 border-b border-foreground/[0.05] flex items-center justify-between">
<button
onClick={onBackToList}
disabled={aiLoading}
className={cn(
"flex items-center gap-1 text-[11px] transition-colors",
aiLoading
? "text-muted-foreground/25 cursor-not-allowed"
: "text-muted-foreground/60 hover:text-muted-foreground"
)}
type="button"
>
<ArrowLeftIcon className="h-3 w-3" />
<span>Back to history</span>
</button>
</div>
{/* Messages */}
<div
ref={messagesContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto overscroll-contain px-4 py-3 space-y-4"
style={{ scrollbarGutter: "stable" }}
>
{messages.map((message: UIMessage, index: number, arr: UIMessage[]) => {
const messageContent = getMessageContent(message);
const toolInvocations = message.role === "assistant" ? getToolInvocations(message) : [];
// For the last assistant message, apply word-by-word streaming
const isLastAssistant = message.role === "assistant" &&
index === arr.length - 1 - (arr[arr.length - 1]?.role === "user" ? 1 : 0);
const displayContent = message.role === "assistant" && isLastAssistant && aiLoading
? getDisplayContent(messageContent)
: messageContent;
// Don't render if no content to show yet AND no tool invocations
if (message.role === "assistant" && isLastAssistant && !displayContent && toolInvocations.length === 0) {
return null;
}
if (message.role === "user") {
return <UserMessage key={message.id || index} content={messageContent} />;
}
return (
<AssistantMessage
key={message.id || index}
content={displayContent}
toolInvocations={toolInvocations}
/>
);
})}
{/* Loading indicator */}
{showLoadingIndicator && (
<div className="flex gap-2.5 justify-start">
<div className="shrink-0 w-6 h-6 rounded-full bg-purple-500/10 flex items-center justify-center">
<SparkleIcon className="h-3 w-3 text-purple-400" />
</div>
<div className="bg-foreground/[0.02] rounded-xl px-3.5 py-2">
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
<SpinnerGapIcon className="h-3.5 w-3.5 animate-spin" />
<span>Thinking...</span>
</div>
</div>
</div>
)}
{/* Streaming indicator */}
{isStreaming && displayedWordCount > 0 && (
<div className="flex items-center gap-1.5 text-[10px] text-muted-foreground/50 pl-8">
<span className="inline-flex gap-0.5">
<span className="w-1 h-1 rounded-full bg-purple-400/60 animate-pulse" />
<span className="w-1 h-1 rounded-full bg-purple-400/60 animate-pulse" style={{ animationDelay: "150ms" }} />
<span className="w-1 h-1 rounded-full bg-purple-400/60 animate-pulse" style={{ animationDelay: "300ms" }} />
</span>
</div>
)}
{/* Error display */}
{aiError && (
<div className="flex items-start gap-2 text-[12px] text-red-400/90 px-3 py-2 bg-red-500/[0.08] rounded-lg ring-1 ring-red-500/20">
<span className="shrink-0 mt-0.5"></span>
<span>{aiError.message || "Failed to get response. Please try again."}</span>
</div>
)}
</div>
{/* Follow-up input + new conversation button */}
<div className="shrink-0 border-t border-foreground/[0.05] px-3.5 py-2.5">
<div className="flex items-center gap-2 rounded-lg bg-foreground/[0.03] px-3 py-1.5 ring-1 ring-foreground/[0.05] focus-within:ring-purple-500/25 transition-shadow">
<input
ref={followUpInputRef}
type="text"
value={followUpInput}
onChange={(e) => setFollowUpInput(e.target.value)}
onKeyDown={handleFollowUpKeyDown}
aria-label="Follow-up question"
placeholder="Ask a follow-up question..."
className="flex-1 bg-transparent text-[13px] outline-none placeholder:text-muted-foreground/40"
autoComplete="off"
autoCorrect="off"
spellCheck={false}
/>
<button
onClick={() => handleFollowUp()}
disabled={!followUpInput.trim() || aiLoading}
aria-label="Send message"
title="Send message"
className={cn(
"p-1 rounded transition-colors hover:transition-none",
followUpInput.trim() && !aiLoading
? "text-purple-400 hover:text-purple-300 hover:bg-purple-500/10"
: "text-muted-foreground/25 cursor-not-allowed"
)}
type="button"
>
<PaperPlaneTiltIcon className="h-3.5 w-3.5" />
</button>
</div>
<div className="flex items-center justify-between mt-1.5">
<button
onClick={onNewChat}
disabled={aiLoading}
className={cn(
"flex items-center gap-1 text-[9px] transition-colors hover:transition-none",
aiLoading
? "text-muted-foreground/25 cursor-not-allowed"
: "text-muted-foreground/40 hover:text-muted-foreground/70"
)}
type="button"
>
<ArrowCounterClockwiseIcon className="h-2.5 w-2.5" />
<span>New conversation</span>
</button>
<p className="text-[9px] text-muted-foreground/40">
Enter to send
</p>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,104 @@
import { buildStackAuthHeaders, CurrentUser } from "@/lib/api-headers";
import { getPublicEnvVar } from "@/lib/env";
import { throwErr } from "@stackframe/stack-shared/dist/utils/errors";
export type ConversationSummary = {
id: string,
title: string,
projectId: string,
updatedAt: string,
};
export type ConversationDetail = {
id: string,
title: string,
projectId: string,
messages: Array<{
id: string,
role: "user" | "assistant",
content: unknown,
}>,
};
function getBaseUrl() {
return getPublicEnvVar("NEXT_PUBLIC_STACK_API_URL") ?? throwErr("NEXT_PUBLIC_STACK_API_URL is not set");
}
async function apiFetch(
currentUser: CurrentUser | null,
path: string,
options: RequestInit = {},
): Promise<Response> {
const headers = await buildStackAuthHeaders(currentUser);
const response = await fetch(`${getBaseUrl()}/api/latest/internal/ai-conversations${path}`, {
...options,
headers: {
...(options.body != null ? { "content-type": "application/json" } : {}),
...headers,
...options.headers,
},
});
if (!response.ok) {
throw new Error(`AI conversations API error: ${response.status}`);
}
return response;
}
export async function listConversations(
currentUser: CurrentUser | null,
projectId: string,
): Promise<ConversationSummary[]> {
const response = await apiFetch(currentUser, `?projectId=${encodeURIComponent(projectId)}`);
const data = await response.json();
return data.conversations;
}
export async function createConversation(
currentUser: CurrentUser | null,
data: { title: string, projectId: string, messages: Array<{ role: string, content: unknown }> },
): Promise<{ id: string, title: string }> {
const response = await apiFetch(currentUser, "", {
method: "POST",
body: JSON.stringify(data),
});
return await response.json();
}
export async function getConversation(
currentUser: CurrentUser | null,
conversationId: string,
): Promise<ConversationDetail> {
const response = await apiFetch(currentUser, `/${encodeURIComponent(conversationId)}`);
return await response.json();
}
export async function updateConversationTitle(
currentUser: CurrentUser | null,
conversationId: string,
title: string,
): Promise<void> {
await apiFetch(currentUser, `/${conversationId}`, {
method: "PATCH",
body: JSON.stringify({ title }),
});
}
export async function replaceConversationMessages(
currentUser: CurrentUser | null,
conversationId: string,
messages: Array<{ role: string, content: unknown }>,
): Promise<void> {
await apiFetch(currentUser, `/${conversationId}/messages`, {
method: "PUT",
body: JSON.stringify({ messages }),
});
}
export async function deleteConversation(
currentUser: CurrentUser | null,
conversationId: string,
): Promise<void> {
await apiFetch(currentUser, `/${conversationId}`, {
method: "DELETE",
});
}