Files
paseo/packages/app/src/screens/settings/providers-section.tsx
T
chyendongnhanh338andGitHub 08ebe5e7e1 Complete client UI i18n migration
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
2026-06-11 14:15:35 +08:00

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];