feat: initial commit
This commit is contained in:
12
packages/bolt/app/components/Header.tsx
Normal file
12
packages/bolt/app/components/Header.tsx
Normal file
@@ -0,0 +1,12 @@
|
||||
import { IconButton } from './ui/IconButton';
|
||||
|
||||
export function Header() {
|
||||
return (
|
||||
<header className="flex items-center bg-white p-4 border-b border-gray-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-2xl font-semibold text-accent">Bolt</div>
|
||||
</div>
|
||||
<IconButton icon="i-ph:gear-duotone" className="ml-auto" />
|
||||
</header>
|
||||
);
|
||||
}
|
||||
29
packages/bolt/app/components/chat/Artifact.tsx
Normal file
29
packages/bolt/app/components/chat/Artifact.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import { workspaceStore } from '~/lib/stores/workspace';
|
||||
|
||||
interface ArtifactProps {
|
||||
messageId: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function Artifact({ messageId, onClick }: ArtifactProps) {
|
||||
const artifacts = useStore(workspaceStore.artifacts);
|
||||
|
||||
const artifact = artifacts[messageId];
|
||||
|
||||
return (
|
||||
<button className="flex border rounded-lg overflow-hidden items-stretch bg-gray-50/25 w-full" onClick={onClick}>
|
||||
<div className="border-r flex items-center px-6 bg-gray-50">
|
||||
{!artifact?.closed ? (
|
||||
<div className="i-svg-spinners:90-ring-with-bg scale-130"></div>
|
||||
) : (
|
||||
<div className="i-ph:code-bold scale-130 text-gray-600"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center px-4 p-2.5">
|
||||
<div className="text-left w-full">{artifact?.title}</div>
|
||||
<small className="w-full text-left">Click to open code</small>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
14
packages/bolt/app/components/chat/AssistantMessage.tsx
Normal file
14
packages/bolt/app/components/chat/AssistantMessage.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { memo } from 'react';
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
interface AssistantMessageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export const AssistantMessage = memo(({ content }: AssistantMessageProps) => {
|
||||
return (
|
||||
<div className="overflow-hidden w-full">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
140
packages/bolt/app/components/chat/BaseChat.tsx
Normal file
140
packages/bolt/app/components/chat/BaseChat.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import type { LegacyRef } from 'react';
|
||||
import React from 'react';
|
||||
import { ClientOnly } from 'remix-utils/client-only';
|
||||
import { IconButton } from '~/components/ui/IconButton';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { SendButton } from './SendButton.client';
|
||||
|
||||
interface BaseChatProps {
|
||||
textareaRef?: LegacyRef<HTMLTextAreaElement> | undefined;
|
||||
messagesSlot?: React.ReactNode;
|
||||
workspaceSlot?: React.ReactNode;
|
||||
chatStarted?: boolean;
|
||||
enhancingPrompt?: boolean;
|
||||
promptEnhanced?: boolean;
|
||||
input?: string;
|
||||
sendMessage?: () => void;
|
||||
handleInputChange?: (event: React.ChangeEvent<HTMLTextAreaElement>) => void;
|
||||
enhancePrompt?: () => void;
|
||||
}
|
||||
|
||||
const EXAMPLES = [{ text: 'Example' }, { text: 'Example' }, { text: 'Example' }, { text: 'Example' }];
|
||||
|
||||
const TEXTAREA_MIN_HEIGHT = 72;
|
||||
|
||||
export const BaseChat = React.forwardRef<HTMLDivElement, BaseChatProps>(
|
||||
(
|
||||
{
|
||||
textareaRef,
|
||||
chatStarted = false,
|
||||
enhancingPrompt = false,
|
||||
promptEnhanced = false,
|
||||
messagesSlot,
|
||||
workspaceSlot,
|
||||
input = '',
|
||||
sendMessage,
|
||||
handleInputChange,
|
||||
enhancePrompt,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
|
||||
return (
|
||||
<div ref={ref} className="h-full flex w-full overflow-scroll px-6">
|
||||
<div className="flex flex-col items-center w-full h-full">
|
||||
<div id="chat" className="w-full">
|
||||
{!chatStarted && (
|
||||
<div id="intro" className="mt-[20vh] mb-14 max-w-2xl mx-auto">
|
||||
<h2 className="text-4xl text-center font-bold text-slate-800 mb-2">Where ideas begin.</h2>
|
||||
<p className="mb-14 text-center">Bring ideas to life in seconds or get help on existing projects.</p>
|
||||
<div className="grid max-md:grid-cols-[repeat(2,1fr)] md:grid-cols-[repeat(2,minmax(200px,1fr))] gap-4">
|
||||
{EXAMPLES.map((suggestion, index) => (
|
||||
<button key={index} className="p-4 rounded-lg shadow-xs bg-white border border-gray-200 text-left">
|
||||
{suggestion.text}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messagesSlot}
|
||||
</div>
|
||||
<div
|
||||
className={classNames('w-full md:max-w-[720px] mx-auto', {
|
||||
'fixed bg-bolt-elements-app-backgroundColor bottom-0': chatStarted,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'relative shadow-sm border border-gray-200 md:mb-6 bg-white rounded-lg overflow-hidden',
|
||||
{
|
||||
'max-md:rounded-none max-md:border-x-none': chatStarted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
if (event.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
sendMessage?.();
|
||||
}
|
||||
}}
|
||||
value={input}
|
||||
onChange={(event) => {
|
||||
handleInputChange?.(event);
|
||||
}}
|
||||
className={`w-full pl-4 pt-4 pr-16 focus:outline-none resize-none`}
|
||||
style={{
|
||||
minHeight: TEXTAREA_MIN_HEIGHT,
|
||||
maxHeight: TEXTAREA_MAX_HEIGHT,
|
||||
}}
|
||||
placeholder="How can Bolt help you today?"
|
||||
translate="no"
|
||||
/>
|
||||
<ClientOnly>{() => <SendButton show={input.length > 0} onClick={sendMessage} />}</ClientOnly>
|
||||
<div className="flex justify-between text-sm p-4 pt-2">
|
||||
<div className="flex gap-1 items-center">
|
||||
<IconButton icon="i-ph:microphone-duotone" className="-ml-1" />
|
||||
<IconButton icon="i-ph:plus-circle-duotone" />
|
||||
<IconButton
|
||||
disabled={input.length === 0 || enhancingPrompt}
|
||||
className={classNames({
|
||||
'opacity-100!': enhancingPrompt,
|
||||
'text-accent! pr-1.5': promptEnhanced,
|
||||
})}
|
||||
onClick={() => enhancePrompt?.()}
|
||||
>
|
||||
{enhancingPrompt ? (
|
||||
<>
|
||||
<div className="i-svg-spinners:90-ring-with-bg text-black text-xl"></div>
|
||||
<div className="ml-1.5">Enhancing prompt...</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="i-blitz:stars text-xl"></div>
|
||||
{promptEnhanced && <div className="ml-1.5">Prompt enhanced</div>}
|
||||
</>
|
||||
)}
|
||||
</IconButton>
|
||||
</div>
|
||||
{input.length > 3 ? (
|
||||
<div className="text-xs">
|
||||
Use <kbd className="bg-gray-100 p-1 rounded-md">Shift</kbd> +{' '}
|
||||
<kbd className="bg-gray-100 p-1 rounded-md">Return</kbd> for a new line
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{workspaceSlot}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
123
packages/bolt/app/components/chat/Chat.client.tsx
Normal file
123
packages/bolt/app/components/chat/Chat.client.tsx
Normal file
@@ -0,0 +1,123 @@
|
||||
import { useChat } from 'ai/react';
|
||||
import { cubicBezier, useAnimate } from 'framer-motion';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMessageParser, usePromptEnhancer } from '~/lib/hooks';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { BaseChat } from './BaseChat';
|
||||
import { Messages } from './Messages';
|
||||
|
||||
const logger = createScopedLogger('Chat');
|
||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
|
||||
export function Chat() {
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const [chatStarted, setChatStarted] = useState(false);
|
||||
|
||||
const [animationScope, animate] = useAnimate();
|
||||
|
||||
const { messages, isLoading, input, handleInputChange, setInput, handleSubmit } = useChat({
|
||||
api: '/api/chat',
|
||||
onError: (error) => {
|
||||
logger.error(error);
|
||||
},
|
||||
onFinish: () => {
|
||||
logger.debug('Finished streaming');
|
||||
},
|
||||
});
|
||||
|
||||
const { enhancingPrompt, promptEnhanced, enhancePrompt, resetEnhancer } = usePromptEnhancer();
|
||||
const { parsedMessages, parseMessages } = useMessageParser();
|
||||
|
||||
const TEXTAREA_MAX_HEIGHT = chatStarted ? 400 : 200;
|
||||
|
||||
useEffect(() => {
|
||||
parseMessages(messages, isLoading);
|
||||
}, [messages, isLoading, parseMessages]);
|
||||
|
||||
const scrollTextArea = () => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.scrollTop = textarea.scrollHeight;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
|
||||
const scrollHeight = textarea.scrollHeight;
|
||||
|
||||
textarea.style.height = `${Math.min(scrollHeight, TEXTAREA_MAX_HEIGHT)}px`;
|
||||
textarea.style.overflowY = scrollHeight > TEXTAREA_MAX_HEIGHT ? 'auto' : 'hidden';
|
||||
}
|
||||
}, [input, textareaRef]);
|
||||
|
||||
const runAnimation = async () => {
|
||||
if (chatStarted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
animate('#chat', { height: '100%' }, { duration: 0.3, ease: customEasingFn }),
|
||||
animate('#intro', { opacity: 0, display: 'none' }, { duration: 0.15, ease: customEasingFn }),
|
||||
]);
|
||||
|
||||
setChatStarted(true);
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
if (input.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
runAnimation();
|
||||
handleSubmit();
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
};
|
||||
|
||||
return (
|
||||
<BaseChat
|
||||
ref={animationScope}
|
||||
textareaRef={textareaRef}
|
||||
input={input}
|
||||
chatStarted={chatStarted}
|
||||
enhancingPrompt={enhancingPrompt}
|
||||
promptEnhanced={promptEnhanced}
|
||||
sendMessage={sendMessage}
|
||||
handleInputChange={handleInputChange}
|
||||
messagesSlot={
|
||||
chatStarted ? (
|
||||
<Messages
|
||||
classNames={{
|
||||
root: 'h-full pt-10',
|
||||
messagesContainer: 'max-w-2xl mx-auto max-md:pb-[calc(140px+1.5rem)] md:pb-[calc(140px+3rem)]',
|
||||
}}
|
||||
messages={messages.map((message, i) => {
|
||||
if (message.role === 'user') {
|
||||
return message;
|
||||
}
|
||||
|
||||
return {
|
||||
...message,
|
||||
content: parsedMessages[i] || '',
|
||||
};
|
||||
})}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
) : null
|
||||
}
|
||||
enhancePrompt={() => {
|
||||
enhancePrompt(input, (input) => {
|
||||
setInput(input);
|
||||
scrollTextArea();
|
||||
});
|
||||
}}
|
||||
></BaseChat>
|
||||
);
|
||||
}
|
||||
10
packages/bolt/app/components/chat/CodeBlock.module.scss
Normal file
10
packages/bolt/app/components/chat/CodeBlock.module.scss
Normal file
@@ -0,0 +1,10 @@
|
||||
.CopyButtonContainer {
|
||||
button:before {
|
||||
content: 'Copied';
|
||||
font-size: 12px;
|
||||
position: absolute;
|
||||
left: -53px;
|
||||
padding: 2px 6px;
|
||||
height: 30px;
|
||||
}
|
||||
}
|
||||
82
packages/bolt/app/components/chat/CodeBlock.tsx
Normal file
82
packages/bolt/app/components/chat/CodeBlock.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import {
|
||||
bundledLanguages,
|
||||
codeToHtml,
|
||||
isSpecialLang,
|
||||
type BundledLanguage,
|
||||
type BundledTheme,
|
||||
type SpecialLanguage,
|
||||
} from 'shiki';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import styles from './CodeBlock.module.scss';
|
||||
|
||||
const logger = createScopedLogger('CodeBlock');
|
||||
|
||||
interface CodeBlockProps {
|
||||
code: string;
|
||||
language?: BundledLanguage;
|
||||
theme?: BundledTheme | SpecialLanguage;
|
||||
}
|
||||
|
||||
export const CodeBlock = memo(({ code, language, theme }: CodeBlockProps) => {
|
||||
const [html, setHTML] = useState<string | undefined>(undefined);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyToClipboard = () => {
|
||||
if (copied) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigator.clipboard.writeText(code);
|
||||
|
||||
setCopied(true);
|
||||
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
}, 2000);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (language && !isSpecialLang(language) && !(language in bundledLanguages)) {
|
||||
logger.warn(`Unsupported language '${language}'`);
|
||||
}
|
||||
|
||||
logger.trace(`Language = ${language}`);
|
||||
|
||||
const processCode = async () => {
|
||||
setHTML(await codeToHtml(code, { lang: language ?? 'plaintext', theme: theme ?? 'dark-plus' }));
|
||||
};
|
||||
|
||||
processCode();
|
||||
}, [code]);
|
||||
|
||||
return (
|
||||
<div className="relative group">
|
||||
<div
|
||||
className={classNames(
|
||||
styles.CopyButtonContainer,
|
||||
'bg-white absolute top-[10px] right-[10px] rounded-md z-10 text-lg flex items-center justify-center opacity-0 group-hover:opacity-100',
|
||||
{
|
||||
'rounded-l-0 opacity-100': copied,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center p-[6px] justify-center before:bg-white before:rounded-l-md before:text-gray-500 before:border-r before:border-gray-300',
|
||||
{
|
||||
'before:opacity-0': !copied,
|
||||
'before:opacity-100': copied,
|
||||
},
|
||||
)}
|
||||
title="Copy Code"
|
||||
onClick={() => copyToClipboard()}
|
||||
>
|
||||
<div className="i-ph:clipboard-text-duotone"></div>
|
||||
</button>
|
||||
</div>
|
||||
<div dangerouslySetInnerHTML={{ __html: html ?? '' }}></div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
136
packages/bolt/app/components/chat/Markdown.module.scss
Normal file
136
packages/bolt/app/components/chat/Markdown.module.scss
Normal file
@@ -0,0 +1,136 @@
|
||||
$font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace,
|
||||
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
$color-text: #333;
|
||||
$color-heading: #2c3e50;
|
||||
$color-link: #3498db;
|
||||
$color-code-bg: #f8f8f8;
|
||||
$color-blockquote-border: #dfe2e5;
|
||||
|
||||
.MarkdownContent {
|
||||
line-height: 1.6;
|
||||
color: $color-text;
|
||||
|
||||
> *:not(:last-child) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
:is(h1, h2, h3, h4, h5, h6) {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
color: $color-heading;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 2em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 1.5em;
|
||||
border-bottom: 1px solid #eaecef;
|
||||
padding-bottom: 0.3em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.25em;
|
||||
}
|
||||
|
||||
h4 {
|
||||
font-size: 1em;
|
||||
}
|
||||
|
||||
h5 {
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
h6 {
|
||||
font-size: 0.85em;
|
||||
color: #6a737d;
|
||||
}
|
||||
|
||||
p:not(:last-of-type) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: $color-link;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
font-family: $font-mono;
|
||||
font-size: 14px;
|
||||
background-color: $color-code-bg;
|
||||
border-radius: 6px;
|
||||
padding: 0.2em 0.4em;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: 20px 16px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
pre:has(> code) {
|
||||
font-family: $font-mono;
|
||||
font-size: 14px;
|
||||
background: transparent;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
color: #6a737d;
|
||||
border-left: 0.25em solid $color-blockquote-border;
|
||||
}
|
||||
|
||||
:is(ul, ol) {
|
||||
padding-left: 2em;
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 0.25em;
|
||||
padding: 0;
|
||||
margin: 24px 0;
|
||||
background-color: #e1e4e8;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin-bottom: 16px;
|
||||
|
||||
:is(th, td) {
|
||||
padding: 6px 13px;
|
||||
border: 1px solid #dfe2e5;
|
||||
}
|
||||
|
||||
tr:nth-child(2n) {
|
||||
background-color: #f6f8fa;
|
||||
}
|
||||
}
|
||||
}
|
||||
66
packages/bolt/app/components/chat/Markdown.tsx
Normal file
66
packages/bolt/app/components/chat/Markdown.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { memo } from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import type { BundledLanguage } from 'shiki';
|
||||
import { createScopedLogger } from '~/utils/logger';
|
||||
import { rehypePlugins, remarkPlugins } from '~/utils/markdown';
|
||||
import { Artifact } from './Artifact';
|
||||
import { CodeBlock } from './CodeBlock';
|
||||
import styles from './Markdown.module.scss';
|
||||
|
||||
const logger = createScopedLogger('MarkdownComponent');
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
}
|
||||
|
||||
export const Markdown = memo(({ children }: MarkdownProps) => {
|
||||
logger.trace('Render');
|
||||
|
||||
return (
|
||||
<ReactMarkdown
|
||||
className={styles.MarkdownContent}
|
||||
components={{
|
||||
div: ({ className, children, node, ...props }) => {
|
||||
if (className?.includes('__boltArtifact__')) {
|
||||
const messageId = node?.properties.dataMessageId as string;
|
||||
|
||||
if (!messageId) {
|
||||
logger.warn(`Invalud message id ${messageId}`);
|
||||
}
|
||||
|
||||
return <Artifact messageId={messageId} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
pre: (props) => {
|
||||
const { children, node, ...rest } = props;
|
||||
|
||||
const [firstChild] = node?.children ?? [];
|
||||
|
||||
if (
|
||||
firstChild &&
|
||||
firstChild.type === 'element' &&
|
||||
firstChild.tagName === 'code' &&
|
||||
firstChild.children[0].type === 'text'
|
||||
) {
|
||||
const { className, ...rest } = firstChild.properties;
|
||||
const [, language = 'plaintext'] = /language-(\w+)/.exec(String(className) || '') ?? [];
|
||||
|
||||
return <CodeBlock code={firstChild.children[0].value} language={language as BundledLanguage} {...rest} />;
|
||||
}
|
||||
|
||||
return <pre {...rest}>{children}</pre>;
|
||||
},
|
||||
}}
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={rehypePlugins}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
55
packages/bolt/app/components/chat/Messages.tsx
Normal file
55
packages/bolt/app/components/chat/Messages.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import type { Message } from 'ai';
|
||||
import { useRef } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
import { AssistantMessage } from './AssistantMessage';
|
||||
import { UserMessage } from './UserMessage';
|
||||
|
||||
interface MessagesProps {
|
||||
id?: string;
|
||||
classNames?: { root?: string; messagesContainer?: string };
|
||||
isLoading?: boolean;
|
||||
messages?: Message[];
|
||||
}
|
||||
|
||||
export function Messages(props: MessagesProps) {
|
||||
const { id, isLoading, messages = [] } = props;
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
return (
|
||||
<div id={id} ref={containerRef} className={props.classNames?.root}>
|
||||
<div className={classNames('flex flex-col', props.classNames?.messagesContainer)}>
|
||||
{messages.length > 0
|
||||
? messages.map((message, i) => {
|
||||
const { role, content } = message;
|
||||
const isUser = role === 'user';
|
||||
const isFirst = i === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={message.id}
|
||||
className={classNames('flex gap-4 border rounded-md p-6 bg-white/80 backdrop-blur-sm', {
|
||||
'mt-4': !isFirst,
|
||||
})}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
'flex items-center justify-center min-w-[34px] min-h-[34px] text-gray-600 rounded-md p-1 self-start',
|
||||
{
|
||||
'bg-gray-100': role === 'user',
|
||||
'bg-accent text-xl': role === 'assistant',
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className={role === 'user' ? 'i-ph:user-fill text-xl' : 'i-blitz:logo'}></div>
|
||||
</div>
|
||||
{isUser ? <UserMessage content={content} /> : <AssistantMessage content={content} />}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: null}
|
||||
{isLoading && <div className="text-center w-full i-svg-spinners:3-dots-fade text-4xl mt-4"></div>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
30
packages/bolt/app/components/chat/SendButton.client.tsx
Normal file
30
packages/bolt/app/components/chat/SendButton.client.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { AnimatePresence, cubicBezier, motion } from 'framer-motion';
|
||||
|
||||
interface SendButtonProps {
|
||||
show: boolean;
|
||||
onClick?: VoidFunction;
|
||||
}
|
||||
|
||||
const customEasingFn = cubicBezier(0.4, 0, 0.2, 1);
|
||||
|
||||
export function SendButton({ show, onClick }: SendButtonProps) {
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{show ? (
|
||||
<motion.button
|
||||
className="absolute flex justify-center items-center top-[18px] right-[22px] p-1 bg-accent hover:brightness-110 color-white rounded-md w-[34px] h-[34px]"
|
||||
transition={{ ease: customEasingFn, duration: 0.17 }}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 10 }}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
onClick?.();
|
||||
}}
|
||||
>
|
||||
<div className="i-ph:arrow-right text-xl"></div>
|
||||
</motion.button>
|
||||
) : null}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
13
packages/bolt/app/components/chat/UserMessage.tsx
Normal file
13
packages/bolt/app/components/chat/UserMessage.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Markdown } from './Markdown';
|
||||
|
||||
interface UserMessageProps {
|
||||
content: string;
|
||||
}
|
||||
|
||||
export function UserMessage({ content }: UserMessageProps) {
|
||||
return (
|
||||
<div className="overflow-hidden">
|
||||
<Markdown>{content}</Markdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
70
packages/bolt/app/components/ui/IconButton.tsx
Normal file
70
packages/bolt/app/components/ui/IconButton.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { memo } from 'react';
|
||||
import { classNames } from '~/utils/classNames';
|
||||
|
||||
type IconSize = 'sm' | 'md' | 'xl';
|
||||
|
||||
interface BaseIconButtonProps {
|
||||
size?: IconSize;
|
||||
className?: string;
|
||||
iconClassName?: string;
|
||||
disabledClassName?: string;
|
||||
disabled?: boolean;
|
||||
onClick?: (event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
type IconButtonWithoutChildrenProps = {
|
||||
icon: string;
|
||||
children?: undefined;
|
||||
} & BaseIconButtonProps;
|
||||
|
||||
type IconButtonWithChildrenProps = {
|
||||
icon?: undefined;
|
||||
children: string | JSX.Element | JSX.Element[];
|
||||
} & BaseIconButtonProps;
|
||||
|
||||
type IconButtonProps = IconButtonWithoutChildrenProps | IconButtonWithChildrenProps;
|
||||
|
||||
export const IconButton = memo(
|
||||
({
|
||||
icon,
|
||||
size = 'xl',
|
||||
className,
|
||||
iconClassName,
|
||||
disabledClassName,
|
||||
disabled = false,
|
||||
onClick,
|
||||
children,
|
||||
}: IconButtonProps) => {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
'flex items-center text-gray-600 enabled:hover:text-gray-900 rounded-md p-1 enabled:hover:bg-gray-200/80 disabled:cursor-not-allowed',
|
||||
{
|
||||
[classNames('opacity-30', disabledClassName)]: disabled,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
onClick={(event) => {
|
||||
if (disabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onClick?.(event);
|
||||
}}
|
||||
>
|
||||
{children ? children : <div className={classNames(icon, getIconSize(size), iconClassName)}></div>}
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
function getIconSize(size: IconSize) {
|
||||
if (size === 'sm') {
|
||||
return 'text-sm';
|
||||
} else if (size === 'md') {
|
||||
return 'text-md';
|
||||
} else {
|
||||
return 'text-xl';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export function Workspace() {
|
||||
return <div>WORKSPACE PANEL</div>;
|
||||
}
|
||||
Reference in New Issue
Block a user