diff --git a/.env.example b/.env.example index 98558dd..d799440 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,6 @@ +# MD5 形式站点访问密码,留空则为公开站点 +# 默认值:<空> +REACT_APP_PASSCODE_MD5= # 站点标题,会显示在浏览器标签页上 # 默认值:ChatGemini REACT_APP_TITLE_SITE= diff --git a/README.md b/README.md index e5f13fd..15e796e 100644 --- a/README.md +++ b/README.md @@ -97,13 +97,14 @@ REACT_APP_GEMINI_API_KEY="您的密钥" 各配置项说明如下: -| 配置项 | 必填 | 可选值 | 默认值 | 说明 | -| :------------------------: | :--- | :-------------- | :----------- | :--------------------------------------- | -| `REACT_APP_GEMINI_API_KEY` | 是 | `string` | 空 | 填入申请得到的 Gemini API 密钥 | -| `REACT_APP_GEMINI_API_URL` | 否 | `string` | 空 | 自定义 Gemini API 地址,具体参考下方说明 | -| `REACT_APP_GEMINI_API_SSE` | 否 | `true`\|`false` | `true` | 是否逐字输出 Gemini 回应,即是否使能 SSE | -| `REACT_APP_TITLE_SITE` | 否 | `string` | `ChatGemini` | 站点标题,将显示在浏览器标签页上 | -| `REACT_APP_TITLE_HEADER` | 否 | `string` | `Gemini Pro` | 应用名称,显示在应用菜单栏和头部 | +| 配置项 | 必填 | 可选值 | 默认值 | 说明 | 备注 | +| :------------------------: | :--- | :------------------- | :----------- | :--------------------------------------- | :----------------------------------------- | +| `REACT_APP_GEMINI_API_KEY` | 是 | `string`\|`string[]` | 空 | 填入 Gemini API 密钥,多个以 `\|` 分隔 | 存在多个密钥时,每次应用加载时随机选用一个 | +| `REACT_APP_GEMINI_API_URL` | 否 | `string` | 空 | 自定义 Gemini API 地址,具体参考下方说明 | 无 | +| `REACT_APP_GEMINI_API_SSE` | 否 | `true`\|`false` | `true` | 是否逐字输出 Gemini 回应,即是否使能 SSE | 无 | +| `REACT_APP_TITLE_SITE` | 否 | `string` | `ChatGemini` | 站点标题,将显示在浏览器标签页上 | 无 | +| `REACT_APP_TITLE_HEADER` | 否 | `string` | `Gemini Pro` | 应用标题,显示在应用侧边栏和头部 | 无 | +| `REACT_APP_PASSCODE_MD5` | 否 | `string`\|`string[]` | 空 | MD5 格式通行码,多个以 `\|` 分隔 | 存在多个通行码时,任意一个通过验证即可登入 | ### 直连 Gemini API @@ -149,6 +150,20 @@ REACT_APP_GEMINI_API_URL="https://example.org/gemini.php?token=Nt6PRcQ2BZ8FY9y7L *若反代同网站位于相同基础路径下,也可简写为 `/gemini.php?token=Nt6PRcQ2BZ8FY9y7Lnk35S&path=`,跨域则须填写完整地址。* +### 站点通行码 + +启用通行码后,用户在每次访问应用时,需要先输入通行码,才能开始使用应用。 + +若要为您的站点启用通行码,可以在 `.env` 中的 `REACT_APP_PASSCODE_MD5` 字段填入 MD5 格式的通行码,多个以 `|` 分隔,例如: + +```bash +REACT_APP_PASSCODE_MD5="E10ADC3949BA59ABBE56E057F20F883E|C33367701511B4F6020EC61DED352059" +``` + +要生成 MD5 格式的通行码,可以使用相关在线工具,例如 [MD5 Hash Generator](https://passwordsgenerator.net/md5-hash-generator/)。 + +**注意:本应用通行码为无盐值 MD5 格式,有一定概率被破解,因此请勿将您的重要密码作为通行码。** + ## 开源许可 本项目基于 MIT 协议开源,具体请参阅 [LICENSE](https://github.com/bclswl0827/ChatGemini/blob/master/LICENSE) diff --git a/package-lock.json b/package-lock.json index 5516fa5..4c83a41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@google/generative-ai": "^0.1.3", "@reduxjs/toolkit": "^2.1.0", "@tailwindcss/typography": "^0.5.10", + "crypto-js": "^4.2.0", "file-saver": "^2.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -33,6 +34,7 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/crypto-js": "^4.2.2", "@types/file-saver": "^2.0.7", "@types/node": "^16.18.76", "@types/react": "^18.2.48", @@ -3682,6 +3684,12 @@ "@types/node": "*" } }, + "node_modules/@types/crypto-js": { + "version": "4.2.2", + "resolved": "https://registry.npmmirror.com/@types/crypto-js/-/crypto-js-4.2.2.tgz", + "integrity": "sha512-sDOLlVbHhXpAUAL0YHDUUwDZf3iN4Bwi4W6a0W0b+QcAezUbRtH4FVb+9J4h+XFPW7l/gQ9F8qC7P+Ec4k8QVQ==", + "dev": true + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmmirror.com/@types/debug/-/debug-4.1.12.tgz", @@ -5713,6 +5721,11 @@ "node": ">= 8" } }, + "node_modules/crypto-js": { + "version": "4.2.0", + "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz", + "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==" + }, "node_modules/crypto-random-string": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz", diff --git a/package.json b/package.json index be9ef73..328e8b6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "chatgemini", - "version": "0.1.0", + "version": "0.2.0", "homepage": ".", "private": true, "dependencies": { @@ -9,6 +9,7 @@ "@google/generative-ai": "^0.1.3", "@reduxjs/toolkit": "^2.1.0", "@tailwindcss/typography": "^0.5.10", + "crypto-js": "^4.2.0", "file-saver": "^2.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -52,6 +53,7 @@ }, "devDependencies": { "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@types/crypto-js": "^4.2.2", "@types/file-saver": "^2.0.7", "@types/node": "^16.18.76", "@types/react": "^18.2.48", diff --git a/src/App.tsx b/src/App.tsx index e0f52d4..716c5dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,4 +1,4 @@ -import { useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { globalConfig } from "./config/global"; import { Sidebar } from "./components/Sidebar"; import { Container } from "./components/Container"; @@ -21,13 +21,16 @@ import { getBase64Img } from "./helpers/getBase64Img"; import { sendUserAlert } from "./helpers/sendUserAlert"; import { sendUserConfirm } from "./helpers/sendUserConfirm"; import { PageScroller } from "./components/PageScroller"; +import { LoginForm } from "./components/LoginForm"; +import siteLogo from "./assets/logo.svg"; +import setLocalStorage from "./helpers/setLocalStorage"; const ModelPlaceholder = "正在思考中..."; const App = () => { - const { routes } = routerConfig; - const { sse, title } = globalConfig; + const { sse, title, passcodes } = globalConfig; const { header, site } = title; + const { routes } = routerConfig; const navigate = useNavigate(); const dispatch = useDispatch(); @@ -37,6 +40,7 @@ const App = () => { const ai = useSelector((state: ReduxStoreProps) => state.ai.ai); const mainSectionRef = useRef(null); + const [hasLogined, setHasLogined] = useState(false); const [uploadInlineData, setUploadInlineData] = useState({ data: "", mimeType: "" }); const [sidebarExpand, setSidebarExpand] = useState(window.innerWidth > 768); @@ -50,7 +54,7 @@ const App = () => { Intl.DateTimeFormat().resolvedOptions().timeZone }\n- 对话时间 ${sessionTime}\n- 导出时间 ${exportTime}\n\n---\n\n`; session.forEach(({ role, parts, timestamp, attachment }) => { - if (attachment?.data.length) { + if (!!attachment?.data.length) { const { data, mimeType } = attachment; const base64ImgData = `data:${mimeType};base64,${data}`; parts += `\n\n图片附件`; @@ -84,6 +88,14 @@ const App = () => { }); }; + const handleLogout = () => { + sendUserConfirm("登出后对话记录仍会保留,确定要登出吗?", () => { + sendUserAlert("已退出登入"); + setHasLogined(false); + setLocalStorage("passcode", "", false); + }); + }; + const handleUpload = async (file: File | null) => { if (file) { const base64EncodedData = await getBase64Img(file); @@ -183,38 +195,66 @@ const App = () => { setUploadInlineData({ data: "", mimeType: "" }); }; + useEffect(() => { + if (!hasLogined && passcodes.length) { + document.title = `登入 - ${site}`; + } + console.log(passcodes); + }, [hasLogined, passcodes, site]); + return ( - - - -
setSidebarExpand((state) => !state)} - /> - } /> - + {hasLogined || !passcodes.length ? ( + <> + + +
+ setSidebarExpand((state) => !state) + } + onLogout={handleLogout} + /> + } /> + + {" "} + + + ) : ( + setHasLogined(true)} /> - - + )} ); }; diff --git a/src/assets/icons/right-from-bracket-solid.svg b/src/assets/icons/right-from-bracket-solid.svg new file mode 100644 index 0000000..555475c --- /dev/null +++ b/src/assets/icons/right-from-bracket-solid.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icon.svg b/src/assets/logo.svg similarity index 100% rename from public/icon.svg rename to src/assets/logo.svg diff --git a/src/components/Container.tsx b/src/components/Container.tsx index e26f024..c61a574 100644 --- a/src/components/Container.tsx +++ b/src/components/Container.tsx @@ -14,7 +14,7 @@ export const Container = forwardRef(
0 + !!(className || "").length ? className : `h-screen w-full grid md:grid-cols-[15rem_1fr] grid-cols-[12rem_1fr]` } diff --git a/src/components/Header.tsx b/src/components/Header.tsx index b7dc784..4614787 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -1,17 +1,20 @@ import menuIcon from "../assets/icons/bars-staggered-solid.svg"; import newChatIcon from "../assets/icons/square-plus-regular.svg"; import purgeIcon from "../assets/icons/broom-ball-solid.svg"; +import logoutIcon from "../assets/icons/right-from-bracket-solid.svg"; import { Link } from "react-router-dom"; interface HeaderProps { readonly title?: string; readonly newChatUrl: string; + readonly onLogout: () => void; readonly onToggleSidebar: () => void; - readonly onPurgeSessions?: () => void; + readonly onPurgeSessions: () => void; } export const Header = (props: HeaderProps) => { - const { title, newChatUrl, onToggleSidebar, onPurgeSessions } = props; + const { title, newChatUrl, onLogout, onToggleSidebar, onPurgeSessions } = + props; return (
+
); diff --git a/src/components/InputArea.tsx b/src/components/InputArea.tsx index 4736b65..1f2aeec 100644 --- a/src/components/InputArea.tsx +++ b/src/components/InputArea.tsx @@ -86,7 +86,7 @@ export const InputArea = (props: InputAreaProps) => { - {attachmentName.length > 0 && ( + {!!attachmentName.length && (
void; +} + +export const LoginForm = (props: LoginFormProps) => { + const { logo, title, passcodes, onPasscodeCorrect } = props; + + const passcodeInputRef = useRef(null); + const autoLoginCheckboxRef = useRef(null); + + const handleLogin = async () => { + const { current } = passcodeInputRef; + if (current) { + const { value } = current; + if (!value.length) { + sendUserAlert("通行码不能为空", true); + return; + } + + const passcode = MD5(value).toString().toLocaleLowerCase(); + if (passcodes.includes(passcode)) { + const { checked: remember } = + autoLoginCheckboxRef.current || {}; + if (remember) { + setLocalStorage("passcode", passcode, false); + } + sendUserAlert("登入成功,即将跳转"); + await new Promise((resolve) => setTimeout(resolve, 500)); + onPasscodeCorrect(); + } else { + sendUserAlert("通行码错误", true); + } + } + }; + + useEffect(() => { + const passcode = getLocalStorage("passcode", "", false); + if (passcode && passcodes.includes(passcode)) { + onPasscodeCorrect(); + } else { + setLocalStorage("passcode", "", false); + } + }, [passcodes, onPasscodeCorrect]); + + return ( + <> +
+ + + {title} + +
+
+
+

+ 输入通行码以继续 +

+

+ 您在访问一个受保护的站点,请输入通行码以继续。 +

+
+ + +
+
+ + +
+ +
+
+ + ); +}; diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index d0dbcc7..1e03238 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -99,9 +99,9 @@ export const Sidebar = (props: SidebarProps) => { + 新聊天
- {Object.keys(sessions).length > 0 ? ( + {!!Object.keys(sessions).length ? (
- {Object.keys(sessionsCategory.today).length > 0 && ( + {!!Object.keys(sessionsCategory.today).length && (

今天

)} {Object.keys(sessionsCategory.today).map( @@ -138,7 +138,7 @@ export const Sidebar = (props: SidebarProps) => {
) )} - {Object.keys(sessionsCategory.yesterday).length > 0 && ( + {!!Object.keys(sessionsCategory.yesterday).length && (

昨天

)} {Object.keys(sessionsCategory.yesterday).map( @@ -178,7 +178,7 @@ export const Sidebar = (props: SidebarProps) => { ) )} - {Object.keys(sessionsCategory.earlier).length > 0 && ( + {!!Object.keys(sessionsCategory.earlier).length && (

更早

)} {Object.keys(sessionsCategory.earlier) diff --git a/src/config/global.tsx b/src/config/global.tsx index c824d9e..73154fd 100644 --- a/src/config/global.tsx +++ b/src/config/global.tsx @@ -1,4 +1,5 @@ const { + REACT_APP_PASSCODE_MD5, REACT_APP_TITLE_SITE, REACT_APP_TITLE_HEADER, REACT_APP_GEMINI_API_KEY, @@ -6,18 +7,26 @@ const { REACT_APP_GEMINI_API_SSE, } = process.env; +const keys = REACT_APP_GEMINI_API_KEY + ? REACT_APP_GEMINI_API_KEY.split("|").map((v) => v.trim()) + : [""]; +const passcodes = REACT_APP_PASSCODE_MD5 + ? REACT_APP_PASSCODE_MD5?.split("|").map((v) => + v.trim().toLocaleLowerCase() + ) + : []; + export const globalConfig = { + passcodes, + keys, title: { - site: - REACT_APP_TITLE_SITE && REACT_APP_TITLE_SITE.length > 0 - ? REACT_APP_TITLE_SITE! - : "ChatGemini", - header: - REACT_APP_TITLE_HEADER && REACT_APP_TITLE_HEADER.length > 0 - ? REACT_APP_TITLE_HEADER! - : "Gemini Pro", + site: !!REACT_APP_TITLE_SITE?.length + ? REACT_APP_TITLE_SITE + : "ChatGemini", + header: !!REACT_APP_TITLE_HEADER?.length + ? REACT_APP_TITLE_HEADER + : "Gemini Pro", }, - key: REACT_APP_GEMINI_API_KEY, api: REACT_APP_GEMINI_API_URL, sse: REACT_APP_GEMINI_API_SSE === "false" ? false : true, }; diff --git a/src/helpers/getAiChats.tsx b/src/helpers/getAiChats.tsx index 33ae07e..7f48524 100644 --- a/src/helpers/getAiChats.tsx +++ b/src/helpers/getAiChats.tsx @@ -11,7 +11,7 @@ export const getAiChats = async ( ) => { try { const payload = history.map((item) => { - const { timestamp, ...rest } = item; + const { timestamp, attachment, ...rest } = item; return rest; }); diff --git a/src/helpers/getLocalStorage.tsx b/src/helpers/getLocalStorage.tsx new file mode 100644 index 0000000..2beb6af --- /dev/null +++ b/src/helpers/getLocalStorage.tsx @@ -0,0 +1,22 @@ +const getLocalStorage = (key: string, fallback: T, json: boolean): T => { + try { + const storedItem = localStorage.getItem(key); + if (storedItem !== null) { + if (!json) { + return storedItem as T; + } + + return JSON.parse(storedItem); + } + } catch (err) { + localStorage.setItem(key, ""); + } + + localStorage.setItem( + key, + json ? JSON.stringify(fallback) : (fallback as string) + ); + return fallback; +}; + +export default getLocalStorage; diff --git a/src/helpers/getRandomArr.tsx b/src/helpers/getRandomArr.tsx index 9c01452..08274e1 100644 --- a/src/helpers/getRandomArr.tsx +++ b/src/helpers/getRandomArr.tsx @@ -1,4 +1,8 @@ export const getRandomArr = (arr: T[], length: number) => { + if (length > arr.length) { + return arr; + } + const result: T[] = []; for (var i = 0; i < length; i++) { var ran = Math.floor(Math.random() * (arr.length - i)); diff --git a/src/helpers/sendUserConfirm.tsx b/src/helpers/sendUserConfirm.tsx index 1af26d7..b990233 100644 --- a/src/helpers/sendUserConfirm.tsx +++ b/src/helpers/sendUserConfirm.tsx @@ -9,7 +9,7 @@ export const sendUserConfirm = ( toast.custom( ({ visible, id }) => (
diff --git a/src/helpers/setLocalStorage.tsx b/src/helpers/setLocalStorage.tsx new file mode 100644 index 0000000..465753a --- /dev/null +++ b/src/helpers/setLocalStorage.tsx @@ -0,0 +1,12 @@ +const setLocalStorage = (key: string, value: T, json: boolean): void => { + if (value) { + localStorage.setItem( + key, + json ? JSON.stringify(value) : (value as string) + ); + } else { + localStorage.removeItem(key); + } +}; + +export default setLocalStorage; diff --git a/src/store/ai.tsx b/src/store/ai.tsx index da6f601..462c553 100644 --- a/src/store/ai.tsx +++ b/src/store/ai.tsx @@ -4,6 +4,7 @@ import { createAiObj } from "../helpers/createAiObj"; import { globalConfig } from "../config/global"; import { AiType, getAiModel } from "../helpers/getAiModel"; import { modelConfig } from "../config/model"; +import { getRandomArr } from "../helpers/getRandomArr"; export interface AI { readonly busy: boolean; @@ -11,8 +12,10 @@ export interface AI { readonly model: Record; } -const { key, api } = globalConfig; -const obj = createAiObj(key ?? "", api); +const { keys, api } = globalConfig; +const [key] = getRandomArr(keys, 1); + +const obj = createAiObj(key, api); const model = { pro: getAiModel(obj, "pro", modelConfig), vision: getAiModel(obj, "vision", modelConfig), diff --git a/src/views/Chat.tsx b/src/views/Chat.tsx index 8401ce3..992eff4 100644 --- a/src/views/Chat.tsx +++ b/src/views/Chat.tsx @@ -51,7 +51,7 @@ const Chat = () => { [ai] ); - const handleDOMNodeInserted = useCallback( + const handleDOMNodeChanged = useCallback( () => scrollToBottom(), [scrollToBottom] ); @@ -135,7 +135,7 @@ const Chat = () => { !ai.busy && id && id in sessions && - prompt.length > 0 && + !!prompt.length && state === SessionEditState.Done ) { const _sessions = { @@ -182,8 +182,8 @@ const Chat = () => { if (id && id in sessions) { setChat(sessions[id]); let sessionTitle = sessions[id][0].parts; - if (sessionTitle.length > 25) { - sessionTitle = `${sessionTitle.substring(0, 25)} ...`; + if (sessionTitle.length > 20) { + sessionTitle = `${sessionTitle.substring(0, 20)} ...`; } document.title = `${sessionTitle} | ${siteTitle}`; scrollToBottom(true); @@ -194,19 +194,25 @@ const Chat = () => { ]); } const { current } = sessionRef; - current?.addEventListener("DOMNodeInserted", handleDOMNodeInserted); - return () => + current?.addEventListener("DOMNodeInserted", handleDOMNodeChanged); + current?.addEventListener("DOMNodeRemoved", handleDOMNodeChanged); + return () => { current?.removeEventListener( "DOMNodeInserted", - handleDOMNodeInserted + handleDOMNodeChanged ); + current?.removeEventListener( + "DOMNodeRemoved", + handleDOMNodeChanged + ); + }; }, [ siteTitle, id, sessions, sessionRef, scrollToBottom, - handleDOMNodeInserted, + handleDOMNodeChanged, ]); return ( @@ -220,7 +226,7 @@ const Chat = () => { mimeType: "", data: "", }; - const base64BlobURL = data.length + const base64BlobURL = !!data.length ? getBase64BlobUrl(`data:${mimeType};base64,${data}`) : ""; const attachmentHtml = `
@@ -256,7 +262,7 @@ const Chat = () => { onEdit={handleEdit} > {`${parts}${ - data.length + !!data.length ? `\n\n---\n\n${attachmentHtml}` : "" }`} diff --git a/src/views/Home.tsx b/src/views/Home.tsx index 81c140d..b4deff0 100644 --- a/src/views/Home.tsx +++ b/src/views/Home.tsx @@ -21,7 +21,7 @@ const Home = () => { useEffect(() => { document.title = `新对话 | ${siteTitle}`; - }); + }, [siteTitle]); return (