diff --git a/.claude/commands/add-block.md b/.claude/commands/add-block.md index fddeb70b482..2e0b8a01260 100644 --- a/.claude/commands/add-block.md +++ b/.claude/commands/add-block.md @@ -27,7 +27,7 @@ export const {ServiceName}Block: BlockConfig = { name: '{Service Name}', // Human readable description: 'Brief description', // One sentence longDescription: 'Detailed description for docs', - docsLink: 'https://docs.sim.ai/tools/{service}', + docsLink: 'https://docs.sim.ai/integrations/{service}', category: 'tools', // 'tools' | 'blocks' | 'triggers' integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) @@ -626,7 +626,7 @@ export const ServiceBlock: BlockConfig = { name: 'Service', description: 'Integrate with Service API', longDescription: 'Full description for documentation...', - docsLink: 'https://docs.sim.ai/tools/service', + docsLink: 'https://docs.sim.ai/integrations/service', category: 'tools', integrationType: IntegrationType.DeveloperTools, tags: ['oauth', 'api'], diff --git a/.claude/commands/add-integration.md b/.claude/commands/add-integration.md index 66553efbe1d..b1d07b39f6d 100644 --- a/.claude/commands/add-integration.md +++ b/.claude/commands/add-integration.md @@ -121,7 +121,7 @@ export const {Service}Block: BlockConfig = { name: '{Service}', description: '...', longDescription: '...', - docsLink: 'https://docs.sim.ai/tools/{service}', + docsLink: 'https://docs.sim.ai/integrations/{service}', category: 'tools', integrationType: IntegrationType.X, // Primary category (see IntegrationType enum) tags: ['oauth', 'api'], // Cross-cutting tags (see IntegrationTag type) diff --git a/.claude/commands/validate-integration.md b/.claude/commands/validate-integration.md index ee188565415..e641486819b 100644 --- a/.claude/commands/validate-integration.md +++ b/.claude/commands/validate-integration.md @@ -185,7 +185,7 @@ For **each tool** in `tools.access`: - [ ] `name` is human-readable (e.g., `'X'`, `'Cloudflare'`) - [ ] `description` is a concise one-liner - [ ] `longDescription` provides detail for docs -- [ ] `docsLink` points to `'https://docs.sim.ai/tools/{service}'` +- [ ] `docsLink` points to `'https://docs.sim.ai/integrations/{service}'` - [ ] `category` is `'tools'` - [ ] `bgColor` uses the service's brand color hex - [ ] `icon` references the correct icon component from `@/components/icons` diff --git a/.claude/rules/sim-settings-pages.md b/.claude/rules/sim-settings-pages.md new file mode 100644 index 00000000000..671d76f4c57 --- /dev/null +++ b/.claude/rules/sim-settings-pages.md @@ -0,0 +1,123 @@ +--- +paths: + - "apps/sim/app/workspace/*/settings/**" + - "apps/sim/ee/**/components/**" +--- + +# Settings Pages + +Every settings page renders through the shared **`SettingsPanel`** primitive +(`@/app/workspace/[workspaceId]/settings/components/settings-panel`). It owns the +page chrome so pages never hand-roll it: a fixed header bar (right-aligned +actions), a scroll region, and a centered `max-w-[48rem]` content column led by a +**title + description that come from navigation metadata**. Pages render only +their body. + +Do NOT hand-roll any of these in a settings page — they are the panel's job: + +- `
` shell +- the header bar (`flex flex-shrink-0 … px-[16px] pt-[8.5px] pb-[8.5px]`) +- the scroll container (`min-h-0 flex-1 overflow-y-auto px-6 [scrollbar-gutter:stable_both-edges]`) +- the content column (`mx-auto … max-w-[48rem] … gap-7`) +- a title block (`

` + `

`) +- the page-level search input + +## Canonical page shape + +```tsx +import { SettingsPanel } from '@/app/workspace/[workspaceId]/settings/components/settings-panel' + +return ( + + Create + + } + search={{ value: searchTerm, onChange: setSearchTerm, placeholder: 'Search …' }} + > + {/* body only — sections, lists, forms */} + +) +``` + +When the page has modal/dialog siblings, wrap them with the panel in a fragment: + +```tsx +return ( + <> + {body} + + +) +``` + +## `SettingsPanel` props + +- `actions?: ReactNode` — right-aligned header chips. Wrap multiple in a fragment; + the slot reserves the 30px chip height even when empty, so vertical rhythm is + identical across pages. Conditional actions are fine: `actions={canManage && }`. +- `search?: { value; onChange: (value: string) => void; placeholder?; disabled? }` — + renders the canonical search field directly below the title. Pass `setSearchTerm` + straight to `onChange`. Use this for a standalone search; if search shares a row + with other controls (sort, filters, a date picker), render that whole row in + `children` instead and omit the prop. +- `title?` / `description?` — overrides for the nav-driven defaults. **Only** for a + detail sub-view that needs a different heading; normal pages never pass these. +- `scrollContainerRef?: React.Ref` — forwards a ref to the scroll + region (e.g. programmatic scroll-to-bottom). +- `contentClassName?` — layout/spacing only; reach for it rarely. Prefer the + default `gap-7`. + +## Title + description live in navigation metadata + +`apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` is the single source +of truth. Every `NavigationItem` carries a one-line `description`; `SettingsPanel` +resolves both via `getSettingsSectionMeta(section)` and the +`SettingsSectionProvider` the settings shell wraps around the active section. + +Adding a new settings page: + +1. Add the `SettingsSection` id + a `NavigationItem` (with `label` **and** + `description`) in `navigation.ts`. Keep descriptions verb-first, one line, + ~40–55 chars, in the product voice (see `.claude/rules/constitution.md`). +2. Render the component inside the shell's `effectiveSection` switch in + `settings/[section]/settings.tsx`. +3. Build the component body inside `` — no shell, no title block. + +## Other shared settings primitives (do not re-roll these) + +- **`SettingsEmptyState`** (`…/components/settings-empty-state`) — the canonical + muted status message. `variant='fill'` (default) centers in the available + height (empty list, or a not-entitled/loading gate); `variant='inline'` sits in + flow (a search "no results"). Never hand-roll + `

