From 354218ff4b0e7ef41d3392e1ed64294b93675a97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BA=84=E5=AE=8F=E5=9F=BA?= Date: Wed, 7 Jun 2023 08:20:56 +0800 Subject: [PATCH] Add API Keys setup screen --- src/components/SvgIcon.tsx | 8 + src/http/apis/hooks.ts | 24 ++- src/http/apis/v1/models.ts | 54 ++++++ src/i18n/en/translation.json | 6 +- src/i18n/zh-Hans/translation.json | 6 +- src/screens/AppContent.tsx | 8 + src/screens/api-keys/BottomInput.tsx | 143 ++++++++++++++ src/screens/api-keys/BottomView.tsx | 274 +++++++++++++++++++++++++++ src/screens/api-keys/BrandText.tsx | 97 ++++++++++ src/screens/api-keys/BrandView.tsx | 74 ++++++++ src/screens/api-keys/index.tsx | 156 +++++++++++++++ src/screens/dev/index.tsx | 14 +- src/screens/screens.d.ts | 1 + src/screens/settings/InputView.tsx | 13 +- src/screens/settings/index.tsx | 4 +- 15 files changed, 861 insertions(+), 21 deletions(-) create mode 100644 src/http/apis/v1/models.ts create mode 100644 src/screens/api-keys/BottomInput.tsx create mode 100644 src/screens/api-keys/BottomView.tsx create mode 100644 src/screens/api-keys/BrandText.tsx create mode 100644 src/screens/api-keys/BrandView.tsx create mode 100644 src/screens/api-keys/index.tsx diff --git a/src/components/SvgIcon.tsx b/src/components/SvgIcon.tsx index a5152f7..b6debdf 100644 --- a/src/components/SvgIcon.tsx +++ b/src/components/SvgIcon.tsx @@ -235,6 +235,10 @@ const SVG_ICONS = { v: '0 96 960 960', d: 'm400 654 250-250q9-9 21-9t21 9q9 9 9 21t-9 21L421 717q-9 9-21 9t-21-9L268 606q-9-9-9-21t9-21q9-9 21-9t21 9l90 90Z', }, + 'down-bottom': { + v: '0 -960 960 960', + d: 'M190-120q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T190-180h580q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T770-120H190Zm289.867-132Q474-252 469-254q-5-2-10-7L307-413q-8-8-8-20.8t9.391-22.4Q317-465 329.5-465q12.5 0 21.5 9l99 100v-454q0-12.75 8.675-21.375 8.676-8.625 21.5-8.625 12.825 0 21.325 8.625T510-810v454l96-96q8.442-8 20.721-8T648-451q9 9 9 21.5t-9 21.5L501-261q-5 5-10.133 7-5.134 2-11 2Z', + }, 'filter-on': { v: '0 96 960 960', d: 'M430 816q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T430 756h100q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T530 816H430ZM150 396q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T150 336h660q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T810 396H150Zm120 210q-12.75 0-21.375-8.675-8.625-8.676-8.625-21.5 0-12.825 8.625-21.325T270 546h420q12.75 0 21.375 8.675 8.625 8.676 8.625 21.5 0 12.825-8.625 21.325T690 606H270Z', @@ -315,6 +319,10 @@ const SVG_ICONS = { v: '0 96 960 960', d: 'm629 637-44-44q26-71-27-118t-115-24l-44-44q17-11 38-16t43-5q71 0 120.5 49.5T650 556q0 22-5.5 43.5T629 637Zm129 129-40-40q49-36 85.5-80.5T857 556q-50-111-150-175.5T490 316q-42 0-86 8t-69 19l-46-47q35-16 89.5-28T485 256q135 0 249 74t174 199q3 5 4 12t1 15q0 8-1 15.5t-4 12.5q-26 55-64 101t-86 81Zm36 204L648 827q-35 14-79 21.5t-89 7.5q-138 0-253-74T52 583q-3-6-4-12.5T47 556q0-8 1.5-15.5T52 528q21-45 53.5-87.5T182 360L77 255q-9-9-9-21t9-21q9-9 21.5-9t21.5 9l716 716q8 8 8 19.5t-8 20.5q-8 10-20.5 10t-21.5-9ZM223 402q-37 27-71.5 71T102 556q51 111 153.5 175.5T488 796q33 0 65-4t48-12l-64-64q-11 5-27 7.5t-30 2.5q-70 0-120-49t-50-121q0-15 2.5-30t7.5-27l-97-97Zm305 142Zm-116 58Z', }, + 'vpn-key': { + v: '0 -960 960 960', + d: 'M280-240q-100 0-170-70T40-480q0-100 70-170t170-70q78 0 131.5 37.5T491-583h369q24.75 0 42.375 17.625T920-523v86q0 24.75-17.625 42.375T860-377h-46v77q0 24.75-17.625 42.375T754-240h-66q-24.75 0-42.375-17.625T628-300v-77H491q-26 62-79.5 99.5T280-240Zm0-60q71 0 116.5-47t53.374-90H692v137h62v-137h106v-86H450q-8-43-53.5-90T280-660q-75 0-127.5 52.5T100-480q0 75 52.5 127.5T280-300Zm0-112q29 0 48.5-19.5T348-480q0-29-19.5-48.5T280-548q-29 0-48.5 19.5T212-480q0 29 19.5 48.5T280-412Zm0-68Z', + }, } satisfies { [key: string]: Icon } export type SvgIconName = keyof typeof SVG_ICONS diff --git a/src/http/apis/hooks.ts b/src/http/apis/hooks.ts index 7ac7565..2e6813d 100644 --- a/src/http/apis/hooks.ts +++ b/src/http/apis/hooks.ts @@ -21,17 +21,25 @@ export function useOpenAIApiUrlOptions() { const [apiUrlPath] = useApiUrlPathPref() const [apiKey] = useApiKeyPref() const checkIsOptionsValid = (): boolean => { - let enterTip = '' if (!apiUrl) { - enterTip = t('Enter API URL first') - } else if (!apiUrlPath) { - enterTip = t('Enter API URL Path first') - } else if (!apiKey) { - enterTip = t('Enter API Key first') + hapticError() + toast('warning', t('Warning'), t('Please enter the API URL first'), () => + navigation.push('Settings') + ) + return false + } + if (!apiUrlPath) { + hapticError() + toast('warning', t('Warning'), t('Please enter the API URL Path first'), () => + navigation.push('Settings') + ) + return false } - if (enterTip) { + if (!apiKey) { hapticError() - toast('warning', t('Warning'), enterTip, () => navigation.push('Settings')) + toast('warning', t('Warning'), t('Please enter the API Key first'), () => { + navigation.push('ApiKeys') + }) return false } return true diff --git a/src/http/apis/v1/models.ts b/src/http/apis/v1/models.ts new file mode 100644 index 0000000..ee66f00 --- /dev/null +++ b/src/http/apis/v1/models.ts @@ -0,0 +1,54 @@ +interface OpenAIModels { + object: string + data: OpenAIModel[] +} + +interface OpenAIModel { + id: string + object: string + created: number + owned_by: string + permission: OpenAIModelPermission[] + root: string + parent?: any +} + +interface OpenAIModelPermission { + id: string + object: string + created: number + allow_create_engine: boolean + allow_sampling: boolean + allow_logprobs: boolean + allow_search_indices: boolean + allow_view: boolean + allow_fine_tuning: boolean + organization: string + group?: any + is_blocking: boolean +} + +interface OpenAIModelsError { + message: string + type: string + param?: any + code: string +} + +export async function requestOpenAIModels(apiKey: string): Promise { + try { + const resp = await fetch('https://api.openai.com/v1/models', { + method: 'GET', + headers: { + Authorization: `Bearer ${apiKey}`, + }, + }) + const result = await resp.json() + if (result.error) { + return Promise.reject(result.error as OpenAIModelsError) + } + return result + } catch (e) { + return Promise.reject(e) + } +} diff --git a/src/i18n/en/translation.json b/src/i18n/en/translation.json index 17bfd2a..e5cd5cc 100644 --- a/src/i18n/en/translation.json +++ b/src/i18n/en/translation.json @@ -65,9 +65,9 @@ "Copied to clipboard": "Copied to clipboard", "Country Not Supported": "Your IP address is from {{name}}, which is not a supported region of OpenAI. Continuing to use without extra network configuration may result in your account being blocked by OpenAI, regardless of your ChatGPT Plus subscription status or any remaining account balance. >>", "Country Not Detected": "We were unable to check if your IP address is in a supported region of OpenAI. Please check your Internet connection, and ensure that you are accessing the API in a supported region of OpenAI, or your account may get banned regardless of your GPT Plus subscription status or any remaining account balance. >>", - "Enter API URL first": "Please enter the API URL first", - "Enter API URL Path first": "Please enter the API URL Path first", - "Enter API Key first": "Please enter the API Key first", + "Please enter the API URL first": "Please enter the API URL first", + "Please enter the API URL Path first": "Please enter the API URL Path first", + "Please enter the API Key first": "Please enter the API Key first", "Warning": "Warning", "Text recognition seems not stable on Android": "Text recognition seems not stable on Android", "ChatMessageClearWarning": "The messages in this chat will be permanently deleted and cannot be recovered, are you sure to clear them ?", diff --git a/src/i18n/zh-Hans/translation.json b/src/i18n/zh-Hans/translation.json index 7ac6151..c664e81 100644 --- a/src/i18n/zh-Hans/translation.json +++ b/src/i18n/zh-Hans/translation.json @@ -65,9 +65,9 @@ "Copied to clipboard": "已复制到剪贴板", "Country Not Supported": "您的 IP 属地为 {{name}},不在 OpenAI 支持的地区范围内。如果不进行额外的网络配置继续使用,即使订阅了 ChatGPT Plus 或账号内仍有余额,您的账户也会被 OpenAI 封禁。 >>", "Country Not Detected": "我们无法检查您的 IP 地址是否位于 OpenAI 的支持的地区,可能是由于您的网络环境无法访问 OpenAI。请检查您的网络连接,并确保您在支持的位置访问 API,否则即使订阅了 ChatGPT Plus 或账号内仍有余额,您的账户也会被 OpenAI 封禁。 >>", - "Enter API URL first": "请先输入 API 网址", - "Enter API URL Path first": "请先输入 API 网址路径", - "Enter API Key first": "请先输入 API 密钥", + "Please enter the API URL first": "请先输入 API 网址", + "Please enter the API URL Path first": "请先输入 API 网址路径", + "Please enter the API Key first": "请先输入 API 密钥", "Warning": "警告", "Text recognition seems not stable on Android": "文本识别在 Android 上似乎不太稳定", "ChatMessageClearWarning": "该对话中的消息将被永久删除且不可恢复,确定清空?", diff --git a/src/screens/AppContent.tsx b/src/screens/AppContent.tsx index 052f010..75b7aab 100644 --- a/src/screens/AppContent.tsx +++ b/src/screens/AppContent.tsx @@ -1,6 +1,7 @@ import { colors } from '../res/colors' import { useThemeDark } from '../themes/hooks' import { TemplateScreen } from './_template' +import { ApiKeysScreen } from './api-keys' import { AwesomePromptsScreen } from './awesome-prompts' import { CustomChatScreen } from './custom-chat' import { CustomChatInitScreen } from './custom-chat-init' @@ -72,6 +73,13 @@ export function AppContent(): JSX.Element { + ) diff --git a/src/screens/api-keys/BottomInput.tsx b/src/screens/api-keys/BottomInput.tsx new file mode 100644 index 0000000..858b5b3 --- /dev/null +++ b/src/screens/api-keys/BottomInput.tsx @@ -0,0 +1,143 @@ +import { SvgIcon } from '../../components/SvgIcon' +import { colors } from '../../res/colors' +import { dimensions } from '../../res/dimensions' +import React, { useEffect, useRef, useState } from 'react' +import { + Pressable, + StyleProp, + StyleSheet, + TextInput, + TextStyle, + View, + ViewStyle, +} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, + useSharedValue, + withTiming, +} from 'react-native-reanimated' +import { useSafeAreaFrame } from 'react-native-safe-area-context' + +const HEIGHT = 64 +const TEXT_LEFT = 16 + +export type BottomInputProps = { + style?: StyleProp + index: number + value: string + verified: boolean | undefined + verifying: boolean + onChangeText: (value: string) => void +} + +export function BottomInput(props: BottomInputProps) { + const { style, index, value, verified, verifying, onChangeText } = props + + const { width: frameWidth } = useSafeAreaFrame() + const inputWidth = frameWidth - dimensions.edgeTwice * 2 + + const anim = useSharedValue(0) + const inputAnimStyle = useAnimatedStyle(() => { + return { + transform: [ + { + translateX: interpolate(anim.value, [0, 1], [4, 0], Extrapolation.CLAMP), + }, + ], + } + }) + const iconAnimStyle = useAnimatedStyle(() => { + return { + transform: [ + { + rotate: `-${anim.value * 90}deg`, + }, + ], + } + }) + + let color = colors.black + let borderColor = colors.transparent + if (verified === true) { + color = colors.in + borderColor = colors.in + } else if (verified === false) { + color = colors.out + borderColor = colors.out + } + + const inputRef = useRef(null) + const [focused, setFocused] = useState(false) + const inputing = focused || value ? true : false + useEffect(() => { + anim.value = withTiming(inputing ? 1 : 0) + }, [inputing]) + + return ( + + + setFocused(true)} + onBlur={() => setFocused(false)} + /> + + { + if (verifying) { + return + } + inputRef.current?.focus() + }}> + + + + + + ) +} + +type Styles = { + container: ViewStyle + text: TextStyle + rowCenter: ViewStyle +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + height: HEIGHT, + alignItems: 'center', + marginBottom: dimensions.edge, + paddingHorizontal: dimensions.edge, + borderWidth: 1, + borderRadius: dimensions.borderRadius, + backgroundColor: colors.white, + }, + text: { + flex: 1, + fontSize: 15, + lineHeight: 21, + marginLeft: TEXT_LEFT, + }, + rowCenter: { + flexDirection: 'row', + height: HEIGHT, + alignItems: 'center', + paddingLeft: 8, + // backgroundColor: 'green', + }, +}) diff --git a/src/screens/api-keys/BottomView.tsx b/src/screens/api-keys/BottomView.tsx new file mode 100644 index 0000000..62abcbd --- /dev/null +++ b/src/screens/api-keys/BottomView.tsx @@ -0,0 +1,274 @@ +import { Button } from '../../components/Button' +import { SvgIcon } from '../../components/SvgIcon' +import { hapticError, hapticSoft, hapticSuccess } from '../../haptic' +import { requestOpenAIModels } from '../../http/apis/v1/models' +import { print } from '../../printer' +import { colors } from '../../res/colors' +import { dimensions } from '../../res/dimensions' +import { stylez } from '../../res/stylez' +import { BottomInput } from './BottomInput' +import BottomSheet from '@gorhom/bottom-sheet' +import React, { useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + ActivityIndicator, + Keyboard, + Pressable, + ScrollView, + StyleProp, + StyleSheet, + Text, + View, + ViewStyle, +} from 'react-native' +import Animated, { + Extrapolation, + interpolate, + useAnimatedStyle, + useSharedValue, +} from 'react-native-reanimated' +import { useSafeAreaFrame } from 'react-native-safe-area-context' + +const BOTTOM = 32 + +type ApiKeyVerifyResult = { [apiKey: string]: boolean } + +export type BottomViewProps = { + style?: StyleProp + apiKey: string + onChangeApiKey: (value: string) => void +} + +export type BottomViewHandle = { + expand: () => void + collapse: () => void +} + +export const BottomView = React.forwardRef((props, ref) => { + const { style, apiKey, onChangeApiKey } = props + + const { t } = useTranslation() + + const { width: frameWidth, height: frameHeight } = useSafeAreaFrame() + const minSheetHeight = frameHeight * 0.5 + const maxSheetHeight = frameHeight * 0.7 + + const confirmButtonWidth = frameWidth - dimensions.edgeTwice * 2 + + const [keysList, setKeysList] = useState(() => { + if (!apiKey) { + return [''] + } + return apiKey.split(',') + }) + const addDisabled = keysList.length < 3 && keysList[keysList.length - 1] ? false : true + + const [verifyResult, setVerifyResult] = useState({}) + const valuableKeys = keysList.filter(v => (v.trim() ? true : false)) + const valuableKeysEmpty = valuableKeys.length === 0 + + const [verifying, setVerifying] = useState(false) + const onVerifyPress = async () => { + try { + Keyboard.dismiss() + hapticSoft() + setVerifying(true) + const results = await Promise.allSettled(valuableKeys.map(v => requestOpenAIModels(v))) + const _verifyResult: ApiKeyVerifyResult = {} + results.forEach((v, index) => { + _verifyResult[valuableKeys[index]] = v.status === 'fulfilled' + }) + setVerifyResult(prev => ({ ...prev, ..._verifyResult })) + if (results.every(v => v.status === 'fulfilled')) { + hapticSuccess() + } else { + hapticError() + } + setVerifying(false) + } catch (e) { + print('error', e) + hapticError() + setVerifying(false) + } + } + + const bottomSheetRef = useRef(null) + const snapPoints = useMemo( + () => [minSheetHeight, maxSheetHeight], + [minSheetHeight, maxSheetHeight] + ) + + const animatedIndex = useSharedValue(0) + const confirmAnimStyle = useAnimatedStyle(() => { + return { + opacity: interpolate(animatedIndex.value, [0, 1], [1, 0], Extrapolation.CLAMP), + transform: [ + { + translateY: interpolate( + animatedIndex.value, + [0, 1], + [0, 48 + BOTTOM], + Extrapolation.CLAMP + ), + }, + ], + } + }) + + useImperativeHandle(ref, () => ({ + expand: () => bottomSheetRef.current?.snapToIndex(1), + collapse: () => bottomSheetRef.current?.snapToIndex(0), + })) + + useEffect(() => { + const subscrition = Keyboard.addListener('keyboardDidShow', () => { + bottomSheetRef.current?.snapToIndex(1) + }) + return () => subscrition.remove() + }, []) + useEffect(() => { + const subscrition = Keyboard.addListener('keyboardDidHide', () => { + bottomSheetRef.current?.snapToIndex(0) + }) + return () => subscrition.remove() + }, []) + + return ( + <> + + + {keysList.map((v, index) => { + return ( + { + const newList = [...keysList] + newList[index] = value + setKeysList(newList) + }} + /> + ) + })} + + + + VERIFY + + {verifying ? ( + + ) : null} + + + { + setKeysList([...keysList, '']) + }}> + + + + + + + +