Skip to content

Commit 8267ebc

Browse files
aadesh18N2D4BilalG1greptile-apps[bot]coderabbitai[bot]
authored
Custom dashboards and unified ai no playground (#1243)
This PR implements unified AI endpoint and custom dashboards. **Unified AI Endpoint** We now use a single endpoint throughout the codebase that makes the call to openrouter. Specifically, email drafts, email templates, email themes, wysiwyg, cmd centre ai search and docs ai, all use this unified ai endpoint. All the tools are defined in the backend, all the prompts exist in the backend. How to review this PR for unified ai endpoint: This PR will be easier to review if we look at the different folders that were affected. under packages - We added streaming functionality, and made renaming changes under docs - there are three files that have changed package.json - we updated the package (we were previously using a very old version of the package) route.ts - we changed the call from a direct call to openrouter to the unified ai endpoint ai-chat.tsx - because of updating the package, we had to make changes to adapt to the latest versions of the package under backend
 route.ts - the main unified ai endpoint. this endpoint uses various support files forward.ts - this is the forward to production functionality models.ts - consists of the models, and the rules for selecting those models prompts.ts - consists of the base prompt + specific system prompts depending upon the usage schema.ts every single file under ai/tools folder - which as the name suggests, consists of the implementations of the different tools that can be provided to the llm route-handlers - added support for streaming to SmartRoute and response under dashboard ai-search/route.ts - refactored the file to use unified ai endpoint chat-adapters.ts - refactored the file to use unified ai endpoint and created extra checks for the ai generated code **Custom Dashboards** We let the user write their query in english. We then use AI to create dashboards that are interactive, live and savable. This PR includes a new package called dashboard-ui-components. This package has components that are used in the dashboard and more importantly, these components are being imported from esm in the ai generated code for custom dashboards. We also change the bar at the top for the products pages. How to review this PR: Review the new package (package/dashboard-ui-components), the setup and the files inside it. Review the schema changes in stack-shared/src Review the changes in dashboard. The following changes have been made Updated the design-components folder since we moved the dashboard components to the new package Updated imports for these components accordingly Updated the title bar of the product pages Created the files for custom dashboards under the dashboards folder and components under commands/create-dashboard Created a script under dashboard/scripts that generates the file with type definitions that would go to the llm Review the backend Started using unified ai endpoint <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added custom dashboards feature allowing users to create and manage personalized dashboards with AI assistance. * Integrated AI-assisted dashboard code generation with visual preview and editing capabilities. * Introduced new AI query endpoints supporting stream and generate modes with configurable model quality/speed settings. * **Improvements** * Reorganized UI components into a dedicated component library package for better code reuse. * Enhanced chat architecture with improved message handling and tool integration. * Updated AI provider integration with improved configuration management. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Co-authored-by: Konsti Wohlwend <n2d4xc@gmail.com> Co-authored-by: Bilal Godil <bg2002@gmail.com> Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 35b7e72 commit 8267ebc

125 files changed

Lines changed: 7281 additions & 2745 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/backend/.env.development

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,9 +58,7 @@ STACK_VERCEL_SANDBOX_TOKEN=vercel_sandbox_disabled_for_local_development
5858
STACK_OPENAI_API_KEY=mock_openai_api_key
5959
STACK_STRIPE_SECRET_KEY=sk_test_mockstripekey
6060
STACK_STRIPE_WEBHOOK_SECRET=mock_stripe_webhook_secret
61-
62-
STACK_OPENROUTER_API_KEY=mock-openrouter-api-key
63-
61+
STACK_OPENROUTER_API_KEY=FORWARD_TO_PRODUCTION
6462
# Email monitor configuration for tests
6563
STACK_EMAIL_MONITOR_VERIFICATION_CALLBACK_URL=http://localhost:8101/handler/email-verification
6664
STACK_EMAIL_MONITOR_PROJECT_ID=internal

apps/backend/package.json

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -52,10 +52,12 @@
5252
"seed": "pnpm run db-seed-script"
5353
},
5454
"dependencies": {
55-
"@ai-sdk/openai": "^1.3.23",
55+
"@ai-sdk/mcp": "^1.0.21",
56+
"@ai-sdk/openai": "^3.0.29",
5657
"@aws-sdk/client-s3": "^3.855.0",
5758
"@clickhouse/client": "^1.14.0",
5859
"@node-oauth/oauth2-server": "^5.1.0",
60+
"@openrouter/ai-sdk-provider": "2.2.3",
5961
"@opentelemetry/api": "^1.9.0",
6062
"@opentelemetry/api-logs": "^0.53.0",
6163
"@opentelemetry/auto-instrumentations-node": "^0.67.3",
@@ -83,7 +85,7 @@
8385
"@vercel/functions": "^2.0.0",
8486
"@vercel/otel": "^1.10.4",
8587
"@vercel/sandbox": "^1.2.0",
86-
"ai": "^4.3.17",
88+
"ai": "^6.0.0",
8789
"bcrypt": "^5.1.1",
8890
"cel-js": "^0.8.2",
8991
"chokidar-cli": "^3.0.0",
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
import { forwardToProduction } from "@/lib/ai/forward";
2+
import { selectModel } from "@/lib/ai/models";
3+
import { getFullSystemPrompt } from "@/lib/ai/prompts";
4+
import { requestBodySchema } from "@/lib/ai/schema";
5+
import { getTools, validateToolNames } from "@/lib/ai/tools";
6+
import { listManagedProjectIds } from "@/lib/projects";
7+
import { SmartResponse } from "@/route-handlers/smart-response";
8+
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
9+
import { yupMixed, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
10+
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
11+
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
12+
import { Json } from "@stackframe/stack-shared/dist/utils/json";
13+
import { generateText, ModelMessage, stepCountIs, streamText } from "ai";
14+
15+
export const POST = createSmartRouteHandler({
16+
metadata: {
17+
hidden: true,
18+
},
19+
request: yupObject({
20+
params: yupObject({
21+
mode: yupString().oneOf(["stream", "generate"]).defined(),
22+
}),
23+
body: requestBodySchema,
24+
}),
25+
response: yupMixed<SmartResponse>().defined(),
26+
async handler({ params, body }, fullReq) {
27+
const { mode } = params;
28+
29+
if (!validateToolNames(body.tools)) {
30+
throw new StatusError(StatusError.BadRequest, `Invalid tool names in request.`);
31+
}
32+
33+
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY");
34+
35+
36+
if (apiKey === "FORWARD_TO_PRODUCTION") {
37+
const prodResponse = await forwardToProduction(mode, body);
38+
return {
39+
statusCode: prodResponse.status,
40+
bodyType: "response" as const,
41+
body: prodResponse,
42+
};
43+
}
44+
45+
const isAuthenticated = fullReq.auth != null;
46+
const { quality, speed, systemPrompt: systemPromptId, tools: toolNames, messages, projectId } = body;
47+
48+
// Verify user has access to the target project
49+
if (projectId != null) {
50+
const user = fullReq.auth?.user;
51+
if (user == null) {
52+
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
53+
}
54+
const managedProjectIds = await listManagedProjectIds(user);
55+
if (!managedProjectIds.includes(projectId)) {
56+
throw new StatusError(StatusError.Forbidden, "You do not have access to this project");
57+
}
58+
}
59+
60+
const model = selectModel(quality, speed, isAuthenticated);
61+
const systemPrompt = getFullSystemPrompt(systemPromptId);
62+
const tools = await getTools(toolNames, { auth: fullReq.auth, targetProjectId: projectId });
63+
const toolsArg = Object.keys(tools).length > 0 ? tools : undefined;
64+
const isDocsOrSearch = systemPromptId === "docs-ask-ai" || systemPromptId === "command-center-ask-ai";
65+
const stepLimit = toolsArg == null ? 1 : isDocsOrSearch ? 50 : 5;
66+
67+
if (mode === "stream") {
68+
const result = streamText({
69+
model,
70+
system: systemPrompt,
71+
messages: messages as ModelMessage[],
72+
tools: toolsArg,
73+
stopWhen: stepCountIs(stepLimit),
74+
});
75+
return {
76+
statusCode: 200,
77+
bodyType: "response" as const,
78+
body: result.toUIMessageStreamResponse(),
79+
};
80+
} else {
81+
const controller = new AbortController();
82+
const timeoutId = setTimeout(() => controller.abort(), 120_000);
83+
const result = await generateText({
84+
model,
85+
system: systemPrompt,
86+
messages: messages as ModelMessage[],
87+
tools: toolsArg,
88+
abortSignal: controller.signal,
89+
stopWhen: stepCountIs(stepLimit),
90+
}).finally(() => clearTimeout(timeoutId));
91+
92+
const contentBlocks: Array<
93+
| { type: "text", text: string }
94+
| {
95+
type: "tool-call",
96+
toolName: string,
97+
toolCallId: string,
98+
args: Json,
99+
argsText: string,
100+
result: Json,
101+
}
102+
> = [];
103+
104+
result.steps.forEach((step) => {
105+
if (step.text) {
106+
contentBlocks.push({
107+
type: "text",
108+
text: step.text,
109+
});
110+
}
111+
112+
const toolResultsByCallId = new Map(
113+
step.toolResults.map((r) => [r.toolCallId, r])
114+
);
115+
116+
step.toolCalls.forEach((toolCall) => {
117+
const toolResult = toolResultsByCallId.get(toolCall.toolCallId);
118+
contentBlocks.push({
119+
type: "tool-call",
120+
toolName: toolCall.toolName,
121+
toolCallId: toolCall.toolCallId,
122+
args: toolCall.input,
123+
argsText: JSON.stringify(toolCall.input),
124+
result: (toolResult?.output ?? null) as Json,
125+
});
126+
});
127+
});
128+
129+
return {
130+
statusCode: 200,
131+
bodyType: "json" as const,
132+
body: { content: contentBlocks },
133+
};
134+
}
135+
},
136+
});

apps/backend/src/app/api/latest/internal/ai-chat/[threadId]/route.tsx

Lines changed: 1 addition & 139 deletions
Original file line numberDiff line numberDiff line change
@@ -1,144 +1,6 @@
1-
import { getChatAdapter } from "@/lib/ai-chat/adapter-registry";
21
import { globalPrismaClient } from "@/prisma-client";
32
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
4-
import { createOpenAI } from "@ai-sdk/openai";
5-
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString, yupUnion } from "@stackframe/stack-shared/dist/schema-fields";
6-
import { getEnvVariable } from "@stackframe/stack-shared/dist/utils/env";
7-
import { generateText } from "ai";
8-
import { InferType } from "yup";
9-
10-
const textContentSchema = yupObject({
11-
type: yupString().oneOf(["text"]).defined(),
12-
text: yupString().defined(),
13-
});
14-
15-
const toolCallContentSchema = yupObject({
16-
type: yupString().oneOf(["tool-call"]).defined(),
17-
toolName: yupString().defined(),
18-
toolCallId: yupString().defined(),
19-
args: yupMixed().defined(),
20-
argsText: yupString().defined(),
21-
result: yupMixed().defined(),
22-
});
23-
24-
const contentSchema = yupArray(yupUnion(textContentSchema, toolCallContentSchema)).defined();
25-
26-
const messageSchema = yupObject({
27-
role: yupString().oneOf(["user", "assistant", "tool"]).defined(),
28-
content: yupMixed().defined(),
29-
});
30-
31-
// Mock mode sentinel value - when API key is not configured, we return mock responses
32-
const MOCK_API_KEY_SENTINEL = "mock-openrouter-api-key";
33-
const apiKey = getEnvVariable("STACK_OPENROUTER_API_KEY", MOCK_API_KEY_SENTINEL);
34-
const isMockMode = apiKey === MOCK_API_KEY_SENTINEL;
35-
36-
// Only create OpenAI client if not in mock mode
37-
const openai = isMockMode ? null : createOpenAI({
38-
apiKey,
39-
baseURL: "https://openrouter.ai/api/v1",
40-
});
41-
42-
// AI request timeout in milliseconds (2 minutes)
43-
const AI_REQUEST_TIMEOUT_MS = 120_000;
44-
45-
export const POST = createSmartRouteHandler({
46-
metadata: {
47-
hidden: true,
48-
},
49-
request: yupObject({
50-
auth: yupObject({
51-
type: yupString().oneOf(["admin"]).defined(),
52-
tenancy: adaptSchema,
53-
}),
54-
params: yupObject({
55-
threadId: yupString().defined(),
56-
}),
57-
body: yupObject({
58-
context_type: yupString().oneOf(["email-theme", "email-template", "email-draft"]).defined(),
59-
messages: yupArray(messageSchema).defined().min(1),
60-
}),
61-
}),
62-
response: yupObject({
63-
statusCode: yupNumber().oneOf([200]).defined(),
64-
bodyType: yupString().oneOf(["json"]).defined(),
65-
body: yupObject({
66-
content: contentSchema,
67-
}).defined(),
68-
}),
69-
async handler({ body, params, auth: { tenancy } }) {
70-
// Mock mode: return a simple text response without calling AI
71-
if (isMockMode) {
72-
return {
73-
statusCode: 200,
74-
bodyType: "json",
75-
body: {
76-
content: [{
77-
type: "text",
78-
text: "This is a mock AI response. Configure a real API key to enable AI features.",
79-
}],
80-
},
81-
};
82-
}
83-
84-
const adapter = getChatAdapter(body.context_type, tenancy, params.threadId);
85-
// Model is configurable via env var; no default to surface missing config errors
86-
const modelName = getEnvVariable("STACK_AI_MODEL");
87-
88-
if (!openai) {
89-
// This shouldn't happen since we check isMockMode above, but guard anyway
90-
throw new Error("OpenAI client not initialized - STACK_OPENROUTER_API_KEY may be missing");
91-
}
92-
93-
// Validate messages structure before passing to AI
94-
const validatedMessages = body.messages.map(msg => ({
95-
role: msg.role,
96-
content: msg.content,
97-
})) as any; // Cast needed: content is a mixed type from yup schema that doesn't map to AI SDK's strict typing
98-
99-
// Create abort controller for timeout
100-
const controller = new AbortController();
101-
const timeoutId = setTimeout(() => controller.abort(), AI_REQUEST_TIMEOUT_MS);
102-
103-
try {
104-
const result = await generateText({
105-
model: openai(modelName),
106-
system: adapter.systemPrompt,
107-
messages: validatedMessages,
108-
tools: adapter.tools,
109-
abortSignal: controller.signal,
110-
});
111-
112-
const contentBlocks: InferType<typeof contentSchema> = [];
113-
result.steps.forEach((step) => {
114-
if (step.text) {
115-
contentBlocks.push({
116-
type: "text",
117-
text: step.text,
118-
});
119-
}
120-
step.toolCalls.forEach(toolCall => {
121-
contentBlocks.push({
122-
type: "tool-call",
123-
toolName: toolCall.toolName,
124-
toolCallId: toolCall.toolCallId,
125-
args: toolCall.args,
126-
argsText: JSON.stringify(toolCall.args),
127-
result: "success",
128-
});
129-
});
130-
});
131-
132-
return {
133-
statusCode: 200,
134-
bodyType: "json",
135-
body: { content: contentBlocks },
136-
};
137-
} finally {
138-
clearTimeout(timeoutId);
139-
}
140-
},
141-
});
3+
import { adaptSchema, yupArray, yupMixed, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
1424

1435
export const PATCH = createSmartRouteHandler({
1446
metadata: {

0 commit comments

Comments
 (0)