Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
380 lines
12 KiB
TypeScript
380 lines
12 KiB
TypeScript
import { useCallback, useMemo, useState } from "react";
|
|
import type { TFunction } from "i18next";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Alert, Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { settingsStyles } from "@/styles/settings";
|
|
import { useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
|
import { buildProviderDefinitions } from "@/utils/provider-definitions";
|
|
import {
|
|
buildAcpProviderConfigPatch,
|
|
type AcpProviderCatalogItem,
|
|
} from "@/hooks/use-acp-provider-catalog";
|
|
import { ProviderCatalogList } from "@/components/provider-catalog-list";
|
|
import { getProviderIcon } from "@/components/provider-icons";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { Switch } from "@/components/ui/switch";
|
|
import { SettingsSection } from "@/screens/settings/settings-section";
|
|
import { useProviderSettingsStore } from "@/stores/provider-settings-store";
|
|
import { ChevronRight } from "lucide-react-native";
|
|
|
|
type ProviderDefinition = ReturnType<typeof buildProviderDefinitions>[number];
|
|
type ProviderEntry = NonNullable<ReturnType<typeof useProvidersSnapshot>["entries"]>[number];
|
|
|
|
type StatusTone = "success" | "warning" | "danger" | "muted" | "loading";
|
|
|
|
interface ProviderStatus {
|
|
tone: StatusTone;
|
|
label: string;
|
|
modelCount: number | null;
|
|
}
|
|
|
|
function getProviderStatus(
|
|
status: string,
|
|
enabled: boolean,
|
|
modelCount: number,
|
|
t: TFunction,
|
|
): ProviderStatus {
|
|
if (!enabled)
|
|
return { tone: "muted", label: t("settings.providers.statuses.disabled"), modelCount: null };
|
|
if (status === "loading") {
|
|
return { tone: "loading", label: t("settings.providers.statuses.loading"), modelCount: null };
|
|
}
|
|
if (status === "error") {
|
|
return { tone: "danger", label: t("settings.providers.statuses.error"), modelCount: null };
|
|
}
|
|
if (status === "ready") {
|
|
return {
|
|
tone: "success",
|
|
label: t("settings.providers.statuses.available"),
|
|
modelCount: modelCount > 0 ? modelCount : null,
|
|
};
|
|
}
|
|
return {
|
|
tone: "warning",
|
|
label: t("settings.providers.statuses.notInstalled"),
|
|
modelCount: null,
|
|
};
|
|
}
|
|
|
|
interface ProviderRowProps {
|
|
def: ProviderDefinition;
|
|
entry: ProviderEntry;
|
|
enabled: boolean;
|
|
isToggling: boolean;
|
|
isFirst: boolean;
|
|
onPress: (providerId: string) => void;
|
|
onToggleEnabled: (providerId: string, enabled: boolean) => void;
|
|
}
|
|
|
|
function ProviderRow({
|
|
def,
|
|
entry,
|
|
enabled,
|
|
isToggling,
|
|
isFirst,
|
|
onPress,
|
|
onToggleEnabled,
|
|
}: ProviderRowProps) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const ProviderIcon = getProviderIcon(def.id);
|
|
const providerError =
|
|
enabled &&
|
|
entry.status === "error" &&
|
|
typeof entry.error === "string" &&
|
|
entry.error.trim().length > 0
|
|
? entry.error.trim()
|
|
: null;
|
|
const modelCount = entry.models?.length ?? 0;
|
|
const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t);
|
|
|
|
const handlePress = useCallback(() => {
|
|
onPress(def.id);
|
|
}, [def.id, onPress]);
|
|
const handleToggleValueChange = useCallback(
|
|
(value: boolean) => {
|
|
onToggleEnabled(def.id, value);
|
|
},
|
|
[def.id, onToggleEnabled],
|
|
);
|
|
const rowStyle = useCallback(
|
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
settingsStyles.row,
|
|
!isFirst && settingsStyles.rowBorder,
|
|
styles.row,
|
|
hovered && styles.rowHovered,
|
|
pressed && styles.rowPressed,
|
|
],
|
|
[isFirst],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
style={rowStyle}
|
|
onPress={handlePress}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.providers.providerDetails", { name: def.label })}
|
|
>
|
|
{({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => (
|
|
<>
|
|
<View style={styles.rowContent}>
|
|
<ChevronRight
|
|
size={theme.iconSize.sm}
|
|
color={hovered ? theme.colors.foreground : theme.colors.foregroundMuted}
|
|
/>
|
|
<ProviderIcon size={theme.iconSize.md} color={theme.colors.foreground} />
|
|
<View style={styles.textColumn}>
|
|
<View style={styles.titleRow}>
|
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
|
{def.label}
|
|
</Text>
|
|
<Text style={styles.separator}>·</Text>
|
|
<StatusIndicator status={providerStatus} />
|
|
</View>
|
|
{providerError ? (
|
|
<Text style={styles.errorText} numberOfLines={3}>
|
|
{providerError}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
<Switch
|
|
value={enabled}
|
|
onValueChange={handleToggleValueChange}
|
|
disabled={isToggling}
|
|
accessibilityLabel={t("settings.providers.enableProvider", { name: def.label })}
|
|
/>
|
|
</>
|
|
)}
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function getDotColor(tone: StatusTone, theme: ReturnType<typeof useUnistyles>["theme"]): string {
|
|
switch (tone) {
|
|
case "success":
|
|
return theme.colors.statusSuccess;
|
|
case "warning":
|
|
return theme.colors.statusWarning;
|
|
case "danger":
|
|
return theme.colors.statusDanger;
|
|
default:
|
|
return theme.colors.foregroundMuted;
|
|
}
|
|
}
|
|
|
|
function StatusIndicator({ status }: { status: ProviderStatus }) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const dotStyle = useMemo(
|
|
() => [styles.statusDot, { backgroundColor: getDotColor(status.tone, theme) }],
|
|
[status.tone, theme],
|
|
);
|
|
|
|
return (
|
|
<View style={styles.statusRow}>
|
|
{status.tone === "loading" ? (
|
|
<LoadingSpinner size={10} color={theme.colors.foregroundMuted} />
|
|
) : (
|
|
<View style={dotStyle} />
|
|
)}
|
|
<Text style={styles.statusLabel}>{status.label}</Text>
|
|
{status.modelCount !== null ? (
|
|
<>
|
|
<Text style={styles.separator}>·</Text>
|
|
<Text style={styles.statusLabel}>
|
|
{status.modelCount === 1
|
|
? t("settings.providers.models.one")
|
|
: t("settings.providers.models.many", { count: status.modelCount })}
|
|
</Text>
|
|
</>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
export interface ProvidersSectionProps {
|
|
serverId: string;
|
|
}
|
|
|
|
export function ProvidersSection({ serverId }: ProvidersSectionProps) {
|
|
const { t } = useTranslation();
|
|
const isConnected = useHostRuntimeIsConnected(serverId);
|
|
const { entries, isLoading, refresh } = useProvidersSnapshot(serverId);
|
|
const { patchConfig } = useDaemonConfig(serverId);
|
|
const openProviderSettings = useProviderSettingsStore((state) => state.open);
|
|
const [pendingProviderId, setPendingProviderId] = useState<string | null>(null);
|
|
const [installingProviderId, setInstallingProviderId] = useState<string | null>(null);
|
|
|
|
const providerDefinitions = useMemo(() => buildProviderDefinitions(entries), [entries]);
|
|
const hasServer = serverId.length > 0;
|
|
|
|
const handleOpenProviderSettings = useCallback(
|
|
(providerId: string) => {
|
|
openProviderSettings({ serverId, provider: providerId });
|
|
},
|
|
[openProviderSettings, serverId],
|
|
);
|
|
|
|
const handleToggleEnabled = useCallback(
|
|
async (providerId: string, enabled: boolean) => {
|
|
setPendingProviderId(providerId);
|
|
try {
|
|
await patchConfig({ providers: { [providerId]: { enabled } } });
|
|
} catch (error) {
|
|
Alert.alert(
|
|
t("settings.providers.updateErrorTitle"),
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setPendingProviderId((current) => (current === providerId ? null : current));
|
|
}
|
|
},
|
|
[patchConfig, t],
|
|
);
|
|
|
|
const handleInstall = useCallback(
|
|
async (entry: AcpProviderCatalogItem) => {
|
|
if (installingProviderId) return;
|
|
setInstallingProviderId(entry.id);
|
|
try {
|
|
await patchConfig(buildAcpProviderConfigPatch(entry));
|
|
await refresh([entry.id]);
|
|
} catch (error) {
|
|
Alert.alert(
|
|
t("settings.providers.addErrorTitle"),
|
|
error instanceof Error ? error.message : String(error),
|
|
);
|
|
} finally {
|
|
setInstallingProviderId((current) => (current === entry.id ? null : current));
|
|
}
|
|
},
|
|
[installingProviderId, patchConfig, refresh, t],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<SettingsSection
|
|
title={t("settings.providers.title")}
|
|
testID="host-page-providers-card"
|
|
style={styles.sectionSpacing}
|
|
>
|
|
{!hasServer || !isConnected ? (
|
|
<View style={EMPTY_CARD_STYLE}>
|
|
<Text style={styles.emptyText}>{t("settings.providers.unavailable")}</Text>
|
|
</View>
|
|
) : null}
|
|
{hasServer && isConnected && isLoading ? (
|
|
<View style={EMPTY_CARD_STYLE}>
|
|
<Text style={styles.emptyText}>{t("settings.providers.loading")}</Text>
|
|
</View>
|
|
) : null}
|
|
{hasServer && isConnected && !isLoading && providerDefinitions.length > 0 ? (
|
|
<View style={settingsStyles.card}>
|
|
{providerDefinitions.map((def, index) => {
|
|
const entry = entries?.find((candidate) => candidate.provider === def.id);
|
|
if (!entry) return null;
|
|
return (
|
|
<ProviderRow
|
|
key={def.id}
|
|
def={def}
|
|
entry={entry}
|
|
enabled={entry.enabled ?? true}
|
|
isToggling={pendingProviderId === def.id}
|
|
isFirst={index === 0}
|
|
onPress={handleOpenProviderSettings}
|
|
onToggleEnabled={handleToggleEnabled}
|
|
/>
|
|
);
|
|
})}
|
|
</View>
|
|
) : null}
|
|
</SettingsSection>
|
|
|
|
{hasServer && isConnected ? (
|
|
<SettingsSection
|
|
title={t("settings.providers.addProvider")}
|
|
testID="host-page-add-provider-card"
|
|
style={styles.addProviderSection}
|
|
>
|
|
<ProviderCatalogList
|
|
serverId={serverId}
|
|
installingProviderId={installingProviderId}
|
|
onInstall={handleInstall}
|
|
/>
|
|
</SettingsSection>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
sectionSpacing: {
|
|
marginBottom: theme.spacing[4],
|
|
},
|
|
addProviderSection: {
|
|
marginTop: theme.spacing[4],
|
|
},
|
|
emptyCard: {
|
|
padding: theme.spacing[4],
|
|
alignItems: "center",
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
row: {
|
|
gap: theme.spacing[3],
|
|
minHeight: 56,
|
|
},
|
|
rowHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
rowPressed: {
|
|
backgroundColor: theme.colors.surface3,
|
|
},
|
|
rowContent: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
textColumn: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
},
|
|
titleRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
statusRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1.5],
|
|
},
|
|
statusDot: {
|
|
width: 8,
|
|
height: 8,
|
|
borderRadius: 4,
|
|
},
|
|
statusLabel: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
separator: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
errorText: {
|
|
color: theme.colors.palette.red[300],
|
|
fontSize: theme.fontSize.xs,
|
|
marginTop: theme.spacing[1],
|
|
},
|
|
}));
|
|
|
|
const EMPTY_CARD_STYLE = [settingsStyles.card, styles.emptyCard];
|