` + or `
`. It owns the `--text-muted` + `text-sm` + tokens, so it also keeps these messages consistent across pages. +- **`RowActionsMenu`** (`…/components/row-actions-menu`) — the trailing `...` + actions menu for a list row. Pass `label` (aria-label) and + `actions: RowAction[]` (`{ label, onSelect, destructive?, disabled? }`); the + component renders the canonical flush `...` trigger + `DropdownMenuContent`. + Conditional items become array spreads: `...(canManage ? [{…}] : [])`. Never + hand-roll the `` + `` trigger per page. + +## Detail sub-views (the one exception) + +A drill-down view reached from a list row (selected MCP server, workflow MCP +server, credential set, permission group) keeps its **own** chrome because it +needs a left-aligned back button (``), which the panel +header (right-actions only) does not model. Leave those returns as hand-rolled +shells; only the list/main view uses `SettingsPanel`. Gate/early-return states +(not-entitled, loading, upgrade prompts) also stay as-is. + +## Audit checklist + +A settings page is design-system-clean when: + +- [ ] Its main return is a `` (or `<>……` with modal siblings) — no hand-rolled shell/header/scroll/column. +- [ ] It renders **no** hand-rolled `

`/description title block — the title comes from nav metadata. +- [ ] Header chips are in `actions`; a standalone search is in the `search` prop. +- [ ] Its `NavigationItem` has an accurate, consistent-length `description`. +- [ ] Detail sub-views and entitlement/loading gates keep their own chrome (intentional). +- [ ] No business logic, handlers, or conditional rendering changed by the migration. +- [ ] `tsc`, `biome`, and the page's tests pass. diff --git a/.claude/skills/add-settings-page/SKILL.md b/.claude/skills/add-settings-page/SKILL.md new file mode 100644 index 00000000000..9e5d7b5671c --- /dev/null +++ b/.claude/skills/add-settings-page/SKILL.md @@ -0,0 +1,57 @@ +--- +name: add-settings-page +description: Add a new Sim settings page, or audit existing settings pages for design-system compliance with the shared SettingsPanel layout. Use when creating a settings tab, or when asked to check/clean up settings pages so they match the design system (consistent title, header, search, spacing). +--- + +# Settings Page (add / audit) + +Sim settings pages all render through the shared **`SettingsPanel`** primitive, +which owns the page chrome and renders a nav-driven title + description. The full +convention lives in `.claude/rules/sim-settings-pages.md` — read it first; this +skill is the procedure. + +Key paths: +- Layout primitive: `apps/sim/app/workspace/[workspaceId]/settings/components/settings-panel/settings-panel.tsx` +- Nav metadata (titles + descriptions): `apps/sim/app/workspace/[workspaceId]/settings/navigation.ts` +- Section switch + provider: `apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx` +- Pages: `apps/sim/app/workspace/[workspaceId]/settings/components//.tsx` and EE pages under `apps/sim/ee//components/` + +## Mode A — Add a new settings page + +1. **Navigation.** In `navigation.ts`: add the id to the `SettingsSection` union, + then a `NavigationItem` with `label` AND a one-line `description` (verb-first, + ~40–55 chars, product voice per `.claude/rules/constitution.md`). Place it in + the right `section` group and set any gating flags (`requiresHosted`, + `requiresEnterprise`, etc.). +2. **Wire the switch.** Add the component to the `effectiveSection` render switch + in `settings/[section]/settings.tsx` (lazy `dynamic(...)` like its siblings). +3. **Build the body inside `SettingsPanel`.** Never hand-roll the shell, header + bar, scroll region, content column, or title block. Put header buttons in + `actions`, a standalone search in `search={{ value, onChange, placeholder }}`, + and the page content as `children`. Modals go beside the panel inside a `<>`. +4. **Verify:** `cd apps/sim && bunx tsc --noEmit`; `bunx biome check --write `. + +## Mode B — Audit existing settings pages + +For each page component, confirm the checklist in `.claude/rules/sim-settings-pages.md`: + +1. Find hand-rolled shells that should be `SettingsPanel`: + `git grep -n "flex h-full flex-col bg-\[var(--bg)\]" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` + — every match should be either `settings-panel.tsx`, a **detail sub-view** + (has a `` back button), or an entitlement/loading + **gate** early-return. Anything else is a page that still needs migrating. +2. Find hand-rolled title blocks (should be 0 outside detail views): + `git grep -n "text-\[var(--text-body)\] text-lg" -- 'apps/sim/**/settings/' 'apps/sim/ee/'` +3. Confirm each page imports `SettingsPanel` and that its `NavigationItem` has an + accurate `description` of consistent length with its peers. +4. When migrating a page, change ONLY the structural shell→`SettingsPanel` swap: + move header chips to `actions`, the standalone search to `search`, delete the + `

