diff --git a/client/.kiwi/en/edit.ts b/client/.kiwi/en/edit.ts index 7ff3761b..f4ccb78e 100644 --- a/client/.kiwi/en/edit.ts +++ b/client/.kiwi/en/edit.ts @@ -9,10 +9,10 @@ export default { chongXinShengChengPei: 'Regenerate Configuration', ziDongShengChengPei: 'Automatically Generate Configuration', diZhiYouWu: 'Invalid Address', - qingShuRuGI: 'Please enter the GitHub project URL', + qingShuRuGI: 'Please enter or select the GitHub project name', fuZhiTOK: 'Copy Token', tOKEN: 'Token has been copied to clipboard', - gITHU: 'GitHub Project URL', + gITHU: 'GitHub project name', bangWoPeiZhiYi: 'Help me create a Q&A bot', chuCiJianMianXian: '👋🏻 Hello! Nice to meet you. Let me introduce myself: I am PeterCat, a robot for an open-source project. You can create a Q&A robot by talking to me.', diff --git a/client/.kiwi/ja/edit.ts b/client/.kiwi/ja/edit.ts index 47a082a3..36410691 100644 --- a/client/.kiwi/ja/edit.ts +++ b/client/.kiwi/ja/edit.ts @@ -9,10 +9,10 @@ export default { chongXinShengChengPei: '設定を再生成', ziDongShengChengPei: '設定を自動生成', diZhiYouWu: 'アドレスに誤りがあります', - qingShuRuGI: 'GitHubプロジェクトのアドレスを入力してください', + qingShuRuGI: 'GitHubプロジェクト名を入力または選択してください', fuZhiTOK: 'トークンをコピー', tOKEN: 'トークンがクリップボードにコピーされました', - gITHU: 'GitHubプロジェクトのアドレス', + gITHU: 'GitHubプロジェクト名', bangWoPeiZhiYi: 'Q&Aボットの設定を手伝ってください', chuCiJianMianXian: '👋🏻 こんにちは!初めまして、自己紹介させていただきます。私はPeterCatと申します。オープンソースプロジェクトのロボットです。私と対話することで、Q&Aロボットを設定できます。', diff --git a/client/.kiwi/ko/edit.ts b/client/.kiwi/ko/edit.ts index 77405ca0..53d28675 100644 --- a/client/.kiwi/ko/edit.ts +++ b/client/.kiwi/ko/edit.ts @@ -9,10 +9,10 @@ export default { chongXinShengChengPei: '구성 다시 생성', ziDongShengChengPei: '구성 자동 생성', diZhiYouWu: '주소 오류', - qingShuRuGI: 'GitHub 프로젝트 주소를 입력하십시오.', + qingShuRuGI: 'GitHub 프로젝트 이름을 입력하거나 선택하세요', fuZhiTOK: '토큰 복사', tOKEN: '토큰이 클립보드에 복사되었습니다.', - gITHU: 'GitHub 프로젝트 주소', + gITHU: 'GitHub 프로젝트 이름', bangWoPeiZhiYi: 'Q&A 봇 설정을 도와주세요.', chuCiJianMianXian: '👋🏻 안녕하세요! 처음 뵙겠습니다. 제 소개를 하겠습니다: 저는 PeterCat입니다, 오픈소스 프로젝트의 로봇입니다. 저와 대화를 통해 질의응답 로봇을 구성할 수 있습니다.', diff --git a/client/.kiwi/zh-CN/edit.ts b/client/.kiwi/zh-CN/edit.ts index 9742212f..3d376d11 100644 --- a/client/.kiwi/zh-CN/edit.ts +++ b/client/.kiwi/zh-CN/edit.ts @@ -8,10 +8,10 @@ export default { chongXinShengChengPei: '重新生成配置', ziDongShengChengPei: '自动生成配置', diZhiYouWu: '地址有误', - qingShuRuGI: '请输入 GitHub 项目地址', + qingShuRuGI: '请输入或选择 GitHub 项目名称', fuZhiTOK: '复制 Token', tOKEN: 'Token 已复制到剪贴板', - gITHU: 'Github 项目地址', + gITHU: 'Github 项目名称', bangWoPeiZhiYi: '帮我配置一个答疑机器人', chuCiJianMianXian: '👋🏻 初次见面,先自我介绍一下:我是 PeterCat,一个开源项目的机器人。你可以通过和我对话配置一个答疑机器人。', diff --git a/client/.kiwi/zh-TW/edit.ts b/client/.kiwi/zh-TW/edit.ts index 9a76b1eb..e2c0212d 100644 --- a/client/.kiwi/zh-TW/edit.ts +++ b/client/.kiwi/zh-TW/edit.ts @@ -8,10 +8,10 @@ export default { chongXinShengChengPei: '重新生成配置', ziDongShengChengPei: '自動生成配置', diZhiYouWu: '地址有誤', - qingShuRuGI: '請輸入 GitHub 項目地址', + qingShuRuGI: '請輸入或選擇 GitHub 項目名稱', fuZhiTOK: '複製 Token', tOKEN: 'Token 已複製到剪貼板', - gITHU: 'GitHub 項目地址', + gITHU: 'GitHub 項目名稱', bangWoPeiZhiYi: '幫我配置一個答疑機器人', chuCiJianMianXian: '👋🏻 初次見面,先自我介紹一下:我是 PeterCat,一個開源項目的機器人。你可以通過和我對話配置一個答疑機器人。', diff --git a/client/app/factory/edit/page.tsx b/client/app/factory/edit/page.tsx index acfe9ff4..79192e31 100644 --- a/client/app/factory/edit/page.tsx +++ b/client/app/factory/edit/page.tsx @@ -1,6 +1,6 @@ 'use client'; import I18N from '@/app/utils/I18N'; -import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState, Key } from 'react'; import { Tabs, Tab, @@ -10,9 +10,11 @@ import { ModalBody, ModalFooter, Button, - Input, Avatar, Checkbox, + Autocomplete, + AutocompleteItem, + Input, } from '@nextui-org/react'; import Image from 'next/image'; import BotCreateFrom from '@/app/factory/edit/components/BotCreateForm'; @@ -28,20 +30,19 @@ import { } from '@/app/hooks/useBot'; import { useAgreement, useAgreementStatus } from '@/app/hooks/useAgreement'; import FullPageSkeleton from '@/components/FullPageSkeleton'; -import { isEmpty } from 'lodash'; +import { isEmpty, map, size } from 'lodash'; import { Chat } from '@petercatai/assistant'; import AIBtnIcon from '@/public/icons/AIBtnIcon'; import ChatIcon from '@/public/icons/ChatIcon'; import ConfigIcon from '@/public/icons/ConfigIcon'; import SaveIcon from '@/public/icons/SaveIcon'; import { useBot } from '@/app/contexts/BotContext'; -import useUser from '@/app/hooks/useUser'; +import { useUser, useUserRepos } from '@/app/hooks/useUser'; import Knowledge from './components/Knowledge'; import { useGlobal } from '@/app/contexts/GlobalContext'; import KnowledgeBtn from './components/KnowledgeBtn'; import { BotTaskProvider } from './components/TaskContext'; 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'; @@ -51,6 +52,8 @@ import AgreementJA from '../../../.kiwi/ja/agreement.md'; import AgreementKO from '../../../.kiwi/ko/agreement.md'; import AgreementZhTW from '../../../.kiwi/zh-TW/agreement.md'; +import 'react-toastify/dist/ReactToastify.css'; + const API_HOST = process.env.NEXT_PUBLIC_API_DOMAIN; enum VisibleTypeEnum { BOT_CONFIG = 'BOT_CONFIG', @@ -78,7 +81,7 @@ export default function Edit() { const [visibleType, setVisibleType] = React.useState( VisibleTypeEnum.BOT_CONFIG, ); - const [gitUrl, setGitUrl] = React.useState(''); + const [gitRepoName, setGitRepoName] = React.useState(''); const [deployModalIsOpen, setDeployModalIsOpen] = useState(false); const [agreementModalIsOpen, setAgreementModalIsOpen] = useState(false); const [agreementAccepted, setAgreementAccepted] = @@ -180,6 +183,8 @@ export default function Edit() { [id, botProfile?.id], ); + const { data: repos } = useUserRepos(!isEdit); + const botId = useMemo(() => { if (!!id && id !== 'new') { return id; @@ -323,46 +328,64 @@ export default function Edit() { )} ); - const manualConfigLabel = ( -
- {I18N.edit.page.gITHU} - {botProfile.id && ( - { - toast.success(I18N.edit.page.tOKEN); - }} - > - {/* @ts-ignore */} - - {I18N.edit.page.fuZhiTOK} - - - )} -
- ); const manualConfigContent = (
-
- { - const url = e.target.value; - setGitUrl(url); - }} - value={gitUrl || botProfile.repoName} - isDisabled={isEdit} - required - classNames={{ label: 'w-full' }} - className="mt-1 mb-6 block w-full border-gray-300 rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" - /> +
+ {I18N.edit.page.gITHU} + {botProfile.id && ( + { + toast.success(I18N.edit.page.tOKEN); + }} + > + {/* @ts-ignore */} + + {I18N.edit.page.fuZhiTOK} + + + )} +
+
+ {!isEdit ? ( + { + const repoName = extractFullRepoNameFromGitHubUrl(value); + setGitRepoName(repoName || ''); + }} + onSelectionChange={(key) => { + setGitRepoName(`${key}`); + }} + allowsCustomValue + defaultInputValue={gitRepoName || botProfile.repoName} + variant="bordered" + className="mt-1 mb-6 block w-full border-gray-300 focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50" + label={I18N.edit.page.qingShuRuGI} + > + {map(repos, (item) => ( + {item.label} + ))} + + ) : ( + + )} +
{!isEdit ? (
@@ -372,7 +395,7 @@ export default function Edit() { startContent={} isLoading={createBotLoading} onClick={() => { - const repoName = extractFullRepoNameFromGitHubUrl(gitUrl); + const repoName = gitRepoName || botProfile.repoName; if (repoName) { onCreateBot({ repo_name: repoName!!, diff --git a/client/app/hooks/useUser.ts b/client/app/hooks/useUser.ts index 23357dc1..53099261 100644 --- a/client/app/hooks/useUser.ts +++ b/client/app/hooks/useUser.ts @@ -1,19 +1,33 @@ import { useUser as useAssistUser } from '@petercatai/assistant'; import { useFingerprint } from './useFingerprint'; +import { useQuery } from '@tanstack/react-query'; +import { getUserRepos } from '../services/UserController'; +import { map } from 'lodash'; const API_DOMAIN = process.env.NEXT_PUBLIC_API_DOMAIN!; -export default function useUser() { +export const useUser = () => { const { data: fingerprint } = useFingerprint(); const { user, isLoading, actions } = useAssistUser({ apiDomain: API_DOMAIN, - fingerprint: fingerprint?.visitorId! + fingerprint: fingerprint?.visitorId!, }); return { user, isLoading, actions, - status: isLoading ? "pending" : 'success', + status: isLoading ? 'pending' : 'success', }; -} +}; + +export const useUserRepos = (enabled: boolean) => { + return useQuery({ + queryKey: [`user.repos`], + queryFn: async () => getUserRepos(), + enabled, + select: (data) => + map(data.data, (item) => ({ label: item.name, key: item.name })), + retry: true, + }); +}; diff --git a/client/app/services/UserController.ts b/client/app/services/UserController.ts index a5946f63..7406e267 100644 --- a/client/app/services/UserController.ts +++ b/client/app/services/UserController.ts @@ -27,6 +27,13 @@ export async function getAgreementStatus() { return response.data; } +export async function getUserRepos() { + const response = await axios.get(`${apiDomain}/api/auth/repos`, { + withCredentials: true, + }); + return response.data; +} + export async function getAvailableLLMs() { const response = await axios.get(`${apiDomain}/api/user/llms`, { withCredentials: true, diff --git a/client/app/utils/tools.ts b/client/app/utils/tools.ts index a7df2aad..8f525ef6 100644 --- a/client/app/utils/tools.ts +++ b/client/app/utils/tools.ts @@ -13,10 +13,11 @@ export const extractParametersByTools = (content: string) => { return null; }; -export const extractFullRepoNameFromGitHubUrl = (githubUrl: string) => { +export const extractFullRepoNameFromGitHubUrl = (input: string) => { try { - const regex = /^https:\/\/github\.com\/([^\/]+)\/([^\/]+)(\/.*)?$/; - const match = githubUrl.match(regex); + // Use a regex that matches both full URLs and `username/reponame` format. + const regex = /^(?:https:\/\/github\.com\/)?([^\/]+)\/([^\/]+)(?:\/.*)?$/; + const match = input.match(regex); if (match && match[1] && match[2]) { return `${match[1]}/${match[2]}`; @@ -24,7 +25,7 @@ export const extractFullRepoNameFromGitHubUrl = (githubUrl: string) => { return null; } } catch (error) { - console.error('Error parsing GitHub URL:', error); + console.error('Error parsing input:', error); return null; } }; diff --git a/client/components/User.tsx b/client/components/User.tsx index 485501ae..0677a2b3 100644 --- a/client/components/User.tsx +++ b/client/components/User.tsx @@ -9,7 +9,7 @@ import { DropdownMenu, DropdownTrigger, } from '@nextui-org/react'; -import useUser from '../app/hooks/useUser'; +import { useUser } from '../app/hooks/useUser'; import GitHubIcon from '@/public/icons/GitHubIcon'; import Link from 'next/link'; @@ -23,13 +23,14 @@ export default function Profile() { className="min-w-[88px] px-4 h-10 inline-block transition-colors bg-[#3F3F46] text-[#FFFFFF] rounded-full leading-10 text-center" > - {I18N.components.User.dengLu} + {I18N.components.User.dengLu} + ); } const avatar = ( - + {I18N.components.User.tOKEN} - + {I18N.components.User.dengChu} diff --git a/server/auth/get_user_info.py b/server/auth/get_user_info.py index 950f916f..7b587108 100644 --- a/server/auth/get_user_info.py +++ b/server/auth/get_user_info.py @@ -11,6 +11,7 @@ AUTH0_DOMAIN = get_env_variable("AUTH0_DOMAIN") + async def getUserInfoByToken(token): userinfo_url = f"https://{AUTH0_DOMAIN}/userinfo" headers = {"authorization": f"Bearer {token}"} diff --git a/server/auth/router.py b/server/auth/router.py index bb8a2ca4..7a7a3e8b 100644 --- a/server/auth/router.py +++ b/server/auth/router.py @@ -1,3 +1,5 @@ +import json +from github import Github from core.dao.profilesDAO import ProfilesDAO from fastapi import APIRouter, Request, HTTPException, status, Depends from fastapi.responses import RedirectResponse, JSONResponse @@ -7,7 +9,12 @@ from authlib.integrations.starlette_client import OAuth from typing import Annotated, Optional -from auth.get_user_info import generateAnonymousUser, getUserInfoByToken, get_user_id +from auth.get_user_info import ( + generateAnonymousUser, + getUserAccessToken, + getUserInfoByToken, + get_user_id, +) AUTH0_DOMAIN = get_env_variable("AUTH0_DOMAIN") @@ -15,26 +22,26 @@ CLIENT_ID = get_env_variable("AUTH0_CLIENT_ID") CLIENT_SECRET = get_env_variable("AUTH0_CLIENT_SECRET") -API_URL = get_env_variable("API_URL") +API_URL = get_env_variable("API_URL") CALLBACK_URL = f"{API_URL}/api/auth/callback" LOGIN_URL = f"{API_URL}/api/auth/login" -WEB_URL = get_env_variable("WEB_URL") +WEB_URL = get_env_variable("WEB_URL") WEB_LOGIN_SUCCESS_URL = f"{WEB_URL}/user/login" MARKET_URL = f"{WEB_URL}/market" -config = Config(environ={ - "AUTH0_CLIENT_ID": CLIENT_ID, - "AUTH0_CLIENT_SECRET": CLIENT_SECRET, -}) +config = Config( + environ={ + "AUTH0_CLIENT_ID": CLIENT_ID, + "AUTH0_CLIENT_SECRET": CLIENT_SECRET, + } +) oauth = OAuth(config) oauth.register( name="auth0", - server_metadata_url=f'https://{AUTH0_DOMAIN}/.well-known/openid-configuration', - client_kwargs={ - 'scope': 'openid email profile' - } + server_metadata_url=f"https://{AUTH0_DOMAIN}/.well-known/openid-configuration", + client_kwargs={"scope": "openid email profile"}, ) router = APIRouter( @@ -43,38 +50,46 @@ responses={404: {"description": "Not found"}}, ) + async def getAnonymousUser(request: Request): clientId = request.query_params.get("clientId") if not clientId: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing clientId") + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing clientId" + ) token, data = await generateAnonymousUser(clientId) supabase = get_client() supabase.table("profiles").upsert(data).execute() - request.session['user'] = data + request.session["user"] = data return data + @router.get("/login") async def login(request: Request): if CLIENT_ID is None: return { "message": "enviroments CLIENT_ID and CLIENT_SECRET required.", } - redirect_response = await oauth.auth0.authorize_redirect(request, redirect_uri=CALLBACK_URL) + redirect_response = await oauth.auth0.authorize_redirect( + request, redirect_uri=CALLBACK_URL + ) return redirect_response -@router.get('/logout') + +@router.get("/logout") async def logout(request: Request): - request.session.pop('user', None) - redirect = request.query_params.get('redirect') + request.session.pop("user", None) + redirect = request.query_params.get("redirect") if redirect: - return RedirectResponse(url=f'{redirect}', status_code=302) - return { "success": True } + return RedirectResponse(url=f"{redirect}", status_code=302) + return {"success": True} + @router.get("/callback") async def callback(request: Request): auth0_token = await oauth.auth0.authorize_access_token(request) - user_info = await getUserInfoByToken(token=auth0_token['access_token']) + user_info = await getUserInfoByToken(token=auth0_token["access_token"]) if user_info: data = { @@ -86,18 +101,20 @@ async def callback(request: Request): "sid": secrets.token_urlsafe(32), "agreement_accepted": user_info.get("agreement_accepted"), } - request.session['user'] = dict(data) + request.session["user"] = dict(data) supabase = get_client() supabase.table("profiles").upsert(data).execute() - return RedirectResponse(url=f'{WEB_LOGIN_SUCCESS_URL}', status_code=302) + return RedirectResponse(url=f"{WEB_LOGIN_SUCCESS_URL}", status_code=302) + @router.get("/userinfo") async def userinfo(request: Request): - user = request.session.get('user') + user = request.session.get("user") if not user: data = await getAnonymousUser(request) - return { "data": data, "status": 200} - return { "data": user, "status": 200} + return {"data": data, "status": 200} + return {"data": user, "status": 200} + @router.get("/agreement/status") async def get_agreement_status(user_id: Optional[str] = Depends(get_user_id)): @@ -107,11 +124,14 @@ async def get_agreement_status(user_id: Optional[str] = Depends(get_user_id)): profiles_dao = ProfilesDAO() response = profiles_dao.get_agreement_status(user_id=user_id) if not response: - raise HTTPException(status_code=404, detail="User does not exist, accept failed.") + raise HTTPException( + status_code=404, detail="User does not exist, accept failed." + ) return {"success": True, "data": response} except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}") + @router.post("/accept/agreement", status_code=200) async def bot_generator( request: Request, @@ -123,9 +143,27 @@ async def bot_generator( profiles_dao = ProfilesDAO() response = profiles_dao.accept_agreement(user_id=user_id) if response: - request.session['user'] = response + request.session["user"] = response return JSONResponse(content={"success": True}) else: raise HTTPException(status_code=400, detail="User update failed") except Exception as e: raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}") + + +@router.get("/repos") +async def get_user_repos(user_id: Optional[str] = Depends(get_user_id)): + if not user_id: + raise HTTPException(status_code=401, detail="User not found") + try: + access_token = await getUserAccessToken(user_id=user_id) + g = Github(access_token) + user = g.get_user() + repos = user.get_repos() + + repo_names = [ + {"name": repo.full_name} for repo in repos if repo.permissions.maintain + ] + return {"data": repo_names, "status": 200} + except Exception as e: + raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")