Files
zeekay a15864b16b ci: amd64-only build (matches hanzo runner pool topology)
hanzo-build-linux-amd64 is the only ARC runner pool installed on
hanzo-k8s today (arm64 pool is paused until DigitalOcean ships
arm64 droplets, per ~/work/hanzo/.github/RUNNERS.md). The arm64
job was waiting on spark-hanzoai-arm64 — not provisioned in this
cluster — so every build sat in queue forever and the amd64 leg
got cancelled.

Set platforms: linux/amd64 on the reusable build call to skip
arm64 entirely. Re-add arm64 if/when DO ships arm64 droplets and
the spark runner pool comes back online.
2026-06-07 11:38:49 -07:00

746 lines
24 KiB
TypeScript

"use client";
import { useCallback, useRef, useState } from "react";
import {
AtSignIcon,
FileTextIcon,
Loader2,
MessageSquareTextIcon,
Plus,
PlusIcon,
SearchIcon,
ShieldCheckIcon,
SparklesIcon,
XIcon,
} from "lucide-react";
import {
Conversation,
ConversationContent,
ConversationEmptyState,
ConversationScrollButton,
} from "@/components/ai-elements/conversation";
import {
PromptInput,
PromptInputButton,
PromptInputFooter,
PromptInputHeader,
type PromptInputMessage,
PromptInputSubmit,
PromptInputTextarea,
PromptInputTools,
} from "@/components/ai-elements/prompt-input";
import { Button } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type {
ChatStreamMetadata,
ChatStreamSource,
} from "../lib/chat/send-message";
import {
CHAT_METADATA_PREFIX,
CHAT_METADATA_SUFFIX,
} from "../lib/chat/send-message";
import { parseTextStream } from "../lib/stream/parse-text-stream";
import ChatMessage from "./chat-message";
import {
DocumentContextSelector,
SelectedContextItem,
SelectedContextItems,
getDocumentsInFolder,
} from "./document-context-selector";
import { useViewerChatSafe } from "./viewer-chat-provider";
import { ViewerThreadSelector } from "./viewer-thread-selector";
interface Message {
id: string;
role: "user" | "assistant";
content: string;
isStreaming?: boolean;
sources?: ChatStreamSource[];
suggestedQuestions?: string[];
}
function extractStreamMetadata(content: string): {
text: string;
metadata?: ChatStreamMetadata;
} {
const prefixIdx = content.indexOf(CHAT_METADATA_PREFIX);
if (prefixIdx === -1) return { text: content };
const jsonStart = prefixIdx + CHAT_METADATA_PREFIX.length;
const suffixIdx = content.indexOf(CHAT_METADATA_SUFFIX, jsonStart);
if (suffixIdx === -1) return { text: content };
try {
const jsonStr = content.slice(jsonStart, suffixIdx);
const metadata = JSON.parse(jsonStr) as ChatStreamMetadata;
const text = content.slice(0, prefixIdx).trim();
return { text, metadata };
} catch {
return { text: content };
}
}
const REFERENCES_REGEX =
/\n\n(?:#{1,6}\s*)?References\s*\n(?:\s*\n)?((?:[-*]\s.*(?:\n|$))+)$/i;
const REFERENCE_LINE_REGEX = /[-*]\s*\[(.+?)]\((.+?)\)(?:\s*-\s*p\.\s*(\d+))?/;
interface ResolvedRef {
dataroomDocumentId: string;
documentName: string;
url: string;
page?: number;
folderPath?: string;
}
function parseMarkdownReferences(content: string): {
text: string;
sources?: ChatStreamSource[];
} {
const match = content.match(REFERENCES_REGEX);
if (!match) return { text: content };
const text = content.replace(REFERENCES_REGEX, "").trim();
const lines = match[1].trim().split("\n").filter(Boolean);
const sources: ChatStreamSource[] = [];
for (let i = 0; i < lines.length; i++) {
const lineMatch = lines[i].match(REFERENCE_LINE_REGEX);
if (lineMatch) {
sources.push({
id: `D-${i + 1}`,
name: lineMatch[1].replace(/\\([\[\]])/g, "$1"),
url: lineMatch[2],
page: lineMatch[3] ? parseInt(lineMatch[3]) : undefined,
});
}
}
return { text, sources: sources.length > 0 ? sources : undefined };
}
interface ViewerChatPanelProps {
className?: string;
}
/**
* Standalone chat panel that reads configuration from ViewerChatProvider.
* Place this anywhere in the component tree within ViewerChatProvider.
* It will render as a fixed panel on the right side when open.
*/
export function ViewerChatPanel({ className }: ViewerChatPanelProps) {
const context = useViewerChatSafe();
// Don't render if not in provider or not enabled
if (!context || !context.isEnabled) {
return null;
}
// Don't render if closed
if (!context.isOpen) {
return null;
}
return (
<div className="fixed bottom-0 right-0 top-16 z-30 w-[400px] shadow-xl">
<ViewerChatPanelContent
onClose={context.close}
dataroomId={context.config.dataroomId}
dataroomName={context.config.dataroomName}
documentId={context.config.documentId}
documentName={context.config.documentName}
linkId={context.config.linkId}
viewId={context.config.viewId}
viewerId={context.config.viewerId}
documents={context.documents}
folders={context.folders}
/>
</div>
);
}
// ============================================================================
// Internal Content Component
// ============================================================================
interface ViewerChatPanelContentProps {
onClose: () => void;
dataroomId?: string;
dataroomName?: string;
documentId?: string;
documentName?: string;
linkId?: string;
viewId?: string;
viewerId?: string;
documents?: Array<{
dataroomDocumentId: string;
id: string;
name: string;
folderId: string | null;
}>;
folders?: Array<{
id: string;
name: string;
parentId: string | null;
path: string;
orderIndex: number | null;
dataroomId: string;
createdAt: Date;
updatedAt: Date;
hierarchicalIndex: string | null;
icon: string | null;
color: string | null;
}>;
}
function ViewerChatPanelContent({
onClose,
dataroomId,
dataroomName,
documentId,
documentName,
linkId,
viewId,
viewerId,
documents = [],
folders = [],
}: ViewerChatPanelContentProps) {
const [messages, setMessages] = useState<Message[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [chatId, setChatId] = useState<string | null>(null);
const [chatTitle, setChatTitle] = useState<string | undefined>();
const [selectedContextItems, setSelectedContextItems] = useState<
SelectedContextItem[]
>([]);
const [selectorOpen, setSelectorOpen] = useState(false);
const abortControllerRef = useRef<AbortController | null>(null);
const contextName = dataroomName || documentName || "Document";
// Handle abort/stop
const handleAbort = useCallback(() => {
if (abortControllerRef.current) {
abortControllerRef.current.abort();
abortControllerRef.current = null;
}
setIsLoading(false);
// Mark any streaming message as complete
setMessages((prev) =>
prev.map((m) =>
m.isStreaming
? {
...m,
isStreaming: false,
content: m.content || "Message cancelled.",
}
: m,
),
);
}, []);
// Calculate selected document IDs from context items (expanding folders)
const getSelectedDocumentIds = useCallback((): string[] => {
if (selectedContextItems.length === 0) return [];
const docIds = new Set<string>();
for (const item of selectedContextItems) {
if (item.type === "document") {
docIds.add(item.id);
} else {
// For folders, get all documents in the folder
const docsInFolder = getDocumentsInFolder(item.id, documents, folders);
docsInFolder.forEach((doc) => docIds.add(doc.dataroomDocumentId));
}
}
return Array.from(docIds);
}, [selectedContextItems, documents, folders]);
// Handle removing a context item
const handleRemoveContextItem = useCallback(
(type: "document" | "folder", id: string) => {
setSelectedContextItems((prev) =>
prev.filter((item) => !(item.type === type && item.id === id)),
);
},
[],
);
// Create chat session
const createChat = async () => {
try {
const response = await fetch(`/api/ai/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
documentId,
dataroomId,
linkId,
viewId,
viewerId,
}),
});
if (!response.ok) throw new Error("Failed to create chat");
const data = await response.json();
setChatId(data.id);
setChatTitle(undefined);
return data.id;
} catch (error) {
console.error("Error creating chat:", error);
return null;
}
};
// Load existing chat
const loadChat = useCallback(
async (selectedChatId: string) => {
try {
setIsLoading(true);
const queryParams = viewerId ? `?viewerId=${viewerId}` : "";
const response = await fetch(
`/api/ai/chat/${selectedChatId}${queryParams}`,
);
if (!response.ok) throw new Error("Failed to load chat");
const data = await response.json();
setChatId(selectedChatId);
setChatTitle(data.title);
const loadedMessages: Message[] = data.messages.map((msg: any) => {
if (msg.role === "assistant") {
const meta = msg.metadata as Record<string, any> | null;
const storedSuggestions = meta?.suggestedQuestions as
| string[]
| undefined;
const storedRefs = meta?.references as ResolvedRef[] | undefined;
if (storedRefs && storedRefs.length > 0) {
const { text } = parseMarkdownReferences(msg.content);
return {
id: msg.id,
role: msg.role,
content: text,
sources: storedRefs.map((ref: ResolvedRef, idx: number) => ({
id: `D-${idx + 1}`,
name: ref.documentName,
url: ref.url,
dataroomDocumentId: ref.dataroomDocumentId,
page: ref.page,
folderPath: ref.folderPath,
})),
suggestedQuestions: storedSuggestions,
};
}
const { text, sources } = parseMarkdownReferences(msg.content);
return {
id: msg.id,
role: msg.role,
content: text,
sources,
suggestedQuestions: storedSuggestions,
};
}
return { id: msg.id, role: msg.role, content: msg.content };
});
setMessages(loadedMessages);
} catch (error) {
console.error("Error loading chat:", error);
} finally {
setIsLoading(false);
}
},
[viewerId],
);
// Start new chat
const handleNewChat = useCallback(() => {
setChatId(null);
setChatTitle(undefined);
setMessages([]);
}, []);
// Delete chat
const handleDeleteChat = useCallback(
async (deleteChatId: string) => {
try {
const queryParams = viewerId ? `?viewerId=${viewerId}` : "";
await fetch(`/api/ai/chat/${deleteChatId}${queryParams}`, {
method: "DELETE",
});
// If deleted current chat, start new one
if (deleteChatId === chatId) {
handleNewChat();
}
} catch (error) {
console.error("Error deleting chat:", error);
}
},
[chatId, viewerId, handleNewChat],
);
const handleSubmit = async (message: PromptInputMessage) => {
if (!message.text.trim() || isLoading) return;
const userMessage: Message = {
id: `user-${Date.now()}`,
role: "user",
content: message.text.trim(),
};
setMessages((prev) => [...prev, userMessage]);
setIsLoading(true);
// Create abort controller for this request
abortControllerRef.current = new AbortController();
try {
// Create chat if needed
let currentChatId = chatId;
if (!currentChatId) {
currentChatId = await createChat();
if (!currentChatId) {
setIsLoading(false);
return;
}
}
const queryParams = viewerId ? `?viewerId=${viewerId}` : "";
// Get selected document IDs for filtering
const filterDataroomDocumentIds = getSelectedDocumentIds();
const response = await fetch(
`/api/ai/chat/${currentChatId}/messages${queryParams}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
content: userMessage.content,
// When viewing a specific document in a dataroom, filter search to that document
filterDocumentId: documentId,
// User-selected context filter (only if items are selected)
...(filterDataroomDocumentIds.length > 0 && {
filterDataroomDocumentIds,
}),
}),
signal: abortControllerRef.current.signal,
},
);
if (!response.ok) throw new Error("Failed to send message");
// Get the reader for streaming
const reader = response.body?.getReader();
if (!reader) throw new Error("No response body");
// Create placeholder for assistant message
const assistantMessageId = `assistant-${Date.now()}`;
const assistantMessage: Message = {
id: assistantMessageId,
role: "assistant",
content: "",
isStreaming: true,
};
setMessages((prev) => [...prev, assistantMessage]);
await parseTextStream(reader, {
onTextDelta: (_delta, accumulated) => {
const { text: visibleText } = extractStreamMetadata(accumulated);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId ? { ...m, content: visibleText } : m,
),
);
},
onTextEnd: (rawContent) => {
const { text, metadata } = extractStreamMetadata(rawContent);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId
? {
...m,
content: text,
isStreaming: false,
sources: metadata?.sources,
suggestedQuestions: metadata?.suggestedQuestions,
}
: m,
),
);
},
onError: (error) => {
console.error("Stream error:", error);
setMessages((prev) =>
prev.map((m) =>
m.id === assistantMessageId
? {
...m,
content:
"Sorry, there was an error processing your request.",
isStreaming: false,
}
: m,
),
);
},
});
} catch (error) {
// Don't show error if aborted
if (error instanceof Error && error.name === "AbortError") {
return;
}
console.error("Error sending message:", error);
setMessages((prev) => [
...prev,
{
id: `error-${Date.now()}`,
role: "assistant",
content: "Sorry, there was an error sending your message.",
},
]);
} finally {
setIsLoading(false);
abortControllerRef.current = null;
}
};
const handleSuggestedQuestion = (question: string) => {
if (isLoading) return;
handleSubmit({ text: question, files: [] });
};
return (
<div className="flex h-full w-full flex-col border-l border-gray-200 bg-white">
{/* Header with Thread Selector and New Chat Button */}
<div className="flex items-center justify-between border-b border-gray-200 px-3 py-2">
<div className="flex min-w-0 flex-1 items-center gap-2">
<ViewerThreadSelector
currentChatId={chatId}
currentChatTitle={chatTitle}
onSelectChat={loadChat}
onNewChat={handleNewChat}
onDeleteChat={handleDeleteChat}
documentId={documentId}
dataroomId={dataroomId}
linkId={linkId}
viewerId={viewerId}
viewId={viewId}
/>
</div>
<div className="flex items-center gap-1">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
onClick={handleNewChat}
className="size-8 p-0 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
>
<Plus className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent>New Chat</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="ghost"
size="sm"
onClick={onClose}
className="size-8 p-0 text-gray-500 hover:bg-gray-100 hover:text-gray-900"
>
<XIcon className="size-4" />
</Button>
</div>
</div>
{/* Context Name */}
<div className="border-b border-gray-100 bg-gray-50/50 px-4 py-1.5">
<p className="truncate text-xs text-gray-500">{contextName}</p>
</div>
{/* Messages */}
<Conversation className="flex-1">
<ConversationContent className="gap-4 p-4">
{messages.length === 0 ? (
<ConversationEmptyState className="items-start text-left">
<div className="flex w-full flex-col items-start gap-5 px-2">
<div>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-full bg-primary/10">
<SparklesIcon className="size-4 text-primary" />
</div>
<h3 className="text-base font-semibold text-gray-900">
Welcome
</h3>
</div>
<p className="mt-1 text-sm text-gray-500">
Your AI Data Room Agent is ready to help.
</p>
</div>
<div className="w-full space-y-3">
<div className="flex items-start gap-2.5">
<SearchIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
<p className="text-xs leading-relaxed text-gray-500">
Get answers to questions about your{" "}
{dataroomId ? "data room" : "document"} files. Open a
specific document to ask targeted questions.
</p>
</div>
<div className="flex items-start gap-2.5">
<MessageSquareTextIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
<p className="text-xs leading-relaxed text-gray-500">
Ask clear, specific questions for more accurate results.
Multiple languages and document types are supported.
</p>
</div>
<div className="flex items-start gap-2.5">
<ShieldCheckIcon className="mt-0.5 size-3.5 shrink-0 text-gray-400" />
<p className="text-xs leading-relaxed text-gray-500">
All conversations are private and confidential.
</p>
</div>
</div>
</div>
</ConversationEmptyState>
) : (
<>
{(() => {
const lastAssistantIdx = messages.reduce(
(acc, m, i) => (m.role === "assistant" ? i : acc),
-1,
);
return messages.map((message, idx) => (
<div key={message.id} className="space-y-2">
<ChatMessage
role={message.role}
content={message.content}
isStreaming={message.isStreaming}
sources={message.sources}
suggestedQuestions={message.suggestedQuestions}
onSuggestedQuestionClick={handleSuggestedQuestion}
isLastAssistantMessage={
message.role === "assistant" &&
idx === lastAssistantIdx &&
!isLoading
}
/>
</div>
));
})()}
{isLoading && messages[messages.length - 1]?.role === "user" && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
<span>Thinking...</span>
</div>
)}
</>
)}
</ConversationContent>
<ConversationScrollButton />
</Conversation>
{/* Input */}
<div className="relative border-t border-gray-200 p-3">
{/* Document Context Selector (@ mention popover) */}
{dataroomId && documents.length > 0 && (
<DocumentContextSelector
documents={documents}
folders={folders}
selectedItems={selectedContextItems}
onSelectionChange={setSelectedContextItems}
open={selectorOpen}
onOpenChange={setSelectorOpen}
/>
)}
<PromptInput onSubmit={handleSubmit}>
{/* Show focused document or selected context items */}
{(documentId && documentName) || selectedContextItems.length > 0 ? (
<PromptInputHeader>
{documentId && documentName ? (
<TooltipProvider>
<Tooltip delayDuration={300}>
<TooltipTrigger asChild>
<div className="relative flex h-8 select-none items-center gap-1.5 rounded-md border border-border bg-muted/50 px-1.5 text-sm font-medium text-muted-foreground transition-all dark:hover:bg-accent/50">
<div className="relative size-5 shrink-0">
<div className="absolute inset-0 flex size-5 items-center justify-center overflow-hidden rounded bg-background">
<FileTextIcon className="size-4 text-muted-foreground" />
</div>
</div>
<span className="max-w-[150px] flex-1 truncate">
{documentName}
</span>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[300px]">
<p className="break-all">{documentName}</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<SelectedContextItems
items={selectedContextItems}
onRemove={handleRemoveContextItem}
/>
)}
</PromptInputHeader>
) : null}
<PromptInputTextarea
placeholder={
dataroomId && documents.length > 0
? "Ask a question... (click + to add context)"
: "Ask a question..."
}
disabled={isLoading}
className="min-h-12"
/>
<PromptInputFooter className="pt-2">
{/* @ button to open document selector */}
{dataroomId && documents.length > 0 && (
<PromptInputTools>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<PromptInputButton
onClick={() => setSelectorOpen(true)}
disabled={isLoading}
>
<PlusIcon className="size-4" />
</PromptInputButton>
</TooltipTrigger>
<TooltipContent>Add document context</TooltipContent>
</Tooltip>
</TooltipProvider>
</PromptInputTools>
)}
<div className="flex-1" />
{isLoading ? (
<PromptInputSubmit onClick={handleAbort} status="streaming" />
) : (
<PromptInputSubmit status="ready" />
)}
</PromptInputFooter>
</PromptInput>
</div>
</div>
);
}