` title block, replace the three closing `

` (column/scroll/shell) + with ``, and keep modal siblings in a `<>` fragment. Do NOT + touch handlers, state, queries, conditional rendering, or detail/gate returns. + Drop per-page `gap-*`/`pt-*` on the content column in favor of the panel default. +5. Remove now-unused imports (`ChipInput`/`Search`) ONLY after grepping that + they are not still used elsewhere in the file (e.g. by a detail view). +6. **Verify the whole sweep:** `tsc --noEmit`, `biome check` on every touched + file, and run the affected pages' tests. Diff each file against the base and + confirm the change is purely structural before shipping. diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore index 2473d52587b..d4dabf71e70 100644 --- a/apps/docs/.gitignore +++ b/apps/docs/.gitignore @@ -38,3 +38,6 @@ next-env.d.ts # Fumadocs /.source/ .plans/ + +# fumadocs generates .source dirs anywhere a source.config sits +**/.source/ diff --git a/apps/docs/app/[lang]/[[...slug]]/page.tsx b/apps/docs/app/[lang]/[[...slug]]/page.tsx index eaae5fe1c57..86539c52302 100644 --- a/apps/docs/app/[lang]/[[...slug]]/page.tsx +++ b/apps/docs/app/[lang]/[[...slug]]/page.tsx @@ -75,16 +75,23 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l } const isOpenAPI = '_openapi' in data && data._openapi != null const isApiReference = slug?.some((s) => s === 'api-reference') ?? false + // Academy lessons are video-first: drop the "On this page" TOC and go full + // width so the lesson hero/video gets the room (chapters live in-page instead). + const isAcademy = slug?.[0] === 'academy' const pageTreeRecord = source.pageTree as Record const pageTree = pageTreeRecord[lang] ?? pageTreeRecord.en ?? Object.values(pageTreeRecord)[0] const rawNeighbours = pageTree ? findNeighbour(pageTree, page.url) : null - const neighbours = isApiReference + // Academy and API Reference are self-contained sections; keep prev/next inside + // the section instead of spilling into the main documentation tree. Match both + // the section's pages (`//...`) and its index (`/`). + const sectionSlug = isApiReference ? 'api-reference' : isAcademy ? 'academy' : null + const inSection = (url?: string) => + url != null && (url.includes(`/${sectionSlug}/`) || url.endsWith(`/${sectionSlug}`)) + const neighbours = sectionSlug ? { - previous: rawNeighbours?.previous?.url.includes('/api-reference/') - ? rawNeighbours.previous - : undefined, - next: rawNeighbours?.next?.url.includes('/api-reference/') ? rawNeighbours.next : undefined, + previous: inSection(rawNeighbours?.previous?.url) ? rawNeighbours?.previous : undefined, + next: inSection(rawNeighbours?.next?.url) ? rawNeighbours?.next : undefined, } : rawNeighbours @@ -197,18 +204,18 @@ export default async function Page(props: { params: Promise<{ slug?: string[]; l /> {children} + diff --git a/apps/docs/app/[lang]/not-found.tsx b/apps/docs/app/[lang]/not-found.tsx index c28c765851f..af647ec4cf2 100644 --- a/apps/docs/app/[lang]/not-found.tsx +++ b/apps/docs/app/[lang]/not-found.tsx @@ -1,5 +1,5 @@ import { DocsPage } from 'fumadocs-ui/page' -import Link from 'next/link' +import { ChipLink } from '@/components/ui/chip' export const metadata = { title: 'Page Not Found', @@ -9,19 +9,16 @@ export default function NotFound() { return (
-

