Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ next-env.d.ts
/public/llms-full.txt
/public/sitemap.xml
/public/robots.txt
/public/ai-suggestions.json

# misc
.DS_Store
Expand Down
2 changes: 1 addition & 1 deletion app/[[...slug]]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ export default async function DocsCatchAll({ params }: { params: Promise<{ slug?
// SiteNav. Only docs routes mount it — the home + API reference render their
// own full-width content.
return (
<DocsShell nav={getNav(config)} ai={aiConfig()}>
<DocsShell nav={getNav(config)} ai={aiConfig()} aiSuggestions={doc.fm.aiSuggestions}>
<DocsPage
crumbs={crumbs}
title={doc.fm.title}
Expand Down
6 changes: 3 additions & 3 deletions app/api-reference.css
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,7 @@
/* MDX callouts (Note / Tip / Info / Warning / Danger / Check) in section &
operation overlays — ported from docs.css since the API route doesn't load it. */
.ml-apiref .callout {
margin-top: 20px; border: 1px solid var(--line); border-left: 2px solid var(--accent);
margin-top: 20px; border: 1px solid var(--line);
border-radius: 9px; padding: 14px 16px;
background: color-mix(in srgb, var(--accent) 5%, var(--panel));
display: flex; gap: 12px; max-width: 68ch;
Expand All @@ -294,9 +294,9 @@
.ml-apiref .callout p, .ml-apiref .callout .callout-body { margin: 0; font-size: 14px; color: var(--ink-2); line-height: 1.6; }
.ml-apiref .callout .callout-body > * + * { margin-top: 8px; }
.ml-apiref .callout strong { color: var(--ink); }
.ml-apiref .callout.tone-warn { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 6%, var(--panel)); }
.ml-apiref .callout.tone-warn { background: color-mix(in srgb, var(--warn) 6%, var(--panel)); }
.ml-apiref .callout.tone-warn .ic { color: var(--warn); }
.ml-apiref .callout.tone-ok { border-left-color: var(--ok); background: color-mix(in srgb, var(--ok) 6%, var(--panel)); }
.ml-apiref .callout.tone-ok { background: color-mix(in srgb, var(--ok) 6%, var(--panel)); }
.ml-apiref .callout.tone-ok .ic { color: var(--ok); }

/* ---------- dark code rail ---------- */
Expand Down
7 changes: 3 additions & 4 deletions app/docs.css
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,6 @@
font-family: var(--mono); font-size: 10.5px; letter-spacing: 0.13em; text-transform: uppercase;
color: var(--ink-4); padding: 0 9px; margin-bottom: 8px; display: flex; align-items: center; gap: 7px;
}
.docs-shell .docs-nav-grp > .t .n { color: var(--ink-4); opacity: .6; }
.docs-shell .docs-nav a {
display: flex; align-items: center; gap: 8px; padding: 6.5px 9px; border-radius: 7px;
font-size: 13.5px; color: var(--ink-3); line-height: 1.3; position: relative; text-decoration: none;
Expand Down Expand Up @@ -190,7 +189,7 @@

/* callout */
.docs-shell .callout {
margin-top: 20px; border: 1px solid var(--line); border-left: 2px solid var(--accent);
margin-top: 20px; border: 1px solid var(--line);
border-radius: 9px; padding: 14px 16px;
background: color-mix(in srgb, var(--accent) 5%, var(--panel));
display: flex; gap: 12px; max-width: 68ch;
Expand All @@ -201,9 +200,9 @@
.docs-shell .callout p, .docs-shell .callout .callout-body { margin: 0; font-size: 14px; color: var(--ink-2); line-height: 1.6; }
.docs-shell .callout .callout-body > * + * { margin-top: 8px; }
.docs-shell .callout strong { color: var(--ink); }
.docs-shell .callout.tone-warn { border-left-color: var(--warn); background: color-mix(in srgb, var(--warn) 6%, var(--panel)); }
.docs-shell .callout.tone-warn { background: color-mix(in srgb, var(--warn) 6%, var(--panel)); }
.docs-shell .callout.tone-warn .ic { color: var(--warn); }
.docs-shell .callout.tone-ok { border-left-color: var(--ok); background: color-mix(in srgb, var(--ok) 6%, var(--panel)); }
.docs-shell .callout.tone-ok { background: color-mix(in srgb, var(--ok) 6%, var(--panel)); }
.docs-shell .callout.tone-ok .ic { color: var(--ok); }

/* steps */
Expand Down
68 changes: 65 additions & 3 deletions components/docs/ai/ask-dock.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"use client";

import { useEffect, useRef, useState } from "react";
import { usePathname } from "next/navigation";
import type { AiPublicConfig } from "@/lib/config";

/**
Expand Down Expand Up @@ -46,8 +47,22 @@ const CHATS_KEY = "markline-ai-chats";
const OPEN_KEY = "markline-ai-open";
const RKEY_KEY = "markline-ai-key";

/** Last-resort starter questions — page-agnostic so they're never wrong. */
const SUGGEST = ["Summarize this page", "Explain this with an example", "What are the key concepts here?"];

/** Build-time generated per-page starter questions (public/ai-suggestions.json,
* written by scripts/build-search.mjs when `ai.suggestions` is enabled).
* Fetched once per session; missing file → empty map (fallbacks apply). */
let suggestionsPromise: Promise<Record<string, { q: string[] }>> | null = null;
function loadGeneratedSuggestions(basePath: string): Promise<Record<string, { q: string[] }>> {
if (!suggestionsPromise) {
suggestionsPromise = fetch(`${basePath}/ai-suggestions.json`)
.then((r) => (r.ok ? r.json() : {}))
.catch(() => ({}));
}
return suggestionsPromise;
}

/** Condense the first question into a clean chat title: strip filler, capitalize,
* cut on a word boundary (never mid-word), drop trailing punctuation. Purely
* deterministic — no model call, so it works even when the assistant is down. */
Expand Down Expand Up @@ -106,7 +121,15 @@ function mdBlocks(s: string): string {
return html;
}

export function AskDock({ ai }: { ai: AiPublicConfig }) {
export function AskDock({
ai,
suggestions,
}: {
ai: AiPublicConfig;
/** Page-specific starter questions. Resolution: this prop (frontmatter /
* surface defaults) → build-time generated (ai-suggestions.json) → generic. */
suggestions?: string[];
}) {
const [chats, setChats] = useState<Chat[]>([{ title: "New chat", messages: [] }]);
const [cur, setCur] = useState(0);
const [open, setOpen] = useState(false);
Expand All @@ -117,12 +140,32 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) {
const [needKey, setNeedKey] = useState(false);
const [keyDraft, setKeyDraft] = useState("");
const [attachments, setAttachments] = useState<Attachment[]>([]);
const [generated, setGenerated] = useState<string[] | null>(null);
const bodyRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileRef = useRef<HTMLInputElement>(null);
const pathname = usePathname();

const messages = chats[cur]?.messages ?? [];

/* per-page starter questions from the build-time generator (when present);
skipped entirely when the page supplies its own via the prop. */
useEffect(() => {
if (suggestions?.length) return;
let on = true;
const basePath = process.env.NEXT_PUBLIC_MARKLINE_BASE_PATH || "";
const key =
(basePath && pathname.startsWith(basePath) ? pathname.slice(basePath.length) : pathname).replace(/\/+$/, "") || "/";
loadGeneratedSuggestions(basePath).then((map) => {
if (on) setGenerated(map[key]?.q?.length ? map[key].q.slice(0, 3) : null);
});
return () => {
on = false;
};
}, [pathname, suggestions]);

const starters = suggestions?.length ? suggestions.slice(0, 3) : generated ?? SUGGEST;

/* hydrate from localStorage + restore open state */
useEffect(() => {
try {
Expand Down Expand Up @@ -387,7 +430,7 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) {
Tip: start a new chat with <kbd>⌘</kbd> <kbd>E</kbd>
</div>
<div className="ac-sugg">
{SUGGEST.map((s) => (
{starters.map((s) => (
<button key={s} className="ac-sg" onClick={() => submit(s)}>
{s}
</button>
Expand Down Expand Up @@ -548,7 +591,7 @@ function Sources({ list }: { list: string[] }) {
{list.map((s) => (
<div key={s} className="ac-src">
<span className="bk">
<Book sm />
<PageIcon />
</span>
{s}
</div>
Expand Down Expand Up @@ -596,3 +639,22 @@ function Book({ sm }: { sm?: boolean }) {
</svg>
);
}
/** Document/page glyph for retrieved sources (a file with a folded corner). */
function PageIcon() {
return (
<svg
width="13"
height="13"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.8"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M14 3v4a1 1 0 0 0 1 1h4" />
<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<path d="M8.5 13.5h7M8.5 17h4.5" />
</svg>
);
}
11 changes: 10 additions & 1 deletion components/docs/api/reference/markline-apiref.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -463,7 +463,16 @@ export function MarklineApiRef({

<SearchPalette index={view.search} aiEnabled={aiOn} />
<MarkdownModal />
{ai && <AskDock ai={ai} />}
{ai && (
<AskDock
ai={ai}
suggestions={[
"How do I authenticate requests?",
`How do I use the ${r.name} API?`,
"What does an error response look like?",
]}
/>
)}
</div>
</EventColorsContext.Provider>
);
Expand Down
35 changes: 26 additions & 9 deletions components/docs/nav.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,8 @@ function pickActiveTabId(tabs: TopTab[], pathname: string): string {
return tabs.find((t) => t.matchPrefixes.includes("__default__"))?.id ?? tabs[0]?.id ?? "";
}

/** Numbered nav groups (the design's .docs-nav): "01 Get started", etc. The
* index is derived from the section's position. Method/badge chips are kept. */
/** Nav groups (the design's .docs-nav): a section title over its links.
* Method/badge chips on links are kept. */
function SidebarSections({
sections,
pathname,
Expand All @@ -56,9 +56,7 @@ function SidebarSections({
<nav className="docs-nav">
{sections.map((sec, si) => (
<div key={sec.title} className="docs-nav-grp">
<div className="t">
<span className="n">{String(si + 1).padStart(2, "0")}</span> {sec.title}
</div>
<div className="t">{sec.title}</div>
{sec.links.map((l) => {
const active = pathname === l.href;
return (
Expand Down Expand Up @@ -86,10 +84,21 @@ function SidebarSections({
* <SiteNav/>; it no longer carries a topbar. The grid (sidebar · main · toc)
* is styled in app/docs.css.
*/
export function DocsShell({ nav, ai = null, children }: { nav: NavData; ai?: AiPublicConfig | null; children: React.ReactNode }) {
export function DocsShell({
nav,
ai = null,
aiSuggestions,
children,
}: {
nav: NavData;
ai?: AiPublicConfig | null;
/** Page-specific Ask AI starter questions (frontmatter `aiSuggestions`). */
aiSuggestions?: string[];
children: React.ReactNode;
}) {
return (
<div className="docs-shell grid">
<DocsSidebar nav={nav} ai={ai} />
<DocsSidebar nav={nav} ai={ai} aiSuggestions={aiSuggestions} />
{children}
{/* ⌘K / sidebar-trigger search palette (modal only — the inline trigger
lives in the sidebar). */}
Expand All @@ -98,7 +107,15 @@ export function DocsShell({ nav, ai = null, children }: { nav: NavData; ai?: AiP
);
}

export function DocsSidebar({ nav, ai = null }: { nav: NavData; ai?: AiPublicConfig | null }) {
export function DocsSidebar({
nav,
ai = null,
aiSuggestions,
}: {
nav: NavData;
ai?: AiPublicConfig | null;
aiSuggestions?: string[];
}) {
const pathname = usePathname();
const tabs = nav.tabsByVariant[pickVariantId(nav, pathname)] ?? [];
const activeId = pickActiveTabId(tabs, pathname);
Expand All @@ -115,7 +132,7 @@ export function DocsSidebar({ nav, ai = null }: { nav: NavData; ai?: AiPublicCon
</aside>
{/* Page-level AI affordances (the doc-ai row + View-as-Markdown modal) live
in the docs shell so they're available on every docs page. */}
{ai && <AskDock ai={ai} />}
{ai && <AskDock ai={ai} suggestions={aiSuggestions} />}
<MarkdownModal />
</>
);
Expand Down
6 changes: 6 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,12 @@ export type AiConfig = {
* the paperclip on/off regardless of the heuristic.
*/
vision?: boolean;
/**
* Opt-in: generate per-page Ask-AI starter questions at build time (on the
* operator's key) into public/ai-suggestions.json. Cached by content hash,
* so only changed pages re-call the provider on rebuilds.
*/
suggestions?: boolean;
maxTokens?: number;
/** Per-IP abuse limits for the proxy route. */
rateLimit?: { perMinute?: number; perDay?: number };
Expand Down
5 changes: 5 additions & 0 deletions lib/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export type DocFrontmatter = {
crumbs?: { label: string; href?: string }[];
/** Page layout: "doc" (default, sidebar+TOC) or "landing" (full-width marketing). */
layout?: "doc" | "landing";
/** Author-curated starter questions for the Ask AI panel on this page (max 3). */
aiSuggestions?: string[];
};

export type Doc = {
Expand Down Expand Up @@ -45,6 +47,9 @@ function readMdx(filePath: string): { fm: DocFrontmatter; body: string } {
last_updated: data.last_updated ? String(data.last_updated) : undefined,
crumbs: Array.isArray(data.crumbs) ? data.crumbs : undefined,
layout: data.layout === "landing" ? "landing" : undefined,
aiSuggestions: Array.isArray(data.aiSuggestions)
? data.aiSuggestions.filter((s: unknown) => typeof s === "string" && s.trim()).map((s: string) => s.trim()).slice(0, 3)
: undefined,
};
return { fm, body: content };
}
Expand Down
Loading
Loading