chore: remove Mintlify AI chat from CMD+K widget (#26485)
* chore: remove Mintlify AI chat from CMD+K widget - Remove MintlifyChat component and related state from Kbar.tsx - Delete packages/features/mintlify-chat directory (MintlifyChat.tsx, util.ts) - Delete apps/web/app/api/mintlify-chat API routes and tests - Delete packages/lib/server/mintlifyChatValidation.ts - Remove Mintlify-related env vars from .env.example and turbo.json - Refactor Kbar.tsx to fix lint issues (explicit types, exports at end) Co-Authored-By: peer@cal.com <peer@cal.com> * fix: use ReactNode import instead of JSX from react The JSX type is not exported from 'react' module. Use the global JSX.Element type (available in React projects) and import ReactNode for the children prop type. Co-Authored-By: peer@cal.com <peer@cal.com> * feat: add all event-types and upcoming bookings to KBar - Increase event types limit from 10 to 100 to show more event types - Add useUpcomingBookingsAction hook to fetch and display upcoming bookings - Bookings show title, date, and time in KBar search results - Navigate to booking details page when selecting a booking Co-Authored-By: peer@cal.com <peer@cal.com> * Revert "feat: add all event-types and upcoming bookings to KBar" This reverts commit 69d03397e3820e45e7207eb55b38117d269eae5e. --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
co-authored by
peer@cal.com
Devin AI
parent
81b5d62f07
commit
5794584624
+1
-8
@@ -434,14 +434,7 @@ BLACKLISTED_GUEST_EMAILS=
|
||||
NEXT_PUBLIC_VAPID_PUBLIC_KEY=
|
||||
VAPID_PRIVATE_KEY=
|
||||
|
||||
# Mintlify chat api
|
||||
# Power AI chat in for docs
|
||||
MINTLIFY_CHAT_API_KEY=
|
||||
NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT=
|
||||
NEXT_PUBLIC_CHAT_API_URL=
|
||||
NEXT_PUBLIC_DOCS_URL=
|
||||
|
||||
# Custom privacy policy / terms URLs (for self-hosters: change to your privacy policy / terms URLs)
|
||||
# Custom privacy policy/ terms URLs (for self-hosters: change to your privacy policy / terms URLs)
|
||||
NEXT_PUBLIC_WEBSITE_PRIVACY_POLICY_URL=
|
||||
NEXT_PUBLIC_WEBSITE_TERMS_URL=
|
||||
|
||||
|
||||
@@ -1,470 +0,0 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
import * as mintlifyChatValidation from "@calcom/lib/server/mintlifyChatValidation";
|
||||
|
||||
import { POST as topicPOST } from "../topic/route";
|
||||
import { POST as messagePOST } from "../message/route";
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn();
|
||||
|
||||
// Polyfill Response.json for test environment
|
||||
if (!Response.json) {
|
||||
Response.json = function (data: unknown, init?: ResponseInit) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...init?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
describe("Mintlify Chat Proxy Endpoints", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Set environment variables
|
||||
process.env.MINTLIFY_CHAT_API_KEY = "test-api-key";
|
||||
process.env.NEXT_PUBLIC_CHAT_API_URL = "https://api.mintlify.com";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete process.env.MINTLIFY_CHAT_API_KEY;
|
||||
delete process.env.NEXT_PUBLIC_CHAT_API_URL;
|
||||
});
|
||||
|
||||
describe("POST /api/mintlify-chat/topic", () => {
|
||||
it("should create a topic successfully", async () => {
|
||||
const mockTopicId = "test-topic-123";
|
||||
|
||||
// Mock Mintlify API response
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ topicId: mockTopicId }),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await topicPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(data.topicId).toBe(mockTopicId);
|
||||
|
||||
// Verify fetch was called with correct parameters
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.mintlify.com/topic",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle missing API key", async () => {
|
||||
delete process.env.MINTLIFY_CHAT_API_KEY;
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await topicPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
// Generic error message - doesn't leak internal config details
|
||||
expect(data.error).toBe("Failed to create topic. Please try again later.");
|
||||
});
|
||||
|
||||
it("should handle Mintlify API errors", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await topicPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(data.error).toBe("Failed to create topic");
|
||||
});
|
||||
|
||||
it("should never expose API key in response", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ topicId: "test-topic-123" }),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await topicPOST(request);
|
||||
const responseText = await response.text();
|
||||
|
||||
expect(responseText).not.toContain("test-api-key");
|
||||
expect(responseText).not.toContain("Bearer");
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /api/mintlify-chat/message", () => {
|
||||
it("should send a message and stream response", async () => {
|
||||
const mockMessage = "What is Cal.com?";
|
||||
const mockTopicId = "test-topic-123";
|
||||
const mockResponse = "Cal.com is a scheduling platform.";
|
||||
|
||||
// Create a mock ReadableStream
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(mockResponse));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockStream,
|
||||
headers: new Headers({
|
||||
"content-type": "text/plain",
|
||||
"x-mintlify-base-url": "https://docs.cal.com",
|
||||
}),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: mockMessage,
|
||||
topicId: mockTopicId,
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com");
|
||||
|
||||
// Verify fetch was called with sanitized input
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
"https://api.mintlify.com/message",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer test-api-key",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: mockMessage,
|
||||
topicId: mockTopicId,
|
||||
}),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject invalid JSON payload", async () => {
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: "invalid json",
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toBe("Invalid JSON payload");
|
||||
});
|
||||
|
||||
it("should reject malformed message payload", async () => {
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "",
|
||||
topicId: "test-topic",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain("Invalid message");
|
||||
});
|
||||
|
||||
it("should reject oversized messages", async () => {
|
||||
const largeMessage = "a".repeat(10001); // Exceeds MAX_MESSAGE_LENGTH
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: largeMessage,
|
||||
topicId: "test-topic",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain("exceeds maximum length");
|
||||
});
|
||||
|
||||
it("should reject path traversal in topicId", async () => {
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "test message",
|
||||
topicId: "../../../etc/passwd",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain("Path traversal");
|
||||
});
|
||||
|
||||
it("should reject control characters in message", async () => {
|
||||
const maliciousMessage = "test\x00message\x01with\x02control\x03chars";
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("sanitized response"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockStream,
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: maliciousMessage,
|
||||
topicId: "test-topic-123",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
|
||||
// Should either sanitize (200) or reject with validation error (400)
|
||||
expect([200, 400]).toContain(response.status);
|
||||
|
||||
if (response.status === 200) {
|
||||
// If sanitized, verify control chars were removed from the proxied request
|
||||
const fetchCall = (global.fetch as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
const bodyArg = fetchCall?.[1]?.body;
|
||||
if (typeof bodyArg === "string") {
|
||||
const parsedBody = JSON.parse(bodyArg);
|
||||
expect(parsedBody.message).not.toContain("\x00");
|
||||
expect(parsedBody.message).not.toContain("\x01");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("should reject invalid topicId format", async () => {
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "test message",
|
||||
topicId: "invalid topic id with spaces!@#$",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain("Invalid topicId");
|
||||
});
|
||||
|
||||
it("should sanitize response headers", async () => {
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("response"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
const mockHeaders = new Headers({
|
||||
"content-type": "text/plain",
|
||||
"x-mintlify-base-url": "https://docs.cal.com",
|
||||
"x-custom-dangerous-header": "should-not-appear",
|
||||
"authorization": "Bearer secret-key",
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockStream,
|
||||
headers: mockHeaders,
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "test",
|
||||
topicId: "test-topic-123",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
|
||||
// Verify only safe headers are included
|
||||
expect(response.headers.get("x-mintlify-base-url")).toBe("https://docs.cal.com");
|
||||
expect(response.headers.get("x-custom-dangerous-header")).toBeNull();
|
||||
expect(response.headers.get("authorization")).toBeNull();
|
||||
});
|
||||
|
||||
it("should never expose API key in any response", async () => {
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode("response text"));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockStream,
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "test",
|
||||
topicId: "test-topic-123",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
|
||||
// Verify no API key in response headers
|
||||
expect(response.headers.get("authorization")).toBeNull();
|
||||
expect(response.headers.get("Authorization")).toBeNull();
|
||||
|
||||
// Read response text (for non-stream responses in test env)
|
||||
const responseText = await response.text();
|
||||
expect(responseText).not.toContain("test-api-key");
|
||||
expect(responseText).not.toContain("Bearer");
|
||||
});
|
||||
|
||||
it("should handle missing message field", async () => {
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
topicId: "test-topic-123",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain("Invalid message");
|
||||
});
|
||||
|
||||
it("should handle missing topicId field", async () => {
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "test message",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await messagePOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(data.error).toContain("Invalid topicId");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Security Tests", () => {
|
||||
it("should never leak API key through error messages", async () => {
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
|
||||
new Error("Upstream error with API key: test-api-key")
|
||||
);
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
}) as NextRequest;
|
||||
|
||||
const response = await topicPOST(request);
|
||||
const data = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
// Verify generic error message doesn't contain API key
|
||||
expect(JSON.stringify(data)).not.toContain("test-api-key");
|
||||
expect(data.error).toBe("Failed to create topic. Please try again later.");
|
||||
});
|
||||
|
||||
it("should validate environment variables on every request", async () => {
|
||||
const validateSpy = vi.spyOn(mintlifyChatValidation, "validateMintlifyConfig");
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ topicId: "test" }),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
}) as NextRequest;
|
||||
|
||||
await topicPOST(request);
|
||||
|
||||
expect(validateSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should validate message payload on every request", async () => {
|
||||
const validateSpy = vi.spyOn(mintlifyChatValidation, "validateChatMessage");
|
||||
|
||||
const mockStream = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
|
||||
(global.fetch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 200,
|
||||
body: mockStream,
|
||||
headers: new Headers(),
|
||||
});
|
||||
|
||||
const request = new Request("http://localhost:3000/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
message: "test",
|
||||
topicId: "test-topic-123",
|
||||
}),
|
||||
}) as NextRequest;
|
||||
|
||||
await messagePOST(request);
|
||||
|
||||
expect(validateSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import {
|
||||
validateChatMessage,
|
||||
validateMintlifyConfig,
|
||||
sanitizeResponseHeaders,
|
||||
} from "@calcom/lib/server/mintlifyChatValidation";
|
||||
|
||||
/**
|
||||
* POST /api/mintlify-chat/message
|
||||
*
|
||||
* Sends a message to a Mintlify chat topic and streams the response.
|
||||
* This endpoint proxies requests to Mintlify's API while:
|
||||
* - Keeping the API key secure on the server side
|
||||
* - Validating and sanitizing user input
|
||||
* - Preventing malicious payloads
|
||||
*
|
||||
* Expected body:
|
||||
* {
|
||||
* message: string,
|
||||
* topicId: string
|
||||
* }
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
// Validate configuration
|
||||
const { apiKey, apiBaseUrl } = validateMintlifyConfig();
|
||||
|
||||
// Parse and validate request body
|
||||
let body;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid JSON payload" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Validate and sanitize the message payload
|
||||
let message: string;
|
||||
let topicId: string;
|
||||
|
||||
try {
|
||||
const validated = validateChatMessage(body);
|
||||
message = validated.message;
|
||||
topicId = validated.topicId;
|
||||
} catch (validationError) {
|
||||
// For validation errors, return detailed message with 400
|
||||
if (validationError instanceof Error) {
|
||||
return NextResponse.json(
|
||||
{ error: validationError.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid request" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
// Make request to Mintlify
|
||||
const messageUrl = `${apiBaseUrl}/message`;
|
||||
console.log("[Mintlify Chat Message] Fetching message endpoint", { messageUrl, topicId });
|
||||
|
||||
const queryResponse = await fetch(messageUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
body: JSON.stringify({ message, topicId }),
|
||||
});
|
||||
|
||||
if (!queryResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send message" },
|
||||
{ status: queryResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
if (!queryResponse.body) {
|
||||
return NextResponse.json(
|
||||
{ error: "No response body from Mintlify" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Sanitize response headers before forwarding
|
||||
const sanitizedHeaders = sanitizeResponseHeaders(queryResponse.headers);
|
||||
|
||||
// Stream the response back to the client
|
||||
// We need to pipe the ReadableStream from Mintlify directly to the client
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const reader = queryResponse.body!.getReader();
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
|
||||
if (done) {
|
||||
controller.close();
|
||||
break;
|
||||
}
|
||||
|
||||
controller.enqueue(value);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[Mintlify Chat Message Stream]", error);
|
||||
controller.error(error);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/plain; charset=utf-8",
|
||||
"Transfer-Encoding": "chunked",
|
||||
...sanitizedHeaders,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Return generic error message to avoid leaking internal details
|
||||
// Validation errors are already handled above with specific messages
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to send message. Please try again later." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type { NextRequest } from "next/server";
|
||||
|
||||
import { validateMintlifyConfig } from "@calcom/lib/server/mintlifyChatValidation";
|
||||
|
||||
/**
|
||||
* POST /api/mintlify-chat/topic
|
||||
*
|
||||
* Creates a new Mintlify chat topic.
|
||||
* This endpoint proxies requests to Mintlify's API while keeping
|
||||
* the API key secure on the server side.
|
||||
*/
|
||||
export async function POST(_req: NextRequest) {
|
||||
try {
|
||||
// Validate configuration
|
||||
const { apiKey, apiBaseUrl } = validateMintlifyConfig();
|
||||
|
||||
// Make request to Mintlify
|
||||
const topicUrl = `${apiBaseUrl}/topic`;
|
||||
console.log("[Mintlify Chat Topic] Fetching topic endpoint", { topicUrl });
|
||||
|
||||
const topicResponse = await fetch(topicUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (!topicResponse.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create topic" },
|
||||
{ status: topicResponse.status }
|
||||
);
|
||||
}
|
||||
|
||||
const topic = await topicResponse.json();
|
||||
|
||||
return NextResponse.json(topic);
|
||||
} catch {
|
||||
// Return generic error message to avoid leaking internal details
|
||||
return NextResponse.json(
|
||||
{ error: "Failed to create topic. Please try again later." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+135
-228
@@ -11,28 +11,85 @@ import {
|
||||
useRegisterActions,
|
||||
} from "kbar";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
|
||||
import { MintlifyChat } from "@calcom/features/mintlify-chat/MintlifyChat";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { isMac } from "@calcom/lib/isMac";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
type shortcutArrayType = {
|
||||
type ShortcutArrayType = {
|
||||
shortcuts?: string[];
|
||||
};
|
||||
|
||||
const getApps = Object.values(appStoreMetadata).map(({ name, slug }) => ({
|
||||
type ActionConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
section?: string;
|
||||
shortcut?: string[];
|
||||
keywords: string;
|
||||
href: string;
|
||||
};
|
||||
|
||||
type AppAction = {
|
||||
id: string;
|
||||
name: string;
|
||||
section: string;
|
||||
keywords: string;
|
||||
};
|
||||
|
||||
const getApps: AppAction[] = Object.values(appStoreMetadata).map(({ name, slug }) => ({
|
||||
id: slug,
|
||||
name,
|
||||
section: "Installable Apps",
|
||||
keywords: `app ${name}`,
|
||||
}));
|
||||
|
||||
const useEventTypesAction = () => {
|
||||
const KBAR_ACTION_CONFIGS: ActionConfig[] = [
|
||||
{ id: "workflows", name: "workflows", section: "workflows", shortcut: ["w", "f"], keywords: "workflows automation", href: "/workflows" },
|
||||
{ id: "event-types", name: "event_types_page_title", section: "event_types_page_title", shortcut: ["e", "t"], keywords: "event types", href: "/event-types" },
|
||||
{ id: "app-store", name: "app_store", section: "apps", shortcut: ["a", "s"], keywords: "app store", href: "/apps" },
|
||||
{ id: "upcoming-bookings", name: "upcoming", section: "bookings", shortcut: ["u", "b"], keywords: "upcoming bookings", href: "/bookings/upcoming" },
|
||||
{ id: "recurring-bookings", name: "recurring", section: "bookings", shortcut: ["r", "b"], keywords: "recurring bookings", href: "/bookings/recurring" },
|
||||
{ id: "past-bookings", name: "past", section: "bookings", shortcut: ["p", "b"], keywords: "past bookings", href: "/bookings/past" },
|
||||
{ id: "cancelled-bookings", name: "cancelled", section: "bookings", shortcut: ["c", "b"], keywords: "cancelled bookings", href: "/bookings/cancelled" },
|
||||
{ id: "schedule", name: "availability", section: "availability", shortcut: ["s", "a"], keywords: "schedule availability", href: "/availability" },
|
||||
{ id: "profile", name: "profile", section: "profile", shortcut: ["p", "s"], keywords: "setting profile", href: "/settings/my-account/profile" },
|
||||
{ id: "avatar", name: "change_avatar", section: "profile", shortcut: ["c", "a"], keywords: "remove change modify avatar", href: "/settings/my-account/profile" },
|
||||
{ id: "timezone", name: "timezone", section: "profile", shortcut: ["c", "t"], keywords: "change modify timezone", href: "/settings/my-account/general" },
|
||||
{ id: "brand-color", name: "brand_color", section: "profile", shortcut: ["b", "c"], keywords: "change modify brand color", href: "/settings/my-account/appearance" },
|
||||
{ id: "teams", name: "teams", shortcut: ["t", "s"], keywords: "add manage modify team", href: "/settings/teams" },
|
||||
{ id: "password", name: "change_password", section: "security", shortcut: ["c", "p"], keywords: "change modify password", href: "/settings/security/password" },
|
||||
{ id: "two-factor", name: "two_factor_auth", section: "security", shortcut: ["t", "f", "a"], keywords: "two factor authentication", href: "/settings/security/two-factor-auth" },
|
||||
{ id: "impersonation", name: "user_impersonation_heading", section: "security", shortcut: ["u", "i"], keywords: "user impersonation", href: "/settings/security/impersonation" },
|
||||
{ id: "license", name: "choose_a_license", section: "admin", shortcut: ["u", "l"], keywords: "license", href: "/auth/setup?step=1" },
|
||||
{ id: "webhooks", name: "Webhooks", section: "developer", shortcut: ["w", "h"], keywords: "webhook automation", href: "/settings/developer/webhooks" },
|
||||
{ id: "api-keys", name: "api_keys", section: "developer", shortcut: ["a", "p", "i"], keywords: "api keys", href: "/settings/developer/api-keys" },
|
||||
{ id: "billing", name: "manage_billing", section: "billing", shortcut: ["m", "b"], keywords: "billing view manage", href: "/settings/billing" },
|
||||
];
|
||||
|
||||
function buildKbarActions(push: (href: string) => void): Action[] {
|
||||
const staticActions: Action[] = KBAR_ACTION_CONFIGS.map((config) => ({
|
||||
id: config.id,
|
||||
name: config.name,
|
||||
section: config.section,
|
||||
shortcut: config.shortcut,
|
||||
keywords: config.keywords,
|
||||
perform: () => push(config.href),
|
||||
}));
|
||||
|
||||
const appStoreActions: Action[] = getApps.map((item) => ({
|
||||
...item,
|
||||
perform: () => push(`/apps/${item.id}`),
|
||||
}));
|
||||
|
||||
return [...staticActions, ...appStoreActions];
|
||||
}
|
||||
|
||||
function useEventTypesAction(): void {
|
||||
const router = useRouter();
|
||||
const { data } = trpc.viewer.eventTypes.getEventTypesFromGroup.useInfiniteQuery(
|
||||
{
|
||||
@@ -42,7 +99,7 @@ const useEventTypesAction = () => {
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
staleTime: 1 * 60 * 60 * 1000,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor,
|
||||
getNextPageParam: (lastPage: { nextCursor?: number | null }) => lastPage.nextCursor,
|
||||
}
|
||||
);
|
||||
|
||||
@@ -59,196 +116,31 @@ const useEventTypesAction = () => {
|
||||
);
|
||||
}) ?? [];
|
||||
|
||||
const actions = eventTypeActions?.length ? eventTypeActions : [];
|
||||
let actions: Action[] = [];
|
||||
if (eventTypeActions.length > 0) {
|
||||
actions = eventTypeActions;
|
||||
}
|
||||
|
||||
return useRegisterActions(actions);
|
||||
};
|
||||
useRegisterActions(actions);
|
||||
}
|
||||
|
||||
export const KBarRoot = ({ children }: { children: React.ReactNode }) => {
|
||||
const KBarRoot = ({ children }: { children: ReactNode }): JSX.Element => {
|
||||
const router = useRouter();
|
||||
|
||||
// grab link to events
|
||||
// quick nested actions would be extremely useful
|
||||
|
||||
const actions = useMemo(() => {
|
||||
const appStoreActions = getApps.map((item) => ({
|
||||
...item,
|
||||
perform: () => router.push(`/apps/${item.id}`),
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: "workflows",
|
||||
name: "workflows",
|
||||
section: "workflows",
|
||||
shortcut: ["w", "f"],
|
||||
keywords: "workflows automation",
|
||||
perform: () => router.push("/workflows"),
|
||||
},
|
||||
{
|
||||
id: "event-types",
|
||||
name: "event_types_page_title",
|
||||
section: "event_types_page_title",
|
||||
shortcut: ["e", "t"],
|
||||
keywords: "event types",
|
||||
perform: () => router.push("/event-types"),
|
||||
},
|
||||
{
|
||||
id: "app-store",
|
||||
name: "app_store",
|
||||
section: "apps",
|
||||
shortcut: ["a", "s"],
|
||||
keywords: "app store",
|
||||
perform: () => router.push("/apps"),
|
||||
},
|
||||
{
|
||||
id: "upcoming-bookings",
|
||||
name: "upcoming",
|
||||
section: "bookings",
|
||||
shortcut: ["u", "b"],
|
||||
keywords: "upcoming bookings",
|
||||
perform: () => router.push("/bookings/upcoming"),
|
||||
},
|
||||
{
|
||||
id: "recurring-bookings",
|
||||
name: "recurring",
|
||||
section: "bookings",
|
||||
shortcut: ["r", "b"],
|
||||
keywords: "recurring bookings",
|
||||
perform: () => router.push("/bookings/recurring"),
|
||||
},
|
||||
{
|
||||
id: "past-bookings",
|
||||
name: "past",
|
||||
section: "bookings",
|
||||
shortcut: ["p", "b"],
|
||||
keywords: "past bookings",
|
||||
perform: () => router.push("/bookings/past"),
|
||||
},
|
||||
{
|
||||
id: "cancelled-bookings",
|
||||
name: "cancelled",
|
||||
section: "bookings",
|
||||
shortcut: ["c", "b"],
|
||||
keywords: "cancelled bookings",
|
||||
perform: () => router.push("/bookings/cancelled"),
|
||||
},
|
||||
{
|
||||
id: "schedule",
|
||||
name: "availability",
|
||||
section: "availability",
|
||||
shortcut: ["s", "a"],
|
||||
keywords: "schedule availability",
|
||||
perform: () => router.push("/availability"),
|
||||
},
|
||||
{
|
||||
id: "profile",
|
||||
name: "profile",
|
||||
section: "profile",
|
||||
shortcut: ["p", "s"],
|
||||
keywords: "setting profile",
|
||||
perform: () => router.push("/settings/my-account/profile"),
|
||||
},
|
||||
{
|
||||
id: "avatar",
|
||||
name: "change_avatar",
|
||||
section: "profile",
|
||||
shortcut: ["c", "a"],
|
||||
keywords: "remove change modify avatar",
|
||||
perform: () => router.push("/settings/my-account/profile"),
|
||||
},
|
||||
{
|
||||
id: "timezone",
|
||||
name: "timezone",
|
||||
section: "profile",
|
||||
shortcut: ["c", "t"],
|
||||
keywords: "change modify timezone",
|
||||
perform: () => router.push("/settings/my-account/general"),
|
||||
},
|
||||
{
|
||||
id: "brand-color",
|
||||
name: "brand_color",
|
||||
section: "profile",
|
||||
shortcut: ["b", "c"],
|
||||
keywords: "change modify brand color",
|
||||
perform: () => router.push("/settings/my-account/appearance"),
|
||||
},
|
||||
{
|
||||
id: "teams",
|
||||
name: "teams",
|
||||
shortcut: ["t", "s"],
|
||||
keywords: "add manage modify team",
|
||||
perform: () => router.push("/settings/teams"),
|
||||
},
|
||||
{
|
||||
id: "password",
|
||||
name: "change_password",
|
||||
section: "security",
|
||||
shortcut: ["c", "p"],
|
||||
keywords: "change modify password",
|
||||
perform: () => router.push("/settings/security/password"),
|
||||
},
|
||||
{
|
||||
id: "two-factor",
|
||||
name: "two_factor_auth",
|
||||
section: "security",
|
||||
shortcut: ["t", "f", "a"],
|
||||
keywords: "two factor authentication",
|
||||
perform: () => router.push("/settings/security/two-factor-auth"),
|
||||
},
|
||||
{
|
||||
id: "impersonation",
|
||||
name: "user_impersonation_heading",
|
||||
section: "security",
|
||||
shortcut: ["u", "i"],
|
||||
keywords: "user impersonation",
|
||||
perform: () => router.push("/settings/security/impersonation"),
|
||||
},
|
||||
{
|
||||
id: "license",
|
||||
name: "choose_a_license",
|
||||
section: "admin",
|
||||
shortcut: ["u", "l"],
|
||||
keywords: "license",
|
||||
perform: () => router.push("/auth/setup?step=1"),
|
||||
},
|
||||
{
|
||||
id: "webhooks",
|
||||
name: "Webhooks",
|
||||
section: "developer",
|
||||
shortcut: ["w", "h"],
|
||||
keywords: "webhook automation",
|
||||
perform: () => router.push("/settings/developer/webhooks"),
|
||||
},
|
||||
{
|
||||
id: "api-keys",
|
||||
name: "api_keys",
|
||||
section: "developer",
|
||||
shortcut: ["a", "p", "i"],
|
||||
keywords: "api keys",
|
||||
perform: () => router.push("/settings/developer/api-keys"),
|
||||
},
|
||||
{
|
||||
id: "billing",
|
||||
name: "manage_billing",
|
||||
section: "billing",
|
||||
shortcut: ["m", "b"],
|
||||
keywords: "billing view manage",
|
||||
perform: () => router.push("/settings/billing"),
|
||||
},
|
||||
...appStoreActions,
|
||||
];
|
||||
}, []);
|
||||
const actions = useMemo(() => buildKbarActions(router.push), [router.push]);
|
||||
|
||||
return <KBarProvider actions={actions}>{children}</KBarProvider>;
|
||||
};
|
||||
|
||||
export const KBarContent = () => {
|
||||
function CommandKey(): JSX.Element {
|
||||
if (isMac) {
|
||||
return <Icon name="command" className="h-3 w-3" />;
|
||||
}
|
||||
return <>CTRL</>;
|
||||
}
|
||||
|
||||
const KBarContent = (): JSX.Element => {
|
||||
const { t } = useLocale();
|
||||
useEventTypesAction();
|
||||
const [inputText, setInputText] = useState("");
|
||||
const [aiResponse, setAiResponse] = useState("");
|
||||
const showAiChat =
|
||||
process.env.NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT === "true" && !!process.env.NEXT_PUBLIC_CHAT_API_URL;
|
||||
|
||||
return (
|
||||
<KBarPortal>
|
||||
@@ -259,23 +151,15 @@ export const KBarContent = () => {
|
||||
<KBarSearch
|
||||
defaultPlaceholder={t("kbar_search_placeholder")}
|
||||
className="bg-default placeholder:text-subtle text-default w-full rounded-sm border-0 py-2.5 px-0 focus:ring-0 focus-visible:outline-none"
|
||||
value={inputText}
|
||||
onChange={(e) => {
|
||||
setInputText(e.currentTarget.value.trim());
|
||||
if (aiResponse) setAiResponse("");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{showAiChat && inputText && (
|
||||
<MintlifyChat aiResponse={aiResponse} setAiResponse={setAiResponse} searchText={inputText} />
|
||||
)}
|
||||
<RenderResults />
|
||||
<div className="text-subtle border-subtle hidden items-center space-x-1 border-t px-2 py-1.5 text-xs sm:flex">
|
||||
<Icon name="arrow-up" className="h-4 w-4" />
|
||||
<Icon name="arrow-down" className="h-4 w-4" /> <span className="pr-2">{t("navigate")}</span>
|
||||
<Icon name="corner-down-left" className="h-4 w-4" />
|
||||
<span className="pr-2">{t("open")}</span>
|
||||
{isMac ? <Icon name="command" className="h-3 w-3" /> : "CTRL"}
|
||||
<CommandKey />
|
||||
<span className="pr-1">+ K </span>
|
||||
<span className="pr-2">{t("close")}</span>
|
||||
</div>
|
||||
@@ -286,23 +170,33 @@ export const KBarContent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const KBarTrigger = () => {
|
||||
function getTooltipContent(): string {
|
||||
if (isMac) {
|
||||
return "⌘ + K";
|
||||
}
|
||||
return "CTRL + K";
|
||||
}
|
||||
|
||||
const KBarTrigger = (): JSX.Element | null => {
|
||||
const { query } = useKBar();
|
||||
return query ? (
|
||||
<>
|
||||
<Tooltip side="right" content={isMac ? "⌘ + K" : "CTRL + K"}>
|
||||
<button
|
||||
color="minimal"
|
||||
onClick={query.toggle}
|
||||
className="text-default hover:bg-subtle todesktop:hover:!bg-transparent lg:hover:bg-emphasis lg:hover:text-emphasis group flex rounded-md px-3 py-2 text-sm font-medium transition lg:px-2">
|
||||
<Icon name="search" className="h-4 w-4 shrink-0 text-inherit" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
</>
|
||||
) : null;
|
||||
|
||||
if (!query) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip side="right" content={getTooltipContent()}>
|
||||
<button
|
||||
color="minimal"
|
||||
onClick={query.toggle}
|
||||
className="text-default hover:bg-subtle todesktop:hover:!bg-transparent lg:hover:bg-emphasis lg:hover:text-emphasis group flex rounded-md px-3 py-2 text-sm font-medium transition lg:px-2">
|
||||
<Icon name="search" className="h-4 w-4 shrink-0 text-inherit" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
const DisplayShortcuts = (item: shortcutArrayType) => {
|
||||
function DisplayShortcuts(item: ShortcutArrayType): JSX.Element {
|
||||
const shortcuts = item.shortcuts;
|
||||
|
||||
return (
|
||||
@@ -318,32 +212,45 @@ const DisplayShortcuts = (item: shortcutArrayType) => {
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderItemProps = {
|
||||
item: string | Action;
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
function RenderResults() {
|
||||
function renderResultItem(item: string | Action, active: boolean, t: (key: string) => string): JSX.Element {
|
||||
if (typeof item === "string") {
|
||||
return <div className="bg-default text-emphasis p-4 text-xs font-bold uppercase">{t(item)}</div>;
|
||||
}
|
||||
|
||||
let background = "var(--cal-bg-default)";
|
||||
let borderLeft = "2px solid transparent";
|
||||
if (active) {
|
||||
background = "var(--cal-bg-subtle)";
|
||||
borderLeft = "2px solid var(--cal-border)";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{ background, borderLeft, color: "var(--cal-text)" }}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-sm transition hover:cursor-pointer">
|
||||
<span>{t(item.name)}</span>
|
||||
<DisplayShortcuts shortcuts={item.shortcut} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RenderResults(): JSX.Element {
|
||||
const { results } = useMatches();
|
||||
const { t } = useLocale();
|
||||
|
||||
return (
|
||||
<KBarResults
|
||||
items={results}
|
||||
onRender={({ item, active }) =>
|
||||
typeof item === "string" ? (
|
||||
<div className="bg-default text-emphasis p-4 text-xs font-bold uppercase">{t(item)}</div>
|
||||
) : (
|
||||
<div
|
||||
// For seeing keyboard up & down navigation in action, we need visual feedback based on "active" prop
|
||||
style={{
|
||||
background: active ? "var(--cal-bg-subtle)" : `var(--cal-bg-default)`,
|
||||
borderLeft: active ? "2px solid var(--cal-border)" : "2px solid transparent",
|
||||
color: "var(--cal-text)",
|
||||
}}
|
||||
className="flex items-center justify-between px-4 py-2.5 text-sm transition hover:cursor-pointer">
|
||||
<span>{t(item.name)}</span>
|
||||
<DisplayShortcuts shortcuts={item.shortcut} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
onRender={({ item, active }: RenderItemProps): JSX.Element => renderResultItem(item, active, t)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { KBarRoot, KBarContent, KBarTrigger };
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
/* eslint-disable react/no-danger */
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import { useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { SkeletonText, SkeletonContainer } from "@calcom/ui/components/skeleton";
|
||||
|
||||
import { getFormattedCitations, handleAiChat, optionallyAddBaseUrl } from "../mintlify-chat/util";
|
||||
|
||||
interface MintlifyChatProps {
|
||||
searchText: string;
|
||||
aiResponse: string;
|
||||
setAiResponse: Dispatch<SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export const MintlifyChat = ({ searchText, aiResponse, setAiResponse }: MintlifyChatProps) => {
|
||||
const { t } = useLocale();
|
||||
const [topicId, setTopicId] = useState("");
|
||||
const [baseUrl, setBaseUrl] = useState(process.env.NEXT_PUBLIC_DOCS_URL ?? "");
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
const onChunkReceived = (chunk: string, baseUrl?: string, finalChunk?: boolean) => {
|
||||
setAiResponse((prev) => {
|
||||
return prev + chunk;
|
||||
});
|
||||
if (baseUrl) {
|
||||
setBaseUrl(baseUrl);
|
||||
}
|
||||
if (finalChunk) {
|
||||
setIsGenerating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const citations = getFormattedCitations(aiResponse.split("||")[1]) ?? [];
|
||||
const answer = aiResponse.split("||")[0] ?? "";
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={async () => {
|
||||
if (isGenerating) return;
|
||||
setIsGenerating(true);
|
||||
setAiResponse("");
|
||||
setError("");
|
||||
const { id, error } = await handleAiChat(onChunkReceived, searchText, topicId);
|
||||
if (id) {
|
||||
setTopicId(id);
|
||||
} else if (error) {
|
||||
setIsGenerating(false);
|
||||
setError(error);
|
||||
}
|
||||
}}
|
||||
className={classNames(
|
||||
"hover:bg-subtle flex items-center gap-3 px-4 py-2 transition",
|
||||
isGenerating ? "cursor-not-allowed" : "cursor-pointer"
|
||||
)}>
|
||||
<div>
|
||||
<Icon name="star" />
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
{t("can_you_tell_me_about")} <span className="font-bold">{searchText}</span>
|
||||
</div>
|
||||
<div className="text-subtle text-sm">{t("use_ai_to_answer_your_questions")}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-2 px-4 text-sm">
|
||||
{error && <p className="mt-1 text-xs text-red-500">{error}</p>}
|
||||
{isGenerating && aiResponse === "" ? (
|
||||
<SkeletonContainer>
|
||||
<SkeletonText className="h-12 w-full" />
|
||||
</SkeletonContainer>
|
||||
) : (
|
||||
<>
|
||||
<div dangerouslySetInnerHTML={{ __html: markdownToSafeHTML(answer) }} />
|
||||
<div className="my-1 flex flex-wrap gap-2">
|
||||
{baseUrl &&
|
||||
citations.map((citation) => {
|
||||
if (citation.title) {
|
||||
const url = optionallyAddBaseUrl(baseUrl, citation.url);
|
||||
return (
|
||||
<a key={url} href={url} target="_blank">
|
||||
<div className="flex h-6 items-center gap-1 rounded-md bg-gray-100 px-1.5 text-xs text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700">
|
||||
{citation.title}
|
||||
</div>
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* This file contains utility functions for interacting with the Mintlify chat API.
|
||||
* The code was adapted from https://mintlify.com/docs/advanced/rest-api/overview#getting-started. The original source can be found at https://github.com/mintlify/discovery-api-example/tree/main/src/utils.
|
||||
*
|
||||
* NOTE: These functions now call our internal proxy API routes at /api/mintlify-chat/*
|
||||
* instead of calling Mintlify directly. This keeps the API key secure on the server side.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Creates a new Mintlify chat topic via our internal proxy
|
||||
*/
|
||||
export const createChat = async () => {
|
||||
try {
|
||||
const topicResponse = await fetch("/api/mintlify-chat/topic", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
if (!topicResponse.ok) {
|
||||
console.error("Failed to create chat topic:", topicResponse.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const topic: unknown = await topicResponse.json();
|
||||
|
||||
if (topic && typeof topic === "object" && "topicId" in topic && typeof topic.topicId === "string") {
|
||||
return topic.topicId;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error creating chat topic:", error);
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates a response from Mintlify via our internal proxy
|
||||
* Streams the response back to the caller in chunks
|
||||
*/
|
||||
export const generateResponse = async ({
|
||||
topicId,
|
||||
userQuery,
|
||||
onChunkReceived,
|
||||
}: {
|
||||
topicId: string;
|
||||
userQuery: string;
|
||||
onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void;
|
||||
}) => {
|
||||
try {
|
||||
const queryResponse = await fetch("/api/mintlify-chat/message", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ message: userQuery, topicId }),
|
||||
});
|
||||
|
||||
if (!queryResponse.ok || !queryResponse.body) {
|
||||
throw Error(queryResponse.statusText);
|
||||
}
|
||||
|
||||
const streamReader = queryResponse.body.getReader();
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) {
|
||||
onChunkReceived("", queryResponse.headers.get("x-mintlify-base-url") ?? "", true);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
const newValue = new TextDecoder().decode(value);
|
||||
onChunkReceived(newValue);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Error generating response:", error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export const handleAiChat = async (
|
||||
onChunkReceived: (chunk: string, baseUrl?: string, finalChunk?: boolean) => void,
|
||||
userQuery: string,
|
||||
topicId?: string
|
||||
) => {
|
||||
let id = null;
|
||||
let error = "";
|
||||
try {
|
||||
if (!topicId) {
|
||||
id = await createChat();
|
||||
}
|
||||
|
||||
if (!id)
|
||||
return {
|
||||
id,
|
||||
error: "Error creating topic. Please try again later",
|
||||
};
|
||||
|
||||
await generateResponse({
|
||||
topicId: id,
|
||||
onChunkReceived,
|
||||
userQuery,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error) {
|
||||
error = err.message;
|
||||
} else {
|
||||
error = "k_bar_ai_error";
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
error,
|
||||
};
|
||||
};
|
||||
|
||||
type ChunkMetadata = {
|
||||
id: string;
|
||||
link?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
chunk_html?: string;
|
||||
};
|
||||
|
||||
export const generateDeeplink = (chunkMetadata: ChunkMetadata) => {
|
||||
if (
|
||||
!(
|
||||
"metadata" in chunkMetadata &&
|
||||
!!chunkMetadata.metadata &&
|
||||
"title" in chunkMetadata.metadata &&
|
||||
"link" in chunkMetadata &&
|
||||
typeof chunkMetadata.link === "string"
|
||||
)
|
||||
)
|
||||
return "";
|
||||
const section = chunkMetadata.metadata.title;
|
||||
const link = optionallyAddLeadingSlash(chunkMetadata.link);
|
||||
if (section && typeof section === "string") {
|
||||
const sectionSlug = section
|
||||
.toLowerCase()
|
||||
.replaceAll(" ", "-")
|
||||
.replaceAll(/[^a-zA-Z0-9-_#]/g, "");
|
||||
|
||||
return `${link}#${sectionSlug}`;
|
||||
}
|
||||
|
||||
return link;
|
||||
};
|
||||
|
||||
type UnformattedCitation = {
|
||||
id: string;
|
||||
link: string;
|
||||
chunk_html: string;
|
||||
metadata: Record<string, string>;
|
||||
};
|
||||
|
||||
export type Citation = {
|
||||
citationNumber: number;
|
||||
title: string;
|
||||
url: string;
|
||||
rootRecordId?: number;
|
||||
rootRecordType?: string;
|
||||
};
|
||||
|
||||
export function getFormattedCitations(rawContent?: string): Citation[] {
|
||||
try {
|
||||
const citations: UnformattedCitation[] = JSON.parse(rawContent ?? "[]");
|
||||
|
||||
const uniqueCitations = new Map(
|
||||
citations.map((citation, index) => {
|
||||
const title = citation.metadata.title ?? "";
|
||||
const formattedCitation = {
|
||||
citationNumber: index,
|
||||
title: citation.metadata.title ?? "",
|
||||
url: generateDeeplink(citation),
|
||||
};
|
||||
|
||||
return [title, formattedCitation];
|
||||
})
|
||||
);
|
||||
|
||||
return Array.from(uniqueCitations.values());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function optionallyRemoveLeadingSlash(path: string) {
|
||||
return path.startsWith("/") ? path.substring(1) : path;
|
||||
}
|
||||
|
||||
export function optionallyAddLeadingSlash(path: string) {
|
||||
return path.startsWith("/") ? path : `/${path}`;
|
||||
}
|
||||
|
||||
export function optionallyAddBaseUrl(baseUrl: string, url: string) {
|
||||
// absolute urls
|
||||
if (url.startsWith("https://")) return url;
|
||||
|
||||
const urlWithLeadingSlash = optionallyAddLeadingSlash(url);
|
||||
return `${baseUrl}${urlWithLeadingSlash}`;
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
/**
|
||||
* Validation and sanitization helpers for Mintlify chat proxy
|
||||
*
|
||||
* This module provides security utilities to validate user input before
|
||||
* proxying requests to the Mintlify API, preventing:
|
||||
* - Path traversal attacks
|
||||
* - Control character injection
|
||||
* - Malicious payloads
|
||||
* - Oversized requests
|
||||
*/
|
||||
|
||||
const MAX_MESSAGE_LENGTH = 10000; // 10KB max message length
|
||||
const MAX_TOPIC_ID_LENGTH = 200;
|
||||
|
||||
const PATH_TRAVERSAL_PATTERNS = ["../", "..\\"]; // basic sequences we never expect inside identifiers
|
||||
|
||||
const stripControlCharacters = (input: string) => {
|
||||
if (typeof input !== "string") {
|
||||
throw new Error("Input must be a string");
|
||||
}
|
||||
|
||||
// Remove null bytes
|
||||
let sanitized = input.replace(/\0/g, "");
|
||||
|
||||
// Remove other control characters (except newlines, tabs, carriage returns which might be needed in messages)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
sanitized = sanitized.replace(/[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]/g, "");
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
/**
|
||||
* Sanitizes free-form user messages by stripping control characters.
|
||||
* This intentionally does not reject path-like substrings so users can ask about "../".
|
||||
*/
|
||||
export function sanitizeMessageString(input: string): string {
|
||||
return stripControlCharacters(input);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes identifiers (topic IDs) and rejects path traversal attempts.
|
||||
*/
|
||||
export function sanitizeTopicIdentifier(input: string): string {
|
||||
const sanitized = stripControlCharacters(input);
|
||||
|
||||
// Check for path traversal attempts
|
||||
if (PATH_TRAVERSAL_PATTERNS.some((pattern) => sanitized.includes(pattern))) {
|
||||
throw new Error("Path traversal detected");
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a chat message payload for the /message endpoint
|
||||
*/
|
||||
export function validateChatMessage(payload: unknown): {
|
||||
message: string;
|
||||
topicId: string;
|
||||
} {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
throw new Error("Invalid payload: must be an object");
|
||||
}
|
||||
|
||||
const { message, topicId } = payload as Record<string, unknown>;
|
||||
|
||||
// Validate message
|
||||
if (typeof message !== "string") {
|
||||
throw new Error("Invalid message: must be a string");
|
||||
}
|
||||
|
||||
if (message.length === 0) {
|
||||
throw new Error("Invalid message: cannot be empty");
|
||||
}
|
||||
|
||||
if (message.length > MAX_MESSAGE_LENGTH) {
|
||||
throw new Error(`Invalid message: exceeds maximum length of ${MAX_MESSAGE_LENGTH} characters`);
|
||||
}
|
||||
|
||||
// Validate topicId
|
||||
if (typeof topicId !== "string") {
|
||||
throw new Error("Invalid topicId: must be a string");
|
||||
}
|
||||
|
||||
if (topicId.length === 0) {
|
||||
throw new Error("Invalid topicId: cannot be empty");
|
||||
}
|
||||
|
||||
if (topicId.length > MAX_TOPIC_ID_LENGTH) {
|
||||
throw new Error(`Invalid topicId: exceeds maximum length of ${MAX_TOPIC_ID_LENGTH} characters`);
|
||||
}
|
||||
|
||||
// Sanitize both fields
|
||||
const sanitizedMessage = sanitizeMessageString(message);
|
||||
const sanitizedTopicId = sanitizeTopicIdentifier(topicId);
|
||||
|
||||
// Validate topicId format (alphanumeric, dashes, underscores only)
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(sanitizedTopicId)) {
|
||||
throw new Error("Invalid topicId: must contain only alphanumeric characters, dashes, and underscores");
|
||||
}
|
||||
|
||||
return {
|
||||
message: sanitizedMessage,
|
||||
topicId: sanitizedTopicId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that the API key and base URL are configured
|
||||
*/
|
||||
export function validateMintlifyConfig(): {
|
||||
apiKey: string;
|
||||
apiBaseUrl: string;
|
||||
} {
|
||||
const apiKey = process.env.MINTLIFY_CHAT_API_KEY;
|
||||
const rawApiBaseUrl = process.env.NEXT_PUBLIC_CHAT_API_URL;
|
||||
const apiBaseUrl = rawApiBaseUrl?.replace(/\/+$/, "");
|
||||
|
||||
if (!apiKey) {
|
||||
throw new Error("MINTLIFY_CHAT_API_KEY is not configured");
|
||||
}
|
||||
|
||||
if (!apiBaseUrl) {
|
||||
throw new Error("NEXT_PUBLIC_CHAT_API_URL is not configured");
|
||||
}
|
||||
|
||||
return { apiKey, apiBaseUrl };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes response headers before sending to client
|
||||
* Only allows safe headers through
|
||||
*/
|
||||
export function sanitizeResponseHeaders(headers: Headers): Record<string, string> {
|
||||
const allowedHeaders = ["content-type", "x-mintlify-base-url"];
|
||||
const sanitized: Record<string, string> = {};
|
||||
|
||||
allowedHeaders.forEach((header) => {
|
||||
const value = headers.get(header);
|
||||
if (value) {
|
||||
sanitized[header] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
@@ -228,10 +228,6 @@
|
||||
"LOCAL_TESTING_DOMAIN_VERCEL",
|
||||
"AUTH_BEARER_TOKEN_CLOUDFLARE",
|
||||
"CLOUDFLARE_ZONE_ID",
|
||||
"MINTLIFY_CHAT_API_KEY",
|
||||
"NEXT_PUBLIC_CHAT_API_URL",
|
||||
"NEXT_PUBLIC_DOCS_URL",
|
||||
"NEXT_PUBLIC_ENABLE_MINTLIFY_CHAT",
|
||||
"CLOUDFLARE_VERCEL_CNAME",
|
||||
"CLOUDFLARE_DNS",
|
||||
"EMBED_PUBLIC_EMBED_FINGER_PRINT",
|
||||
|
||||
Reference in New Issue
Block a user