e7738de6d2
CI / Deep Native Runtime Cases (1/6) (push) Has been skipped
CI / Native Preflight (push) Failing after 1s
CI / Native Runtime Cases (1/2) (push) Failing after 0s
CI / Native Runtime Cases (2/2) (push) Failing after 1s
CI / Native Metadata Reports (push) Failing after 0s
CI / Native Direct Backend Artifacts (push) Failing after 0s
CI / Native Sanitizer Smoke (push) Failing after 1s
CI / Command Contract Snapshots (push) Failing after 1s
CI / Deep Conformance Suite (push) Has been skipped
CI / Graph Build Perf (push) Failing after 1s
CI / Deep Native Preflight (push) Has been skipped
CI / Deep Native Runtime Cases (2/6) (push) Has been skipped
CI / Deep Native Runtime Cases (3/6) (push) Has been skipped
CI / Conformance Suite (push) Failing after 1s
CI / Workspace Checks (push) Failing after 0s
CI / Deep Native Runtime Cases (5/6) (push) Has been skipped
CI / Deep Native Runtime Cases (6/6) (push) Has been skipped
CI / Deep Native Runtime Cases (4/6) (push) Has been skipped
CI / Deep Graph Build Perf (push) Has been skipped
387 lines
14 KiB
TypeScript
387 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { isValidElement, useCallback, useEffect, useRef, useState } from "react";
|
|
import type { ComponentPropsWithoutRef, ReactNode } from "react";
|
|
import { useChat } from "@ai-sdk/react";
|
|
import { DefaultChatTransport } from "ai";
|
|
import { Streamdown } from "streamdown";
|
|
import type {
|
|
Components as StreamdownComponents,
|
|
ExtraProps as StreamdownExtraProps,
|
|
} from "streamdown";
|
|
import type {
|
|
DynamicToolUIPart,
|
|
UIDataTypes,
|
|
UIMessage,
|
|
UIMessagePart,
|
|
UITools,
|
|
ToolUIPart,
|
|
} from "ai";
|
|
import Link from "next/link";
|
|
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
|
import { highlight } from "@/lib/highlight";
|
|
|
|
const STORAGE_KEY = "docs-chat-messages";
|
|
const transport = new DefaultChatTransport({ api: "/api/docs-chat" });
|
|
|
|
const TOOL_LABELS = {
|
|
readFile: { label: "Reading", pastLabel: "Read", argKey: "path" },
|
|
bash: { label: "Running", pastLabel: "Ran", argKey: "command" },
|
|
};
|
|
|
|
type ToolLabel = {
|
|
label: string;
|
|
pastLabel: string;
|
|
argKey?: string;
|
|
};
|
|
|
|
type ToolDisplayPart = ToolUIPart<UITools> | DynamicToolUIPart;
|
|
|
|
function extractCodeText(children: ReactNode): string {
|
|
if (children == null || children === false) return "";
|
|
if (typeof children === "string") return children;
|
|
if (Array.isArray(children)) return children.map(extractCodeText).join("");
|
|
if (isValidElement<{ children?: ReactNode }>(children)) {
|
|
return extractCodeText(children.props.children);
|
|
}
|
|
return String(children);
|
|
}
|
|
|
|
function ChatCodeBlock({
|
|
children,
|
|
}: ComponentPropsWithoutRef<"pre"> & StreamdownExtraProps) {
|
|
const codeEl = Array.isArray(children) ? children[0] : children;
|
|
const className = isValidElement<{ className?: string }>(codeEl)
|
|
? codeEl.props.className ?? ""
|
|
: "";
|
|
const language = /language-([A-Za-z0-9_-]+)/.exec(className)?.[1] ?? "";
|
|
const code = extractCodeText(
|
|
isValidElement<{ children?: ReactNode }>(codeEl) ? codeEl.props.children : codeEl,
|
|
).replace(/\n$/, "");
|
|
const html = highlight(code, language);
|
|
return (
|
|
<pre className="not-prose my-3 overflow-x-auto rounded-md border border-border bg-code-bg p-3 text-[0.8125rem] leading-relaxed text-code-fg">
|
|
<code
|
|
className={className}
|
|
dangerouslySetInnerHTML={{ __html: html }}
|
|
/>
|
|
</pre>
|
|
);
|
|
}
|
|
|
|
const STREAMDOWN_COMPONENTS: StreamdownComponents = {
|
|
pre: ChatCodeBlock,
|
|
};
|
|
|
|
function isToolPart(
|
|
part: UIMessagePart<UIDataTypes, UITools>,
|
|
): part is ToolDisplayPart {
|
|
return part.type.startsWith("tool-") || part.type === "dynamic-tool";
|
|
}
|
|
|
|
function getToolName(part: ToolDisplayPart): string {
|
|
if (part.type === "dynamic-tool") return part.toolName ?? "tool";
|
|
return part.type.replace(/^tool-/, "");
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null;
|
|
}
|
|
|
|
function ToolCallDisplay({ part }: { part: ToolDisplayPart }) {
|
|
const toolName = getToolName(part);
|
|
const config: ToolLabel = TOOL_LABELS[toolName as keyof typeof TOOL_LABELS] ?? {
|
|
label: toolName,
|
|
pastLabel: toolName,
|
|
};
|
|
const isDone = part.state === "output-available";
|
|
const isError = part.state === "output-error";
|
|
const isRunning = !isDone && !isError;
|
|
const displayLabel = isRunning ? config.label : config.pastLabel;
|
|
|
|
const args = isRecord(part.input) ? part.input : {};
|
|
const argValue = config.argKey ? args[config.argKey] : undefined;
|
|
const argPreview =
|
|
argValue != null
|
|
? String(argValue)
|
|
.replace(/^\/workspace\//, "/")
|
|
.replace(/\.md$/, "")
|
|
.replace(/\/index$/, "") || "/"
|
|
: "";
|
|
|
|
const docsLink = toolName === "readFile" && argPreview.startsWith("/") ? argPreview : null;
|
|
|
|
const argEl = argPreview ? (
|
|
docsLink ? (
|
|
<Link href={docsLink} className="truncate underline underline-offset-2">
|
|
{argPreview}
|
|
</Link>
|
|
) : (
|
|
<span className="truncate">{argPreview}</span>
|
|
)
|
|
) : null;
|
|
|
|
return (
|
|
<div className="min-w-0 py-0.5 text-xs">
|
|
<span
|
|
className={`inline-flex min-w-0 max-w-full items-center gap-1 font-mono ${
|
|
isRunning
|
|
? "animate-pulse text-neutral-500 dark:text-neutral-400"
|
|
: "text-neutral-400 dark:text-neutral-500"
|
|
}`}
|
|
>
|
|
<span className="shrink-0">{displayLabel}</span>
|
|
{argEl}
|
|
{isError && <span className="text-red-500">failed</span>}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const SUGGESTIONS = [
|
|
"What is Zero?",
|
|
"How do I install it?",
|
|
"What commands are available?",
|
|
"How does cross-compilation work?",
|
|
"Show me a hello world.",
|
|
];
|
|
|
|
export function DocsChat() {
|
|
const [open, setOpen] = useState(false);
|
|
const [input, setInput] = useState("");
|
|
const messagesScrollRef = useRef<HTMLDivElement | null>(null);
|
|
const inputRef = useRef<HTMLTextAreaElement | null>(null);
|
|
const restoredRef = useRef(false);
|
|
|
|
const { messages, sendMessage, status, setMessages, error } = useChat({ transport });
|
|
|
|
const isLoading = status === "streaming" || status === "submitted";
|
|
const showMessages = messages.length > 0 || !!error || isLoading;
|
|
|
|
useEffect(() => {
|
|
if (restoredRef.current) return;
|
|
restoredRef.current = true;
|
|
try {
|
|
const stored = sessionStorage.getItem(STORAGE_KEY);
|
|
if (stored) {
|
|
const parsed = JSON.parse(stored);
|
|
if (Array.isArray(parsed) && parsed.length > 0) setMessages(parsed as UIMessage[]);
|
|
}
|
|
} catch {}
|
|
}, [setMessages]);
|
|
|
|
useEffect(() => {
|
|
if (!restoredRef.current || isLoading) return;
|
|
if (messages.length === 0) {
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
return;
|
|
}
|
|
try {
|
|
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(messages));
|
|
} catch {}
|
|
}, [messages, isLoading]);
|
|
|
|
useEffect(() => {
|
|
function onKey(e: globalThis.KeyboardEvent) {
|
|
if (e.key === "i" && (e.metaKey || e.ctrlKey)) {
|
|
e.preventDefault();
|
|
setOpen((prev) => !prev);
|
|
}
|
|
}
|
|
document.addEventListener("keydown", onKey);
|
|
return () => document.removeEventListener("keydown", onKey);
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
const t = setTimeout(() => inputRef.current?.focus(), 200);
|
|
return () => clearTimeout(t);
|
|
}
|
|
}, [open]);
|
|
|
|
useEffect(() => {
|
|
if (error) setOpen(true);
|
|
}, [error]);
|
|
|
|
useEffect(() => {
|
|
const el = messagesScrollRef.current;
|
|
if (!el) return;
|
|
requestAnimationFrame(() => {
|
|
el.scrollTop = el.scrollHeight;
|
|
});
|
|
}, [messages, error]);
|
|
|
|
const handleSubmit = useCallback(
|
|
(e?: { preventDefault: () => void }) => {
|
|
e?.preventDefault();
|
|
if (!input.trim() || isLoading) return;
|
|
sendMessage({ text: input });
|
|
setInput("");
|
|
},
|
|
[input, isLoading, sendMessage],
|
|
);
|
|
|
|
const handleClear = useCallback(() => {
|
|
setMessages([]);
|
|
sessionStorage.removeItem(STORAGE_KEY);
|
|
}, [setMessages]);
|
|
|
|
return (
|
|
<>
|
|
{!open && (
|
|
<button
|
|
onClick={() => setOpen(true)}
|
|
className="fixed bottom-4 left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-lg bg-neutral-900 px-4 py-2 text-sm font-medium text-white shadow-lg transition-opacity hover:opacity-90 dark:bg-neutral-100 dark:text-neutral-900 sm:left-auto sm:right-4 sm:translate-x-0"
|
|
aria-label="Ask AI"
|
|
>
|
|
Ask AI
|
|
<kbd className="hidden items-center gap-0.5 font-mono text-xs opacity-60 sm:inline-flex">
|
|
<span>⌘</span>I
|
|
</kbd>
|
|
</button>
|
|
)}
|
|
|
|
<Sheet open={open} onOpenChange={setOpen}>
|
|
<SheetContent
|
|
side="right"
|
|
showCloseButton={false}
|
|
className="flex w-full max-w-md flex-col gap-0 p-0 sm:max-w-md"
|
|
>
|
|
<SheetTitle className="sr-only">AI Chat</SheetTitle>
|
|
|
|
<div className="flex shrink-0 items-center justify-between border-b border-neutral-200 px-4 py-3 dark:border-neutral-800">
|
|
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
|
Zero Docs
|
|
</span>
|
|
<div className="flex items-center gap-3">
|
|
{showMessages && (
|
|
<button
|
|
onClick={handleClear}
|
|
className="text-xs text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
|
aria-label="Clear conversation"
|
|
>
|
|
Clear
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => setOpen(false)}
|
|
className="text-neutral-500 transition-colors hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100"
|
|
aria-label="Close panel"
|
|
>
|
|
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="18" y1="6" x2="6" y2="18" />
|
|
<line x1="6" y1="6" x2="18" y2="18" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{showMessages ? (
|
|
<div ref={messagesScrollRef} className="flex-1 space-y-4 overflow-y-auto p-4">
|
|
{messages.map((message) => {
|
|
const hasVisible = message.parts?.some(
|
|
(p) => (p.type === "text" && p.text.length > 0) || isToolPart(p),
|
|
);
|
|
if (!hasVisible) return null;
|
|
return (
|
|
<div key={message.id}>
|
|
{message.role === "user" ? (
|
|
<div className="whitespace-pre-wrap text-sm leading-relaxed text-neutral-500 dark:text-neutral-400">
|
|
{message.parts.filter((p) => p.type === "text").map((p) => p.text).join("")}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{message.parts.map((part, i) => {
|
|
if (part.type === "text" && part.text) {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="docs-chat-content max-w-none text-sm leading-relaxed text-neutral-900 dark:text-neutral-100"
|
|
>
|
|
<Streamdown components={STREAMDOWN_COMPONENTS}>
|
|
{part.text}
|
|
</Streamdown>
|
|
</div>
|
|
);
|
|
}
|
|
if (isToolPart(part)) {
|
|
return <ToolCallDisplay key={part.toolCallId ?? i} part={part} />;
|
|
}
|
|
return null;
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
{error && (
|
|
<div className="rounded-md bg-red-50 px-3 py-2 text-sm text-red-600/80 dark:bg-red-950/30 dark:text-red-400/80">
|
|
{(() => {
|
|
try {
|
|
const parsed = JSON.parse(error.message);
|
|
return parsed.message || parsed.error || error.message;
|
|
} catch {
|
|
return error.message || "Something went wrong. Please try again.";
|
|
}
|
|
})()}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="flex flex-1 flex-col">
|
|
<div className="flex flex-wrap gap-2 p-4">
|
|
{SUGGESTIONS.map((s) => (
|
|
<button
|
|
key={s}
|
|
type="button"
|
|
onClick={() => sendMessage({ text: s })}
|
|
className="rounded-full border border-neutral-200 bg-neutral-100 px-3 py-1.5 text-xs font-medium text-neutral-600 transition-colors hover:text-neutral-900 dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-400 dark:hover:text-neutral-100"
|
|
>
|
|
{s}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={handleSubmit}
|
|
className="flex shrink-0 items-end gap-2 border-t border-neutral-200 px-4 py-3 dark:border-neutral-800"
|
|
>
|
|
<textarea
|
|
ref={inputRef}
|
|
value={input}
|
|
onChange={(e) => {
|
|
setInput(e.target.value);
|
|
e.target.style.height = "auto";
|
|
e.target.style.height = `${e.target.scrollHeight}px`;
|
|
}}
|
|
rows={1}
|
|
enterKeyHint="send"
|
|
placeholder="Ask a question..."
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleSubmit(e);
|
|
}
|
|
}}
|
|
className="max-h-32 flex-1 resize-none bg-transparent text-base leading-relaxed text-neutral-900 outline-none placeholder:text-neutral-400 disabled:opacity-50 dark:text-neutral-100 dark:placeholder:text-neutral-500 sm:text-sm"
|
|
/>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || !input.trim()}
|
|
className="shrink-0 rounded-full bg-neutral-900 p-1.5 text-white transition-colors hover:bg-neutral-700 disabled:opacity-30 dark:bg-neutral-100 dark:text-neutral-900 dark:hover:bg-neutral-300"
|
|
aria-label="Send message"
|
|
>
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
|
<line x1="12" y1="19" x2="12" y2="5" />
|
|
<polyline points="5 12 12 5 19 12" />
|
|
</svg>
|
|
</button>
|
|
</form>
|
|
</SheetContent>
|
|
</Sheet>
|
|
</>
|
|
);
|
|
}
|