Skip to content

Commit 0b3d362

Browse files
ouiliameclaude
andcommitted
docs: render Ask AI answers as markdown + harden the chat endpoint
Markdown: render assistant messages with streamdown (the same AI-streaming markdown component the main app's chat uses), so bold/lists/code render instead of raw **asterisks**. User messages stay plain text. Abuse guards: the endpoint proxies a paid LLM, so cap the cost of any single request — max messages, max input size, max output tokens, fewer tool steps — and reject obvious cross-origin calls (lenient: Origin is a filter, not a boundary). Durable per-IP rate limiting, a provider spend cap, and edge bot protection are provisioned separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent c9c01c1 commit 0b3d362

4 files changed

Lines changed: 71 additions & 11 deletions

File tree

apps/docs/app/api/chat/route.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,45 @@ const CHAT_MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-5.4-mini'
1414
/** Max documentation chunks returned per search to ground an answer. */
1515
const SEARCH_LIMIT = 6
1616

17+
/**
18+
* Abuse guards. This endpoint proxies a paid LLM, so an unauthenticated public
19+
* route is a target for scripted "free inference". These bounds cap the cost of
20+
* any single request; durable per-IP rate limiting, a provider spend cap, and
21+
* edge bot protection are provisioned separately (see the PR checklist).
22+
*/
23+
const MAX_MESSAGES = 30
24+
const MAX_INPUT_CHARS = 12_000
25+
const MAX_OUTPUT_TOKENS = 800
26+
const MAX_STEPS = 3
27+
28+
/**
29+
* Reject obvious cross-origin calls. Same-origin browser requests send an
30+
* `Origin` header matching the host; we allow those, plus any host in
31+
* DOCS_ALLOWED_ORIGINS (comma-separated). Requests with no Origin (e.g. curl)
32+
* are allowed through to the cost caps rather than blocked, since Origin is
33+
* trivially spoofable and is a filter, not a security boundary.
34+
*/
35+
function isAllowedOrigin(req: Request): boolean {
36+
const origin = req.headers.get('origin')
37+
if (!origin) return true
38+
39+
let originHost: string
40+
try {
41+
originHost = new URL(origin).host
42+
} catch {
43+
return false
44+
}
45+
46+
const requestHost = req.headers.get('x-forwarded-host') ?? req.headers.get('host')
47+
if (originHost === requestHost) return true
48+
49+
const allowlist = (process.env.DOCS_ALLOWED_ORIGINS ?? '')
50+
.split(',')
51+
.map((value) => value.trim())
52+
.filter(Boolean)
53+
return allowlist.includes(originHost)
54+
}
55+
1756
const SYSTEM_PROMPT = `You are the documentation assistant for Sim — the open-source AI workspace where teams build, deploy, and manage AI agents.
1857
1958
Answer questions about Sim using the documentation. Always call the searchDocs tool before answering anything specific about Sim's features, configuration, or usage — do not answer from memory. Base your answer only on the returned documentation; if the docs do not cover the question, say so plainly rather than guessing.
@@ -52,13 +91,25 @@ async function searchDocs(query: string) {
5291
}
5392

5493
export async function POST(req: Request) {
94+
if (!isAllowedOrigin(req)) {
95+
return new Response('Forbidden', { status: 403 })
96+
}
97+
5598
const { messages }: { messages: UIMessage[] } = await req.json()
5699

100+
if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) {
101+
return new Response('Invalid request', { status: 400 })
102+
}
103+
if (JSON.stringify(messages).length > MAX_INPUT_CHARS) {
104+
return new Response('Request too large', { status: 413 })
105+
}
106+
57107
const result = streamText({
58108
model: openai(CHAT_MODEL),
59109
system: SYSTEM_PROMPT,
60110
messages: convertToModelMessages(messages),
61-
stopWhen: stepCountIs(5),
111+
stopWhen: stepCountIs(MAX_STEPS),
112+
maxOutputTokens: MAX_OUTPUT_TOKENS,
62113
tools: {
63114
searchDocs: tool({
64115
description:

apps/docs/components/ai/ask-ai.tsx

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ import { type FormEvent, useEffect, useRef, useState } from 'react'
44
import { useChat } from '@ai-sdk/react'
55
import { DefaultChatTransport } from 'ai'
66
import { ArrowUp, MessageCircle, Square, X } from 'lucide-react'
7+
import { Streamdown } from 'streamdown'
78
import { cn } from '@/lib/utils'
9+
import 'streamdown/styles.css'
810

911
interface DocSource {
1012
title: string
@@ -110,16 +112,21 @@ export function AskAI() {
110112
message.role === 'user' ? 'items-end' : 'items-start'
111113
)}
112114
>
113-
<div
114-
className={cn(
115-
'max-w-[90%] whitespace-pre-wrap rounded-lg px-3 py-2 text-sm',
116-
message.role === 'user'
117-
? 'bg-[var(--surface-active)] text-[var(--text-base)]'
118-
: 'text-[var(--text-base)]'
119-
)}
120-
>
121-
{text || (isBusy ? '…' : '')}
122-
</div>
115+
{message.role === 'user' ? (
116+
<div className='max-w-[90%] whitespace-pre-wrap rounded-lg bg-[var(--surface-active)] px-3 py-2 text-[var(--text-base)] text-sm'>
117+
{text}
118+
</div>
119+
) : (
120+
<div className='max-w-[90%] text-[var(--text-base)] text-sm'>
121+
{text ? (
122+
<Streamdown className='space-y-2 text-sm leading-relaxed [&_a]:text-[var(--text-link)] [&_a]:underline [&_li]:my-0.5 [&_ol]:list-decimal [&_ol]:pl-5 [&_ul]:list-disc [&_ul]:pl-5'>
123+
{text}
124+
</Streamdown>
125+
) : isBusy ? (
126+
'…'
127+
) : null}
128+
</div>
129+
)}
123130
{sources.length > 0 && (
124131
<div className='flex max-w-[90%] flex-wrap gap-1.5'>
125132
{sources.map((source) => (

apps/docs/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"react": "19.2.4",
3535
"react-dom": "19.2.4",
3636
"shiki": "4.0.0",
37+
"streamdown": "2.5.0",
3738
"tailwind-merge": "^3.0.2",
3839
"reactflow": "^11.11.4",
3940
"framer-motion": "^12.5.0",

bun.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)