diff --git a/create-app.ts b/create-app.ts index 05186ed410c03e8689a112734be3089507f33d03..06b7b0582ff80b9d6a5bc06cfce8c81296049c53 100644 --- a/create-app.ts +++ b/create-app.ts @@ -31,6 +31,7 @@ export async function createApp({ frontend, openAIKey, model, + communityProjectPath, }: InstallAppArgs): Promise<void> { const root = path.resolve(appPath); @@ -69,6 +70,7 @@ export async function createApp({ eslint, openAIKey, model, + communityProjectPath, }; if (frontend) { @@ -106,7 +108,7 @@ export async function createApp({ console.log( `Now have a look at the ${terminalLink( "README.md", - `file://${appName}/README.md`, + `file://${root}/README.md`, )} and learn how to get started.`, ); console.log(); diff --git a/helpers/constant.ts b/helpers/constant.ts new file mode 100644 index 0000000000000000000000000000000000000000..341fba2c0c45f599c5329678b60bb0acc285dff9 --- /dev/null +++ b/helpers/constant.ts @@ -0,0 +1,2 @@ +export const COMMUNITY_OWNER = "run-llama"; +export const COMMUNITY_REPO = "create_llama_projects"; diff --git a/helpers/repo.ts b/helpers/repo.ts new file mode 100644 index 0000000000000000000000000000000000000000..2471a91a4ef8e72279cce5e9ab7d6e756d6cd8a6 --- /dev/null +++ b/helpers/repo.ts @@ -0,0 +1,63 @@ +import { createWriteStream, promises } from "fs"; +import got from "got"; +import { tmpdir } from "os"; +import { join } from "path"; +import { Stream } from "stream"; +import tar from "tar"; +import { promisify } from "util"; +import { makeDir } from "./make-dir"; + +export type RepoInfo = { + username: string; + name: string; + branch: string; + filePath: string; +}; + +const pipeline = promisify(Stream.pipeline); + +async function downloadTar(url: string) { + const tempFile = join(tmpdir(), `next.js-cna-example.temp-${Date.now()}`); + await pipeline(got.stream(url), createWriteStream(tempFile)); + return tempFile; +} + +export async function downloadAndExtractRepo( + root: string, + { username, name, branch, filePath }: RepoInfo, +) { + await makeDir(root); + + const tempFile = await downloadTar( + `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`, + ); + + await tar.x({ + file: tempFile, + cwd: root, + strip: filePath ? filePath.split("/").length + 1 : 1, + filter: (p) => + p.startsWith( + `${name}-${branch.replace(/\//g, "-")}${ + filePath ? `/${filePath}/` : "/" + }`, + ), + }); + + await promises.unlink(tempFile); +} + +export async function getRepoRootFolders( + owner: string, + repo: string, +): Promise<string[]> { + const url = `https://api.github.com/repos/${owner}/${repo}/contents`; + + const response = await got(url, { + responseType: "json", + }); + + const data = response.body as any[]; + const folders = data.filter((item) => item.type === "dir"); + return folders.map((item) => item.name); +} diff --git a/index.ts b/index.ts index 67a0f53cee61859280f5729ed73c1f5e81e22238..72ee5cd8ba00eef6c6e61c3b326743f47df0d5c3 100644 --- a/index.ts +++ b/index.ts @@ -1,18 +1,18 @@ #!/usr/bin/env node /* eslint-disable import/no-extraneous-dependencies */ -import ciInfo from "ci-info"; import Commander from "commander"; import Conf from "conf"; import fs from "fs"; import path from "path"; -import { blue, bold, cyan, green, red, yellow } from "picocolors"; +import { bold, cyan, green, red, yellow } from "picocolors"; import prompts from "prompts"; import checkForUpdate from "update-check"; -import { InstallAppArgs, createApp } from "./create-app"; +import { createApp } from "./create-app"; import { getPkgManager } from "./helpers/get-pkg-manager"; import { isFolderEmpty } from "./helpers/is-folder-empty"; import { validateNpmName } from "./helpers/validate-pkg"; import packageJson from "./package.json"; +import { QuestionArgs, askQuestions, onPromptState } from "./questions"; let projectPath: string = ""; @@ -21,16 +21,6 @@ const handleSigTerm = () => process.exit(0); process.on("SIGINT", handleSigTerm); process.on("SIGTERM", handleSigTerm); -const onPromptState = (state: any) => { - if (state.aborted) { - // If we don't re-enable the terminal cursor before exiting - // the program, the cursor will remain hidden - process.stdout.write("\x1B[?25h"); - process.stdout.write("\n"); - process.exit(1); - } -}; - const program = new Commander.Command(packageJson.name) .version(packageJson.version) .arguments("<project-directory>") @@ -155,220 +145,8 @@ async function run(): Promise<void> { process.exit(1); } - // TODO: use Args also for program - type Args = Omit<InstallAppArgs, "appPath" | "packageManager">; - - const preferences = (conf.get("preferences") || {}) as Args; - - const defaults: Args = { - template: "streaming", - framework: "nextjs", - engine: "simple", - ui: "html", - eslint: true, - frontend: false, - openAIKey: "", - model: "gpt-3.5-turbo", - }; - const getPrefOrDefault = (field: keyof Args) => - preferences[field] ?? defaults[field]; - - const handlers = { - onCancel: () => { - console.error("Exiting."); - process.exit(1); - }, - }; - - if (!program.framework) { - if (ciInfo.isCI) { - program.framework = getPrefOrDefault("framework"); - } else { - const { framework } = await prompts( - { - type: "select", - name: "framework", - message: "Which framework would you like to use?", - choices: [ - { title: "NextJS", value: "nextjs" }, - { title: "Express", value: "express" }, - { title: "FastAPI (Python)", value: "fastapi" }, - ], - initial: 0, - }, - handlers, - ); - program.framework = framework; - preferences.framework = framework; - } - } - - if (program.framework === "nextjs") { - program.template = "streaming"; - } - if (!program.template) { - if (ciInfo.isCI) { - program.template = getPrefOrDefault("template"); - } else { - const { template } = await prompts( - { - type: "select", - name: "template", - message: "Which template would you like to use?", - choices: [ - { title: "Chat without streaming", value: "simple" }, - { title: "Chat with streaming", value: "streaming" }, - ], - initial: 1, - }, - handlers, - ); - program.template = template; - preferences.template = template; - } - } - - if (program.framework === "express" || program.framework === "fastapi") { - // if a backend-only framework is selected, ask whether we should create a frontend - if (!program.frontend) { - if (ciInfo.isCI) { - program.frontend = getPrefOrDefault("frontend"); - } else { - const styledNextJS = blue("NextJS"); - const styledBackend = green( - program.framework === "express" - ? "Express " - : program.framework === "fastapi" - ? "FastAPI (Python) " - : "", - ); - const { frontend } = await prompts({ - onState: onPromptState, - type: "toggle", - name: "frontend", - message: `Would you like to generate a ${styledNextJS} frontend for your ${styledBackend}backend?`, - initial: getPrefOrDefault("frontend"), - active: "Yes", - inactive: "No", - }); - program.frontend = Boolean(frontend); - preferences.frontend = Boolean(frontend); - } - } - } - - if (program.framework === "nextjs" || program.frontend) { - if (!program.ui) { - if (ciInfo.isCI) { - program.ui = getPrefOrDefault("ui"); - } else { - const { ui } = await prompts( - { - type: "select", - name: "ui", - message: "Which UI would you like to use?", - choices: [ - { title: "Just HTML", value: "html" }, - { title: "Shadcn", value: "shadcn" }, - ], - initial: 0, - }, - handlers, - ); - program.ui = ui; - preferences.ui = ui; - } - } - } - - if (program.framework === "nextjs") { - if (!program.model) { - if (ciInfo.isCI) { - program.model = getPrefOrDefault("model"); - } else { - const { model } = await prompts( - { - type: "select", - name: "model", - message: "Which model would you like to use?", - choices: [ - { title: "gpt-3.5-turbo", value: "gpt-3.5-turbo" }, - { title: "gpt-4", value: "gpt-4" }, - { title: "gpt-4-1106-preview", value: "gpt-4-1106-preview" }, - { title: "gpt-4-vision-preview", value: "gpt-4-vision-preview" }, - ], - initial: 0, - }, - handlers, - ); - program.model = model; - preferences.model = model; - } - } - } - - if (program.framework === "express" || program.framework === "nextjs") { - if (!program.engine) { - if (ciInfo.isCI) { - program.engine = getPrefOrDefault("engine"); - } else { - const { engine } = await prompts( - { - type: "select", - name: "engine", - message: "Which chat engine would you like to use?", - choices: [ - { title: "ContextChatEngine", value: "context" }, - { - title: "SimpleChatEngine (no data, just chat)", - value: "simple", - }, - ], - initial: 0, - }, - handlers, - ); - program.engine = engine; - preferences.engine = engine; - } - } - } - - if (!program.openAIKey) { - const { key } = await prompts( - { - type: "text", - name: "key", - message: "Please provide your OpenAI API key (leave blank to skip):", - }, - handlers, - ); - program.openAIKey = key; - preferences.openAIKey = key; - } - - if ( - program.framework !== "fastapi" && - !process.argv.includes("--eslint") && - !process.argv.includes("--no-eslint") - ) { - if (ciInfo.isCI) { - program.eslint = getPrefOrDefault("eslint"); - } else { - const styledEslint = blue("ESLint"); - const { eslint } = await prompts({ - onState: onPromptState, - type: "toggle", - name: "eslint", - message: `Would you like to use ${styledEslint}?`, - initial: getPrefOrDefault("eslint"), - active: "Yes", - inactive: "No", - }); - program.eslint = Boolean(eslint); - preferences.eslint = Boolean(eslint); - } - } + const preferences = (conf.get("preferences") || {}) as QuestionArgs; + await askQuestions(program as unknown as QuestionArgs, preferences); await createApp({ template: program.template, @@ -381,6 +159,7 @@ async function run(): Promise<void> { frontend: program.frontend, openAIKey: program.openAIKey, model: program.model, + communityProjectPath: program.communityProjectPath, }); conf.set("preferences", preferences); } diff --git a/questions.ts b/questions.ts new file mode 100644 index 0000000000000000000000000000000000000000..7f0786acb0197988b8423025c09fe796c98f6085 --- /dev/null +++ b/questions.ts @@ -0,0 +1,277 @@ +import ciInfo from "ci-info"; +import { blue, green } from "picocolors"; +import prompts from "prompts"; +import { InstallAppArgs } from "./create-app"; +import { COMMUNITY_OWNER, COMMUNITY_REPO } from "./helpers/constant"; +import { getRepoRootFolders } from "./helpers/repo"; + +export type QuestionArgs = Omit<InstallAppArgs, "appPath" | "packageManager">; + +const defaults: QuestionArgs = { + template: "streaming", + framework: "nextjs", + engine: "simple", + ui: "html", + eslint: true, + frontend: false, + openAIKey: "", + model: "gpt-3.5-turbo", + communityProjectPath: "", +}; + +const handlers = { + onCancel: () => { + console.error("Exiting."); + process.exit(1); + }, +}; + +export const onPromptState = (state: any) => { + if (state.aborted) { + // If we don't re-enable the terminal cursor before exiting + // the program, the cursor will remain hidden + process.stdout.write("\x1B[?25h"); + process.stdout.write("\n"); + process.exit(1); + } +}; + +export const askQuestions = async ( + program: QuestionArgs, + preferences: QuestionArgs, +) => { + const getPrefOrDefault = <K extends keyof QuestionArgs>( + field: K, + ): QuestionArgs[K] => preferences[field] ?? defaults[field]; + + if (!program.template) { + if (ciInfo.isCI) { + program.template = getPrefOrDefault("template"); + } else { + const styledRepo = blue( + `https://github.com/${COMMUNITY_OWNER}/${COMMUNITY_REPO}`, + ); + const { template } = await prompts( + { + type: "select", + name: "template", + message: "Which template would you like to use?", + choices: [ + { title: "Chat without streaming", value: "simple" }, + { title: "Chat with streaming", value: "streaming" }, + { + title: `Community template from ${styledRepo}`, + value: "community", + }, + ], + initial: 1, + }, + handlers, + ); + program.template = template; + preferences.template = template; + } + } + + if (program.template === "community") { + const rootFolderNames = await getRepoRootFolders( + COMMUNITY_OWNER, + COMMUNITY_REPO, + ); + const { communityProjectPath } = await prompts( + { + type: "select", + name: "communityProjectPath", + message: "Select community template", + choices: rootFolderNames.map((name) => ({ + title: name, + value: name, + })), + initial: 0, + }, + { + onCancel: () => { + console.error("Exiting."); + process.exit(1); + }, + }, + ); + + program.communityProjectPath = communityProjectPath; + preferences.communityProjectPath = communityProjectPath; + return; // early return - no further questions needed for community projects + } + + if (!program.framework) { + if (ciInfo.isCI) { + program.framework = getPrefOrDefault("framework"); + } else { + const choices = [ + { title: "Express", value: "express" }, + { title: "FastAPI (Python)", value: "fastapi" }, + ]; + if (program.template === "streaming") { + // allow NextJS only for streaming template + choices.unshift({ title: "NextJS", value: "nextjs" }); + } + + const { framework } = await prompts( + { + type: "select", + name: "framework", + message: "Which framework would you like to use?", + choices, + initial: 0, + }, + handlers, + ); + program.framework = framework; + preferences.framework = framework; + } + } + + if (program.framework === "express" || program.framework === "fastapi") { + // if a backend-only framework is selected, ask whether we should create a frontend + if (!program.frontend) { + if (ciInfo.isCI) { + program.frontend = getPrefOrDefault("frontend"); + } else { + const styledNextJS = blue("NextJS"); + const styledBackend = green( + program.framework === "express" + ? "Express " + : program.framework === "fastapi" + ? "FastAPI (Python) " + : "", + ); + const { frontend } = await prompts({ + onState: onPromptState, + type: "toggle", + name: "frontend", + message: `Would you like to generate a ${styledNextJS} frontend for your ${styledBackend}backend?`, + initial: getPrefOrDefault("frontend"), + active: "Yes", + inactive: "No", + }); + program.frontend = Boolean(frontend); + preferences.frontend = Boolean(frontend); + } + } + } + + if (program.framework === "nextjs" || program.frontend) { + if (!program.ui) { + if (ciInfo.isCI) { + program.ui = getPrefOrDefault("ui"); + } else { + const { ui } = await prompts( + { + type: "select", + name: "ui", + message: "Which UI would you like to use?", + choices: [ + { title: "Just HTML", value: "html" }, + { title: "Shadcn", value: "shadcn" }, + ], + initial: 0, + }, + handlers, + ); + program.ui = ui; + preferences.ui = ui; + } + } + } + + if (program.framework === "nextjs") { + if (!program.model) { + if (ciInfo.isCI) { + program.model = getPrefOrDefault("model"); + } else { + const { model } = await prompts( + { + type: "select", + name: "model", + message: "Which model would you like to use?", + choices: [ + { title: "gpt-3.5-turbo", value: "gpt-3.5-turbo" }, + { title: "gpt-4", value: "gpt-4" }, + { title: "gpt-4-1106-preview", value: "gpt-4-1106-preview" }, + { + title: "gpt-4-vision-preview", + value: "gpt-4-vision-preview", + }, + ], + initial: 0, + }, + handlers, + ); + program.model = model; + preferences.model = model; + } + } + } + + if (program.framework === "express" || program.framework === "nextjs") { + if (!program.engine) { + if (ciInfo.isCI) { + program.engine = getPrefOrDefault("engine"); + } else { + const { engine } = await prompts( + { + type: "select", + name: "engine", + message: "Which chat engine would you like to use?", + choices: [ + { title: "ContextChatEngine", value: "context" }, + { + title: "SimpleChatEngine (no data, just chat)", + value: "simple", + }, + ], + initial: 0, + }, + handlers, + ); + program.engine = engine; + preferences.engine = engine; + } + } + } + + if (!program.openAIKey) { + const { key } = await prompts( + { + type: "text", + name: "key", + message: "Please provide your OpenAI API key (leave blank to skip):", + }, + handlers, + ); + program.openAIKey = key; + preferences.openAIKey = key; + } + + if ( + program.framework !== "fastapi" && + !process.argv.includes("--eslint") && + !process.argv.includes("--no-eslint") + ) { + if (ciInfo.isCI) { + program.eslint = getPrefOrDefault("eslint"); + } else { + const styledEslint = blue("ESLint"); + const { eslint } = await prompts({ + onState: onPromptState, + type: "toggle", + name: "eslint", + message: `Would you like to use ${styledEslint}?`, + initial: getPrefOrDefault("eslint"), + active: "Yes", + inactive: "No", + }); + program.eslint = Boolean(eslint); + preferences.eslint = Boolean(eslint); + } + } +}; diff --git a/templates/components/ui/html/chat/chat-avatar.tsx b/templates/components/ui/html/chat/chat-avatar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cd241104e4ef210c728aec47a1ab8b0161ad6538 --- /dev/null +++ b/templates/components/ui/html/chat/chat-avatar.tsx @@ -0,0 +1,34 @@ +"use client"; + +import Image from "next/image"; +import { Message } from "./chat-messages"; + +export default function ChatAvatar(message: Message) { + if (message.role === "user") { + return ( + <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background"> + <svg + xmlns="http://www.w3.org/2000/svg" + viewBox="0 0 256 256" + fill="currentColor" + className="h-4 w-4" + > + <path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8ZM72 96a56 56 0 1 1 56 56 56.06 56.06 0 0 1-56-56Z"></path> + </svg> + </div> + ); + } + + return ( + <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-black text-white"> + <Image + className="rounded-md" + src="/llama.png" + alt="Llama Logo" + width={24} + height={24} + priority + /> + </div> + ); +} diff --git a/templates/components/ui/html/chat/chat-input.tsx b/templates/components/ui/html/chat/chat-input.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7c3e87280b03ed571e8fc081a38c15a8d36df1ab --- /dev/null +++ b/templates/components/ui/html/chat/chat-input.tsx @@ -0,0 +1,43 @@ +"use client"; + +export interface ChatInputProps { + /** The current value of the input */ + input?: string; + /** An input/textarea-ready onChange handler to control the value of the input */ + handleInputChange?: ( + e: + | React.ChangeEvent<HTMLInputElement> + | React.ChangeEvent<HTMLTextAreaElement>, + ) => void; + /** Form submission handler to automatically reset input and append a user message */ + handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; + isLoading: boolean; + multiModal?: boolean; +} + +export default function ChatInput(props: ChatInputProps) { + return ( + <> + <form + onSubmit={props.handleSubmit} + className="flex items-start justify-between w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl gap-4" + > + <input + autoFocus + name="message" + placeholder="Type a message" + className="w-full p-4 rounded-xl shadow-inner flex-1" + value={props.input} + onChange={props.handleInputChange} + /> + <button + disabled={props.isLoading} + type="submit" + className="p-4 text-white rounded-xl shadow-xl bg-gradient-to-r from-cyan-500 to-sky-500 disabled:opacity-50 disabled:cursor-not-allowed" + > + Send message + </button> + </form> + </> + ); +} diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-item.tsx b/templates/components/ui/html/chat/chat-item.tsx similarity index 100% rename from templates/types/streaming/nextjs/app/components/ui/chat/chat-item.tsx rename to templates/components/ui/html/chat/chat-item.tsx diff --git a/templates/components/ui/html/chat/chat-messages.tsx b/templates/components/ui/html/chat/chat-messages.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0e978394015bd985af40646e87fa6620e9001a2f --- /dev/null +++ b/templates/components/ui/html/chat/chat-messages.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import ChatItem from "./chat-item"; + +export interface Message { + id: string; + content: string; + role: string; +} + +export default function ChatMessages({ + messages, + isLoading, + reload, + stop, +}: { + messages: Message[]; + isLoading?: boolean; + stop?: () => void; + reload?: () => void; +}) { + const scrollableChatContainerRef = useRef<HTMLDivElement>(null); + + const scrollToBottom = () => { + if (scrollableChatContainerRef.current) { + scrollableChatContainerRef.current.scrollTop = + scrollableChatContainerRef.current.scrollHeight; + } + }; + + useEffect(() => { + scrollToBottom(); + }, [messages.length]); + + return ( + <div className="w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl"> + <div + className="flex flex-col gap-5 divide-y h-[50vh] overflow-auto" + ref={scrollableChatContainerRef} + > + {messages.map((m: Message) => ( + <ChatItem key={m.id} {...m} /> + ))} + </div> + </div> + ); +} diff --git a/templates/components/ui/shadcn/chat/index.ts b/templates/components/ui/html/chat/index.ts similarity index 54% rename from templates/components/ui/shadcn/chat/index.ts rename to templates/components/ui/html/chat/index.ts index c7990f9c13fff2f6cc17faf0a87c7d42f5326471..5de7dce47fc1e2330759171db291eedbca19e722 100644 --- a/templates/components/ui/shadcn/chat/index.ts +++ b/templates/components/ui/html/chat/index.ts @@ -1,5 +1,6 @@ import ChatInput from "./chat-input"; import ChatMessages from "./chat-messages"; -export { type ChatHandler, type Message } from "./chat.interface"; +export type { ChatInputProps } from "./chat-input"; +export type { Message } from "./chat-messages"; export { ChatInput, ChatMessages }; diff --git a/templates/components/ui/shadcn/chat/chat-avatar.tsx b/templates/components/ui/shadcn/chat/chat-avatar.tsx deleted file mode 100644 index ce04e306a7164e49e7ea6950a55c4f5cedc2ee2a..0000000000000000000000000000000000000000 --- a/templates/components/ui/shadcn/chat/chat-avatar.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { User2 } from "lucide-react"; -import Image from "next/image"; - -export default function ChatAvatar({ role }: { role: string }) { - if (role === "user") { - return ( - <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-background shadow"> - <User2 className="h-4 w-4" /> - </div> - ); - } - - return ( - <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-black text-white shadow"> - <Image - className="rounded-md" - src="/llama.png" - alt="Llama Logo" - width={24} - height={24} - priority - /> - </div> - ); -} diff --git a/templates/components/ui/shadcn/chat/chat-input.tsx b/templates/components/ui/shadcn/chat/chat-input.tsx deleted file mode 100644 index 435637e5ec94fdb9fe03faa3c3e1791a0be584bb..0000000000000000000000000000000000000000 --- a/templates/components/ui/shadcn/chat/chat-input.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useState } from "react"; -import { Button } from "../button"; -import FileUploader from "../file-uploader"; -import { Input } from "../input"; -import UploadImagePreview from "../upload-image-preview"; -import { ChatHandler } from "./chat.interface"; - -export default function ChatInput( - props: Pick< - ChatHandler, - | "isLoading" - | "input" - | "onFileUpload" - | "onFileError" - | "handleSubmit" - | "handleInputChange" - > & { - multiModal?: boolean; - }, -) { - const [imageUrl, setImageUrl] = useState<string | null>(null); - - const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { - if (imageUrl) { - props.handleSubmit(e, { - data: { imageUrl: imageUrl }, - }); - setImageUrl(null); - return; - } - props.handleSubmit(e); - }; - - const onRemovePreviewImage = () => setImageUrl(null); - - const handleUploadImageFile = async (file: File) => { - const base64 = await new Promise<string>((resolve, reject) => { - const reader = new FileReader(); - reader.readAsDataURL(file); - reader.onload = () => resolve(reader.result as string); - reader.onerror = (error) => reject(error); - }); - setImageUrl(base64); - }; - - const handleUploadFile = async (file: File) => { - try { - if (props.multiModal && file.type.startsWith("image/")) { - return await handleUploadImageFile(file); - } - props.onFileUpload?.(file); - } catch (error: any) { - props.onFileError?.(error.message); - } - }; - - return ( - <form - onSubmit={onSubmit} - className="rounded-xl bg-white p-4 shadow-xl space-y-4" - > - {imageUrl && ( - <UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} /> - )} - <div className="flex w-full items-start justify-between gap-4 "> - <Input - autoFocus - name="message" - placeholder="Type a message" - className="flex-1" - value={props.input} - onChange={props.handleInputChange} - /> - <FileUploader - onFileUpload={handleUploadFile} - onFileError={props.onFileError} - /> - <Button type="submit" disabled={props.isLoading}> - Send message - </Button> - </div> - </form> - ); -} diff --git a/templates/components/ui/shadcn/chat/chat-messages.tsx b/templates/components/ui/shadcn/chat/chat-messages.tsx deleted file mode 100644 index ee40cfd642db317c39c47097f9c1cd1b1fd299f2..0000000000000000000000000000000000000000 --- a/templates/components/ui/shadcn/chat/chat-messages.tsx +++ /dev/null @@ -1,64 +0,0 @@ -import { useEffect, useRef } from "react"; -import { Loader2 } from "lucide-react"; - -import ChatActions from "./chat-actions"; -import ChatMessage from "./chat-message"; -import { ChatHandler } from "./chat.interface"; - -export default function ChatMessages( - props: Pick<ChatHandler, "messages" | "isLoading" | "reload" | "stop">, -) { - const scrollableChatContainerRef = useRef<HTMLDivElement>(null); - const messageLength = props.messages.length; - const lastMessage = props.messages[messageLength - 1]; - - const scrollToBottom = () => { - if (scrollableChatContainerRef.current) { - scrollableChatContainerRef.current.scrollTop = - scrollableChatContainerRef.current.scrollHeight; - } - }; - - const isLastMessageFromAssistant = - messageLength > 0 && lastMessage?.role !== "user"; - const showReload = - props.reload && !props.isLoading && isLastMessageFromAssistant; - const showStop = props.stop && props.isLoading; - - // `isPending` indicate - // that stream response is not yet received from the server, - // so we show a loading indicator to give a better UX. - const isPending = props.isLoading && !isLastMessageFromAssistant; - - useEffect(() => { - scrollToBottom(); - }, [messageLength, lastMessage]); - - return ( - <div className="w-full rounded-xl bg-white p-4 shadow-xl pb-0"> - <div - className="flex h-[50vh] flex-col gap-5 divide-y overflow-y-auto pb-4" - ref={scrollableChatContainerRef} - > - {props.messages.map((m) => ( - <ChatMessage key={m.id} {...m} /> - ))} - {isPending && ( - <div - className='flex justify-center items-center pt-10' - > - <Loader2 className="h-4 w-4 animate-spin"/> - </div> - )} - </div> - <div className="flex justify-end py-4"> - <ChatActions - reload={props.reload} - stop={props.stop} - showReload={showReload} - showStop={showStop} - /> - </div> - </div> - ); -} diff --git a/templates/index.ts b/templates/index.ts index fd5377d87115361e4e7c609569928ac4da7686fd..cd675448cbc5b93847d6ea28a449b90826fa8c56 100644 --- a/templates/index.ts +++ b/templates/index.ts @@ -7,7 +7,9 @@ import path from "path"; import { bold, cyan } from "picocolors"; import { version } from "../../core/package.json"; +import { COMMUNITY_OWNER, COMMUNITY_REPO } from "../helpers/constant"; import { PackageManager } from "../helpers/get-pkg-manager"; +import { downloadAndExtractRepo } from "../helpers/repo"; import { InstallTemplateArgs, TemplateEngine, @@ -160,7 +162,7 @@ const installTSTemplate = async ({ /** * Copy the selected UI files to the target directory and reference it. */ - if (framework === "nextjs" && ui !== "html") { + if (framework === "nextjs" && ui !== "shadcn") { console.log("\nUsing UI:", ui, "\n"); const uiPath = path.join(compPath, "ui", ui); const destUiPath = path.join(root, "app", "components", "ui"); @@ -225,26 +227,26 @@ const installTSTemplate = async ({ }; } - if (framework === "nextjs" && ui === "shadcn") { - // add shadcn dependencies to package.json + if (framework === "nextjs" && ui === "html") { + // remove shadcn dependencies if html ui is selected packageJson.dependencies = { ...packageJson.dependencies, - "tailwind-merge": "^2", - "@radix-ui/react-slot": "^1", - "class-variance-authority": "^0.7", - clsx: "^1.2.1", - "lucide-react": "^0.291", - remark: "^14.0.3", - "remark-code-import": "^1.2.0", - "remark-gfm": "^3.0.1", - "remark-math": "^5.1.1", - "react-markdown": "^8.0.7", - "react-syntax-highlighter": "^15.5.0", + "tailwind-merge": undefined, + "@radix-ui/react-slot": undefined, + "class-variance-authority": undefined, + clsx: undefined, + "lucide-react": undefined, + remark: undefined, + "remark-code-import": undefined, + "remark-gfm": undefined, + "remark-math": undefined, + "react-markdown": undefined, + "react-syntax-highlighter": undefined, }; packageJson.devDependencies = { ...packageJson.devDependencies, - "@types/react-syntax-highlighter": "^15.5.6", + "@types/react-syntax-highlighter": undefined, }; } @@ -306,10 +308,29 @@ const installPythonTemplate = async ({ ); }; +const installCommunityProject = async ({ + root, + communityProjectPath, +}: Pick<InstallTemplateArgs, "root" | "communityProjectPath">) => { + console.log("\nInstalling community project:", communityProjectPath!); + await downloadAndExtractRepo(root, { + username: COMMUNITY_OWNER, + name: COMMUNITY_REPO, + branch: "main", + filePath: communityProjectPath!, + }); +}; + export const installTemplate = async ( props: InstallTemplateArgs & { backend: boolean }, ) => { process.chdir(props.root); + + if (props.template === "community" && props.communityProjectPath) { + await installCommunityProject(props); + return; + } + if (props.framework === "fastapi") { await installPythonTemplate(props); } else { diff --git a/templates/types.ts b/templates/types.ts index f6af4de02e21cbd018b2b82cf7473ce54e1caa6e..b6ff2f835fee0efae287077e2a13fdae539fe08f 100644 --- a/templates/types.ts +++ b/templates/types.ts @@ -1,6 +1,6 @@ import { PackageManager } from "../helpers/get-pkg-manager"; -export type TemplateType = "simple" | "streaming"; +export type TemplateType = "simple" | "streaming" | "community"; export type TemplateFramework = "nextjs" | "express" | "fastapi"; export type TemplateEngine = "simple" | "context"; export type TemplateUI = "html" | "shadcn"; @@ -19,4 +19,5 @@ export interface InstallTemplateArgs { openAIKey?: string; forBackend?: string; model: string; + communityProjectPath?: string; } diff --git a/templates/types/streaming/nextjs/app/api/chat/route.ts b/templates/types/streaming/nextjs/app/api/chat/route.ts index eb3ddb0d49ef5de1b88fe1649d6b3c4e90b2f261..e4b5c768547c356c8b1bffed8d0dc5c50c1c156d 100644 --- a/templates/types/streaming/nextjs/app/api/chat/route.ts +++ b/templates/types/streaming/nextjs/app/api/chat/route.ts @@ -44,7 +44,7 @@ export async function POST(request: NextRequest) { const llm = new OpenAI({ model: MODEL, - maxTokens: 4096, + maxTokens: 2048, }); const chatEngine = await createChatEngine(llm); diff --git a/templates/components/ui/shadcn/README-template.md b/templates/types/streaming/nextjs/app/components/ui/README-template.md similarity index 100% rename from templates/components/ui/shadcn/README-template.md rename to templates/types/streaming/nextjs/app/components/ui/README-template.md diff --git a/templates/components/ui/shadcn/button.tsx b/templates/types/streaming/nextjs/app/components/ui/button.tsx similarity index 100% rename from templates/components/ui/shadcn/button.tsx rename to templates/types/streaming/nextjs/app/components/ui/button.tsx diff --git a/templates/components/ui/shadcn/chat/chat-actions.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-actions.tsx similarity index 100% rename from templates/components/ui/shadcn/chat/chat-actions.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/chat-actions.tsx diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx index cd241104e4ef210c728aec47a1ab8b0161ad6538..ce04e306a7164e49e7ea6950a55c4f5cedc2ee2a 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-avatar.tsx @@ -1,26 +1,17 @@ -"use client"; - +import { User2 } from "lucide-react"; import Image from "next/image"; -import { Message } from "./chat-messages"; -export default function ChatAvatar(message: Message) { - if (message.role === "user") { +export default function ChatAvatar({ role }: { role: string }) { + if (role === "user") { return ( - <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border shadow bg-background"> - <svg - xmlns="http://www.w3.org/2000/svg" - viewBox="0 0 256 256" - fill="currentColor" - className="h-4 w-4" - > - <path d="M230.92 212c-15.23-26.33-38.7-45.21-66.09-54.16a72 72 0 1 0-73.66 0c-27.39 8.94-50.86 27.82-66.09 54.16a8 8 0 1 0 13.85 8c18.84-32.56 52.14-52 89.07-52s70.23 19.44 89.07 52a8 8 0 1 0 13.85-8ZM72 96a56 56 0 1 1 56 56 56.06 56.06 0 0 1-56-56Z"></path> - </svg> + <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-background shadow"> + <User2 className="h-4 w-4" /> </div> ); } return ( - <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-black text-white"> + <div className="flex h-8 w-8 shrink-0 select-none items-center justify-center rounded-md border bg-black text-white shadow"> <Image className="rounded-md" src="/llama.png" diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx index 7c3e87280b03ed571e8fc081a38c15a8d36df1ab..435637e5ec94fdb9fe03faa3c3e1791a0be584bb 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-input.tsx @@ -1,43 +1,84 @@ -"use client"; +import { useState } from "react"; +import { Button } from "../button"; +import FileUploader from "../file-uploader"; +import { Input } from "../input"; +import UploadImagePreview from "../upload-image-preview"; +import { ChatHandler } from "./chat.interface"; -export interface ChatInputProps { - /** The current value of the input */ - input?: string; - /** An input/textarea-ready onChange handler to control the value of the input */ - handleInputChange?: ( - e: - | React.ChangeEvent<HTMLInputElement> - | React.ChangeEvent<HTMLTextAreaElement>, - ) => void; - /** Form submission handler to automatically reset input and append a user message */ - handleSubmit: (e: React.FormEvent<HTMLFormElement>) => void; - isLoading: boolean; - multiModal?: boolean; -} +export default function ChatInput( + props: Pick< + ChatHandler, + | "isLoading" + | "input" + | "onFileUpload" + | "onFileError" + | "handleSubmit" + | "handleInputChange" + > & { + multiModal?: boolean; + }, +) { + const [imageUrl, setImageUrl] = useState<string | null>(null); + + const onSubmit = (e: React.FormEvent<HTMLFormElement>) => { + if (imageUrl) { + props.handleSubmit(e, { + data: { imageUrl: imageUrl }, + }); + setImageUrl(null); + return; + } + props.handleSubmit(e); + }; + + const onRemovePreviewImage = () => setImageUrl(null); + + const handleUploadImageFile = async (file: File) => { + const base64 = await new Promise<string>((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(file); + reader.onload = () => resolve(reader.result as string); + reader.onerror = (error) => reject(error); + }); + setImageUrl(base64); + }; + + const handleUploadFile = async (file: File) => { + try { + if (props.multiModal && file.type.startsWith("image/")) { + return await handleUploadImageFile(file); + } + props.onFileUpload?.(file); + } catch (error: any) { + props.onFileError?.(error.message); + } + }; -export default function ChatInput(props: ChatInputProps) { return ( - <> - <form - onSubmit={props.handleSubmit} - className="flex items-start justify-between w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl gap-4" - > - <input + <form + onSubmit={onSubmit} + className="rounded-xl bg-white p-4 shadow-xl space-y-4" + > + {imageUrl && ( + <UploadImagePreview url={imageUrl} onRemove={onRemovePreviewImage} /> + )} + <div className="flex w-full items-start justify-between gap-4 "> + <Input autoFocus name="message" placeholder="Type a message" - className="w-full p-4 rounded-xl shadow-inner flex-1" + className="flex-1" value={props.input} onChange={props.handleInputChange} /> - <button - disabled={props.isLoading} - type="submit" - className="p-4 text-white rounded-xl shadow-xl bg-gradient-to-r from-cyan-500 to-sky-500 disabled:opacity-50 disabled:cursor-not-allowed" - > + <FileUploader + onFileUpload={handleUploadFile} + onFileError={props.onFileError} + /> + <Button type="submit" disabled={props.isLoading}> Send message - </button> - </form> - </> + </Button> + </div> + </form> ); } diff --git a/templates/components/ui/shadcn/chat/chat-message.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-message.tsx similarity index 100% rename from templates/components/ui/shadcn/chat/chat-message.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/chat-message.tsx diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx index 0e978394015bd985af40646e87fa6620e9001a2f..abc3e52d7d047c769a58c3cecd404b86a7922a7d 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx +++ b/templates/types/streaming/nextjs/app/components/ui/chat/chat-messages.tsx @@ -1,26 +1,16 @@ -"use client"; - +import { Loader2 } from "lucide-react"; import { useEffect, useRef } from "react"; -import ChatItem from "./chat-item"; -export interface Message { - id: string; - content: string; - role: string; -} +import ChatActions from "./chat-actions"; +import ChatMessage from "./chat-message"; +import { ChatHandler } from "./chat.interface"; -export default function ChatMessages({ - messages, - isLoading, - reload, - stop, -}: { - messages: Message[]; - isLoading?: boolean; - stop?: () => void; - reload?: () => void; -}) { +export default function ChatMessages( + props: Pick<ChatHandler, "messages" | "isLoading" | "reload" | "stop">, +) { const scrollableChatContainerRef = useRef<HTMLDivElement>(null); + const messageLength = props.messages.length; + const lastMessage = props.messages[messageLength - 1]; const scrollToBottom = () => { if (scrollableChatContainerRef.current) { @@ -29,19 +19,43 @@ export default function ChatMessages({ } }; + const isLastMessageFromAssistant = + messageLength > 0 && lastMessage?.role !== "user"; + const showReload = + props.reload && !props.isLoading && isLastMessageFromAssistant; + const showStop = props.stop && props.isLoading; + + // `isPending` indicate + // that stream response is not yet received from the server, + // so we show a loading indicator to give a better UX. + const isPending = props.isLoading && !isLastMessageFromAssistant; + useEffect(() => { scrollToBottom(); - }, [messages.length]); + }, [messageLength, lastMessage]); return ( - <div className="w-full max-w-5xl p-4 bg-white rounded-xl shadow-xl"> + <div className="w-full rounded-xl bg-white p-4 shadow-xl pb-0"> <div - className="flex flex-col gap-5 divide-y h-[50vh] overflow-auto" + className="flex h-[50vh] flex-col gap-5 divide-y overflow-y-auto pb-4" ref={scrollableChatContainerRef} > - {messages.map((m: Message) => ( - <ChatItem key={m.id} {...m} /> + {props.messages.map((m) => ( + <ChatMessage key={m.id} {...m} /> ))} + {isPending && ( + <div className="flex justify-center items-center pt-10"> + <Loader2 className="h-4 w-4 animate-spin" /> + </div> + )} + </div> + <div className="flex justify-end py-4"> + <ChatActions + reload={props.reload} + stop={props.stop} + showReload={showReload} + showStop={showStop} + /> </div> </div> ); diff --git a/templates/components/ui/shadcn/chat/chat.interface.ts b/templates/types/streaming/nextjs/app/components/ui/chat/chat.interface.ts similarity index 100% rename from templates/components/ui/shadcn/chat/chat.interface.ts rename to templates/types/streaming/nextjs/app/components/ui/chat/chat.interface.ts diff --git a/templates/components/ui/shadcn/chat/codeblock.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/codeblock.tsx similarity index 100% rename from templates/components/ui/shadcn/chat/codeblock.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/codeblock.tsx diff --git a/templates/types/streaming/nextjs/app/components/ui/chat/index.ts b/templates/types/streaming/nextjs/app/components/ui/chat/index.ts index 5de7dce47fc1e2330759171db291eedbca19e722..c7990f9c13fff2f6cc17faf0a87c7d42f5326471 100644 --- a/templates/types/streaming/nextjs/app/components/ui/chat/index.ts +++ b/templates/types/streaming/nextjs/app/components/ui/chat/index.ts @@ -1,6 +1,5 @@ import ChatInput from "./chat-input"; import ChatMessages from "./chat-messages"; -export type { ChatInputProps } from "./chat-input"; -export type { Message } from "./chat-messages"; +export { type ChatHandler, type Message } from "./chat.interface"; export { ChatInput, ChatMessages }; diff --git a/templates/components/ui/shadcn/chat/markdown.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/markdown.tsx similarity index 100% rename from templates/components/ui/shadcn/chat/markdown.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/markdown.tsx diff --git a/templates/components/ui/shadcn/chat/use-copy-to-clipboard.tsx b/templates/types/streaming/nextjs/app/components/ui/chat/use-copy-to-clipboard.tsx similarity index 100% rename from templates/components/ui/shadcn/chat/use-copy-to-clipboard.tsx rename to templates/types/streaming/nextjs/app/components/ui/chat/use-copy-to-clipboard.tsx diff --git a/templates/components/ui/shadcn/file-uploader.tsx b/templates/types/streaming/nextjs/app/components/ui/file-uploader.tsx similarity index 100% rename from templates/components/ui/shadcn/file-uploader.tsx rename to templates/types/streaming/nextjs/app/components/ui/file-uploader.tsx diff --git a/templates/components/ui/shadcn/input.tsx b/templates/types/streaming/nextjs/app/components/ui/input.tsx similarity index 100% rename from templates/components/ui/shadcn/input.tsx rename to templates/types/streaming/nextjs/app/components/ui/input.tsx diff --git a/templates/components/ui/shadcn/lib/utils.ts b/templates/types/streaming/nextjs/app/components/ui/lib/utils.ts similarity index 100% rename from templates/components/ui/shadcn/lib/utils.ts rename to templates/types/streaming/nextjs/app/components/ui/lib/utils.ts diff --git a/templates/components/ui/shadcn/upload-image-preview.tsx b/templates/types/streaming/nextjs/app/components/ui/upload-image-preview.tsx similarity index 100% rename from templates/components/ui/shadcn/upload-image-preview.tsx rename to templates/types/streaming/nextjs/app/components/ui/upload-image-preview.tsx diff --git a/templates/types/streaming/nextjs/package.json b/templates/types/streaming/nextjs/package.json index d4aa4755b53301588e9838b383b7692a955800b6..3160e53c098029b9eb476aa0b54741d1904bdaa1 100644 --- a/templates/types/streaming/nextjs/package.json +++ b/templates/types/streaming/nextjs/package.json @@ -8,22 +8,35 @@ "lint": "next lint" }, "dependencies": { - "ai": "^2.2.25", - "llamaindex": "0.0.31", + "@radix-ui/react-slot": "^1.0.2", + "ai": "^2.2.27", + "class-variance-authority": "^0.7.0", + "clsx": "^1.2.1", "dotenv": "^16.3.1", + "llamaindex": "0.0.37", + "lucide-react": "^0.294.0", "next": "^14.0.3", "react": "^18.2.0", - "react-dom": "^18.2.0" + "react-dom": "^18.2.0", + "react-markdown": "^8.0.7", + "react-syntax-highlighter": "^15.5.0", + "remark": "^14.0.3", + "remark-code-import": "^1.2.0", + "remark-gfm": "^3.0.1", + "remark-math": "^5.1.1", + "supports-color": "^9.4.0", + "tailwind-merge": "^2.1.0" }, "devDependencies": { - "@types/node": "^20.9.5", - "@types/react": "^18.2.38", + "@types/node": "^20.10.3", + "@types/react": "^18.2.42", "@types/react-dom": "^18.2.17", "autoprefixer": "^10.4.16", - "eslint": "^8.54.0", + "eslint": "^8.55.0", "eslint-config-next": "^14.0.3", - "postcss": "^8.4.31", - "tailwindcss": "^3.3.5", - "typescript": "^5.3.2" + "postcss": "^8.4.32", + "tailwindcss": "^3.3.6", + "typescript": "^5.3.2", + "@types/react-syntax-highlighter": "^15.5.11" } } \ No newline at end of file