mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-21 21:09:49 +08:00
Sync suggestion branch with base branch
This commit is contained in:
commit
00dec8ad81
@ -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);
|
||||
@ -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
|
||||
|
||||
@ -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: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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 },
|
||||
};
|
||||
},
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
534
apps/dashboard/src/components/commands/ai-chat-shared.tsx
Normal file
534
apps/dashboard/src/components/commands/ai-chat-shared.tsx
Normal 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,
|
||||
};
|
||||
}
|
||||
@ -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" />
|
||||
|
||||
@ -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} />}
|
||||
|
||||
712
apps/dashboard/src/components/stack-companion/ai-chat-widget.tsx
Normal file
712
apps/dashboard/src/components/stack-companion/ai-chat-widget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
apps/dashboard/src/lib/ai-conversations.ts
Normal file
104
apps/dashboard/src/lib/ai-conversations.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user