+

404

-

Page Not Found

-

+

Page Not Found

+

The page you're looking for doesn't exist or has been moved.

- + Go home - +
) diff --git a/apps/docs/app/api/chat/route.ts b/apps/docs/app/api/chat/route.ts new file mode 100644 index 00000000000..2150e044980 --- /dev/null +++ b/apps/docs/app/api/chat/route.ts @@ -0,0 +1,344 @@ +import { openai } from '@ai-sdk/openai' +import { convertToModelMessages, stepCountIs, streamText, tool, type UIMessage } from 'ai' +import { sql } from 'drizzle-orm' +import { z } from 'zod' +import { db, docsEmbeddings } from '@/lib/db' +import { generateSearchEmbedding } from '@/lib/embeddings' + +export const runtime = 'nodejs' +export const maxDuration = 30 + +/** Model used for the Ask AI chat. Override with OPENAI_CHAT_MODEL in the environment. */ +const CHAT_MODEL = process.env.OPENAI_CHAT_MODEL || 'gpt-5.4-mini' + +/** Max documentation chunks returned per search to ground an answer. */ +const SEARCH_LIMIT = 6 + +/** Candidates pulled before locale filtering, so a locale still yields SEARCH_LIMIT results. */ +const SEARCH_CANDIDATES = SEARCH_LIMIT * 4 + +/** Minimum cosine similarity for an English vector match (mirrors the site search route). */ +const SIMILARITY_THRESHOLD = 0.6 + +/** Locales the docs are published in (mirrors the site search route). */ +const KNOWN_LOCALES = ['en', 'es', 'fr', 'de', 'ja', 'zh'] +const DEFAULT_LOCALE = 'en' + +/** Postgres full-text config per locale (mirrors the site search route). */ +const TS_CONFIG: Record = { + en: 'english', + es: 'spanish', + fr: 'french', + de: 'german', + ja: 'simple', + zh: 'simple', +} + +/** + * Abuse guards. This endpoint proxies a paid LLM, so an unauthenticated public + * route is a target for scripted "free inference". These bounds cap the cost of + * any single request; an in-memory per-IP rate limit (below) caps volume on the + * hot path. A shared-store rate limit, a provider spend cap, and edge bot + * protection remain the durable controls (see the PR checklist). + * + * The size cap counts only user-authored text — NOT the conversation history, + * assistant turns, or retrieved doc chunks we add via the searchDocs tool, which + * legitimately grow large over a multi-turn chat. + */ +const MAX_MESSAGES = 200 +const MAX_USER_INPUT_CHARS = 400_000 +const MAX_OUTPUT_TOKENS = 4000 +const MAX_STEPS = 6 +/** Backstop on the sanitized model payload — bounds total LLM input (e.g. stuffed assistant text). */ +const MAX_TOTAL_CHARS = 1_000_000 + +/** + * Per-IP rate limit. Fixed window, in-memory: this bounds volume from a single + * source on a warm instance without external infra. It is best-effort on + * serverless (state is per-instance, not shared across regions/cold starts); + * a shared store (e.g. Vercel KV) and an edge WAF remain the durable controls, + * but this closes the "no volume limit at all" gap on the hot path. + */ +const RATE_LIMIT_MAX = 20 +const RATE_LIMIT_WINDOW_MS = 60_000 +const rateLimitHits = new Map() + +/** Resolve the client IP from forwarding headers, falling back to a shared bucket. */ +function getClientIp(req: Request): string { + const forwarded = req.headers.get('x-forwarded-for') + if (forwarded) return forwarded.split(',')[0].trim() + return req.headers.get('x-real-ip') ?? 'unknown' +} + +/** Fixed-window check. Returns retry-after seconds when the caller is over the limit, else null. */ +function rateLimit(ip: string, now: number): number | null { + const entry = rateLimitHits.get(ip) + if (!entry || now >= entry.resetAt) { + rateLimitHits.set(ip, { count: 1, resetAt: now + RATE_LIMIT_WINDOW_MS }) + return null + } + if (entry.count >= RATE_LIMIT_MAX) { + return Math.ceil((entry.resetAt - now) / 1000) + } + entry.count += 1 + return null +} + +/** Drop expired buckets so the Map doesn't grow unbounded on a long-lived instance. */ +function sweepRateLimit(now: number): void { + if (rateLimitHits.size < 10_000) return + for (const [ip, entry] of rateLimitHits) { + if (now >= entry.resetAt) rateLimitHits.delete(ip) + } +} + +/** A structurally valid UI message: has a role and a parts array. */ +function isValidMessage(message: unknown): message is UIMessage { + return ( + typeof message === 'object' && + message !== null && + typeof (message as { role?: unknown }).role === 'string' && + Array.isArray((message as { parts?: unknown }).parts) + ) +} + +/** Total length of user-authored text across the conversation. */ +function userInputChars(messages: UIMessage[]): number { + let total = 0 + for (const message of messages) { + if (message.role !== 'user') continue + for (const part of message.parts) { + if (part.type === 'text' && typeof part.text === 'string') total += part.text.length + } + } + return total +} + +/** + * Strip everything the model shouldn't trust from client-supplied history: + * drop `system` messages (client-injected instructions) and every non-text part + * (e.g. crafted tool results faking searchDocs output). Only user/assistant text + * survives, so grounding comes from the server-run searchDocs tool — not the + * client's payload. + */ +function sanitizeMessages(messages: UIMessage[]): UIMessage[] { + return messages + .filter((message) => message.role === 'user' || message.role === 'assistant') + .map((message) => ({ + ...message, + parts: message.parts.filter((part) => part.type === 'text' && typeof part.text === 'string'), + })) + .filter((message) => message.parts.length > 0) +} + +/** + * Reject obvious cross-origin calls. Same-origin browser requests send an + * `Origin` header matching the host; we allow those, plus any host in + * DOCS_ALLOWED_ORIGINS (comma-separated). Requests with no Origin (e.g. curl) + * are allowed through to the cost caps rather than blocked, since Origin is + * trivially spoofable and is a filter, not a security boundary. + */ +function isAllowedOrigin(req: Request): boolean { + const origin = req.headers.get('origin') + if (!origin) return true + + let originHost: string + try { + originHost = new URL(origin).host.toLowerCase() + } catch { + return false + } + + const forwardedHost = req.headers.get('x-forwarded-host') ?? req.headers.get('host') + const requestHost = forwardedHost?.split(',')[0].trim().toLowerCase() + if (requestHost && originHost === requestHost) return true + + const allowlist = (process.env.DOCS_ALLOWED_ORIGINS ?? '') + .split(',') + .map((value) => value.trim().toLowerCase()) + .filter(Boolean) + return allowlist.includes(originHost) +} + +const SYSTEM_PROMPT = `You are the documentation assistant for Sim — the open-source AI workspace where teams build, deploy, and manage AI agents. + +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. + +Guidelines: +- Be direct and concrete. Lead with the answer, then the detail. +- Reference the relevant pages by their titles so the user knows where to read more. +- When you show configuration or code, keep it minimal and correct. +- The agent is called "Sim" and the chat surface is "Chat" — never say "Mothership" or "copilot". +- If a question is unrelated to Sim, briefly say it's outside the docs' scope.` + +const SEARCH_COLUMNS = { + chunkId: docsEmbeddings.chunkId, + title: docsEmbeddings.headerText, + url: docsEmbeddings.sourceLink, + content: docsEmbeddings.chunkText, + sourceDocument: docsEmbeddings.sourceDocument, +} + +/** Reciprocal-rank-fusion constant, matching the site search route. */ +const RRF_K = 60 + +/** + * SQL predicate selecting only the locale's documents, so the row limit applies + * to matching rows: non-English docs are prefixed with their locale segment; + * English is everything not prefixed with another locale. + */ +function localeFilter(locale: string) { + const firstSegment = sql`split_part(${docsEmbeddings.sourceDocument}, '/', 1)` + if (locale === DEFAULT_LOCALE) { + const others = KNOWN_LOCALES.filter((l) => l !== DEFAULT_LOCALE) + return sql`${firstSegment} not in (${sql.join( + others.map((l) => sql`${l}`), + sql`, ` + )})` + } + return sql`${firstSegment} = ${locale}` +} + +type SearchRow = { + chunkId: string + title: string + url: string + content: string + sourceDocument: string +} + +/** + * Retrieve candidate chunks for grounding, mirroring the site search route's + * hybrid strategy: Postgres full-text keyword search for every locale, plus + * vector similarity (thresholded) for English — fused by reciprocal rank so a + * page found by either signal can ground the answer. + */ +async function searchDocs(query: string, locale: string) { + const tsConfig = TS_CONFIG[locale] ?? 'simple' + + // Each retrieval path is best-effort and independent: a failure in one still + // lets the other ground the answer (both empty just yields no grounding). + let keywordRows: SearchRow[] = [] + try { + keywordRows = await db + .select(SEARCH_COLUMNS) + .from(docsEmbeddings) + .where( + sql`${docsEmbeddings.chunkTextTsv} @@ plainto_tsquery(${tsConfig}, ${query}) and ${localeFilter(locale)}` + ) + .orderBy( + sql`ts_rank(${docsEmbeddings.chunkTextTsv}, plainto_tsquery(${tsConfig}, ${query})) DESC` + ) + .limit(SEARCH_CANDIDATES) + } catch (error) { + console.error('Ask AI keyword search failed:', error) + } + + let vectorRows: SearchRow[] = [] + if (locale === DEFAULT_LOCALE) { + // Vector retrieval (embedding call + pgvector query) is best-effort: if it + // fails, fall back to the keyword rows already fetched rather than losing all + // grounding for the turn. + try { + const embedding = await generateSearchEmbedding(query) + const vectorLiteral = JSON.stringify(embedding) + vectorRows = await db + .select(SEARCH_COLUMNS) + .from(docsEmbeddings) + .where( + sql`1 - (${docsEmbeddings.embedding} <=> ${vectorLiteral}::vector) >= ${SIMILARITY_THRESHOLD} and ${localeFilter(locale)}` + ) + .orderBy(sql`${docsEmbeddings.embedding} <=> ${vectorLiteral}::vector`) + .limit(SEARCH_CANDIDATES) + } catch (error) { + console.error('Ask AI vector search failed; using keyword results only:', error) + } + } + + // Reciprocal rank fusion across the two rankings, deduped by chunk. + const scores = new Map() + const rowById = new Map() + for (const list of [vectorRows, keywordRows]) { + list.forEach((row, index) => { + scores.set(row.chunkId, (scores.get(row.chunkId) ?? 0) + 1 / (RRF_K + index + 1)) + if (!rowById.has(row.chunkId)) rowById.set(row.chunkId, row) + }) + } + + return [...rowById.values()] + .sort((a, b) => (scores.get(b.chunkId) ?? 0) - (scores.get(a.chunkId) ?? 0)) + .slice(0, SEARCH_LIMIT) + .map((row) => ({ + title: row.title, + url: row.url, + content: row.content, + })) +} + +export async function POST(req: Request) { + if (!isAllowedOrigin(req)) { + return new Response('Forbidden', { status: 403 }) + } + + const now = Date.now() + sweepRateLimit(now) + const retryAfter = rateLimit(getClientIp(req), now) + if (retryAfter !== null) { + return new Response('Too many requests', { + status: 429, + headers: { 'Retry-After': String(retryAfter) }, + }) + } + + let body: { messages: UIMessage[]; locale?: string } + try { + body = await req.json() + } catch { + return new Response('Invalid JSON', { status: 400 }) + } + const { messages } = body + const locale = KNOWN_LOCALES.includes(body.locale ?? '') + ? (body.locale as string) + : DEFAULT_LOCALE + + if (!Array.isArray(messages) || messages.length === 0 || messages.length > MAX_MESSAGES) { + return new Response('Invalid request', { status: 400 }) + } + if (!messages.every(isValidMessage)) { + return new Response('Invalid request', { status: 400 }) + } + if (userInputChars(messages) > MAX_USER_INPUT_CHARS) { + return new Response('Request too large', { status: 413 }) + } + + const modelMessages = sanitizeMessages(messages) + if (modelMessages.length === 0) { + return new Response('Invalid request', { status: 400 }) + } + // Bound what actually reaches the model. Measured AFTER sanitization, so the + // prior searchDocs tool outputs that accumulate in client history (and are + // stripped here) don't count — only user/assistant text the model will see. + if (JSON.stringify(modelMessages).length > MAX_TOTAL_CHARS) { + return new Response('Request too large', { status: 413 }) + } + + const result = streamText({ + model: openai(CHAT_MODEL), + system: SYSTEM_PROMPT, + messages: convertToModelMessages(modelMessages), + stopWhen: stepCountIs(MAX_STEPS), + maxOutputTokens: MAX_OUTPUT_TOKENS, + tools: { + searchDocs: tool({ + description: + 'Search the Sim documentation for relevant content. Use this before answering any question about Sim.', + inputSchema: z.object({ + query: z.string().describe('A focused natural-language search query.'), + }), + execute: async ({ query }) => searchDocs(query, locale), + }), + }, + }) + + return result.toUIMessageStreamResponse() +} diff --git a/apps/docs/app/global.css b/apps/docs/app/global.css index 4d0dcd81d32..2c24e2867eb 100644 --- a/apps/docs/app/global.css +++ b/apps/docs/app/global.css @@ -1669,6 +1669,7 @@ main article blockquote { --wp-chip-text: var(--text-body); --wp-divider: #ededed; /* --divider */ --wp-edge: #e0e0e0; /* --workflow-edge */ + --wp-highlight: #33b4ff; /* traced edge / highlighted output node */ /* text */ --wp-text: var(--text-primary); diff --git a/apps/docs/components/ai/ask-ai.tsx b/apps/docs/components/ai/ask-ai.tsx new file mode 100644 index 00000000000..dca3886305f --- /dev/null +++ b/apps/docs/components/ai/ask-ai.tsx @@ -0,0 +1,218 @@ +'use client' + +import { type FormEvent, useEffect, useMemo, useRef, useState } from 'react' +import { useChat } from '@ai-sdk/react' +import { DefaultChatTransport } from 'ai' +import { ArrowUp, MessageCircle, Square, X } from 'lucide-react' +import { Streamdown } from 'streamdown' +import { cn } from '@/lib/utils' +import 'streamdown/styles.css' + +interface DocSource { + title: string + url: string +} + +/** Pull the deduped doc sources surfaced by the searchDocs tool out of a message's parts. */ +function getSources(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): DocSource[] { + const seen = new Set() + const sources: DocSource[] = [] + + for (const part of parts) { + if (part.type !== 'tool-searchDocs') continue + const output = (part as { output?: unknown }).output + if (!Array.isArray(output)) continue + for (const item of output as DocSource[]) { + if (!item?.url || seen.has(item.url)) continue + seen.add(item.url) + sources.push({ title: item.title, url: item.url }) + } + } + + return sources +} + +/** Concatenate the streamed text parts of a message. */ +function getText(parts: ReadonlyArray<{ type: string; [key: string]: unknown }>): string { + return parts + .filter((part) => part.type === 'text') + .map((part) => (part as unknown as { text: string }).text) + .join('') +} + +interface AskAIProps { + /** Active docs locale, forwarded so retrieval is scoped to the reader's language. */ + locale: string +} + +export function AskAI({ locale }: AskAIProps) { + const [open, setOpen] = useState(false) + const [input, setInput] = useState('') + const scrollRef = useRef(null) + + // Stable transport; the locale is sent per-message (below) so it stays current + // after a language switch instead of being frozen into the transport. + const transport = useMemo(() => new DefaultChatTransport({ api: '/api/chat' }), []) + + const { messages, sendMessage, status, stop, error } = useChat({ transport }) + + const isBusy = status === 'submitted' || status === 'streaming' + + // Jump to the bottom instantly when the panel opens (a mount transition). + useEffect(() => { + if (!open) return + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight }) + }, [open]) + + // Smooth-scroll as new messages stream in (an explicit re-orientation cue). + useEffect(() => { + scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' }) + }, [messages]) + + const handleSubmit = (event: FormEvent) => { + event.preventDefault() + const text = input.trim() + if (!text || isBusy) return + sendMessage({ text }, { body: { locale } }) + setInput('') + } + + return ( + <> + {!open && ( + + )} + + {open && ( +
+
+ + + Ask AI + + +
+ +
+ {messages.length === 0 && ( +

+ Ask anything about building, deploying, and managing AI agents in Sim. +

+ )} + + {messages.map((message, index) => { + const text = getText(message.parts) + // Only the in-progress (last) message should show the loading state. + const isStreaming = isBusy && index === messages.length - 1 + const sources = message.role === 'assistant' ? getSources(message.parts) : [] + return ( +
+ {message.role === 'user' ? ( +
+ {text} +
+ ) : ( +
+ {text ? ( + + {text} + + ) : isStreaming ? ( + '…' + ) : sources.length === 0 ? ( + No answer returned. + ) : null} +
+ )} + {sources.length > 0 && ( +
+ {sources.map((source) => ( + + {source.title || source.url} + + ))} +
+ )} +
+ ) + })} + + {error && ( +

+ Something went wrong. Please try again. +

+ )} +
+ +
+