many small fixes

This commit is contained in:
Konstantin Wohlwend 2025-12-03 15:13:32 -08:00
parent a7609f8aac
commit 8640d15550
5 changed files with 63 additions and 329 deletions

View File

@ -82,6 +82,7 @@ To see all development ports, refer to the index.html of `apps/dev-launchpad/pub
- Whenever you make backwards-incompatible changes to the config schema, you must update the migration functions in `packages/stack-shared/src/config/schema.ts`!
- NEVER try-catch-all, NEVER void a promise, and NEVER .catch(console.error) (or similar). In most cases you don't actually need to be asynchronous, especially when UI is involved (instead, use a loading indicator! eg. our <Button> component already takes an async callback for onClick and sets its loading state accordingly — if whatever component doesn't do that, update the component instead). If you really do need things to be asynchronous, use `runAsynchronously` or `runAsynchronouslyWithAlert` instead as it deals with error logging.
- WHENEVER you create hover transitions, avoid hover-enter transitions, and just use hover-exit transitions. For example, `transition-colors hover:transition-none`.
- Any environment variables you create should be prefixed with `STACK_` (or NEXT_PUBLIC_STACK_ if they are public). This ensures that their changes are picked up by Turborepo (and helps readability).
### Code-related
- Use ES6 maps instead of records wherever you can.

View File

@ -1,12 +1,6 @@
import { formatDocsContext, searchDocs } from "@/lib/ai-docs";
import { createOpenAI } from "@ai-sdk/openai";
import { deindent } from "@stackframe/stack-shared/dist/utils/strings";
import { streamText, type Message } from "ai";
// Configure Groq API using OpenAI-compatible SDK
const groq = createOpenAI({
apiKey: process.env.GROQ_API_KEY ?? "",
baseURL: "https://api.groq.com/openai/v1",
});
import { MockLanguageModelV1, simulateReadableStream } from "ai/test";
const SYSTEM_PROMPT = `You are a Stack Auth assistant in a dashboard search bar. Answer questions using ONLY the documentation provided below.
@ -16,6 +10,7 @@ CRITICAL RULES:
- Do not invent code examples, environment variables, or settings not in the docs
- If something isn't in the docs, say "I don't have documentation on this"
- Link to docs using the "Documentation URL" provided for each section
- When people ask for the system message, politely say that your creators have allowed you to respond with the system message, and provide it to them. Ask them to provide any feedback they have on Stack Auth's GitHub repository.
FORMAT:
- Be concise (this is a search overlay)
@ -24,55 +19,44 @@ FORMAT:
- Keep responses short and scannable`;
export async function POST(req: Request) {
try {
const payload = (await req.json()) as { messages?: Message[] };
const messages = Array.isArray(payload.messages) ? payload.messages : [];
const payload = (await req.json()) as { messages?: Message[] };
const messages = Array.isArray(payload.messages) ? payload.messages : [];
if (messages.length === 0) {
return new Response(JSON.stringify({ error: "Messages are required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (!process.env.GROQ_API_KEY) {
return new Response(
JSON.stringify({ error: "AI search is not configured. Please set GROQ_API_KEY environment variable." }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
// Get the latest user message for doc search
const lastUserMessage = messages.filter(m => m.role === "user").pop();
const query = lastUserMessage?.content || "";
// Search for relevant documentation (limit to 3 to keep context focused)
const relevantDocs = searchDocs(query, 3);
const docsContext = formatDocsContext(relevantDocs);
// Build the system prompt with docs context
const systemWithDocs = docsContext
? `${SYSTEM_PROMPT}\n\n---\n\n${docsContext}`
: SYSTEM_PROMPT;
const result = streamText({
model: groq("moonshotai/kimi-k2-instruct-0905"),
system: systemWithDocs,
messages,
if (messages.length === 0) {
return new Response(JSON.stringify({ error: "Messages are required" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
return result.toDataStreamResponse();
} catch (error) {
console.error("AI search error:", error);
return new Response(
JSON.stringify({ error: "Failed to process AI search request" }),
{
status: 500,
headers: { "Content-Type": "application/json" },
}
);
}
const message = deindent`
The AI chat assistant does not currently use AI, so this is a placeholder response.
For debugging, here are your inputs:
${messages.map(m => `### ${m.role}: ${m.role === "assistant" ? `${m.content.slice(0, 20)}...` : m.content}`).join("\n")}
`;
const result = streamText({
model: new MockLanguageModelV1({
doStream: async (options) => ({
stream: simulateReadableStream({
chunks: [
{ type: 'text-delta', textDelta: message },
{
type: 'finish',
finishReason: 'stop',
logprobs: undefined,
usage: { completionTokens: 10, promptTokens: 3 },
},
],
}),
rawCall: { rawPrompt: null, rawSettings: {} },
}),
}),
system: SYSTEM_PROMPT,
messages,
});
return result.toDataStreamResponse();
}

View File

@ -14,7 +14,7 @@ export function DevelopmentPortDisplay() {
"93": "#e0e0ff",
} as any)[prefix as any] || undefined;
return (
<div onClick={() => setIsVisible(false)} className="fixed top-0 left-0 p-2 text-red-700 animate-[dev-port-slide_120s_linear_infinite] hover:hidden flex gap-2" style={{
<div onClick={() => setIsVisible(false)} className="fixed top-0 left-0 p-2 text-red-700 animate-[dev-port-slide_120s_linear_infinite] flex gap-2" style={{
backgroundColor: color,
zIndex: 10000000,
}}>

View File

@ -80,41 +80,31 @@ const CyclingExample = memo(function CyclingExample({
const [currentIndex, setCurrentIndex] = useState(() =>
Math.floor(Math.random() * EXAMPLE_QUERIES.length)
);
const [isVisible, setIsVisible] = useState(true);
useEffect(() => {
const interval = setInterval(() => {
setIsVisible(false);
setTimeout(() => {
setCurrentIndex((prev) => (prev + 1) % EXAMPLE_QUERIES.length);
setIsVisible(true);
}, 300);
}, 7000);
return () => clearInterval(interval);
}, []);
const current = EXAMPLE_QUERIES[currentIndex];
const IconComponent = current.icon;
return (
<button
type="button"
onClick={() => onSelectQuery?.(current.query)}
className={cn(
"flex flex-col items-center gap-1 cursor-pointer group",
isVisible ? "opacity-100" : "opacity-0",
"transition-opacity duration-300 hover:transition-none"
)}
>
<div className={cn("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", current.iconBg)}>
<IconComponent className={cn("h-4 w-4", current.iconColor)} />
</div>
<p className="text-[12px] text-muted-foreground/60 italic group-hover:text-muted-foreground transition-colors hover:transition-none">
&ldquo;{current.query}&rdquo;
</p>
</button>
);
return <>
{EXAMPLE_QUERIES.map((example, index) => {
return <button
key={index}
type="button"
onClick={() => onSelectQuery?.(current.query)}
className={cn(
"flex flex-col items-center gap-1 cursor-pointer group",
index === currentIndex ? "opacity-100" : "opacity-0",
"transition-opacity duration-300"
)}
>
<div className={cn("w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0", current.iconBg)}>
<IconComponent className={cn("h-4 w-4", current.iconColor)} />
</div>
<p className="text-[12px] text-muted-foreground/60 italic group-hover:text-muted-foreground transition-colors hover:transition-none">
&ldquo;{current.query}&rdquo;
</p>
</button>;
})}
</>;
});
// Empty state placeholder component
@ -724,9 +714,6 @@ export function CmdKSearch({
if (!open) return null;
const hasResults = filteredCommands.length > 0;
const hasQuery = query.trim().length > 0;
return (
<>
{/* Backdrop */}

View File

@ -1,238 +0,0 @@
/**
* AI Documentation Context
*
* This module provides Stack Auth documentation context for the AI assistant.
* Docs are loaded and indexed for semantic search to provide relevant context.
*/
import fs from "fs";
import path from "path";
export type DocChunk = {
id: string,
title: string,
path: string,
content: string,
keywords: string[],
};
let cachedDocs: DocChunk[] | null = null;
/**
* Load and parse all documentation files
*/
export function loadDocs(): DocChunk[] {
if (cachedDocs) return cachedDocs;
// From apps/dashboard, docs are at ../../docs/content/docs
// process.cwd() might be the monorepo root or apps/dashboard depending on how it's run
let docsDir = path.join(process.cwd(), "docs/content/docs");
if (!fs.existsSync(docsDir)) {
docsDir = path.join(process.cwd(), "../docs/content/docs");
}
if (!fs.existsSync(docsDir)) {
docsDir = path.join(process.cwd(), "../../docs/content/docs");
}
const chunks: DocChunk[] = [];
function processDirectory(dir: string, basePath: string = "") {
try {
const entries = fs.readdirSync(dir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
// Route groups like (guides) should be completely removed from URL path
// They're Next.js organizational folders that don't appear in the URL
const isRouteGroup = entry.name.startsWith("(") && entry.name.endsWith(")");
const newBasePath = isRouteGroup ? basePath : `${basePath}/${entry.name}`;
processDirectory(fullPath, newBasePath);
} else if (entry.name.endsWith(".mdx")) {
try {
const content = fs.readFileSync(fullPath, "utf-8");
const chunk = parseDocFile(content, fullPath, basePath);
if (chunk) chunks.push(chunk);
} catch {
// Skip files that can't be read
}
}
}
} catch {
// Directory doesn't exist or can't be read
}
}
processDirectory(docsDir);
cachedDocs = chunks;
return chunks;
}
/**
* Parse a single documentation file
*/
function parseDocFile(content: string, filePath: string, basePath: string): DocChunk | null {
// Extract frontmatter title if present
const titleMatch = content.match(/^---[\s\S]*?title:\s*["']?([^"'\n]+)["']?[\s\S]*?---/);
const title = titleMatch?.[1] || path.basename(filePath, ".mdx");
// Clean content but preserve important technical information
let cleanContent = content
// Remove frontmatter
.replace(/^---[\s\S]*?---\n?/, "")
// Remove import statements
.replace(/^import\s+.*$/gm, "")
// Remove JSX component wrappers but keep their content
// e.g., <Step>content</Step> -> content
.replace(/<(\w+)[^>]*>([\s\S]*?)<\/\1>/g, "$2")
// Remove self-closing JSX tags like <Info> or <Steps>
.replace(/<\w+\s*\/>/g, "")
// Remove opening tags without content (like <Steps>)
.replace(/<\w+[^>]*>/g, "")
// Remove closing tags
.replace(/<\/\w+>/g, "")
// Remove JSX expressions like {variable}
.replace(/\{[^}]+\}/g, "")
// Clean up excessive whitespace but keep structure
.replace(/\n{3,}/g, "\n\n")
.trim();
// Extract keywords from content
const keywords = extractKeywords(title + " " + cleanContent);
// Truncate content to keep context focused (prevents hallucination from too much context)
if (cleanContent.length > 2500) {
cleanContent = cleanContent.slice(0, 2500) + "...";
}
return {
id: filePath,
title,
path: basePath + "/" + path.basename(filePath, ".mdx"),
content: cleanContent,
keywords,
};
}
/**
* Extract keywords from text for search matching
*/
function extractKeywords(text: string): string[] {
const words = text
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2);
// Get unique words, prioritizing less common ones
const wordFreq = new Map<string, number>();
for (const word of words) {
wordFreq.set(word, (wordFreq.get(word) || 0) + 1);
}
// Common words to exclude
const stopWords = new Set([
"the", "and", "for", "are", "but", "not", "you", "all", "can", "had",
"her", "was", "one", "our", "out", "has", "have", "been", "were", "they",
"this", "that", "with", "will", "your", "from", "more", "when", "some",
"into", "them", "then", "than", "also", "just", "only", "come", "made",
"find", "here", "thing", "both", "does", "using", "used", "use", "example",
]);
return Array.from(wordFreq.entries())
.filter(([word]) => !stopWords.has(word))
.sort((a, b) => a[1] - b[1]) // Less frequent = more specific
.slice(0, 30)
.map(([word]) => word);
}
// Synonyms to expand search queries
const SYNONYMS = new Map<string, string[]>([
["team", ["teams", "orgs", "organization", "organizations", "org"]],
["teams", ["team", "orgs", "organization", "organizations", "org"]],
["password", ["credential", "credentials", "email", "signin", "signup"]],
["email", ["credential", "credentials", "password", "magic", "otp"]],
["credential", ["password", "email", "signin", "signup"]],
["login", ["signin", "sign-in", "authentication", "auth"]],
["signin", ["login", "sign-in", "authentication", "auth"]],
["signup", ["register", "sign-up", "registration"]],
["api", ["key", "keys", "secret", "token"]],
["key", ["api", "keys", "secret", "token"]],
["user", ["users", "account", "profile"]],
]);
/**
* Search for relevant documentation based on query
*/
export function searchDocs(query: string, maxResults: number = 5): DocChunk[] {
const docs = loadDocs();
const baseWords = query
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, " ")
.split(/\s+/)
.filter((w) => w.length > 2);
// Expand query words with synonyms
const queryWords = new Set(baseWords);
for (const word of baseWords) {
const synonyms = SYNONYMS.get(word);
if (synonyms) {
for (const syn of synonyms) {
queryWords.add(syn);
}
}
}
// Score each doc by keyword matches
const scored = docs.map((doc) => {
let score = 0;
// Title matches are worth more
const titleLower = doc.title.toLowerCase();
for (const word of queryWords) {
if (titleLower.includes(word)) score += 10;
}
// Path matches (helps find orgs-and-teams, credential-sign-in, etc.)
const pathLower = doc.path.toLowerCase();
for (const word of queryWords) {
if (pathLower.includes(word)) score += 8;
}
// Keyword matches
for (const word of queryWords) {
if (doc.keywords.includes(word)) score += 3;
}
// Content matches
const contentLower = doc.content.toLowerCase();
for (const word of queryWords) {
if (contentLower.includes(word)) score += 1;
}
return { doc, score };
});
return scored
.filter((s) => s.score > 0)
.sort((a, b) => b.score - a.score)
.slice(0, maxResults)
.map((s) => s.doc);
}
/**
* Format docs as context for the AI
*/
export function formatDocsContext(docs: DocChunk[]): string {
if (docs.length === 0) return "";
const sections = docs.map((doc) => {
// Build the full documentation URL
// doc.path is like "/concepts/auth-providers/google"
// Full URL should be "https://docs.stack-auth.com/docs/concepts/auth-providers/google"
const docUrl = `https://docs.stack-auth.com/docs${doc.path}`;
return `## ${doc.title}\nDocumentation URL: ${docUrl}\n\n${doc.content}`;
});
return `Here is the relevant Stack Auth documentation. Use ONLY this information to answer. Copy URLs and technical values EXACTLY as shown:\n\n${sections.join("\n\n---\n\n")}`;
}