Polish chat UI and wire flue agent env

- Add chat components (header, composer, conversation, message, thinking response, identity)
- Add chat hooks (use-chat-agent, use-chat-composer)
- Add chat lib (constants, transforms, types)
- Wire agent model config through env (provider, name, api, base url, key, context window, max tokens)
- Refresh web styling and routes
This commit is contained in:
-Puter
2026-07-19 19:33:41 +05:30
parent 8cd479f20c
commit 5347db578d
24 changed files with 1336 additions and 93 deletions

View File

@@ -5,6 +5,7 @@
"scripts": {
"build": "react-router build",
"dev": "react-router dev",
"dev:tailscale": "react-router dev --host 0.0.0.0",
"start": "react-router-serve ./build/server/index.js",
"typecheck": "react-router typegen && tsc"
},
@@ -13,6 +14,8 @@
"@code/env": "workspace:*",
"@code/ui": "workspace:*",
"@convex-dev/better-auth": "catalog:",
"@flue/react": "1.0.0-beta.9",
"@flue/sdk": "1.0.0-beta.9",
"@react-router/fs-routes": "^8.1.0",
"@react-router/node": "^8.1.0",
"@react-router/serve": "^8.1.0",
@@ -26,6 +29,7 @@
"react-dom": "^19.2.7",
"react-router": "^8.1.0",
"sonner": "catalog:",
"streamdown": "2.5.0",
"zod": "catalog:"
},
"devDependencies": {

View File

@@ -0,0 +1,27 @@
import { MessageHeader } from "@code/ui/components/message";
import { Sparkles } from "lucide-react";
import type { AssistantResponseState } from "@/lib/chat/types";
interface AssistantIdentityProps {
state?: AssistantResponseState;
}
export const AssistantIdentity = ({ state }: AssistantIdentityProps) => (
<MessageHeader className="mb-1.5 gap-2.5 px-0 text-xs font-medium text-foreground/70">
<span className="zopu-mark flex size-6 items-center justify-center rounded-lg">
<Sparkles className="size-3.5" />
</span>
Zopu
{state && (
<span className="response-state" data-state={state}>
<span aria-hidden="true" className="response-state-dots">
<i />
<i />
<i />
</span>
{state === "thinking" ? "Thinking" : "Writing"}
</span>
)}
</MessageHeader>
);

View File

@@ -0,0 +1,103 @@
import {
InputGroup,
InputGroupAddon,
InputGroupButton,
InputGroupTextarea,
} from "@code/ui/components/input-group";
import { CircleAlert, LoaderCircle, Send } from "lucide-react";
import type { ReactNode } from "react";
import { useChatComposer } from "@/hooks/chat/use-chat-composer";
import type { ChatComposerProps } from "@/lib/chat/types";
export const ChatComposer = ({ error, onSend, status }: ChatComposerProps) => {
const busy = status === "submitted" || status === "streaming";
const composer = useChatComposer({ busy, onSend });
let statusContent: ReactNode = (
<span className="hidden sm:inline">
Enter to send · Shift + Enter for a new line
</span>
);
if (busy) {
statusContent = (
<>
<LoaderCircle className="size-3.5 animate-spin" />
Zopu is responding
</>
);
}
if (error) {
statusContent = (
<span
id="composer-error"
className="flex items-center gap-1.5 text-destructive"
>
<CircleAlert className="size-3.5" />
{error.message || "Message failed to send"}
</span>
);
}
return (
<div className="composer-dock shrink-0 pb-[env(safe-area-inset-bottom)]">
<form
aria-label="Send a message"
className="mx-auto w-full max-w-3xl px-3 pb-3 sm:px-5 sm:pb-5"
onSubmit={composer.handleSubmit}
>
<InputGroup
className={`composer-shell overflow-hidden rounded-[1.5rem] border-border/70 bg-card/95 shadow-[0_16px_50px_-18px_rgb(0_0_0/0.38)] backdrop-blur-xl transition-[border-color,box-shadow] focus-within:border-foreground/20 focus-within:shadow-[0_18px_60px_-20px_rgb(0_0_0/0.48)] dark:bg-card/90 ${
composer.isFocused
? "composer-shell-expanded"
: "composer-shell-compact"
}`}
data-disabled={busy || undefined}
>
<InputGroupTextarea
{...composer.inputProps}
aria-describedby={error ? "composer-error" : undefined}
aria-invalid={error ? true : undefined}
aria-label="Message Zopu"
className={`max-h-40 px-4 text-base leading-6 transition-[min-height,padding] duration-200 placeholder:text-muted-foreground/70 sm:px-5 ${
composer.isFocused
? "min-h-24 pb-2 pt-4"
: "min-h-14 py-[0.95rem] pr-2"
}`}
disabled={busy}
placeholder="Ask anything…"
rows={1}
/>
<InputGroupAddon
align="inline-end"
className={`shrink-0 self-end pr-2 transition-[padding] duration-200 ${
composer.isFocused ? "pb-2.5" : "pb-1.5"
}`}
>
<InputGroupButton
aria-label="Send message"
className="size-11 shrink-0 rounded-2xl bg-foreground text-background transition-all hover:scale-[1.03] hover:bg-foreground/85 active:scale-95 disabled:bg-muted disabled:text-muted-foreground sm:size-10 sm:rounded-xl"
disabled={!composer.canSend}
size="icon-sm"
type="submit"
variant="default"
>
{busy ? (
<LoaderCircle className="size-5 animate-spin" />
) : (
<Send className="size-5" />
)}
</InputGroupButton>
</InputGroupAddon>
</InputGroup>
<div className="mt-2 flex min-h-4 items-center justify-center px-2 text-center text-[10px] text-muted-foreground/80 sm:text-[11px]">
{composer.isFocused || busy || error ? (
<span className="flex items-center gap-2">{statusContent}</span>
) : (
<span>Zopu can make mistakes. Check important information.</span>
)}
</div>
</form>
</div>
);
};

View File

@@ -0,0 +1,77 @@
import { Button } from "@code/ui/components/button";
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@code/ui/components/empty";
import { MessageGroup } from "@code/ui/components/message";
import { MessageScrollerItem } from "@code/ui/components/message-scroller";
import { Sparkles } from "lucide-react";
import { SUGGESTIONS } from "@/lib/chat/constants";
import type { ChatConversationProps } from "@/lib/chat/types";
import { ChatMessage } from "./chat-message";
import { ChatThinkingResponse } from "./chat-thinking-response";
export const ChatConversation = ({
historyReady,
messages,
onSuggestion,
status,
}: ChatConversationProps) => {
if (historyReady && messages.length === 0) {
return (
<Empty className="min-h-full items-start justify-end px-1 pb-7 pt-20 sm:items-center sm:justify-center sm:px-6 sm:py-20">
<EmptyMedia className="zopu-mark size-11 rounded-2xl" variant="icon">
<Sparkles className="size-5" />
</EmptyMedia>
<EmptyHeader className="items-start text-left sm:items-center sm:text-center">
<EmptyTitle className="text-2xl font-semibold tracking-[-0.035em] sm:text-3xl">
What can I help with?
</EmptyTitle>
<EmptyDescription className="max-w-md text-[15px] leading-6">
Think, write, plan, or explore an idea with Zopu.
</EmptyDescription>
</EmptyHeader>
<EmptyContent className="mt-3 grid w-full max-w-xl gap-2 sm:grid-cols-3">
{SUGGESTIONS.map(([title, prompt]) => (
<Button
key={title}
className="group h-auto min-h-16 w-full flex-col items-start justify-center gap-0.5 rounded-2xl border-border/60 bg-card/60 px-4 py-3 text-left shadow-sm transition-all hover:-translate-y-0.5 hover:border-foreground/20 hover:bg-card hover:shadow-md sm:min-h-24"
onClick={() => onSuggestion(prompt)}
variant="outline"
>
<span className="font-medium text-foreground">{title}</span>
<span className="text-xs leading-4 font-normal text-muted-foreground whitespace-normal">
{prompt}
</span>
</Button>
))}
</EmptyContent>
</Empty>
);
}
return (
<MessageGroup className="gap-8 pb-5 md:gap-10 md:pb-8">
{messages.map((message, index) => (
<MessageScrollerItem
key={message.id}
messageId={message.id}
scrollAnchor={index === messages.length - 1}
>
<ChatMessage message={message} />
</MessageScrollerItem>
))}
{status === "submitted" && (
<MessageScrollerItem scrollAnchor>
<ChatThinkingResponse />
</MessageScrollerItem>
)}
</MessageGroup>
);
};

View File

@@ -0,0 +1,32 @@
import { Sparkles } from "lucide-react";
import { MODEL_LABEL, STATUS_COPY } from "@/lib/chat/constants";
import { getStatusDotClass } from "@/lib/chat/transforms";
import type { ChatHeaderProps } from "@/lib/chat/types";
export const ChatHeader = ({ status }: ChatHeaderProps) => (
<header className="chat-header flex h-14 shrink-0 items-center border-b border-border/60 px-4 sm:h-16 sm:px-6">
<div className="mx-auto flex w-full max-w-5xl items-center justify-between">
<div className="flex min-w-0 items-center gap-3">
<div className="zopu-mark flex size-9 shrink-0 items-center justify-center rounded-xl">
<Sparkles className="size-4" />
</div>
<div className="min-w-0">
<h1 className="truncate text-sm font-semibold tracking-tight sm:text-[15px]">
Zopu
</h1>
<p className="flex items-center gap-1.5 text-[11px] text-muted-foreground">
<span
aria-hidden="true"
className={`size-1.5 rounded-full ${getStatusDotClass(status)}`}
/>
<span aria-live="polite">{STATUS_COPY[status]}</span>
</p>
</div>
</div>
<p className="rounded-full border border-border/60 bg-card/50 px-2.5 py-1 text-[10px] font-medium tracking-wide text-muted-foreground sm:text-[11px]">
{MODEL_LABEL}
</p>
</div>
</header>
);

View File

@@ -0,0 +1,72 @@
import { Bubble, BubbleContent } from "@code/ui/components/bubble";
import { Message, MessageContent } from "@code/ui/components/message";
import type { ReactNode } from "react";
import { Streamdown } from "streamdown";
import { getMessageText, isMessageStreaming } from "@/lib/chat/transforms";
import type { ChatMessageProps } from "@/lib/chat/types";
import { AssistantIdentity } from "./assistant-identity";
export const ChatMessage = ({ message }: ChatMessageProps) => {
const isUser = message.role === "user";
const isStreaming = isMessageStreaming(message);
const text = getMessageText(message);
if (!text && !isStreaming) {
return null;
}
let content: ReactNode = text;
if (!isUser) {
content = text ? (
<Streamdown
caret={isStreaming ? "block" : undefined}
className="chat-markdown min-w-0"
isAnimating={isStreaming}
>
{text}
</Streamdown>
) : (
<div className="thinking-line" aria-label="Zopu is preparing a response">
<span />
<span />
<span />
</div>
);
}
return (
<Message
align={isUser ? "end" : "start"}
className="chat-message text-[15px] md:text-base"
>
<MessageContent
className={
isUser
? "max-w-[88%] sm:max-w-[76%] md:max-w-[68%]"
: "max-w-full md:max-w-[92%]"
}
>
{!isUser && (
<AssistantIdentity state={isStreaming ? "writing" : undefined} />
)}
<Bubble
align={isUser ? "end" : "start"}
variant={isUser ? "secondary" : "ghost"}
className="max-w-full"
>
<BubbleContent
className={
isUser
? "rounded-[1.35rem] rounded-br-md border border-border/60 bg-secondary/80 px-4 py-2.5 leading-6 text-foreground shadow-sm whitespace-pre-wrap dark:bg-secondary/70"
: "w-full max-w-none overflow-visible leading-7 text-foreground/95"
}
>
{content}
</BubbleContent>
</Bubble>
</MessageContent>
</Message>
);
};

View File

@@ -0,0 +1,59 @@
import {
MessageScroller,
MessageScrollerButton,
MessageScrollerContent,
MessageScrollerProvider,
MessageScrollerViewport,
} from "@code/ui/components/message-scroller";
import { useChatAgent } from "@/hooks/chat/use-chat-agent";
import { ChatComposer } from "./chat-composer";
import { ChatConversation } from "./chat-conversation";
import { ChatHeader } from "./chat-header";
export const ChatPage = () => {
const agent = useChatAgent();
const handleSendMessage = (message: string) => agent.sendMessage(message);
return (
<section className="chat-surface flex h-full min-h-0 flex-col bg-background">
<ChatHeader status={agent.status} />
<main aria-label="Conversation" className="min-h-0 flex-1">
<MessageScrollerProvider
autoScroll
defaultScrollPosition="end"
scrollEdgeThreshold={96}
scrollMargin={24}
>
<MessageScroller>
<MessageScrollerViewport aria-label="Chat messages">
<MessageScrollerContent className="mx-auto w-full max-w-3xl px-4 pb-8 pt-7 sm:px-6 sm:pb-10 sm:pt-10">
<ChatConversation
historyReady={agent.historyReady}
messages={agent.messages}
onSuggestion={(suggestion) =>
void handleSendMessage(suggestion)
}
status={agent.status}
/>
</MessageScrollerContent>
</MessageScrollerViewport>
<MessageScrollerButton
aria-label="Scroll to latest message"
className="bottom-4 size-10 rounded-full border border-border/70 bg-card/95 shadow-lg backdrop-blur"
direction="end"
/>
</MessageScroller>
</MessageScrollerProvider>
</main>
<ChatComposer
error={agent.error}
onSend={handleSendMessage}
status={agent.status}
/>
</section>
);
};

View File

@@ -0,0 +1,16 @@
import { Message, MessageContent } from "@code/ui/components/message";
import { AssistantIdentity } from "./assistant-identity";
export const ChatThinkingResponse = () => (
<Message align="start" className="chat-message">
<MessageContent className="max-w-full md:max-w-[92%]">
<AssistantIdentity state="thinking" />
<div className="thinking-line" aria-label="Zopu is thinking">
<span />
<span />
<span />
</div>
</MessageContent>
</Message>
);

View File

@@ -0,0 +1,16 @@
import { useFlueAgent } from "@flue/react";
import { CHAT_AGENT } from "@/lib/chat/constants";
import type { ChatAgentState } from "@/lib/chat/types";
export const useChatAgent = (): ChatAgentState => {
const agent = useFlueAgent(CHAT_AGENT);
return {
error: agent.error,
historyReady: agent.historyReady,
messages: agent.messages,
sendMessage: agent.sendMessage,
status: agent.status,
};
};

View File

@@ -0,0 +1,58 @@
import type { ChangeEvent, FormEvent, KeyboardEvent } from "react";
import { useRef, useState } from "react";
interface UseChatComposerOptions {
busy: boolean;
onSend: (message: string) => Promise<void>;
}
export const useChatComposer = ({ busy, onSend }: UseChatComposerOptions) => {
const [draft, setDraft] = useState("");
const [isFocused, setIsFocused] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const canSend = draft.trim().length > 0 && !busy;
const handleSubmit = async (event?: FormEvent) => {
event?.preventDefault();
const message = draft.trim();
if (!message || busy) {
return;
}
setDraft("");
try {
await onSend(message);
} finally {
textareaRef.current?.focus();
}
};
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
if (
event.key === "Enter" &&
!event.shiftKey &&
!event.nativeEvent.isComposing
) {
event.preventDefault();
void handleSubmit();
}
};
const inputProps = {
onBlur: () => setIsFocused(false),
onChange: (event: ChangeEvent<HTMLTextAreaElement>) =>
setDraft(event.target.value),
onFocus: () => setIsFocused(true),
onKeyDown: handleKeyDown,
ref: textareaRef,
value: draft,
};
return {
canSend,
draft,
handleSubmit,
inputProps,
isFocused,
};
};

View File

@@ -1 +1,257 @@
@import "@code/ui/globals.css";
@import "streamdown/styles.css";
html,
body {
min-height: 100%;
overflow: hidden;
}
.chat-surface {
position: relative;
isolation: isolate;
background:
radial-gradient(
circle at 50% -12%,
oklch(0.72 0.045 255 / 9%),
transparent 32rem
),
var(--background);
}
.chat-surface::before {
position: absolute;
z-index: -1;
inset: 0;
background-image: radial-gradient(
oklch(0.5 0 0 / 9%) 0.55px,
transparent 0.55px
);
background-size: 18px 18px;
mask-image: linear-gradient(to bottom, black, transparent 42%);
content: "";
pointer-events: none;
}
.chat-header {
background: color-mix(in oklch, var(--background) 88%, transparent);
backdrop-filter: blur(18px) saturate(1.25);
}
.zopu-mark {
border: 1px solid color-mix(in oklch, var(--foreground) 10%, transparent);
background:
linear-gradient(145deg, oklch(1 0 0 / 65%), transparent 55%),
color-mix(in oklch, var(--muted) 88%, var(--background));
color: var(--foreground);
box-shadow:
inset 0 1px 0 oklch(1 0 0 / 48%),
0 6px 18px -12px oklch(0 0 0 / 55%);
}
.chat-message {
animation: chat-message-enter 220ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.response-state {
display: inline-flex;
align-items: center;
gap: 0.4rem;
color: var(--muted-foreground);
font-weight: 400;
}
.response-state-dots {
display: inline-flex;
align-items: center;
gap: 2px;
}
.response-state-dots i,
.thinking-line span {
border-radius: 999px;
background: currentColor;
animation: response-dot 1.25s ease-in-out infinite;
}
.response-state-dots i {
width: 3px;
height: 3px;
}
.response-state-dots i:nth-child(2),
.thinking-line span:nth-child(2) {
animation-delay: 140ms;
}
.response-state-dots i:nth-child(3),
.thinking-line span:nth-child(3) {
animation-delay: 280ms;
}
.thinking-line {
display: flex;
width: fit-content;
min-height: 1.75rem;
align-items: center;
gap: 0.3rem;
color: color-mix(in oklch, var(--foreground) 42%, transparent);
}
.thinking-line span {
width: 5px;
height: 5px;
}
.chat-markdown[data-streamdown="true"] {
min-height: 1.75rem;
}
@keyframes response-dot {
0%,
60%,
100% {
opacity: 0.28;
transform: translateY(0) scale(0.84);
}
30% {
opacity: 0.9;
transform: translateY(-2px) scale(1);
}
}
.composer-dock {
position: relative;
background: linear-gradient(to top, var(--background) 68%, transparent);
}
.composer-shell {
transform: translateZ(0);
}
@keyframes chat-message-enter {
from {
opacity: 0;
transform: translateY(5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.chat-markdown {
color: var(--foreground);
}
.chat-markdown > :first-child {
margin-top: 0;
}
.chat-markdown > :last-child {
margin-bottom: 0;
}
.chat-markdown p,
.chat-markdown ul,
.chat-markdown ol,
.chat-markdown blockquote,
.chat-markdown pre,
.chat-markdown table {
margin-block: 0.8rem;
}
.chat-markdown h1,
.chat-markdown h2,
.chat-markdown h3,
.chat-markdown h4 {
margin-block: 1.4rem 0.65rem;
font-weight: 650;
line-height: 1.25;
letter-spacing: -0.02em;
}
.chat-markdown h1 {
font-size: 1.5rem;
}
.chat-markdown h2 {
font-size: 1.25rem;
}
.chat-markdown h3,
.chat-markdown h4 {
font-size: 1.05rem;
}
.chat-markdown ul,
.chat-markdown ol {
padding-inline-start: 1.4rem;
}
.chat-markdown ul {
list-style: disc;
}
.chat-markdown ol {
list-style: decimal;
}
.chat-markdown li + li {
margin-top: 0.35rem;
}
.chat-markdown blockquote {
border-inline-start: 2px solid var(--border);
padding-inline-start: 1rem;
color: var(--muted-foreground);
}
.chat-markdown a {
color: inherit;
text-decoration: underline;
text-decoration-color: var(--muted-foreground);
text-underline-offset: 3px;
}
.chat-markdown :not(pre) > code {
border: 1px solid var(--border);
background: var(--muted);
padding: 0.1rem 0.35rem;
font-size: 0.875em;
}
.chat-markdown pre {
max-width: 100%;
overflow-x: auto;
border: 1px solid var(--border);
background: var(--muted);
padding: 1rem;
font-size: 0.8125rem;
line-height: 1.6;
}
.chat-markdown table {
display: block;
width: 100%;
overflow-x: auto;
border-collapse: collapse;
}
.chat-markdown th,
.chat-markdown td {
border: 1px solid var(--border);
padding: 0.5rem 0.75rem;
text-align: start;
}
@media (prefers-reduced-motion: reduce) {
.chat-markdown *,
.chat-message,
.cn-message-scroller-button,
.response-state-dots i,
.thinking-line span {
animation-duration: 0.01ms !important;
transition-duration: 0.01ms !important;
}
}

View File

@@ -0,0 +1,23 @@
import type { AgentStatus } from "@flue/react";
export const CHAT_AGENT = {
id: "main",
live: "sse",
name: "zopu",
} as const;
export const MODEL_LABEL = "MiniMax M3";
export const SUGGESTIONS = [
["Think it through", "Help me make a difficult decision"],
["Make a plan", "Turn an idea into clear next steps"],
["Explain simply", "Break down something complex"],
] as const;
export const STATUS_COPY: Record<AgentStatus, string> = {
connecting: "Connecting",
error: "Connection issue",
idle: "Ready",
streaming: "Responding",
submitted: "Thinking",
};

View File

@@ -0,0 +1,41 @@
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
export const stripThinkingMarkup = (text: string): string => {
const withoutCompletedBlocks = text.replaceAll(
/<think>[\s\S]*?<\/think>/giu,
""
);
const openTagIndex = withoutCompletedBlocks
.toLowerCase()
.lastIndexOf("<think>");
const visibleText =
openTagIndex === -1
? withoutCompletedBlocks
: withoutCompletedBlocks.slice(0, openTagIndex);
return visibleText.replaceAll("</think>", "").trimStart();
};
export const getMessageText = (message: FlueConversationMessage): string =>
stripThinkingMarkup(
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
);
export const isMessageStreaming = (message: FlueConversationMessage): boolean =>
message.parts.some(
(part) =>
(part.type === "text" || part.type === "reasoning") &&
part.state === "streaming"
);
export const getStatusDotClass = (status: AgentStatus): string => {
if (status === "error") {
return "bg-destructive";
}
if (status === "connecting") {
return "bg-amber-500";
}
return "bg-emerald-500";
};

View File

@@ -0,0 +1,32 @@
import type { AgentStatus, FlueConversationMessage } from "@flue/react";
export interface ChatAgentState {
error?: Error;
historyReady: boolean;
messages: FlueConversationMessage[];
sendMessage: (message: string) => Promise<void>;
status: AgentStatus;
}
export interface ChatComposerProps {
error?: Error;
onSend: (message: string) => Promise<void>;
status: AgentStatus;
}
export interface ChatConversationProps {
historyReady: boolean;
messages: FlueConversationMessage[];
onSuggestion: (suggestion: string) => void;
status: AgentStatus;
}
export interface ChatHeaderProps {
status: AgentStatus;
}
export interface ChatMessageProps {
message: FlueConversationMessage;
}
export type AssistantResponseState = "thinking" | "writing";

View File

@@ -1,8 +1,10 @@
import { env } from "@code/env/web";
import { Toaster } from "@code/ui/components/sonner";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { FlueProvider } from "@flue/react";
import "./index.css";
import { ConvexBetterAuthProvider } from "@convex-dev/better-auth/react";
import { createFlueClient } from "@flue/sdk";
import { ConvexReactClient } from "convex/react";
import {
isRouteErrorResponse,
@@ -16,67 +18,111 @@ import {
import { authClient } from "@/lib/auth-client";
import type { Route } from "./+types/root";
import Header from "./components/header";
import { ThemeProvider } from "./components/theme-provider";
export const links: Route.LinksFunction = () => [
{ rel: "preconnect", href: "https://fonts.googleapis.com" },
{ rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" },
{ href: "https://fonts.googleapis.com", rel: "preconnect" },
{
crossOrigin: "anonymous",
href: "https://fonts.gstatic.com",
rel: "preconnect",
},
{
rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap",
rel: "stylesheet",
},
];
export function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
}
const flueBaseUrl = new URL(env.VITE_FLUE_URL);
const flueFetch: typeof fetch = async (input, init) => {
const response = await fetch(input, init);
if (
!response.ok ||
!response.headers.get("content-type")?.includes("application/json")
) {
return response;
}
export default function App() {
const body = (await response.clone().json()) as unknown;
if (
typeof body !== "object" ||
body === null ||
!("streamUrl" in body) ||
typeof body.streamUrl !== "string"
) {
return response;
}
const streamUrl = new URL(body.streamUrl);
streamUrl.protocol = flueBaseUrl.protocol;
streamUrl.host = flueBaseUrl.host;
const headers = new Headers(response.headers);
headers.set("location", streamUrl.toString());
return Response.json(
{ ...body, streamUrl: streamUrl.toString() },
{
headers,
status: response.status,
statusText: response.statusText,
}
);
};
const flueClient = createFlueClient({
baseUrl: flueBaseUrl.toString(),
fetch: flueFetch,
});
export const Layout = ({ children }: { children: React.ReactNode }) => (
<html lang="en">
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<Meta />
<Links />
</head>
<body>
{children}
<ScrollRestoration />
<Scripts />
</body>
</html>
);
const App = () => {
const convex = new ConvexReactClient(env.VITE_CONVEX_URL);
return (
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<div className="grid grid-rows-[auto_1fr] h-svh">
<Header />
<Outlet />
</div>
<Toaster richColors />
</ThemeProvider>
</ConvexBetterAuthProvider>
<FlueProvider client={flueClient}>
<ConvexBetterAuthProvider client={convex} authClient={authClient}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
disableTransitionOnChange
storageKey="vite-ui-theme"
>
<div className="h-svh min-h-0 overflow-hidden">
<Outlet />
</div>
<Toaster richColors />
</ThemeProvider>
</ConvexBetterAuthProvider>
</FlueProvider>
);
}
};
export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
export default App;
export const ErrorBoundary = ({ error }: Route.ErrorBoundaryProps) => {
let message = "Oops!";
let details = "An unexpected error occurred.";
let stack: string | undefined;
if (isRouteErrorResponse(error)) {
message = error.status === 404 ? "404" : "Error";
const { status, statusText } = error;
message = status === 404 ? "404" : "Error";
details =
error.status === 404 ? "The requested page could not be found." : error.statusText || details;
status === 404
? "The requested page could not be found."
: statusText || details;
} else if (import.meta.env.DEV && error && error instanceof Error) {
details = error.message;
stack = error.stack;
({ message: details, stack } = error);
}
return (
<main className="pt-16 p-4 container mx-auto">
@@ -89,4 +135,4 @@ export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
)}
</main>
);
}
};

View File

@@ -1,51 +1,12 @@
import { api } from "@code/backend/convex/_generated/api";
import { useQuery } from "convex/react";
import { ChatPage } from "@/components/chat/chat-page";
import type { Route } from "./+types/_index";
const TITLE_TEXT = `
██████╗ ███████╗████████╗████████╗███████╗██████╗
██╔══██╗██╔════╝╚══██╔══╝╚══██╔══╝██╔════╝██╔══██╗
██████╔╝█████╗ ██║ ██║ █████╗ ██████╔╝
██╔══██╗██╔══╝ ██║ ██║ ██╔══╝ ██╔══██╗
██████╔╝███████╗ ██║ ██║ ███████╗██║ ██║
╚═════╝ ╚══════╝ ╚═╝ ╚═╝ ╚══════╝╚═╝ ╚═╝
export const meta = (_args: Route.MetaArgs) => [
{ title: "Zopu" },
{ content: "Chat with Zopu", name: "description" },
];
████████╗ ███████╗████████╗ █████╗ ██████╗██╗ ██╗
╚══██╔══╝ ██╔════╝╚══██╔══╝██╔══██╗██╔════╝██║ ██╔╝
██║ ███████╗ ██║ ███████║██║ █████╔╝
██║ ╚════██║ ██║ ██╔══██║██║ ██╔═██╗
██║ ███████║ ██║ ██║ ██║╚██████╗██║ ██╗
╚═╝ ╚══════╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝╚═╝ ╚═╝
`;
const Home = () => <ChatPage />;
export function meta({}: Route.MetaArgs) {
return [{ title: "code" }, { name: "description", content: "code is a web application" }];
}
export default function Home() {
const healthCheck = useQuery(api.healthCheck.get);
return (
<div className="container mx-auto max-w-3xl px-4 py-2">
<pre className="overflow-x-auto font-mono text-sm">{TITLE_TEXT}</pre>
<div className="grid gap-6">
<section className="rounded-lg border p-4">
<h2 className="mb-2 font-medium">API Status</h2>
<div className="flex items-center gap-2">
<div
className={`h-2 w-2 rounded-full ${healthCheck === "OK" ? "bg-green-500" : healthCheck === undefined ? "bg-orange-400" : "bg-red-500"}`}
/>
<span className="text-sm text-muted-foreground">
{healthCheck === undefined
? "Checking..."
: healthCheck === "OK"
? "Connected"
: "Error"}
</span>
</div>
</section>
</div>
</div>
);
}
export default Home;