mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-04-25 16:36:22 +02:00
mv experimental apps
This commit is contained in:
parent
7f6ece90f8
commit
f722591ccd
53 changed files with 31 additions and 31 deletions
27
apps/experimental/chat_widget/app/api/bootstrap.js/route.ts
Normal file
27
apps/experimental/chat_widget/app/api/bootstrap.js/route.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
export const dynamic = 'force-dynamic'
|
||||
|
||||
// Fetch template once when module loads
|
||||
const templatePromise = fetch(process.env.CHAT_WIDGET_HOST + '/bootstrap.template.js')
|
||||
.then(res => res.text());
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Reuse the cached content
|
||||
const template = await templatePromise;
|
||||
|
||||
// Replace placeholder values with actual URLs
|
||||
const contents = template
|
||||
.replace('__CHAT_WIDGET_HOST__', process.env.CHAT_WIDGET_HOST || '')
|
||||
.replace('__ROWBOAT_HOST__', process.env.ROWBOAT_HOST || '');
|
||||
|
||||
return new Response(contents, {
|
||||
headers: {
|
||||
'Content-Type': 'application/javascript',
|
||||
'Cache-Control': 'no-cache, no-store, must-revalidate',
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error serving bootstrap.js:', error);
|
||||
return new Response('Error loading script', { status: 500 });
|
||||
}
|
||||
}
|
||||
466
apps/experimental/chat_widget/app/app.tsx
Normal file
466
apps/experimental/chat_widget/app/app.tsx
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
"use client";
|
||||
import { useEffect, useRef, useState, useCallback } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { z } from "zod";
|
||||
import { Button, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, Textarea } from "@nextui-org/react";
|
||||
import MarkdownContent from "./markdown-content";
|
||||
|
||||
type Message = {
|
||||
role: "user" | "assistant" | "system" | "tool";
|
||||
content: string;
|
||||
tool_call_id?: string;
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
function ChatWindowHeader({
|
||||
chatId,
|
||||
closeChat,
|
||||
closed,
|
||||
setMinimized,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
closeChat: () => Promise<void>;
|
||||
closed: boolean;
|
||||
setMinimized: (minimized: boolean) => void;
|
||||
}) {
|
||||
return <div className="shrink-0 flex justify-between items-center gap-2 bg-gray-400 px-2 py-1 rounded-t-lg dark:bg-gray-800">
|
||||
<div className="text-gray-800 dark:text-white">Chat</div>
|
||||
<div className="flex gap-1 items-center">
|
||||
{(chatId && !closed) && <Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeWidth="2" d="M6 12h.01m6 0h.01m5.99 0h.01" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu onAction={(key) => {
|
||||
if (key === "close") {
|
||||
closeChat();
|
||||
}
|
||||
}}>
|
||||
<DropdownItem key="close">
|
||||
Close chat
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>}
|
||||
<button onClick={() => setMinimized(true)}>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="m19 9-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function LoadingAssistantResponse() {
|
||||
return <div className="flex gap-2 items-end">
|
||||
<div className="shrink-0 w-10 h-10 bg-gray-400 rounded-full dark:bg-gray-800"></div>
|
||||
<div className="bg-white rounded-md dark:bg-gray-800 text-gray-800 dark:text-white mr-[20%] rounded-bl-none p-2">
|
||||
<div className="flex gap-1">
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce [animation-delay:0.2s]"></div>
|
||||
<div className="w-2 h-2 rounded-full bg-gray-400 dark:bg-gray-600 animate-bounce [animation-delay:0.4s]"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
function AssistantMessage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-1 items-start">
|
||||
<div className="text-gray-800 dark:text-white text-xs pl-2">Assistant</div>
|
||||
<div className="bg-gray-200 rounded-md dark:bg-gray-800 text-gray-800 dark:text-white mr-[20%] rounded-bl-none p-2">
|
||||
{typeof children === 'string' ? <MarkdownContent content={children} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
function UserMessage({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <div className="flex flex-col gap-1 items-end">
|
||||
<div className="bg-gray-200 rounded-md dark:bg-gray-800 text-gray-800 dark:text-white ml-[20%] rounded-br-none p-2">
|
||||
{typeof children === 'string' ? <MarkdownContent content={children} /> : children}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
function ChatWindowMessages({
|
||||
messages,
|
||||
loadingAssistantResponse,
|
||||
}: {
|
||||
messages: Message[];
|
||||
loadingAssistantResponse: boolean;
|
||||
}) {
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
return <div className="flex flex-col grow p-2 gap-4 overflow-auto">
|
||||
<AssistantMessage>
|
||||
Hello! I'm Rowboat, your personal assistant. How can I help you today?
|
||||
</AssistantMessage>
|
||||
{messages.map((message, index) => {
|
||||
switch (message.role) {
|
||||
case "user":
|
||||
return <UserMessage key={index}>{message.content}</UserMessage>;
|
||||
case "assistant":
|
||||
return <AssistantMessage key={index}>{message.content}</AssistantMessage>;
|
||||
case "system":
|
||||
return null; // Hide system messages from the UI
|
||||
case "tool":
|
||||
return <AssistantMessage key={index}>
|
||||
Tool response ({message.tool_name}): {message.content}
|
||||
</AssistantMessage>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
{loadingAssistantResponse && <LoadingAssistantResponse />}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindowInput({
|
||||
handleUserMessage,
|
||||
}: {
|
||||
handleUserMessage: (message: string) => Promise<void>;
|
||||
}) {
|
||||
const [prompt, setPrompt] = useState<string>("");
|
||||
|
||||
function handleInputKeyDown(event: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (event.key === 'Enter' && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
const input = prompt.trim();
|
||||
setPrompt('');
|
||||
|
||||
handleUserMessage(input);
|
||||
}
|
||||
}
|
||||
|
||||
return <div className="bg-white rounded-md dark:bg-gray-900 shrink-0 p-2">
|
||||
<Textarea
|
||||
placeholder="Ask me anything..."
|
||||
minRows={1}
|
||||
maxRows={3}
|
||||
variant="flat"
|
||||
className="w-full"
|
||||
onKeyDown={handleInputKeyDown}
|
||||
value={prompt}
|
||||
onValueChange={setPrompt}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindowBody({
|
||||
chatId,
|
||||
createChat,
|
||||
getAssistantResponse,
|
||||
closed,
|
||||
resetState,
|
||||
messages,
|
||||
setMessages,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
createChat: () => Promise<string>;
|
||||
getAssistantResponse: (chatId: string, message: string) => Promise<Message>;
|
||||
closed: boolean;
|
||||
resetState: () => Promise<void>;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
}) {
|
||||
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
|
||||
|
||||
async function handleUserMessage(message: string) {
|
||||
const userMessage: Message = { role: "user", content: message };
|
||||
setMessages([...messages, userMessage]);
|
||||
setLoadingAssistantResponse(true);
|
||||
|
||||
let availableChatId = chatId;
|
||||
if (!availableChatId) {
|
||||
availableChatId = await createChat();
|
||||
}
|
||||
|
||||
const response = await getAssistantResponse(availableChatId, message);
|
||||
setMessages([...messages, userMessage, response]);
|
||||
setLoadingAssistantResponse(false);
|
||||
}
|
||||
|
||||
return <div className="flex flex-col grow bg-white rounded-b-lg dark:bg-gray-900 overflow-auto">
|
||||
<ChatWindowMessages messages={messages} loadingAssistantResponse={loadingAssistantResponse} />
|
||||
{!closed && <ChatWindowInput
|
||||
handleUserMessage={handleUserMessage}
|
||||
/>}
|
||||
{closed && <div className="flex flex-col items-center py-4 gap-2">
|
||||
<div className="text-gray-800 dark:text-white">This chat is closed</div>
|
||||
<Button
|
||||
onPress={resetState}
|
||||
>
|
||||
Start new chat
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function ChatWindow({
|
||||
chatId,
|
||||
closed,
|
||||
closeChat,
|
||||
createChat,
|
||||
getAssistantResponse,
|
||||
resetState,
|
||||
messages,
|
||||
setMessages,
|
||||
setMinimized,
|
||||
}: {
|
||||
chatId: string | null;
|
||||
closed: boolean;
|
||||
closeChat: () => Promise<void>;
|
||||
createChat: () => Promise<string>;
|
||||
getAssistantResponse: (chatId: string, message: string) => Promise<Message>;
|
||||
resetState: () => Promise<void>;
|
||||
messages: Message[];
|
||||
setMessages: (messages: Message[]) => void;
|
||||
setMinimized: (minimized: boolean) => void;
|
||||
}) {
|
||||
return <div className="h-full flex flex-col rounded-lg overflow-hidden">
|
||||
<ChatWindowHeader
|
||||
chatId={chatId}
|
||||
closeChat={closeChat}
|
||||
closed={closed}
|
||||
setMinimized={setMinimized}
|
||||
/>
|
||||
<ChatWindowBody
|
||||
chatId={chatId}
|
||||
createChat={createChat}
|
||||
getAssistantResponse={getAssistantResponse}
|
||||
closed={closed}
|
||||
resetState={resetState}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
|
||||
export function App({
|
||||
apiUrl,
|
||||
}: {
|
||||
apiUrl: string;
|
||||
}) {
|
||||
const searchParams = useSearchParams();
|
||||
const sessionId = searchParams.get("session_id");
|
||||
const [minimized, setMinimized] = useState(searchParams.get("minimized") === 'true');
|
||||
const [chatId, setChatId] = useState<string | null>(null);
|
||||
const [closed, setClosed] = useState(false);
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
|
||||
const fetchLastChat = useCallback(async (): Promise<{
|
||||
chat: z.infer<typeof apiV1.ApiGetChatsResponse.shape.chats.element>;
|
||||
messages: Message[];
|
||||
} | null> => {
|
||||
const response = await fetch(`${apiUrl}/chats`, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
return null;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to fetch last chat");
|
||||
}
|
||||
const { chats }: z.infer<typeof apiV1.ApiGetChatsResponse> = await response.json();
|
||||
if (chats.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const chat = chats[0];
|
||||
|
||||
// fetch all chat messages
|
||||
let allMessages: Message[] = [];
|
||||
let nextCursor: string | undefined = undefined;
|
||||
|
||||
do {
|
||||
const url = new URL(`${apiUrl}/chats/${chat.id}/messages`);
|
||||
if (nextCursor) {
|
||||
url.searchParams.set('next', nextCursor);
|
||||
}
|
||||
|
||||
const messagesResponse = await fetch(url, {
|
||||
headers: {
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (!messagesResponse.ok) {
|
||||
throw new Error("Failed to fetch chat messages");
|
||||
}
|
||||
const { messages, next }: z.infer<typeof apiV1.ApiGetChatMessagesResponse> = await messagesResponse.json();
|
||||
|
||||
const formattedMessages = messages.map(m => ({
|
||||
role: m.role,
|
||||
content: m.role === "assistant" ? (m.content || '') : m.content,
|
||||
...(m.role === "tool" ? {
|
||||
tool_call_id: m.tool_call_id,
|
||||
tool_name: m.tool_name,
|
||||
} : {})
|
||||
}));
|
||||
|
||||
allMessages = [...allMessages, ...formattedMessages];
|
||||
nextCursor = next;
|
||||
} while (nextCursor);
|
||||
|
||||
return {
|
||||
chat,
|
||||
messages: allMessages,
|
||||
};
|
||||
}, [sessionId, apiUrl]);
|
||||
|
||||
async function resetState() {
|
||||
setChatId(null);
|
||||
setClosed(false);
|
||||
setMessages([]);
|
||||
}
|
||||
|
||||
async function closeChat() {
|
||||
const response = await fetch(`${apiUrl}/chats/${chatId}/close`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to close chat");
|
||||
}
|
||||
setClosed(true);
|
||||
}
|
||||
|
||||
async function createChat(): Promise<string> {
|
||||
const response = await fetch(`${apiUrl}/chats`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
|
||||
const { id }: z.infer<typeof apiV1.ApiCreateChatResponse> = await response.json();
|
||||
setChatId(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function getAssistantResponse(chatId: string, message: string): Promise<Message> {
|
||||
const response = await fetch(`${apiUrl}/chats/${chatId}/turn`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": `Bearer ${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
}),
|
||||
});
|
||||
if (response.status === 403) {
|
||||
window.parent.postMessage({
|
||||
type: 'sessionExpired'
|
||||
}, '*');
|
||||
throw new Error("Session expired");
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error("Failed to get assistant response");
|
||||
}
|
||||
const { content }: z.infer<typeof apiV1.ApiChatTurnResponse> = await response.json();
|
||||
return {
|
||||
role: "assistant",
|
||||
content: content || '',
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
window.parent.postMessage({
|
||||
type: 'chatStateChange',
|
||||
isMinimized: minimized
|
||||
}, '*');
|
||||
}, [minimized]);
|
||||
|
||||
useEffect(() => {
|
||||
let abort = false;
|
||||
async function process(){
|
||||
const lastChat = await fetchLastChat();
|
||||
if (abort) {
|
||||
return;
|
||||
}
|
||||
if (lastChat) {
|
||||
setChatId(lastChat.chat.id);
|
||||
setClosed(lastChat.chat.closed || false);
|
||||
setMessages(lastChat.messages);
|
||||
}
|
||||
}
|
||||
process()
|
||||
.finally(() => {
|
||||
if (!abort) {
|
||||
window.parent.postMessage({
|
||||
type: 'chatLoaded',
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
abort = true;
|
||||
}
|
||||
}, [sessionId, fetchLastChat]);
|
||||
|
||||
if (!sessionId) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>
|
||||
{minimized && <div className="fixed bottom-0 right-0">
|
||||
<button
|
||||
onClick={() => setMinimized(false)}
|
||||
className="w-12 h-12 bg-gray-200 dark:bg-gray-800 hover:bg-gray-300 dark:hover:bg-gray-700 rounded-full flex items-center justify-center shadow-lg transition-colors"
|
||||
>
|
||||
<svg className="w-6 h-6 text-gray-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17h6l3 3v-3h2V9h-2M4 4h11v8H9l-3 3v-3H4V4Z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>}
|
||||
{!minimized && <div className="fixed h-full">
|
||||
<ChatWindow
|
||||
key={sessionId}
|
||||
chatId={chatId}
|
||||
closed={closed}
|
||||
closeChat={closeChat}
|
||||
createChat={createChat}
|
||||
getAssistantResponse={getAssistantResponse}
|
||||
resetState={resetState}
|
||||
messages={messages}
|
||||
setMessages={setMessages}
|
||||
setMinimized={setMinimized}
|
||||
/>
|
||||
</div>}
|
||||
</>
|
||||
}
|
||||
BIN
apps/experimental/chat_widget/app/favicon.ico
Normal file
BIN
apps/experimental/chat_widget/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
BIN
apps/experimental/chat_widget/app/fonts/GeistMonoVF.woff
Normal file
BIN
apps/experimental/chat_widget/app/fonts/GeistMonoVF.woff
Normal file
Binary file not shown.
BIN
apps/experimental/chat_widget/app/fonts/GeistVF.woff
Normal file
BIN
apps/experimental/chat_widget/app/fonts/GeistVF.woff
Normal file
Binary file not shown.
7
apps/experimental/chat_widget/app/globals.css
Normal file
7
apps/experimental/chat_widget/app/globals.css
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
body {
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
35
apps/experimental/chat_widget/app/layout.tsx
Normal file
35
apps/experimental/chat_widget/app/layout.tsx
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import type { Metadata } from "next";
|
||||
import localFont from "next/font/local";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = localFont({
|
||||
src: "./fonts/GeistVF.woff",
|
||||
variable: "--font-geist-sans",
|
||||
weight: "100 900",
|
||||
});
|
||||
const geistMono = localFont({
|
||||
src: "./fonts/GeistMonoVF.woff",
|
||||
variable: "--font-geist-mono",
|
||||
weight: "100 900",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "RowBoat Chat",
|
||||
description: "RowBoat Chat",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="h-full bg-transparent">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased h-full`}
|
||||
>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
51
apps/experimental/chat_widget/app/markdown-content.tsx
Normal file
51
apps/experimental/chat_widget/app/markdown-content.tsx
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Markdown from 'react-markdown'
|
||||
import remarkGfm from 'remark-gfm'
|
||||
|
||||
export default function MarkdownContent({ content }: { content: string }) {
|
||||
return <Markdown
|
||||
className="overflow-auto break-words"
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
strong({ children }) {
|
||||
return <span className="font-semibold">{children}</span>
|
||||
},
|
||||
p({ children }) {
|
||||
return <p className="py-1">{children}</p>
|
||||
},
|
||||
ul({ children }) {
|
||||
return <ul className="py-1 pl-5 list-disc">{children}</ul>
|
||||
},
|
||||
ol({ children }) {
|
||||
return <ul className="py-1 pl-5 list-decimal">{children}</ul>
|
||||
},
|
||||
h3({ children }) {
|
||||
return <h3 className="font-semibold">{children}</h3>
|
||||
},
|
||||
table({ children }) {
|
||||
return <table className="my-1 border-collapse border border-gray-400 rounded">{children}</table>
|
||||
},
|
||||
th({ children }) {
|
||||
return <th className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</th>
|
||||
},
|
||||
td({ children }) {
|
||||
return <td className="px-2 py-1 border-collapse border border-gray-300 rounded">{children}</td>
|
||||
},
|
||||
blockquote({ children }) {
|
||||
return <blockquote className='bg-gray-200 px-1'>{children}</blockquote>;
|
||||
},
|
||||
a(props) {
|
||||
const { children, ...rest } = props
|
||||
return <a className="inline-flex items-center gap-1" target="_blank" {...rest} >
|
||||
<span className='underline'>
|
||||
{children}
|
||||
</span>
|
||||
<svg className="w-[16px] h-[16px]" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="none" viewBox="0 0 24 24">
|
||||
<path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1" d="M18 14v4.833A1.166 1.166 0 0 1 16.833 20H5.167A1.167 1.167 0 0 1 4 18.833V7.167A1.166 1.166 0 0 1 5.167 6h4.618m4.447-2H20v5.768m-7.889 2.121 7.778-7.778" />
|
||||
</svg>
|
||||
</a>
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content}
|
||||
</Markdown>;
|
||||
}
|
||||
10
apps/experimental/chat_widget/app/page.tsx
Normal file
10
apps/experimental/chat_widget/app/page.tsx
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { Suspense } from 'react';
|
||||
import { App } from './app';
|
||||
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
export default function Page() {
|
||||
return <Suspense>
|
||||
<App apiUrl={`${process.env.ROWBOAT_HOST}/api/widget/v1`} />
|
||||
</Suspense>
|
||||
}
|
||||
16
apps/experimental/chat_widget/app/providers.tsx
Normal file
16
apps/experimental/chat_widget/app/providers.tsx
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import * as React from "react";
|
||||
|
||||
// 1. import `NextUIProvider` component
|
||||
import {NextUIProvider} from "@nextui-org/react";
|
||||
|
||||
export default function Providers({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<NextUIProvider>
|
||||
{children}
|
||||
</NextUIProvider>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue