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:
@@ -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": {
|
||||
|
||||
27
apps/web/src/components/chat/assistant-identity.tsx
Normal file
27
apps/web/src/components/chat/assistant-identity.tsx
Normal 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>
|
||||
);
|
||||
103
apps/web/src/components/chat/chat-composer.tsx
Normal file
103
apps/web/src/components/chat/chat-composer.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
77
apps/web/src/components/chat/chat-conversation.tsx
Normal file
77
apps/web/src/components/chat/chat-conversation.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
apps/web/src/components/chat/chat-header.tsx
Normal file
32
apps/web/src/components/chat/chat-header.tsx
Normal 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>
|
||||
);
|
||||
72
apps/web/src/components/chat/chat-message.tsx
Normal file
72
apps/web/src/components/chat/chat-message.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
59
apps/web/src/components/chat/chat-page.tsx
Normal file
59
apps/web/src/components/chat/chat-page.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
16
apps/web/src/components/chat/chat-thinking-response.tsx
Normal file
16
apps/web/src/components/chat/chat-thinking-response.tsx
Normal 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>
|
||||
);
|
||||
16
apps/web/src/hooks/chat/use-chat-agent.ts
Normal file
16
apps/web/src/hooks/chat/use-chat-agent.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
58
apps/web/src/hooks/chat/use-chat-composer.ts
Normal file
58
apps/web/src/hooks/chat/use-chat-composer.ts
Normal 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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
23
apps/web/src/lib/chat/constants.ts
Normal file
23
apps/web/src/lib/chat/constants.ts
Normal 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",
|
||||
};
|
||||
41
apps/web/src/lib/chat/transforms.ts
Normal file
41
apps/web/src/lib/chat/transforms.ts
Normal 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";
|
||||
};
|
||||
32
apps/web/src/lib/chat/types.ts
Normal file
32
apps/web/src/lib/chat/types.ts
Normal 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";
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user