Initial commit: EGBE chatbot template
Stripped from vercel/chatbot (Apache 2.0): - Dropped @vercel/* packages and AI Gateway - Removed artifacts feature (code/text/sheet/image side panel) - Switched AI provider to @ai-sdk/openai-compatible -> EGBE LiteLLM - Replaced Vercel Blob upload with data URLs - Dropped Redis resumable streams and rate limiter (in-memory now) - Added Dockerfile (Next.js standalone) + entrypoint that runs migrations - Wired DATABASE_URL, EGBE_AI_API_URL/KEY, NEXT_PUBLIC_BASE_URL for app-deploy.sh
This commit is contained in:
commit
3e21c2334c
129 changed files with 21913 additions and 0 deletions
301
components/chat/message.tsx
Normal file
301
components/chat/message.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
"use client";
|
||||
import type { UseChatHelpers } from "@ai-sdk/react";
|
||||
import type { Vote } from "@/lib/db/schema";
|
||||
import type { ChatMessage } from "@/lib/types";
|
||||
import { cn, sanitizeText } from "@/lib/utils";
|
||||
import { MessageContent, MessageResponse } from "../ai-elements/message";
|
||||
import { Shimmer } from "../ai-elements/shimmer";
|
||||
import {
|
||||
Tool,
|
||||
ToolContent,
|
||||
ToolHeader,
|
||||
ToolInput,
|
||||
} from "../ai-elements/tool";
|
||||
import { useDataStream } from "./data-stream-provider";
|
||||
import { SparklesIcon } from "./icons";
|
||||
import { MessageActions } from "./message-actions";
|
||||
import { MessageReasoning } from "./message-reasoning";
|
||||
import { PreviewAttachment } from "./preview-attachment";
|
||||
import { Weather } from "./weather";
|
||||
|
||||
const PurePreviewMessage = ({
|
||||
addToolApprovalResponse,
|
||||
chatId,
|
||||
message,
|
||||
vote,
|
||||
isLoading,
|
||||
setMessages: _setMessages,
|
||||
regenerate: _regenerate,
|
||||
isReadonly,
|
||||
requiresScrollPadding: _requiresScrollPadding,
|
||||
onEdit,
|
||||
}: {
|
||||
addToolApprovalResponse: UseChatHelpers<ChatMessage>["addToolApprovalResponse"];
|
||||
chatId: string;
|
||||
message: ChatMessage;
|
||||
vote: Vote | undefined;
|
||||
isLoading: boolean;
|
||||
setMessages: UseChatHelpers<ChatMessage>["setMessages"];
|
||||
regenerate: UseChatHelpers<ChatMessage>["regenerate"];
|
||||
isReadonly: boolean;
|
||||
requiresScrollPadding: boolean;
|
||||
onEdit?: (message: ChatMessage) => void;
|
||||
}) => {
|
||||
const attachmentsFromMessage = message.parts.filter(
|
||||
(part) => part.type === "file"
|
||||
);
|
||||
|
||||
useDataStream();
|
||||
|
||||
const isUser = message.role === "user";
|
||||
const isAssistant = message.role === "assistant";
|
||||
|
||||
const hasAnyContent = message.parts?.some(
|
||||
(part) =>
|
||||
(part.type === "text" && part.text?.trim().length > 0) ||
|
||||
(part.type === "reasoning" &&
|
||||
"text" in part &&
|
||||
part.text?.trim().length > 0) ||
|
||||
part.type.startsWith("tool-")
|
||||
);
|
||||
const isThinking = isAssistant && isLoading && !hasAnyContent;
|
||||
|
||||
const attachments = attachmentsFromMessage.length > 0 && (
|
||||
<div
|
||||
className="flex flex-row justify-end gap-2"
|
||||
data-testid={"message-attachments"}
|
||||
>
|
||||
{attachmentsFromMessage.map((attachment) => (
|
||||
<PreviewAttachment
|
||||
attachment={{
|
||||
name: attachment.filename ?? "file",
|
||||
contentType: attachment.mediaType,
|
||||
url: attachment.url,
|
||||
}}
|
||||
key={attachment.url}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const mergedReasoning = message.parts?.reduce(
|
||||
(acc, part) => {
|
||||
if (part.type === "reasoning" && part.text?.trim().length > 0) {
|
||||
return {
|
||||
text: acc.text ? `${acc.text}\n\n${part.text}` : part.text,
|
||||
isStreaming: "state" in part ? part.state === "streaming" : false,
|
||||
rendered: false,
|
||||
};
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{ text: "", isStreaming: false, rendered: false }
|
||||
) ?? { text: "", isStreaming: false, rendered: false };
|
||||
|
||||
const parts = message.parts?.map((part, index) => {
|
||||
const { type } = part;
|
||||
const key = `message-${message.id}-part-${index}`;
|
||||
|
||||
if (type === "reasoning") {
|
||||
if (!mergedReasoning.rendered && mergedReasoning.text) {
|
||||
mergedReasoning.rendered = true;
|
||||
return (
|
||||
<MessageReasoning
|
||||
isLoading={isLoading || mergedReasoning.isStreaming}
|
||||
key={key}
|
||||
reasoning={mergedReasoning.text}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
if (type === "text") {
|
||||
return (
|
||||
<MessageContent
|
||||
className={cn("text-[13px] leading-[1.65]", {
|
||||
"w-fit max-w-[min(80%,56ch)] overflow-hidden break-words rounded-2xl rounded-br-lg border border-border/30 bg-gradient-to-br from-secondary to-muted px-3.5 py-2 shadow-[var(--shadow-card)]":
|
||||
message.role === "user",
|
||||
})}
|
||||
data-testid="message-content"
|
||||
key={key}
|
||||
>
|
||||
<MessageResponse>{sanitizeText(part.text)}</MessageResponse>
|
||||
</MessageContent>
|
||||
);
|
||||
}
|
||||
|
||||
if (type === "tool-getWeather") {
|
||||
const { toolCallId, state } = part;
|
||||
const approvalId = (part as { approval?: { id: string } }).approval?.id;
|
||||
const isDenied =
|
||||
state === "output-denied" ||
|
||||
(state === "approval-responded" &&
|
||||
(part as { approval?: { approved?: boolean } }).approval?.approved ===
|
||||
false);
|
||||
const widthClass = "w-[min(100%,450px)]";
|
||||
|
||||
if (state === "output-available") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Weather weatherAtLocation={part.output} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDenied) {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state="output-denied" type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
<div className="px-4 py-3 text-muted-foreground text-sm">
|
||||
Weather lookup was denied.
|
||||
</div>
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "approval-responded") {
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
<ToolInput input={part.input} />
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={widthClass} key={toolCallId}>
|
||||
<Tool className="w-full" defaultOpen={true}>
|
||||
<ToolHeader state={state} type="tool-getWeather" />
|
||||
<ToolContent>
|
||||
{(state === "input-available" ||
|
||||
state === "approval-requested") && (
|
||||
<ToolInput input={part.input} />
|
||||
)}
|
||||
{state === "approval-requested" && approvalId && (
|
||||
<div className="flex items-center justify-end gap-2 border-t px-4 py-3">
|
||||
<button
|
||||
className="rounded-md px-3 py-1.5 text-muted-foreground text-sm transition-colors hover:bg-muted hover:text-foreground"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: false,
|
||||
reason: "User denied weather lookup",
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
<button
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground text-sm transition-colors hover:bg-primary/90"
|
||||
onClick={() => {
|
||||
addToolApprovalResponse({
|
||||
id: approvalId,
|
||||
approved: true,
|
||||
});
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
Allow
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</ToolContent>
|
||||
</Tool>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
|
||||
const actions = !isReadonly && (
|
||||
<MessageActions
|
||||
chatId={chatId}
|
||||
isLoading={isLoading}
|
||||
key={`action-${message.id}`}
|
||||
message={message}
|
||||
onEdit={onEdit ? () => onEdit(message) : undefined}
|
||||
vote={vote}
|
||||
/>
|
||||
);
|
||||
|
||||
const content = isThinking ? (
|
||||
<div className="flex h-[calc(13px*1.65)] items-center text-[13px] leading-[1.65]">
|
||||
<Shimmer className="font-medium" duration={1}>
|
||||
Thinking...
|
||||
</Shimmer>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{attachments}
|
||||
{parts}
|
||||
{actions}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/message w-full",
|
||||
!isAssistant && "animate-[fade-up_0.25s_cubic-bezier(0.22,1,0.36,1)]"
|
||||
)}
|
||||
data-role={message.role}
|
||||
data-testid={`message-${message.role}`}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
isUser ? "flex flex-col items-end gap-2" : "flex items-start gap-3"
|
||||
)}
|
||||
>
|
||||
{isAssistant && (
|
||||
<div className="flex h-[calc(13px*1.65)] shrink-0 items-center">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={13} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isAssistant ? (
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-2">{content}</div>
|
||||
) : (
|
||||
content
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const PreviewMessage = PurePreviewMessage;
|
||||
|
||||
export const ThinkingMessage = () => {
|
||||
return (
|
||||
<div
|
||||
className="group/message w-full"
|
||||
data-role="assistant"
|
||||
data-testid="message-assistant-loading"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex h-[calc(13px*1.65)] shrink-0 items-center">
|
||||
<div className="flex size-7 items-center justify-center rounded-lg bg-muted/60 text-muted-foreground ring-1 ring-border/50">
|
||||
<SparklesIcon size={13} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex h-[calc(13px*1.65)] items-center text-[13px] leading-[1.65]">
|
||||
<Shimmer className="font-medium" duration={1}>
|
||||
Thinking...
|
||||
</Shimmer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue