From dbd4708b58429eddbdc59c85a4e060828a8fbadd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BC=A8=E7=BC=A8?= Date: Tue, 26 Nov 2024 16:37:36 +0800 Subject: [PATCH] feat: add the user agreement model (#516) * chore: release @petercatai/assistant@1.0.16 * feat: add the agreement model * feat: add the agreement model * feat: add the accept agreement api * style: tweak the agreement * chore: fix ci --- assistant/package.json | 2 +- assistant/src/Chat/index.tsx | 2 +- assistant/src/style.css | 4 + client/.kiwi/en/app.ts | 3 +- client/.kiwi/ja/app.ts | 3 +- client/.kiwi/ko/app.ts | 3 +- client/.kiwi/zh-CN/app.ts | 3 +- client/.kiwi/zh-TW/app.ts | 3 +- client/app/agreement/page.tsx | 7 +- client/app/factory/edit/page.tsx | 447 ++++++++++++------ client/app/globals.css | 2 +- client/app/hooks/useAgreement.ts | 15 + client/app/page.tsx | 2 +- client/app/policy/page.tsx | 6 +- client/app/services/UserController.ts | 25 +- client/components/Markdown.tsx | 8 +- client/package.json | 2 +- client/public/images/agreementIcon.svg | 7 + client/types/database.types.ts | 3 + client/yarn.lock | 8 +- .../20240911082857_remote_schema.sql | 3 +- server/auth/get_user_info.py | 2 + server/auth/router.py | 42 +- server/core/models/user.py | 1 + server/tests/mock_session.py | 4 +- 25 files changed, 416 insertions(+), 191 deletions(-) create mode 100644 client/app/hooks/useAgreement.ts create mode 100644 client/public/images/agreementIcon.svg diff --git a/assistant/package.json b/assistant/package.json index ccdbd7a6..a0817c7b 100644 --- a/assistant/package.json +++ b/assistant/package.json @@ -1,6 +1,6 @@ { "name": "@petercatai/assistant", - "version": "1.0.15", + "version": "1.0.16", "description": "PeterCat Assistant Application", "module": "dist/esm/index.js", "types": "dist/esm/index.d.ts", diff --git a/assistant/src/Chat/index.tsx b/assistant/src/Chat/index.tsx index fe729d69..3d0ecf17 100644 --- a/assistant/src/Chat/index.tsx +++ b/assistant/src/Chat/index.tsx @@ -211,7 +211,7 @@ const Chat: FC = memo(
{!hideLogo && } {disabled && ( -
+
)}
- - - +
); diff --git a/client/app/factory/edit/page.tsx b/client/app/factory/edit/page.tsx index bb29ff40..2923faae 100644 --- a/client/app/factory/edit/page.tsx +++ b/client/app/factory/edit/page.tsx @@ -1,7 +1,20 @@ 'use client'; import I18N from '@/app/utils/I18N'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; -import { Tabs, Tab, Button, Input, Avatar } from '@nextui-org/react'; +import { + Tabs, + Tab, + Modal, + ModalContent, + ModalHeader, + ModalBody, + ModalFooter, + Button, + Input, + Avatar, + Checkbox, +} from '@nextui-org/react'; +import Image from 'next/image'; import BotCreateFrom from '@/app/factory/edit/components/BotCreateForm'; import { toast, ToastContainer } from 'react-toastify'; import BackIcon from '@/public/icons/BackIcon'; @@ -13,6 +26,7 @@ import { useBotCreate, useBotEdit, } from '@/app/hooks/useBot'; +import { useAgreement } from '@/app/hooks/useAgreement'; import FullPageSkeleton from '@/components/FullPageSkeleton'; import { isEmpty } from 'lodash'; import { Chat } from '@petercatai/assistant'; @@ -30,6 +44,9 @@ import { useSearchParams } from 'next/navigation'; import 'react-toastify/dist/ReactToastify.css'; import { extractFullRepoNameFromGitHubUrl } from '@/app/utils/tools'; import DeployBotModal from './components/DeployBotModal'; +import Markdown from '@/components/Markdown'; +import AgreementZhCN from '../../../.kiwi/zh-CN/agreement.md'; +import AgreementEN from '../../../.kiwi/en/agreement.md'; const API_HOST = process.env.NEXT_PUBLIC_API_DOMAIN; enum VisibleTypeEnum { @@ -42,7 +59,6 @@ enum ConfigTypeEnum { } export default function Edit() { const { language } = useGlobal(); - const { botProfile, setBotProfile } = useBot(); const { user, status } = useUser(); const router = useRouter(); @@ -56,11 +72,31 @@ export default function Edit() { ); const [gitUrl, setGitUrl] = React.useState(''); const [deployModalIsOpen, setDeployModalIsOpen] = useState(false); + const [agreementModalIsOpen, setAgreementModalIsOpen] = useState(false); + const [agreementAccepted, setAgreementAccepted] = + React.useState(true); const apiDomain = process.env.NEXT_PUBLIC_API_DOMAIN; + const markdownContent = useMemo(() => { + switch (language) { + case 'zh-CN': + return AgreementZhCN; + case 'en': + return AgreementEN; + default: + return AgreementEN; + } + }, [language]); + useEffect(() => { if (!user || status !== 'success' || user.id.startsWith('client|')) { router.push(`${apiDomain}/api/auth/login`); + } else { + if (!user?.agreement_accepted) { + setAgreementModalIsOpen(true); + } else { + setAgreementModalIsOpen(false); + } } }, [user, status]); @@ -79,6 +115,13 @@ export default function Edit() { error: createError, } = useBotCreate(); + const { + acceptAgreement: onAcceptAgreement, + isLoading: acceptAgreementLoading, + isSuccess: acceptAgreementSuccess, + error: acceptAgreementError, + } = useAgreement(); + const { data: generatorResponseData, getBotInfoByRepoName, @@ -159,6 +202,19 @@ export default function Edit() { }); }, [generatorResponseData]); + useEffect(() => { + if (acceptAgreementSuccess) { + toast.error('An error has occurred'); + setAgreementModalIsOpen(true); + } + }, [acceptAgreementError]); + + useEffect(() => { + if (acceptAgreementSuccess) { + setAgreementModalIsOpen(false); + } + }, [acceptAgreementSuccess]); + useEffect(() => { if (createSuccess) { setDeployModalIsOpen(true); @@ -352,170 +408,259 @@ export default function Edit() { return (
- - {visibleType === VisibleTypeEnum.BOT_CONFIG ? ( -
-
-
-
-
- - - + <> + + {visibleType === VisibleTypeEnum.BOT_CONFIG ? ( +
+
+
+
- - {botProfile?.name!} + + + +
+ + {botProfile?.name!} +
+
+
+ + setActiveTab(`${key}` as ConfigTypeEnum) + } + classNames={{ + base: 'min-w-[230px] h-[36px]', + tab: 'shadow-none h-[36px] px-0 py-0', + tabContent: + 'group-data-[selected=true]:bg-[#FAE4CB] rounded-full px-3 py-2 h-[36px]', + cursor: 'shadow-none rounded-full ', + }} + > + + + + {I18N.edit.page.duiHuaTiaoShi} + +
+ } + /> + + + + + {I18N.edit.page.shouDongPeiZhi} + +
+ } + /> +
-
- - setActiveTab(`${key}` as ConfigTypeEnum) - } - classNames={{ - base: 'min-w-[230px] h-[36px]', - tab: 'shadow-none h-[36px] px-0 py-0', - tabContent: - 'group-data-[selected=true]:bg-[#FAE4CB] rounded-full px-3 py-2 h-[36px]', - cursor: 'shadow-none rounded-full ', +
+
- - - - {I18N.edit.page.duiHuaTiaoShi} - -
- } - /> - - - - - {I18N.edit.page.shouDongPeiZhi} - -
- } - /> -
+ {chatConfigContent} +
+
+ {manualConfigContent} +
-
-
- {chatConfigContent} +
+
+
+
+
+
{I18N.edit.page.yuLanYuCeShi}
-
- {manualConfigContent} +
+
-
-
-
-
-
-
-
{I18N.edit.page.yuLanYuCeShi}
-
-
- -
-
-
-
- {typeof window !== 'undefined' && ( - - )} +
+
+ {typeof window !== 'undefined' && ( + + )} +
+ { + setDeployModalIsOpen(false); + }} + />
- { - setDeployModalIsOpen(false); + ) : ( + <> + )} + {visibleType === VisibleTypeEnum.KNOWLEDGE_DETAIL ? ( + { + setVisibleType(VisibleTypeEnum.BOT_CONFIG); }} - /> -
- ) : ( - <> - )} + > + ) : ( + <> + )} + - {visibleType === VisibleTypeEnum.KNOWLEDGE_DETAIL ? ( - { - setVisibleType(VisibleTypeEnum.BOT_CONFIG); - }} - > - ) : ( - <> - )} + + + {() => ( + <> + +
+ icon +
+ {I18N.app.page.agreement} +
+
+
+ +
+ +
+
+ +
+ { + setAgreementAccepted(e.target.checked); + }} + radius="sm" + color="default" + > + {I18N.app.page.agreementLabel} + +
+
+ + +
+
+ + )} +
+
); diff --git a/client/app/globals.css b/client/app/globals.css index 92f95279..a93d9b1c 100644 --- a/client/app/globals.css +++ b/client/app/globals.css @@ -22,7 +22,7 @@ body textarea { } a { - color: #2d7bd4; + color: #1D4ED8; text-decoration: none; } diff --git a/client/app/hooks/useAgreement.ts b/client/app/hooks/useAgreement.ts new file mode 100644 index 00000000..9a56cf2f --- /dev/null +++ b/client/app/hooks/useAgreement.ts @@ -0,0 +1,15 @@ +import { useMutation } from '@tanstack/react-query'; +import { acceptAgreement } from '@/app/services/UserController'; +export function useAgreement() { + const mutation = useMutation({ + mutationFn: acceptAgreement, + }); + + return { + data: mutation.data, + acceptAgreement: mutation.mutate, + isLoading: mutation.isPending, + error: mutation.error, + isSuccess: mutation.isSuccess, + }; +} diff --git a/client/app/page.tsx b/client/app/page.tsx index 1f64247d..f0e29a28 100644 --- a/client/app/page.tsx +++ b/client/app/page.tsx @@ -604,7 +604,7 @@ export default function Homepage() { href="/agreement" target="_blank" > - {I18N.app.page.service} + {I18N.app.page.agreement}
diff --git a/client/app/policy/page.tsx b/client/app/policy/page.tsx index d6e5e763..e246d034 100644 --- a/client/app/policy/page.tsx +++ b/client/app/policy/page.tsx @@ -5,8 +5,6 @@ import HomeHeader from '@/components/HomeHeader'; import Markdown from '@/components/Markdown'; import { useGlobal } from '@/app/contexts/GlobalContext'; -import { ThemeProvider } from 'antd-style'; - import PolicyZhCN from '../../.kiwi/zh-CN/policy.md'; import PolicyEN from '../../.kiwi/en/policy.md'; @@ -27,9 +25,7 @@ export default function Policy() {
- - - +
); diff --git a/client/app/services/UserController.ts b/client/app/services/UserController.ts index b5c24cc0..08f1742d 100644 --- a/client/app/services/UserController.ts +++ b/client/app/services/UserController.ts @@ -3,18 +3,33 @@ import axios from 'axios'; const apiDomain = process.env.NEXT_PUBLIC_API_DOMAIN; // Get the public bot profile by id -export async function getUserInfo({ clientId }: { clientId?: string }) { - const response = await axios.get(`${apiDomain}/api/auth/userinfo?clientId=${clientId}`, { withCredentials: true }); +export async function getUserInfo({ clientId }: { clientId?: string }) { + const response = await axios.get( + `${apiDomain}/api/auth/userinfo?clientId=${clientId}`, + { withCredentials: true }, + ); return response.data.data; } +export async function acceptAgreement() { + const response = await axios.post( + `${apiDomain}/api/auth/accept/agreement`, + {}, + { withCredentials: true }, + ); + return response.data; +} export async function getAvaliableLLMs() { - const response = await axios.get(`${apiDomain}/api/user/llms`, { withCredentials: true }); + const response = await axios.get(`${apiDomain}/api/user/llms`, { + withCredentials: true, + }); return response.data; } export async function requestLogout() { - const response = await axios.get(`${apiDomain}/api/auth/logout`, { withCredentials: true }); + const response = await axios.get(`${apiDomain}/api/auth/logout`, { + withCredentials: true, + }); return response.data; -} \ No newline at end of file +} diff --git a/client/components/Markdown.tsx b/client/components/Markdown.tsx index 1c769d79..2f6ba49b 100644 --- a/client/components/Markdown.tsx +++ b/client/components/Markdown.tsx @@ -3,8 +3,9 @@ import React, { useEffect, useState } from 'react'; import { remark } from 'remark'; import html from 'remark-html'; -const Markdown: React.FC<{ markdownContent: string }> = ({ +const Markdown: React.FC<{ markdownContent: string; theme?: boolean }> = ({ markdownContent, + theme = true, }) => { const [content, setContent] = useState(''); @@ -18,7 +19,10 @@ const Markdown: React.FC<{ markdownContent: string }> = ({ processMarkdown(); }, [markdownContent]); return ( -
+
); }; diff --git a/client/package.json b/client/package.json index e652ca9c..0abd1323 100644 --- a/client/package.json +++ b/client/package.json @@ -22,7 +22,7 @@ "@fullpage/react-fullpage": "^0.1.42", "@next/bundle-analyzer": "^13.4.19", "@nextui-org/react": "^2.2.9", - "@petercatai/assistant": "^1.0.15", + "@petercatai/assistant": "^1.0.16", "@sentry/nextjs": "^8.28.0", "@supabase/supabase-js": "^2.32.0", "@tanstack/react-query": "^5.17.19", diff --git a/client/public/images/agreementIcon.svg b/client/public/images/agreementIcon.svg new file mode 100644 index 00000000..e31add23 --- /dev/null +++ b/client/public/images/agreementIcon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/client/types/database.types.ts b/client/types/database.types.ts index 62c1ed26..c134ef1c 100644 --- a/client/types/database.types.ts +++ b/client/types/database.types.ts @@ -229,6 +229,7 @@ export type Database = { picture: string | null; sid: string | null; sub: string | null; + agreement_accepted: boolean | null; }; Insert: { created_at?: string; @@ -238,6 +239,7 @@ export type Database = { picture?: string | null; sid?: string | null; sub?: string | null; + agreement_accepted?: boolean | null; }; Update: { created_at?: string; @@ -247,6 +249,7 @@ export type Database = { picture?: string | null; sid?: string | null; sub?: string | null; + agreement_accepted?: boolean | null; }; Relationships: []; }; diff --git a/client/yarn.lock b/client/yarn.lock index c0e2c3bb..21401693 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2842,10 +2842,10 @@ resolved "https://registry.yarnpkg.com/@panva/hkdf/-/hkdf-1.2.1.tgz#cb0d111ef700136f4580349ff0226bf25c853f23" integrity sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw== -"@petercatai/assistant@^1.0.15": - version "1.0.15" - resolved "https://registry.yarnpkg.com/@petercatai/assistant/-/assistant-1.0.15.tgz#96eaf839ef6d21a0e825131fe6d169a99f2d9318" - integrity sha512-aP5BPPy8sOikmmTl48zoBrhpQOCRhkBGraIQOmX6MR29KeP4T7dbe4x3qk7ZvKkY0qBiWngEOabIcNCKwtGjxw== +"@petercatai/assistant@^1.0.16": + version "1.0.16" + resolved "https://registry.yarnpkg.com/@petercatai/assistant/-/assistant-1.0.16.tgz#315d74220a5765155a51dc0b1f6a8cb475040e2e" + integrity sha512-sCsBAjDAVwfXOqjy8Q0gnFvh6iXGM84v6zotSGAinJINqb52hEkrL3J3n0/Wp/6UXWQiiE0IdSA4qi9W7k9QhA== dependencies: "@ant-design/icons" "^5.3.5" "@ant-design/pro-chat" "^1.9.0" diff --git a/migrations/supabase/migrations/20240911082857_remote_schema.sql b/migrations/supabase/migrations/20240911082857_remote_schema.sql index e691cc4e..2dc5ed68 100644 --- a/migrations/supabase/migrations/20240911082857_remote_schema.sql +++ b/migrations/supabase/migrations/20240911082857_remote_schema.sql @@ -320,7 +320,8 @@ CREATE TABLE IF NOT EXISTS "public"."profiles" ( "name" character varying, "picture" character varying, "sid" character varying, - "sub" character varying + "sub" character varying, + "agreement_accepted" boolean DEFAULT false; ); ALTER TABLE "public"."profiles" OWNER TO "postgres"; diff --git a/server/auth/get_user_info.py b/server/auth/get_user_info.py index c59c2c2b..950f916f 100644 --- a/server/auth/get_user_info.py +++ b/server/auth/get_user_info.py @@ -26,6 +26,7 @@ async def getUserInfoByToken(token): "avatar": user_info.get("picture"), "sub": user_info["sub"], "sid": secrets.token_urlsafe(32), + "agreement_accepted": user_info.get("agreement_accepted"), } return data else: @@ -62,6 +63,7 @@ async def generateAnonymousUser(clientId: str): "name": random_name, "picture": f"https://picsum.photos/seed/{seed}/100/100", "sid": secrets.token_urlsafe(32), + "agreement_accepted": False, } return token, data diff --git a/server/auth/router.py b/server/auth/router.py index 2a3f5cc6..bb6d4f8d 100644 --- a/server/auth/router.py +++ b/server/auth/router.py @@ -1,11 +1,12 @@ -from fastapi import APIRouter, Request, HTTPException, status -from fastapi.responses import RedirectResponse +from fastapi import APIRouter, Request, HTTPException, status, Depends +from fastapi.responses import RedirectResponse, JSONResponse import secrets from petercat_utils import get_client, get_env_variable from starlette.config import Config from authlib.integrations.starlette_client import OAuth +from typing import Annotated -from auth.get_user_info import generateAnonymousUser, getUserInfoByToken +from auth.get_user_info import generateAnonymousUser, getUserInfoByToken, get_user_id AUTH0_DOMAIN = get_env_variable("AUTH0_DOMAIN") @@ -81,7 +82,8 @@ async def callback(request: Request): "name": user_info.get("name"), "picture": user_info.get("picture"), "sub": user_info["sub"], - "sid": secrets.token_urlsafe(32) + "sid": secrets.token_urlsafe(32), + "agreement_accepted": user_info.get("agreement_accepted"), } request.session['user'] = dict(data) supabase = get_client() @@ -91,8 +93,38 @@ async def callback(request: Request): @router.get("/userinfo") async def userinfo(request: Request): user = request.session.get('user') - if not user: data = await getAnonymousUser(request) return { "data": data, "status": 200} return { "data": user, "status": 200} + +@router.post("/accept/agreement", status_code=200) +async def bot_generator( + request: Request, + user_id: Annotated[str | None, Depends(get_user_id)] = None, +): + if not user_id: + return JSONResponse( + content={ + "success": False, + "errorMessage": "User not found", + }, + status_code=401, + ) + try: + supabase = get_client() + response = supabase.table("profiles").update({"agreement_accepted": True}).match({"id": user_id}).execute() + + if not response.data: + return JSONResponse( + content={ + "success": False, + "errorMessage": "User does not exist, accept failed.", + } + ) + request.session['user'] = response.data[0] + return JSONResponse(content={"success": True}) + except Exception as e: + return JSONResponse( + content={"success": False, "errorMessage": str(e)}, status_code=500 + ) diff --git a/server/core/models/user.py b/server/core/models/user.py index 0d84fefe..97eaab36 100644 --- a/server/core/models/user.py +++ b/server/core/models/user.py @@ -8,6 +8,7 @@ class User(BaseModel): nickname: str avatar: Optional[str] = None picture: Optional[str] + agreement_token: Optional[bool] = False anonymous: Optional[bool] = True access_token: Optional[str] = None diff --git a/server/tests/mock_session.py b/server/tests/mock_session.py index ab942dc6..bf87b57f 100644 --- a/server/tests/mock_session.py +++ b/server/tests/mock_session.py @@ -13,10 +13,10 @@ def create_session_cookie(data) -> str: b64encode(json.dumps(data).encode('utf-8')), ).decode('utf-8') -mock_user = User(id="1", sub="1", sid="1", avatar="1", picture="1", nickname="1", access_token="1", anonymous=False) +mock_user = User(id="1", sub="1", sid="1", avatar="1", picture="1", nickname="1", access_token="1", anonymous=False, agreement_accepted=False) def get_mock_user(): return mock_user def mock_session(): - return {'session': create_session_cookie({"user": dict(mock_user) }) } \ No newline at end of file + return {'session': create_session_cookie({"user": dict(mock_user) }) }