From e0dfd267807637bbd58a61d69a08220ebdd8f1ca Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Feb 2026 11:21:35 +0000 Subject: [PATCH 1/2] feat(mobile): add BYOK settings for AI translation and summary --- apps/mobile/src/atoms/settings/ai.ts | 31 ++++ .../src/atoms/settings/internal/helper.ts | 2 +- apps/mobile/src/initialize/hydrate.ts | 2 + apps/mobile/src/lib/api-client.ts | 56 ++++++++ .../src/modules/settings/SettingsList.tsx | 10 ++ .../mobile/src/modules/settings/routes/AI.tsx | 133 ++++++++++++++++++ .../mobile/src/modules/settings/sync-queue.ts | 10 +- 7 files changed, 241 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/atoms/settings/ai.ts create mode 100644 apps/mobile/src/modules/settings/routes/AI.tsx diff --git a/apps/mobile/src/atoms/settings/ai.ts b/apps/mobile/src/atoms/settings/ai.ts new file mode 100644 index 00000000000..f993ed4c512 --- /dev/null +++ b/apps/mobile/src/atoms/settings/ai.ts @@ -0,0 +1,31 @@ +import { defaultAISettings } from "@follow/shared/settings/defaults" +import type { + AISettings, + ByokProviderName, + UserByokProviderConfig, +} from "@follow/shared/settings/interface" + +import { createSettingAtom } from "./internal/helper" + +const createDefaultSettings = (): AISettings => ({ + ...defaultAISettings, +}) + +export const { + useSettingKey: useAISettingKey, + useSettingSelector: useAISettingSelector, + useSettingKeys: useAISettingKeys, + setSetting: setAISetting, + clearSettings: clearAISettings, + initializeDefaultSettings: initializeDefaultAISettings, + getSettings: getAISettings, + useSettingValue: useAISettingValue, + settingAtom: __aiSettingAtom, +} = createSettingAtom("ai", createDefaultSettings) + +export const aiServerSyncWhiteListKeys: (keyof AISettings)[] = [] + +export const getByokProviderConfig = (provider: ByokProviderName): UserByokProviderConfig => { + const { byok } = getAISettings() + return byok.providers.find((item) => item.provider === provider) ?? { provider } +} diff --git a/apps/mobile/src/atoms/settings/internal/helper.ts b/apps/mobile/src/atoms/settings/internal/helper.ts index 7254c263fdd..63003694e53 100644 --- a/apps/mobile/src/atoms/settings/internal/helper.ts +++ b/apps/mobile/src/atoms/settings/internal/helper.ts @@ -119,7 +119,7 @@ export const createSettingAtom = ( const updated = Date.now() EventBus.dispatch("SETTING_CHANGE_EVENT", { - key: settingKey as "general" | "ui", + key: settingKey as "general" | "ui" | "ai", payload: { [key]: value, }, diff --git a/apps/mobile/src/initialize/hydrate.ts b/apps/mobile/src/initialize/hydrate.ts index ca0723d594c..fb6e5435fb6 100644 --- a/apps/mobile/src/initialize/hydrate.ts +++ b/apps/mobile/src/initialize/hydrate.ts @@ -1,5 +1,6 @@ import { persistQueryClient } from "@tanstack/react-query-persist-client" +import { initializeDefaultAISettings } from "../atoms/settings/ai" import { initializeDefaultDataSettings } from "../atoms/settings/data" import { initializeDefaultGeneralSettings } from "../atoms/settings/general" import { initializeDefaultUISettings } from "../atoms/settings/ui" @@ -17,6 +18,7 @@ export const hydrateSettings = () => { initializeDefaultUISettings() initializeDefaultGeneralSettings() initializeDefaultDataSettings() + initializeDefaultAISettings() } export const hydrateQueryClient = () => { persistQueryClient({ diff --git a/apps/mobile/src/lib/api-client.ts b/apps/mobile/src/lib/api-client.ts index 37de2e4eaf1..ab6eb9a3617 100644 --- a/apps/mobile/src/lib/api-client.ts +++ b/apps/mobile/src/lib/api-client.ts @@ -1,3 +1,4 @@ +import type { UserByokProviderConfig } from "@follow/shared/settings/interface" import { userActions } from "@follow/store/user/store" import { createMobileAPIHeaders } from "@follow/utils/headers" import { FollowClient } from "@follow-app/client-sdk" @@ -6,6 +7,7 @@ import { nativeApplicationVersion } from "expo-application" import { Platform } from "react-native" import DeviceInfo from "react-native-device-info" +import { getAISettings } from "../atoms/settings/ai" import { LoginScreen } from "../screens/(modal)/LoginScreen" import { getCookie } from "./auth" import { getClientId, getSessionId } from "./client-session" @@ -21,6 +23,52 @@ export const followClient = new FollowClient({ }) export const followApi = followClient.api + +const BYOK_PROVIDER_ROUTES = ["/ai/summary", "/ai/translation-batch"] + +const normalizeByokProvider = ( + provider: UserByokProviderConfig, +): { + provider: string + baseURL?: string + apiKey?: string + headers?: Record +} | null => { + if (!provider?.provider) return null + + const normalized: { + provider: string + baseURL?: string + apiKey?: string + headers?: Record + } = { + provider: provider.provider, + } + + if (provider.baseURL) normalized.baseURL = provider.baseURL + if (provider.apiKey) normalized.apiKey = provider.apiKey + if (provider.headers && Object.keys(provider.headers).length > 0) { + normalized.headers = provider.headers + } + + return normalized +} + +const resolveOpenAIByokProvider = () => { + const aiSettings = getAISettings() + const { byok } = aiSettings + if (!byok?.enabled) return null + + const provider = byok.providers.find((item) => item.provider === "openai") + if (!provider?.apiKey) return null + + return normalizeByokProvider(provider) +} + +const shouldAttachByok = (url: string) => { + return BYOK_PROVIDER_ROUTES.some((path) => url.includes(path)) +} + followClient.addRequestInterceptor(async (ctx) => { const { url } = ctx @@ -51,6 +99,14 @@ followClient.addRequestInterceptor(async (ctx) => { installerPackageName: await DeviceInfo.getInstallerPackageName(), }) + if (shouldAttachByok(ctx.url)) { + const openaiByokProvider = resolveOpenAIByokProvider() + if (openaiByokProvider) { + header["X-AI-Provider-Type"] = "byok" + header["X-AI-Provider-Config"] = JSON.stringify(openaiByokProvider) + } + } + options.headers = { ...header, ...apiHeader, diff --git a/apps/mobile/src/modules/settings/SettingsList.tsx b/apps/mobile/src/modules/settings/SettingsList.tsx index 5408a3ba9a9..06508f80809 100644 --- a/apps/mobile/src/modules/settings/SettingsList.tsx +++ b/apps/mobile/src/modules/settings/SettingsList.tsx @@ -33,6 +33,7 @@ import { accentColor } from "@/src/theme/colors" import { AboutScreen } from "./routes/About" import { AccountScreen } from "./routes/Account" import { ActionsScreen } from "./routes/Actions" +import { AIScreen } from "./routes/AI" import { AppearanceScreen } from "./routes/Appearance" import { DataScreen } from "./routes/Data" import { FeedsScreen } from "./routes/Feeds" @@ -80,6 +81,15 @@ const SettingGroupNavigationLinks: GroupNavigationLink[] = [ }, iconBackgroundColor: "#8B5CF6", }, + { + label: "titles.ai", + icon: Magic2CuteFiIcon, + onPress: ({ navigation }) => { + navigation.pushControllerView(AIScreen) + }, + iconBackgroundColor: "#9333EA", + anonymous: false, + }, { label: "titles.data_control", icon: DatabaseIcon, diff --git a/apps/mobile/src/modules/settings/routes/AI.tsx b/apps/mobile/src/modules/settings/routes/AI.tsx new file mode 100644 index 00000000000..0e8b5d2c5dd --- /dev/null +++ b/apps/mobile/src/modules/settings/routes/AI.tsx @@ -0,0 +1,133 @@ +import type { ByokProviderName, UserByokProviderConfig } from "@follow/shared/settings/interface" +import { useMemo } from "react" +import { useTranslation } from "react-i18next" +import { View } from "react-native" + +import { setAISetting, useAISettingSelector } from "@/src/atoms/settings/ai" +import { + NavigationBlurEffectHeaderView, + SafeNavigationScrollView, +} from "@/src/components/layouts/views/SafeNavigationScrollView" +import { PlainTextField } from "@/src/components/ui/form/TextField" +import { + GroupedInsetListCard, + GroupedInsetListCell, + GroupedInsetListSectionHeader, + GroupedOutlineDescription, +} from "@/src/components/ui/grouped/GroupedList" +import { Switch } from "@/src/components/ui/switch/Switch" +import type { NavigationControllerView } from "@/src/lib/navigation/types" + +const OPENAI_PROVIDER: ByokProviderName = "openai" + +export const AIScreen: NavigationControllerView = () => { + const { t: tSettings } = useTranslation("settings") + const tAi = (key: string) => tSettings(key as any) + + const byok = useAISettingSelector((s) => s.byok) + + const enabled = byok?.enabled ?? false + const providers = byok?.providers ?? [] + + const openaiProvider = useMemo(() => { + return ( + providers.find((item) => item.provider === OPENAI_PROVIDER) ?? { provider: OPENAI_PROVIDER } + ) + }, [providers]) + + const updateByok = (next: { enabled?: boolean; provider?: Partial }) => { + const currentProviders = byok?.providers ?? [] + const normalizedProviders = [...currentProviders] + + if (next.provider) { + const index = normalizedProviders.findIndex((item) => item.provider === OPENAI_PROVIDER) + const mergedProvider: UserByokProviderConfig = { + provider: OPENAI_PROVIDER, + ...(index !== -1 ? normalizedProviders[index] : {}), + ...next.provider, + } + + if (index !== -1) { + normalizedProviders[index] = mergedProvider + } else { + normalizedProviders.push(mergedProvider) + } + } + + setAISetting("byok", { + enabled: next.enabled ?? enabled, + providers: normalizedProviders, + }) + } + + return ( + } + > + + + + + { + updateByok({ enabled: value }) + }} + /> + + + + {enabled ? ( + <> + + + + + { + updateByok({ provider: { baseURL: text || null } }) + }} + autoCapitalize="none" + autoCorrect={false} + keyboardType="url" + placeholder={tAi("byok.providers.form.base_url_placeholder")} + /> + + + + + + { + updateByok({ provider: { apiKey: text || null } }) + }} + autoCapitalize="none" + autoCorrect={false} + secureTextEntry + placeholder={tAi("byok.providers.form.api_key_placeholder")} + /> + + + + + + + + ) : null} + + ) +} diff --git a/apps/mobile/src/modules/settings/sync-queue.ts b/apps/mobile/src/modules/settings/sync-queue.ts index a980733d5dc..07379efc0b7 100644 --- a/apps/mobile/src/modules/settings/sync-queue.ts +++ b/apps/mobile/src/modules/settings/sync-queue.ts @@ -1,10 +1,11 @@ -import type { GeneralSettings, UISettings } from "@follow/shared/settings/interface" +import type { AISettings, GeneralSettings, UISettings } from "@follow/shared/settings/interface" import { isEmptyObject, jotaiStore, sleep } from "@follow/utils" import { EventBus } from "@follow/utils/event-bus" import type { SettingsTab } from "@follow-app/client-sdk" import { omit } from "es-toolkit/compat" import type { PrimitiveAtom } from "jotai" +import { __aiSettingAtom, aiServerSyncWhiteListKeys, getAISettings } from "@/src/atoms/settings/ai" import { __generalSettingAtom, generalServerSyncWhiteListKeys, @@ -17,6 +18,7 @@ import { kv } from "@/src/lib/kv" type SettingMapping = { appearance: UISettings general: GeneralSettings + ai: AISettings } const omitKeys: string[] = [] @@ -24,6 +26,7 @@ const omitKeys: string[] = [] const localSettingGetterMap = { appearance: () => omit(getUISettings(), uiServerSyncWhiteListKeys, omitKeys), general: () => omit(getGeneralSettings(), generalServerSyncWhiteListKeys, omitKeys), + ai: () => omit(getAISettings(), aiServerSyncWhiteListKeys, omitKeys), } const createInternalSetter = @@ -36,15 +39,18 @@ const createInternalSetter = const localSettingSetterMap = { appearance: createInternalSetter(__uiSettingAtom), general: createInternalSetter(__generalSettingAtom), + ai: createInternalSetter(__aiSettingAtom), } const settingWhiteListMap = { appearance: uiServerSyncWhiteListKeys, general: generalServerSyncWhiteListKeys, + ai: aiServerSyncWhiteListKeys, } const bizSettingKeyToTabMapping = { ui: "appearance", general: "general", + ai: "ai", } export type SettingSyncTab = keyof SettingMapping @@ -58,7 +64,7 @@ declare module "@follow/utils/event-bus" { interface CustomEvent { SETTING_CHANGE_EVENT: { key: keyof typeof bizSettingKeyToTabMapping - payload: any + payload: Partial | Partial | Partial } } } From e9407576aae2e92e2e07ba152ada74b3b18db3f1 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 10 Feb 2026 13:46:01 +0000 Subject: [PATCH 2/2] ci(android): add no-expo apk build workflow path --- .github/workflows/build-android.yml | 51 +++++++++-------------------- 1 file changed, 15 insertions(+), 36 deletions(-) diff --git a/.github/workflows/build-android.yml b/.github/workflows/build-android.yml index fb66914ff60..f15ec3aa1dd 100644 --- a/.github/workflows/build-android.yml +++ b/.github/workflows/build-android.yml @@ -7,28 +7,21 @@ on: paths: - "apps/mobile/**" - "pnpm-lock.yaml" + - ".github/workflows/build-android.yml" workflow_dispatch: inputs: - profile: - type: choice - default: preview - options: - - preview - - production - description: "Build profile" release: type: boolean default: false description: "Create a release draft for the build" concurrency: - group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event.inputs.profile }} + group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build: - name: Build Android apk for device - if: github.secret_source != 'None' + name: Build Android APK (no Expo account) runs-on: ubuntu-latest steps: @@ -63,40 +56,27 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: 📱 Setup EAS - uses: expo/expo-github-action@v8 - with: - eas-version: latest - token: ${{ secrets.EXPO_TOKEN }} - - name: Install dependencies run: pnpm install - - name: 🔨 Build Android app + - name: Generate native Android project working-directory: apps/mobile - run: eas build --platform android --profile ${{ github.event.inputs.profile || 'preview' }} --local --output=${{ github.workspace }}/build.${{ github.event.inputs.profile == 'production' && 'aab' || 'apk' }} + env: + CI: "1" + PROFILE: preview + run: pnpm exec expo prebuild --platform android --non-interactive --no-install --clean - - name: 📤 Upload apk Artifact - if: github.event.inputs.profile != 'production' - uses: actions/upload-artifact@v6 - with: - name: app-android - path: ${{ github.workspace }}/build.apk - retention-days: 90 + - name: 🔨 Build debug APK via Gradle + working-directory: apps/mobile/android + run: ./gradlew --no-daemon assembleDebug - - name: 📤 Upload aab Artifact - if: github.event.inputs.profile == 'production' + - name: 📤 Upload APK artifact uses: actions/upload-artifact@v6 with: - name: aab-android - path: ${{ github.workspace }}/build.aab + name: app-android-debug + path: apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk retention-days: 90 - - name: Submit to Google Play - if: github.event.inputs.profile == 'production' - working-directory: apps/mobile - run: eas submit --platform android --path ${{ github.workspace }}/build.aab --non-interactive - - name: Setup Version if: github.event.inputs.release == 'true' id: version @@ -112,5 +92,4 @@ jobs: draft: false prerelease: true tag_name: mobile/v${{ steps.version.outputs.APP_VERSION }} - # .aab cannot be installed directly on your Android Emulator or device. - files: ${{ github.workspace }}/build.apk + files: apps/mobile/android/app/build/outputs/apk/debug/app-debug.apk