├── .eslintrc.json ├── .github └── workflows │ └── docker.yml ├── .gitignore ├── .prettierignore ├── Dockerfile ├── README.md ├── app ├── api │ ├── chat │ │ ├── route.ts │ │ └── tools.ts │ └── openai.ts ├── apple-icon.png ├── favicon.ico ├── globals.css ├── layout.tsx └── page.tsx ├── components.json ├── components ├── button-icon.tsx ├── chat-body.tsx ├── chat-input.tsx ├── chat-msg.tsx ├── chat-new.tsx ├── chat-tool.tsx ├── chat.tsx ├── dialog │ ├── chat-list.tsx │ └── common-edit.tsx ├── header-topic.tsx ├── header.tsx ├── markdown.tsx ├── mode-toggle.tsx ├── mounted.tsx ├── select-warp.tsx ├── settings │ ├── settings-google.tsx │ ├── settings-input.tsx │ ├── settings-model.tsx │ ├── settings-plugins.tsx │ ├── settings-quick.tsx │ ├── settings-switch.tsx │ ├── settings-temperature.tsx │ └── settings.tsx ├── svg.tsx ├── textarea.tsx ├── tooltip-wrap.tsx ├── ui │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── dropdown-menu.tsx │ ├── input.tsx │ ├── label.tsx │ ├── scroll-area.tsx │ ├── select.tsx │ ├── separator.tsx │ ├── sheet.tsx │ ├── skeleton.tsx │ ├── slider.tsx │ ├── sonner.tsx │ ├── switch.tsx │ └── tooltip.tsx └── umami.tsx ├── lib ├── constants.ts ├── mitt.ts ├── store │ ├── chat.ts │ ├── config-chat.ts │ └── config.ts ├── types.ts └── utils.ts ├── next.config.js ├── package.json ├── pnpm-lock.yaml ├── postcss.config.js ├── prettier.config.js ├── public ├── icon192.png ├── icon512.png ├── manifest.json └── nothing.svg ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["next/core-web-vitals", "prettier"], 3 | "rules": { 4 | "react-hooks/exhaustive-deps": "off" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Images 2 | 3 | on: 4 | workflow_dispatch: 5 | release: 6 | types: [published] 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | - name: Set up QEMU 15 | uses: docker/setup-qemu-action@v3 16 | with: 17 | platforms: amd64,arm64 18 | - name: Set up Docker Buildx 19 | uses: docker/setup-buildx-action@v3 20 | with: 21 | platforms: linux/amd64,linux/arm64 22 | - name: Login to Docker Hub 23 | uses: docker/login-action@v3 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | - name: Docker meta 28 | id: meta 29 | uses: docker/metadata-action@v5 30 | with: 31 | images: sunls24/nextai 32 | tags: | 33 | type=raw,value=latest 34 | type=ref,event=tag 35 | - name: Build and push 36 | uses: docker/build-push-action@v6 37 | with: 38 | context: . 39 | push: true 40 | platforms: linux/amd64,linux/arm64 41 | tags: ${{ steps.meta.outputs.tags }} 42 | labels: ${{ steps.meta.outputs.labels }} 43 | cache-from: type=gha 44 | cache-to: type=gha,mode=max 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | 37 | .idea 38 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | pnpm-lock.yaml 2 | manifest.json -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:20-alpine AS base 2 | 3 | # Install dependencies only when needed 4 | FROM base AS deps 5 | RUN apk add --no-cache libc6-compat 6 | WORKDIR /app 7 | 8 | COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./ 9 | RUN \ 10 | if [ -f yarn.lock ]; then yarn --frozen-lockfile; \ 11 | elif [ -f package-lock.json ]; then npm ci; \ 12 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \ 13 | else echo "Lockfile not found." && exit 1; \ 14 | fi 15 | 16 | # Rebuild the source code only when needed 17 | FROM base AS builder 18 | WORKDIR /app 19 | COPY --from=deps /app/node_modules ./node_modules 20 | COPY . . 21 | 22 | ENV NEXT_TELEMETRY_DISABLED 1 23 | RUN \ 24 | if [ -f yarn.lock ]; then yarn run build; \ 25 | elif [ -f package-lock.json ]; then npm run build; \ 26 | elif [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \ 27 | else echo "Lockfile not found." && exit 1; \ 28 | fi 29 | 30 | # Production image, copy all the files and run next 31 | FROM base AS runner 32 | WORKDIR /app 33 | 34 | ENV NODE_ENV production 35 | ENV NEXT_TELEMETRY_DISABLED 1 36 | 37 | RUN addgroup --system --gid 1001 nodejs 38 | RUN adduser --system --uid 1001 nextjs 39 | 40 | COPY --from=builder /app/public ./public 41 | 42 | RUN mkdir .next 43 | RUN chown nextjs:nodejs .next 44 | 45 | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ 46 | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static 47 | 48 | USER nextjs 49 | 50 | EXPOSE 3000 51 | ENV PORT 3000 52 | ENV HOSTNAME 127.0.0.1 53 | CMD node server.js -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 💬 概述 2 | 3 | 一个简单而优雅的 AI 聊天程序 4 | 5 | 支持 ChatGPT **函数调用:** 6 | 7 | - Google 搜索 (使用[`Programmable Search Engine`](https://programmablesearchengine.google.com/about/)) 8 | 9 | ## ⚙️ 设置 10 | 11 | #### 环境变量 12 | 13 | - `OPENAI_API_KEY`:懂得都懂 14 | - `OPENAI_BASE_URL`: OpenAI 代理或者 OneAPI 地址(可选) 15 | - `GOOGLE_API_KEY`:用于 Google 搜索插件(可选) 16 | - `GOOGLE_ENGINE_ID`:用于 Google 搜索插件(可选) 17 | 18 | ## 🚀 本地运行 19 | 20 | 1. 克隆仓库: 21 | 22 | ```sh 23 | git clone https://github.com/sunls24/nextai 24 | ``` 25 | 26 | 2. 安装依赖项: 27 | 28 | ```bash 29 | pnpm install 30 | ``` 31 | 32 | 3. 本地运行: 33 | 34 | ```bash 35 | # 设置环境变量 OPENAI_API_KEY=sk-xxx 36 | touch .env.local 37 | # 本地运行 38 | pnpm dev 39 | ``` 40 | 41 | ## ☁️ 使用 Vercel 部署 42 | 43 | [![Deploy with Vercel](https://vercel.com/button)](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Fsunls24%2Fnextai&env=OPENAI_API_KEY,OPENAI_BASE_URL,GOOGLE_API_KEY,GOOGLE_ENGINE_ID) 44 | -------------------------------------------------------------------------------- /app/api/chat/route.ts: -------------------------------------------------------------------------------- 1 | import { getOpenAI } from "@/app/api/openai"; 2 | import { streamText } from "ai"; 3 | import { getLocaleTime } from "@/lib/utils"; 4 | import { tools } from "@/app/api/chat/tools"; 5 | import { NextResponse } from "next/server"; 6 | import { ERROR_PREFIX, MODE_TRANSLATE } from "@/lib/constants"; 7 | 8 | const STREAM_INTERVAL = 60; 9 | const MAX_SIZE = 6; 10 | 11 | export async function POST(req: Request) { 12 | const { messages, config } = await req.json(); 13 | 14 | try { 15 | const controller = new AbortController(); 16 | const { fullStream } = streamText({ 17 | abortSignal: controller.signal, 18 | temperature: config.temperature, 19 | model: getOpenAI(config.apiKey).chat(config.model), 20 | system: getSystem(config.systemPrompt), 21 | messages: messages, 22 | maxRetries: 0, 23 | maxSteps: 6, 24 | tools: Object.fromEntries( 25 | Object.entries(tools).filter(([key]) => config.plugins[key] === true), 26 | ), 27 | }); 28 | 29 | let intervalId: any; 30 | const stream = new ReadableStream({ 31 | async start(controller) { 32 | let buffer = ""; 33 | let done = false; 34 | intervalId = setInterval(() => { 35 | if (buffer.length === 0) { 36 | if (done) { 37 | clearInterval(intervalId); 38 | controller.close(); 39 | } 40 | return; 41 | } 42 | if (buffer.length <= MAX_SIZE) { 43 | controller.enqueue(buffer); 44 | buffer = ""; 45 | } else { 46 | const chunk = buffer.slice(0, MAX_SIZE); 47 | buffer = buffer.slice(MAX_SIZE); 48 | controller.enqueue(chunk); 49 | } 50 | }, STREAM_INTERVAL); 51 | 52 | for await (const part of fullStream) { 53 | switch (part.type) { 54 | case "text-delta": { 55 | buffer += part.textDelta; 56 | break; 57 | } 58 | case "error": { 59 | const err = part.error as any; 60 | controller.enqueue( 61 | ERROR_PREFIX + (err.message ?? err.toString()), 62 | ); 63 | break; 64 | } 65 | } 66 | } 67 | done = true; 68 | }, 69 | cancel() { 70 | clearInterval(intervalId); 71 | controller.abort(); 72 | }, 73 | }); 74 | 75 | return new NextResponse(stream, { 76 | headers: { 77 | "Content-Type": "text/plain; charset=utf-8", 78 | }, 79 | }); 80 | } catch (err: any) { 81 | return new NextResponse(err.message ?? err.toString(), { status: 500 }); 82 | } 83 | } 84 | 85 | function getSystem(prompt: any) { 86 | if (prompt === true) { 87 | return systemPrompt(); 88 | } 89 | switch (prompt) { 90 | case MODE_TRANSLATE: 91 | return translatePrompt(); 92 | } 93 | } 94 | 95 | function systemPrompt(): string { 96 | return `You are an AI assistant, your duty is to provide accurate and rigorous answers. When encountering questions that cannot be handled, you need to clearly inform and guide the user to propose new questions. Please reply in Chinese. 97 | Current time: ${getLocaleTime()}`; 98 | } 99 | 100 | function translatePrompt(): string { 101 | return "You are a professional, authentic translation engine. You only return the translated text, please do not explain or understand original text. (Chinese-English bidirectional translation)"; 102 | } 103 | -------------------------------------------------------------------------------- /app/api/chat/tools.ts: -------------------------------------------------------------------------------- 1 | import { CoreTool, tool } from "ai"; 2 | import { z } from "zod"; 3 | 4 | export const tools: Record = { 5 | googleSearch: tool({ 6 | description: "using Google to search the internet", 7 | parameters: z.object({ 8 | keyword: z.string().describe("search keyword"), 9 | }), 10 | execute: async ({ keyword }) => { 11 | console.log(`googleSearch: ${keyword}`); 12 | 13 | const apiKey = process.env.GOOGLE_API_KEY; 14 | const engineId = process.env.GOOGLE_ENGINE_ID; 15 | if (!apiKey || !engineId) { 16 | return "apiKey or engineId is empty"; 17 | } 18 | try { 19 | const res = await fetch( 20 | `https://www.googleapis.com/customsearch/v1?&fields=items(title,link,snippet,pagemap/metatags(og:description))&key=${apiKey}&cx=${engineId}&q=${keyword}`, 21 | ); 22 | return (await res.json()).items; 23 | } catch (err: any) { 24 | return (err.cause ?? err).toString(); 25 | } 26 | }, 27 | }), 28 | }; 29 | -------------------------------------------------------------------------------- /app/api/openai.ts: -------------------------------------------------------------------------------- 1 | import { createOpenAI, OpenAIProvider } from "@ai-sdk/openai"; 2 | 3 | const baseURL = process.env.OPENAI_BASE_URL; 4 | const OPENAI_API_KEY = process.env.OPENAI_API_KEY ?? ""; 5 | const clientPool: Map = new Map(); 6 | 7 | export function getOpenAI(apiKey: string): OpenAIProvider { 8 | apiKey = apiKey || OPENAI_API_KEY; 9 | if (!clientPool.has(apiKey)) { 10 | clientPool.set(apiKey, createOpenAI({ apiKey, baseURL })); 11 | } 12 | return clientPool.get(apiKey)!; 13 | } 14 | -------------------------------------------------------------------------------- /app/apple-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunls24/nextai/ee58eed13fa182741ad304af1ae89b1782d44a95/app/apple-icon.png -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sunls24/nextai/ee58eed13fa182741ad304af1ae89b1782d44a95/app/favicon.ico -------------------------------------------------------------------------------- /app/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | :root { 7 | --background: 0 0% 100%; 8 | --foreground: 240 4% 16%; 9 | --card: 0 0% 100%; 10 | --card-foreground: 240 4% 16%; 11 | --popover: 0 0% 100%; 12 | --popover-foreground: 240 4% 16%; 13 | --primary: 240 4% 16%; 14 | --primary-foreground: 0 0% 98%; 15 | --secondary: 240 4.8% 95.9%; 16 | --secondary-foreground: 240 4% 16%; 17 | --muted: 240 4.8% 95.9%; 18 | --muted-foreground: 240 3.8% 46.1%; 19 | --accent: 240 4.8% 95.9%; 20 | --accent-foreground: 240 4% 16%; 21 | --destructive: 0 84.2% 60.2%; 22 | --destructive-foreground: 0 0% 98%; 23 | --border: 240 5.9% 90%; 24 | --input: 240 4.8% 95.9%; 25 | --ring: 240 5% 34%; 26 | --radius: 0.5rem; 27 | } 28 | 29 | .dark { 30 | --background: 240 6% 10%; 31 | --foreground: 0 0% 98%; 32 | --card: 240 6% 10%; 33 | --card-foreground: 0 0% 98%; 34 | --popover: 240 6% 10%; 35 | --popover-foreground: 0 0% 98%; 36 | --primary: 0 0% 98%; 37 | --primary-foreground: 240 4% 16%; 38 | --secondary: 240 3.7% 15.9%; 39 | --secondary-foreground: 0 0% 98%; 40 | --muted: 240 3.7% 15.9%; 41 | --muted-foreground: 240 5% 64.9%; 42 | --accent: 240 5% 26%; 43 | --accent-foreground: 0 0% 98%; 44 | --destructive: 0 62.8% 30.6%; 45 | --destructive-foreground: 0 0% 98%; 46 | --border: 240 5% 34%; 47 | --input: 240 3.7% 15.9%; 48 | --ring: 240 4.9% 83.9%; 49 | } 50 | } 51 | 52 | @layer base { 53 | * { 54 | @apply border-border; 55 | } 56 | 57 | body { 58 | @apply bg-background text-sm text-foreground; 59 | } 60 | } 61 | 62 | html, 63 | body { 64 | @apply h-full; 65 | } 66 | 67 | body { 68 | padding-bottom: max(env(safe-area-inset-bottom), 13px); 69 | } 70 | 71 | .hover-trigger:hover .hover-show { 72 | @apply scale-100 opacity-100; 73 | } 74 | 75 | .hover-trigger:hover .hover-hidden { 76 | @apply scale-90 opacity-0; 77 | } 78 | 79 | .hover-show { 80 | @apply scale-90 opacity-0 transition duration-300; 81 | } 82 | 83 | .hover-hidden { 84 | @apply scale-100 opacity-100 transition duration-300; 85 | } 86 | 87 | .input { 88 | @apply rounded-md bg-input p-2 outline-2 outline-offset-2 outline-ring focus:outline; 89 | } 90 | 91 | ::-webkit-scrollbar { 92 | --bar-width: 5px; 93 | width: var(--bar-width); 94 | height: var(--bar-width); 95 | } 96 | 97 | ::-webkit-scrollbar-thumb { 98 | @apply rounded-md bg-border; 99 | } 100 | -------------------------------------------------------------------------------- /app/layout.tsx: -------------------------------------------------------------------------------- 1 | import "./globals.css"; 2 | import type { Metadata, Viewport } from "next"; 3 | import React from "react"; 4 | import { Toaster } from "@/components/ui/sonner"; 5 | import Umami from "@/components/umami"; 6 | import { ThemeProvider } from "next-themes"; 7 | 8 | export const metadata: Metadata = { 9 | title: { 10 | default: "NEXT AI", 11 | template: "%s - NEXT AI", 12 | }, 13 | description: 14 | "一个简单而优雅的 AI 聊天程序,支持 Google 搜索工具调用, GPT-3.5-Turbo, GPT4, Tools, Image input", 15 | appleWebApp: { 16 | title: "NEXT AI", 17 | }, 18 | }; 19 | 20 | export const viewport: Viewport = { 21 | viewportFit: "cover", 22 | width: "device-width", 23 | initialScale: 1, 24 | maximumScale: 1, 25 | themeColor: [ 26 | { media: "(prefers-color-scheme: light)", color: "#fff" }, 27 | { media: "(prefers-color-scheme: dark)", color: "#18181b" }, 28 | ], 29 | }; 30 | 31 | export default function RootLayout({ 32 | children, 33 | }: { 34 | children: React.ReactNode; 35 | }) { 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | 48 | {children} 49 | 50 | 51 | 52 | 53 | 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /app/page.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Card } from "@/components/ui/card"; 3 | import { Separator } from "@/components/ui/separator"; 4 | import Header from "@/components/header"; 5 | import Chat from "@/components/chat"; 6 | import SettingsQuick from "@/components/settings/settings-quick"; 7 | 8 | export default function Page() { 9 | return ( 10 |
11 | 12 |
13 | 14 | 15 | 16 | 17 | 18 |
19 | ); 20 | } 21 | -------------------------------------------------------------------------------- /components.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://ui.shadcn.com/schema.json", 3 | "style": "new-york", 4 | "rsc": true, 5 | "tsx": true, 6 | "tailwind": { 7 | "config": "tailwind.config.ts", 8 | "css": "app/globals.css", 9 | "baseColor": "zinc", 10 | "cssVariables": true 11 | }, 12 | "aliases": { 13 | "components": "@/components", 14 | "utils": "@/lib/utils" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /components/button-icon.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | import React, { ReactElement } from "react"; 3 | import { GITHUB_URL, VERSION } from "@/lib/constants"; 4 | import { Button } from "@/components/ui/button"; 5 | import { GithubIcon } from "lucide-react"; 6 | import { clsx } from "clsx"; 7 | 8 | function ButtonIcon({ 9 | url, 10 | text, 11 | icon, 12 | textClass, 13 | }: { 14 | url: string; 15 | text: string; 16 | icon: ReactElement; 17 | textClass?: string; 18 | }) { 19 | return ( 20 | 35 | ); 36 | } 37 | 38 | export default ButtonIcon; 39 | 40 | export function GithubButton() { 41 | return ( 42 | } 46 | /> 47 | ); 48 | } 49 | -------------------------------------------------------------------------------- /components/chat-body.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import ChatMsg from "@/components/chat-msg"; 3 | import { Message } from "ai"; 4 | import { emitter, mittKey } from "@/lib/mitt"; 5 | import { ScrollArea } from "@/components/ui/scroll-area"; 6 | import { Loader } from "lucide-react"; 7 | import Mounted from "@/components/mounted"; 8 | 9 | function ChatBody({ 10 | isLoading, 11 | messages, 12 | reload, 13 | deleteMsg, 14 | editMsg, 15 | }: { 16 | isLoading: boolean; 17 | messages: Message[]; 18 | reload: () => void; 19 | deleteMsg: (index: number) => void; 20 | editMsg: (index: number, content: string) => void; 21 | }) { 22 | const scrollRef = useRef(null); 23 | const [autoScroll, setAutoScroll] = useState(false); 24 | 25 | useEffect(() => { 26 | (scrollRef.current!.firstElementChild as HTMLDivElement).style.display = 27 | "block"; 28 | 29 | emitter.on(mittKey.SCROLL, scrollTo); 30 | return () => emitter.off(mittKey.SCROLL, scrollTo); 31 | }, []); 32 | 33 | useEffect(() => { 34 | setAutoScroll(isLoading); 35 | }, [isLoading]); 36 | 37 | useEffect(() => { 38 | if (!autoScroll) { 39 | return; 40 | } 41 | scrollTo(); 42 | }); 43 | 44 | function scrollTo() { 45 | requestAnimationFrame(() => { 46 | if ( 47 | !scrollRef.current || 48 | scrollRef.current.scrollHeight === 49 | scrollRef.current.clientHeight + scrollRef.current.scrollTop 50 | ) { 51 | return; 52 | } 53 | scrollRef.current.scrollTo(0, scrollRef.current.scrollHeight); 54 | }); 55 | } 56 | 57 | function onScroll(e: HTMLDivElement) { 58 | if (!isLoading) { 59 | return; 60 | } 61 | const hitBottom = e.scrollTop + e.clientHeight >= e.scrollHeight - 30; 62 | if (hitBottom === autoScroll) { 63 | return; 64 | } 65 | setAutoScroll(hitBottom); 66 | } 67 | 68 | return ( 69 | onScroll(e.currentTarget)} 74 | > 75 | 76 | {messages.map((value, index) => { 77 | return ( 78 |
79 | 97 |
98 | ); 99 | })} 100 |
101 | {isLoading && messages[messages.length - 1]?.role === "user" && ( 102 | 103 | )} 104 |
105 | ); 106 | } 107 | 108 | export default ChatBody; 109 | -------------------------------------------------------------------------------- /components/chat-input.tsx: -------------------------------------------------------------------------------- 1 | import React, { ChangeEventHandler, useState } from "react"; 2 | import Textarea from "@/components/textarea"; 3 | import { Button } from "@/components/ui/button"; 4 | import { 5 | Languages, 6 | ListRestart, 7 | PauseCircle, 8 | RefreshCcw, 9 | SendHorizontal, 10 | } from "lucide-react"; 11 | import { cn } from "@/lib/utils"; 12 | import { useChatStore } from "@/lib/store/chat"; 13 | import { toast } from "sonner"; 14 | import { Separator } from "@/components/ui/separator"; 15 | import { useConfig } from "@/lib/store/config-chat"; 16 | import { clsx } from "clsx"; 17 | import { MODE_TRANSLATE } from "@/lib/constants"; 18 | 19 | function ChatInput({ 20 | isLoading, 21 | input, 22 | setInput, 23 | handleInputChange, 24 | handleSubmit, 25 | stop, 26 | }: { 27 | input: string; 28 | isLoading: boolean; 29 | setInput: React.Dispatch>; 30 | handleInputChange: ChangeEventHandler; 31 | handleSubmit: ChangeEventHandler; 32 | stop: () => void; 33 | }) { 34 | const resetSession = useChatStore((state) => state.resetSession); 35 | const [mode, update] = useConfig((state) => [state.mode, state.update]); 36 | 37 | const [lastInput, setLastInput] = useState(); 38 | 39 | function onKeyDown(e: React.KeyboardEvent) { 40 | if ( 41 | !e.nativeEvent.isComposing && 42 | e.key === "ArrowUp" && 43 | !input && 44 | lastInput 45 | ) { 46 | e.preventDefault(); 47 | setInput(lastInput); 48 | return; 49 | } 50 | if (e.key !== "Enter" || e.nativeEvent.isComposing || e.shiftKey) { 51 | return; 52 | } 53 | e.preventDefault(); 54 | if (isLoading) { 55 | return; 56 | } 57 | e.currentTarget.form?.requestSubmit(); 58 | input && setLastInput(input); 59 | } 60 | 61 | function onTranslateMode() { 62 | update((cfg) => (cfg.mode = mode ? "" : MODE_TRANSLATE)); 63 | } 64 | 65 | return ( 66 |
67 |
68 | 82 | 83 | {!isLoading && ( 84 | 97 | )} 98 | {isLoading && ( 99 | 108 | )} 109 |
110 |