Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 15 additions & 36 deletions .github/workflows/build-android.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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
31 changes: 31 additions & 0 deletions apps/mobile/src/atoms/settings/ai.ts
Original file line number Diff line number Diff line change
@@ -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 }
}
2 changes: 1 addition & 1 deletion apps/mobile/src/atoms/settings/internal/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ export const createSettingAtom = <T extends object>(
const updated = Date.now()

EventBus.dispatch("SETTING_CHANGE_EVENT", {
key: settingKey as "general" | "ui",
key: settingKey as "general" | "ui" | "ai",
payload: {
[key]: value,
},
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/initialize/hydrate.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -17,6 +18,7 @@ export const hydrateSettings = () => {
initializeDefaultUISettings()
initializeDefaultGeneralSettings()
initializeDefaultDataSettings()
initializeDefaultAISettings()
}
export const hydrateQueryClient = () => {
persistQueryClient({
Expand Down
56 changes: 56 additions & 0 deletions apps/mobile/src/lib/api-client.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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"
Expand All @@ -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<string, string>
} | null => {
if (!provider?.provider) return null

const normalized: {
provider: string
baseURL?: string
apiKey?: string
headers?: Record<string, string>
} = {
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

Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions apps/mobile/src/modules/settings/SettingsList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand Down
133 changes: 133 additions & 0 deletions apps/mobile/src/modules/settings/routes/AI.tsx
Original file line number Diff line number Diff line change
@@ -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<UserByokProviderConfig>(() => {
return (
providers.find((item) => item.provider === OPENAI_PROVIDER) ?? { provider: OPENAI_PROVIDER }
)
}, [providers])

const updateByok = (next: { enabled?: boolean; provider?: Partial<UserByokProviderConfig> }) => {
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 (
<SafeNavigationScrollView
className="bg-system-grouped-background"
Header={<NavigationBlurEffectHeaderView title={tSettings("titles.ai")} />}
>
<GroupedInsetListSectionHeader label={tAi("byok.title")} marginSize="small" />

<GroupedInsetListCard>
<GroupedInsetListCell label={tAi("byok.enabled")} description={tAi("byok.description")}>
<Switch
size="sm"
value={enabled}
onValueChange={(value) => {
updateByok({ enabled: value })
}}
/>
</GroupedInsetListCell>
</GroupedInsetListCard>

{enabled ? (
<>
<GroupedInsetListSectionHeader label={tAi("byok.providers.title")} />
<GroupedInsetListCard>
<GroupedInsetListCell
label={tAi("byok.providers.form.base_url")}
leftClassName="flex-none"
rightClassName="flex-1"
>
<View className="flex-1">
<PlainTextField
className="w-full flex-1 text-right text-secondary-label"
value={openaiProvider.baseURL ?? ""}
onChangeText={(text) => {
updateByok({ provider: { baseURL: text || null } })
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
placeholder={tAi("byok.providers.form.base_url_placeholder")}
/>
</View>
</GroupedInsetListCell>

<GroupedInsetListCell
label={tAi("byok.providers.form.api_key")}
leftClassName="flex-none"
rightClassName="flex-1"
>
<View className="flex-1">
<PlainTextField
className="w-full flex-1 text-right text-secondary-label"
value={openaiProvider.apiKey ?? ""}
onChangeText={(text) => {
updateByok({ provider: { apiKey: text || null } })
}}
autoCapitalize="none"
autoCorrect={false}
secureTextEntry
placeholder={tAi("byok.providers.form.api_key_placeholder")}
/>
</View>
</GroupedInsetListCell>
</GroupedInsetListCard>

<GroupedOutlineDescription description={tAi("byok.providers.form.api_key_help")} />
<GroupedOutlineDescription description={tAi("byok.providers.form.base_url_help")} />
</>
) : null}
</SafeNavigationScrollView>
)
}
Loading
Loading