diff --git a/app/api-reference.css b/app/api-reference.css index 481879a..0070af5 100644 --- a/app/api-reference.css +++ b/app/api-reference.css @@ -501,11 +501,21 @@ body.aichat-open .ml-apiref .aichat { transform: none; } .ml-apiref .ac-ctx { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; font-size: 12px; color: var(--accent-ink); background: var(--accent-dim); border: 1px solid var(--accent-line); border-radius: 8px; padding: 6px 10px; margin-bottom: 10px; } .ml-apiref .ac-ctx .bk { flex: none; display: inline-flex; } .ml-apiref .ac-ctx .ctxl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ml-apiref .ac-inwrap { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line-2); border-radius: 11px; padding: 8px 8px 8px 13px; background: var(--panel-2); } +.ml-apiref .ac-inwrap { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--line-2); border-radius: 11px; padding: 10px 10px 8px; background: var(--panel-2); } .ml-apiref .ac-inwrap:focus-within { border-color: var(--accent-line); } -.ml-apiref .ac-in { flex: 1; min-width: 0; background: transparent; border: 0; outline: 0; font-family: var(--sans); font-size: 13.5px; color: var(--ink); } +.ml-apiref .ac-in { width: 100%; min-height: 46px; max-height: 168px; resize: none; overflow-y: auto; background: transparent; border: 0; outline: 0; font-family: var(--sans); font-size: 13.5px; line-height: 1.5; color: var(--ink); } .ml-apiref .ac-in::placeholder { color: var(--ink-4); } -.ml-apiref .ac-send { width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 8px; border: 0; background: var(--accent); color: var(--on-accent); } +.ml-apiref .ac-inbar { display: flex; align-items: center; justify-content: space-between; } +.ml-apiref .ac-attach { width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 8px; border: 0; background: transparent; color: var(--ink-4); cursor: pointer; } +.ml-apiref .ac-attach:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 8%, transparent); color: var(--ink); } +.ml-apiref .ac-attach:disabled { opacity: .4; cursor: default; } +.ml-apiref .ac-attachments { display: flex; flex-wrap: wrap; gap: 6px; } +.ml-apiref .ac-chip { position: relative; width: 46px; height: 46px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line-2); } +.ml-apiref .ac-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } +.ml-apiref .ac-chip-x { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(0,0,0,.6); color: #fff; cursor: pointer; padding: 0; } +.ml-apiref .ac-msg-imgs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; } +.ml-apiref .ac-msg-imgs img { max-width: 160px; max-height: 160px; border-radius: 8px; display: block; } +.ml-apiref .ac-send { margin-left: auto; width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 8px; border: 0; background: var(--accent); color: var(--on-accent); cursor: pointer; } .ml-apiref .ac-send:hover { background: color-mix(in srgb, var(--accent) 86%, #fff); } .ml-apiref .ac-send:disabled { opacity: .4; } .ml-apiref .ac-send .ico { width: 15px; height: 15px; } diff --git a/app/api/ai/route.ts b/app/api/ai/route.ts index 0cefe61..1f6feca 100644 --- a/app/api/ai/route.ts +++ b/app/api/ai/route.ts @@ -46,7 +46,7 @@ export async function POST(req: Request) { return new Response("Rate limit exceeded", { status: 429 }); } - let body: { question?: string; context?: string }; + let body: { question?: string; context?: string; images?: string[] }; try { body = await req.json(); } catch { @@ -54,7 +54,13 @@ export async function POST(req: Request) { } const question = (body.question ?? "").trim().slice(0, 4000); if (!question) return new Response("Empty question", { status: 400 }); - const context = body.context?.slice(0, 8000); + // Retrieved grounding context (current page + top docs) — larger than the old + // section-name hint, so allow more headroom. + const context = body.context?.slice(0, 16000); + // Accept up to 4 inline image data URLs (~base64). Drop anything else. + const images = Array.isArray(body.images) + ? body.images.filter((u) => typeof u === "string" && /^data:image\//.test(u)).slice(0, 4) + : undefined; const baseUrl = resolveAiBaseUrl(ai); try { @@ -64,13 +70,20 @@ export async function POST(req: Request) { key, provider: ai.provider ?? "openai", maxTokens: ai.maxTokens ?? 1024, - messages: buildMessages(ai.systemPrompt, context, question), + messages: buildMessages(ai.systemPrompt, context, question, images), referer: req.headers.get("origin") ?? undefined, title: cfg.name, }); return Response.json({ text }); } catch (e) { - const message = e instanceof Error ? e.message : "AI request failed"; - return new Response(message, { status: 502 }); + // Log the upstream detail for the operator; never leak provider internals + // (keys, billing/privacy URLs, model ids) to the reader. + const detail = e instanceof Error ? e.message : String(e); + console.error("[markline] AI proxy upstream error:", detail); + const isRate = /\b429\b|rate.?limit|too many/i.test(detail); + return new Response( + isRate ? "Too many requests. Please try again shortly." : "The assistant is temporarily unavailable.", + { status: isRate ? 429 : 502 }, + ); } } diff --git a/app/docs.css b/app/docs.css index 387502f..922af94 100644 --- a/app/docs.css +++ b/app/docs.css @@ -397,11 +397,21 @@ body.aichat-open .docs-shell .aichat { transform: none; } .docs-shell .ac-ctx { display: inline-flex; align-items: center; gap: 8px; max-width: 100%; font-size: 12px; color: var(--accent-ink); background: var(--accent-dim); border: 1px solid var(--accent-line); border-radius: 8px; padding: 6px 10px; margin-bottom: 10px; } .docs-shell .ac-ctx .bk { flex: none; display: inline-flex; } .docs-shell .ac-ctx .ctxl { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.docs-shell .ac-inwrap { display: flex; align-items: center; gap: 8px; border: 1px solid var(--line-2); border-radius: 11px; padding: 8px 8px 8px 13px; background: var(--panel-2); } +.docs-shell .ac-inwrap { display: flex; flex-direction: column; gap: 8px; border: 1px solid var(--line-2); border-radius: 11px; padding: 10px 10px 8px; background: var(--panel-2); } .docs-shell .ac-inwrap:focus-within { border-color: var(--accent-line); } -.docs-shell .ac-in { flex: 1; min-width: 0; background: transparent; border: 0; outline: 0; font-family: inherit; font-size: 13.5px; color: var(--ink); } +.docs-shell .ac-in { width: 100%; min-height: 46px; max-height: 168px; resize: none; overflow-y: auto; background: transparent; border: 0; outline: 0; font-family: inherit; font-size: 13.5px; line-height: 1.5; color: var(--ink); } .docs-shell .ac-in::placeholder { color: var(--ink-4); } -.docs-shell .ac-send { width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 8px; border: 0; background: var(--accent); color: rgb(var(--c-on-brand)); cursor: pointer; } +.docs-shell .ac-inbar { display: flex; align-items: center; justify-content: space-between; } +.docs-shell .ac-attach { width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 8px; border: 0; background: transparent; color: var(--ink-4); cursor: pointer; } +.docs-shell .ac-attach:hover:not(:disabled) { background: color-mix(in srgb, var(--ink) 8%, transparent); color: var(--ink); } +.docs-shell .ac-attach:disabled { opacity: .4; cursor: default; } +.docs-shell .ac-attachments { display: flex; flex-wrap: wrap; gap: 6px; } +.docs-shell .ac-chip { position: relative; width: 46px; height: 46px; border-radius: 8px; overflow: hidden; border: 1px solid var(--line-2); } +.docs-shell .ac-chip img { width: 100%; height: 100%; object-fit: cover; display: block; } +.docs-shell .ac-chip-x { position: absolute; top: 2px; right: 2px; width: 16px; height: 16px; display: grid; place-items: center; border: 0; border-radius: 50%; background: rgba(0,0,0,.6); color: #fff; cursor: pointer; padding: 0; } +.docs-shell .ac-msg-imgs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 6px; } +.docs-shell .ac-msg-imgs img { max-width: 160px; max-height: 160px; border-radius: 8px; display: block; } +.docs-shell .ac-send { margin-left: auto; width: 30px; height: 30px; flex: none; display: grid; place-items: center; border-radius: 8px; border: 0; background: var(--accent); color: rgb(var(--c-on-brand)); cursor: pointer; } .docs-shell .ac-send:hover { background: color-mix(in srgb, var(--accent) 86%, #fff); } .docs-shell .ac-send:disabled { opacity: .4; } .docs-shell .ac-send .ico { width: 15px; height: 15px; } diff --git a/components/docs/ai/ask-dock.tsx b/components/docs/ai/ask-dock.tsx index d5418de..2f8b5f8 100644 --- a/components/docs/ai/ask-dock.tsx +++ b/components/docs/ai/ask-dock.tsx @@ -25,18 +25,56 @@ export function openAskPanel(context?: string) { window.dispatchEvent(new CustomEvent("ml-ai-open", { detail: { context: context ?? null } })); } -type Msg = { role: "user" | "ai"; text: string; sources?: string[]; error?: boolean }; +type Msg = { role: "user" | "ai"; text: string; sources?: string[]; error?: boolean; images?: string[] }; +type Attachment = { name: string; url: string }; + +const MAX_ATTACHMENTS = 4; +const MAX_IMAGE_BYTES = 5 * 1024 * 1024; // 5 MB per image +const COMPOSER_MAX_H = 168; // px — textarea grows to here, then scrolls + +function readAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const r = new FileReader(); + r.onload = () => resolve(String(r.result)); + r.onerror = () => reject(r.error); + r.readAsDataURL(file); + }); +} type Chat = { title: string; messages: Msg[] }; const CHATS_KEY = "markline-ai-chats"; const OPEN_KEY = "markline-ai-open"; const RKEY_KEY = "markline-ai-key"; -const SUGGEST = ["How do I authenticate requests?", "How do I create a resource?", "What does an error response look like?"]; +const SUGGEST = ["Summarize this page", "Explain this with an example", "What are the key concepts here?"]; -function trunc(s: string, n: number) { - s = s.replace(/\s+/g, " ").trim(); - return s.length > n ? s.slice(0, n - 1) + "…" : s; +/** 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. */ +function summarizeTitle(q: string): string { + let s = q.replace(/\s+/g, " ").trim(); + s = s.replace(/^(hi|hey|hello|yo|please|pls|so|um|ok|okay)[,\s]+/i, ""); + if (!s) return "New chat"; + s = s.charAt(0).toUpperCase() + s.slice(1); + const LIMIT = 42; + if (s.length <= LIMIT) return s.replace(/[\s?.!,;:]+$/, ""); + let cut = s.slice(0, LIMIT); + const lastSpace = cut.lastIndexOf(" "); + if (lastSpace > 18) cut = cut.slice(0, lastSpace); + return cut.replace(/[\s?.!,;:]+$/, "") + "…"; +} + +/** Map a raw transport/provider error to a safe, public-facing message. The raw + * detail is never surfaced to readers — it's logged to the console instead. */ +function friendlyError(detail: string, mode: "proxy" | "byok"): string { + const d = detail.toLowerCase(); + if (/\b429\b|rate.?limit|too many/.test(d)) return "Too many requests right now. Please wait a moment and try again."; + if (mode === "byok") { + if (/\b401\b|\b403\b|unauthor|invalid.*key|api key|no auth/.test(d)) return "That key was rejected. Check your provider key and try again."; + if (/\b404\b|model|endpoint/.test(d)) return "Your provider couldn't serve that model. Check the model name in your settings."; + return "Couldn't reach your provider. Check your key and model, then try again."; + } + return "The assistant is unavailable right now. Please try again shortly."; } function esc(s: string) { return String(s).replace(/&/g, "&").replace(//g, ">"); @@ -78,8 +116,10 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { const [thinking, setThinking] = useState(false); const [needKey, setNeedKey] = useState(false); const [keyDraft, setKeyDraft] = useState(""); + const [attachments, setAttachments] = useState([]); const bodyRef = useRef(null); - const inputRef = useRef(null); + const inputRef = useRef(null); + const fileRef = useRef(null); const messages = chats[cur]?.messages ?? []; @@ -97,10 +137,15 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { } }, []); - /* persist chats */ + /* persist chats — drop inline image data so we never blow the localStorage + quota; the conversation text survives a reload, the thumbnails don't. */ useEffect(() => { try { - localStorage.setItem(CHATS_KEY, JSON.stringify({ chats, cur })); + const slim = chats.map((c) => ({ + ...c, + messages: c.messages.map(({ images, ...m }) => m), + })); + localStorage.setItem(CHATS_KEY, JSON.stringify({ chats: slim, cur })); } catch { /* ignore */ } @@ -125,6 +170,14 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; }, [messages, thinking]); + /* auto-grow the composer textarea up to COMPOSER_MAX_H, then scroll */ + useEffect(() => { + const el = inputRef.current; + if (!el) return; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, COMPOSER_MAX_H) + "px"; + }, [input, open]); + /* external open trigger + keyboard shortcuts */ useEffect(() => { const onOpen = (e: Event) => { @@ -179,8 +232,14 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { setMenu(false); } - async function callAi(question: string): Promise<{ text: string; sources: string[] }> { - const sources = pickSources(); + async function callAi(question: string, images?: string[]): Promise<{ text: string; sources: string[] }> { + const imgs = ai.vision && images?.length ? images : undefined; + // Real retrieval: ground the answer in the current page + the most relevant + // docs for the question (from llms-full.txt). `sources` are the pages used. + const { buildGroundingContext } = await import("@/lib/ai/retrieval"); + const basePath = process.env.NEXT_PUBLIC_MARKLINE_BASE_PATH || ""; + const { context: grounded, sources } = await buildGroundingContext(question, { basePath, sectionHint: context }); + if (ai.mode === "byok") { let key = ""; try { @@ -200,7 +259,7 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { key, provider: ai.provider, maxTokens: ai.maxTokens, - messages: buildMessages(undefined, `You are viewing the "${context}" section.`, question), + messages: buildMessages(undefined, grounded, question, imgs), referer: location.origin, title: document.title, }); @@ -210,23 +269,44 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { const res = await fetch(ai.endpoint || "/api/ai", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ question, context: `The user is viewing the "${context}" section.` }), + body: JSON.stringify({ question, context: grounded, images: imgs }), }); if (!res.ok) throw new Error((await res.text().catch(() => "")) || `Request failed (${res.status})`); const data = await res.json(); return { text: (data.text || "").trim(), sources }; } + async function addFiles(files: FileList | null) { + if (!files?.length) return; + const room = MAX_ATTACHMENTS - attachments.length; + const picked: Attachment[] = []; + for (const file of Array.from(files).slice(0, Math.max(0, room))) { + if (!file.type.startsWith("image/") || file.size > MAX_IMAGE_BYTES) continue; + try { + picked.push({ name: file.name, url: await readAsDataUrl(file) }); + } catch { + /* skip unreadable file */ + } + } + if (picked.length) setAttachments((a) => [...a, ...picked].slice(0, MAX_ATTACHMENTS)); + if (fileRef.current) fileRef.current.value = ""; // allow re-picking the same file + } + function removeAttachment(i: number) { + setAttachments((a) => a.filter((_, idx) => idx !== i)); + } + async function submit(forced?: string) { const q = (forced ?? input).trim(); - if (!q || thinking) return; + const imgs = attachments.map((a) => a.url); + if ((!q && !imgs.length) || thinking) return; setInput(""); + setAttachments([]); const first = messages.length === 0; - updateMessages((m) => [...m, { role: "user", text: q }]); - if (first) setChats((cs) => cs.map((c, i) => (i === cur ? { ...c, title: trunc(q, 30) } : c))); + updateMessages((m) => [...m, { role: "user", text: q, images: imgs.length ? imgs : undefined }]); + if (first) setChats((cs) => cs.map((c, i) => (i === cur ? { ...c, title: summarizeTitle(q || "Image") } : c))); setThinking(true); try { - const { text, sources } = await callAi(q); + const { text, sources } = await callAi(q, imgs); updateMessages((m) => [...m, { role: "ai", text, sources }]); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -234,11 +314,9 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { setThinking(false); return; // key prompt shown; question stays in transcript } - const friendly = - ai.mode === "byok" - ? "Couldn't reach your provider. Check your key and model, then try again." - : "The assistant is unavailable right now. In a real deployment this runs on your own key (BYOK) — nothing routes through Markline."; - updateMessages((m) => [...m, { role: "ai", text: friendly + (msg && msg.length < 200 ? `\n\n\`${msg}\`` : ""), error: true }]); + // Log the raw detail for operators; show readers only a safe message. + console.error("[markline] Ask AI error:", e); + updateMessages((m) => [...m, { role: "ai", text: friendlyError(msg, ai.mode), error: true }]); } finally { setThinking(false); } @@ -259,11 +337,6 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { if (last) submit(last.text); } - function pickSources(): string[] { - const all = [`API Reference — ${context}`, "Authentication", "The object model", "Errors", "Guides"]; - return all.slice(0, 3); - } - const title = chats[cur]?.title || "New chat"; return ( @@ -309,7 +382,7 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) {
{messages.length === 0 && !thinking ? (
-
Ask questions about this API and get help with your integration.
+
Ask questions about this page.
Tip: start a new chat with E
@@ -326,6 +399,14 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { {messages.map((m, i) => m.role === "user" ? (
+ {m.images?.length ? ( +
+ {m.images.map((src, k) => ( + // eslint-disable-next-line @next/next/no-img-element + attachment + ))} +
+ ) : null} {m.text}
) : ( @@ -376,26 +457,79 @@ export function AskDock({ ai }: { ai: AiPublicConfig }) { - API Reference — {context} + {context}
- 0 && ( +
+ {attachments.map((a, i) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {a.name} + +
+ ))} +
+ )} +