diff --git a/app/api/openai/[...path]/route.ts b/app/api/openai/[...path]/route.ts index 981749e7e..04f3b6da8 100644 --- a/app/api/openai/[...path]/route.ts +++ b/app/api/openai/[...path]/route.ts @@ -1,14 +1,32 @@ +import { OpenaiPath } from "@/app/constant"; import { prettyObject } from "@/app/utils/format"; import { NextRequest, NextResponse } from "next/server"; import { auth } from "../../auth"; import { requestOpenai } from "../../common"; +const ALLOWD_PATH = new Set(Object.values(OpenaiPath)); + async function handle( req: NextRequest, { params }: { params: { path: string[] } }, ) { console.log("[OpenAI Route] params ", params); + const subpath = params.path.join("/"); + + if (!ALLOWD_PATH.has(subpath)) { + console.log("[OpenAI Route] forbidden path ", subpath); + return NextResponse.json( + { + error: true, + msg: "you are not allowed to request " + subpath, + }, + { + status: 403, + }, + ); + } + const authResult = auth(req); if (authResult.error) { return NextResponse.json(authResult, { diff --git a/app/client/platforms/openai.ts b/app/client/platforms/openai.ts index 84c4a2df0..fd4c33655 100644 --- a/app/client/platforms/openai.ts +++ b/app/client/platforms/openai.ts @@ -1,4 +1,4 @@ -import { REQUEST_TIMEOUT_MS } from "@/app/constant"; +import { OpenaiPath, REQUEST_TIMEOUT_MS } from "@/app/constant"; import { useAccessStore, useAppConfig, useChatStore } from "@/app/store"; import { ChatOptions, getHeaders, LLMApi, LLMUsage } from "../api"; @@ -10,10 +10,6 @@ import { import { prettyObject } from "@/app/utils/format"; export class ChatGPTApi implements LLMApi { - public ChatPath = "v1/chat/completions"; - public UsagePath = "dashboard/billing/usage"; - public SubsPath = "dashboard/billing/subscription"; - path(path: string): string { let openaiUrl = useAccessStore.getState().openaiUrl; if (openaiUrl.endsWith("/")) { @@ -55,7 +51,7 @@ export class ChatGPTApi implements LLMApi { options.onController?.(controller); try { - const chatPath = this.path(this.ChatPath); + const chatPath = this.path(OpenaiPath.ChatPath); const chatPayload = { method: "POST", body: JSON.stringify(requestPayload), @@ -177,14 +173,14 @@ export class ChatGPTApi implements LLMApi { const [used, subs] = await Promise.all([ fetch( this.path( - `${this.UsagePath}?start_date=${startDate}&end_date=${endDate}`, + `${OpenaiPath.UsagePath}?start_date=${startDate}&end_date=${endDate}`, ), { method: "GET", headers: getHeaders(), }, ), - fetch(this.path(this.SubsPath), { + fetch(this.path(OpenaiPath.SubsPath), { method: "GET", headers: getHeaders(), }), @@ -228,3 +224,4 @@ export class ChatGPTApi implements LLMApi { } as LLMUsage; } } +export { OpenaiPath }; diff --git a/app/components/chat.module.scss b/app/components/chat.module.scss index 0e2741e70..644c917a1 100644 --- a/app/components/chat.module.scss +++ b/app/components/chat.module.scss @@ -17,10 +17,38 @@ transition: all ease 0.3s; margin-bottom: 10px; align-items: center; + height: 16px; + width: var(--icon-width); &:not(:last-child) { margin-right: 5px; } + + .text { + white-space: nowrap; + padding-left: 5px; + opacity: 0; + transform: translateX(-5px); + transition: all ease 0.3s; + transition-delay: 0.1s; + pointer-events: none; + } + + &:hover { + width: var(--full-width); + + .text { + opacity: 1; + transform: translate(0); + } + } + + .text, + .icon { + display: flex; + align-items: center; + justify-content: center; + } } } diff --git a/app/components/chat.tsx b/app/components/chat.tsx index 70fd462d9..ffd2b7d29 100644 --- a/app/components/chat.tsx +++ b/app/components/chat.tsx @@ -1,5 +1,5 @@ import { useDebouncedCallback } from "use-debounce"; -import { useState, useRef, useEffect, useLayoutEffect } from "react"; +import React, { useState, useRef, useEffect, useLayoutEffect } from "react"; import SendWhiteIcon from "../icons/send-white.svg"; import BrainIcon from "../icons/brain.svg"; @@ -279,6 +279,57 @@ function ClearContextDivider() { ); } +function ChatAction(props: { + text: string; + icon: JSX.Element; + onClick: () => void; +}) { + const iconRef = useRef(null); + const textRef = useRef(null); + const [width, setWidth] = useState({ + full: 20, + icon: 20, + }); + + function updateWidth() { + if (!iconRef.current || !textRef.current) return; + const getWidth = (dom: HTMLDivElement) => dom.getBoundingClientRect().width; + const textWidth = getWidth(textRef.current); + const iconWidth = getWidth(iconRef.current); + setWidth({ + full: textWidth + iconWidth, + icon: iconWidth, + }); + } + + useEffect(() => { + updateWidth(); + }, []); + + return ( +
{ + props.onClick(); + setTimeout(updateWidth, 1); + }} + style={ + { + "--icon-width": `${width.icon}px`, + "--full-width": `${width.full}px`, + } as React.CSSProperties + } + > +
+ {props.icon} +
+
+ {props.text} +
+
+ ); +} + function useScrollToBottom() { // for auto-scroll const scrollRef = useRef(null); @@ -330,61 +381,60 @@ export function ChatActions(props: { return (
{couldStop && ( -
- -
+ text={Locale.Chat.InputActions.Stop} + icon={} + /> )} {!props.hitBottom && ( -
- -
+ text={Locale.Chat.InputActions.ToBottom} + icon={} + /> )} {props.hitBottom && ( -
- -
+ text={Locale.Chat.InputActions.Settings} + icon={} + /> )} -
- {theme === Theme.Auto ? ( - - ) : theme === Theme.Light ? ( - - ) : theme === Theme.Dark ? ( - - ) : null} -
+ text={Locale.Chat.InputActions.Theme[theme]} + icon={ + <> + {theme === Theme.Auto ? ( + + ) : theme === Theme.Light ? ( + + ) : theme === Theme.Dark ? ( + + ) : null} + + } + /> -
- -
+ text={Locale.Chat.InputActions.Prompt} + icon={} + /> -
{ navigate(Path.Masks); }} - > - -
+ text={Locale.Chat.InputActions.Masks} + icon={} + /> -
} onClick={() => { chatStore.updateCurrentSession((session) => { if (session.clearContextIndex === session.messages.length) { @@ -395,9 +445,7 @@ export function ChatActions(props: { } }); }} - > - -
+ />
); } diff --git a/app/constant.ts b/app/constant.ts index 9cd4ec8f1..5cc60b288 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -45,3 +45,9 @@ export const LAST_INPUT_KEY = "last-input"; export const REQUEST_TIMEOUT_MS = 60000; export const EXPORT_MESSAGE_CLASS_NAME = "export-markdown"; + +export const OpenaiPath = { + ChatPath: "v1/chat/completions", + UsagePath: "dashboard/billing/usage", + SubsPath: "dashboard/billing/subscription", +}; diff --git a/app/locales/cn.ts b/app/locales/cn.ts index c3cd8f457..d33ba101b 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -27,6 +27,19 @@ const cn = { Retry: "重试", Delete: "删除", }, + InputActions: { + Stop: "停止响应", + ToBottom: "滚到最新", + Theme: { + auto: "自动主题", + light: "亮色模式", + dark: "深色模式", + }, + Prompt: "快捷指令", + Masks: "所有面具", + Clear: "清除聊天", + Settings: "对话设置", + }, Rename: "重命名对话", Typing: "正在输入…", Input: (submitKey: string) => { diff --git a/app/locales/en.ts b/app/locales/en.ts index 068b2e583..9c8bc2a79 100644 --- a/app/locales/en.ts +++ b/app/locales/en.ts @@ -28,6 +28,19 @@ const en: RequiredLocaleType = { Retry: "Retry", Delete: "Delete", }, + InputActions: { + Stop: "Stop", + ToBottom: "To Latest", + Theme: { + auto: "Auto", + light: "Light Theme", + dark: "Dark Theme", + }, + Prompt: "Prompts", + Masks: "Masks", + Clear: "Clear Context", + Settings: "Settings", + }, Rename: "Rename Chat", Typing: "Typing…", Input: (submitKey: string) => {