mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-14 20:32:46 +00:00
Adds client-side i18n support, language settings, translated UI copy, and locale parity coverage.
852 lines
25 KiB
TypeScript
852 lines
25 KiB
TypeScript
import { AlertTriangle, FileText, Plus, RotateCw, Trash2 } from "lucide-react-native";
|
|
import type { TFunction } from "i18next";
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import {
|
|
ActivityIndicator,
|
|
Pressable,
|
|
type PressableStateCallbackType,
|
|
ScrollView,
|
|
Text,
|
|
View,
|
|
} from "react-native";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import {
|
|
AdaptiveModalSheet,
|
|
AdaptiveTextInput,
|
|
type SheetHeader,
|
|
} from "@/components/adaptive-modal-sheet";
|
|
import { Button } from "@/components/ui/button";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
|
import { isWeb } from "@/constants/platform";
|
|
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
|
|
import { useDaemonConfig } from "@/hooks/use-daemon-config";
|
|
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
|
import { useHostRuntimeClient } from "@/runtime/host-runtime";
|
|
import { settingsStyles } from "@/styles/settings";
|
|
import { resolveProviderLabel } from "@/utils/provider-definitions";
|
|
import { formatTimeAgo } from "@/utils/time";
|
|
import { compareMatchScores, scoreTextFields } from "@/utils/score-match";
|
|
import type { AgentModelDefinition, AgentProvider } from "@getpaseo/protocol/agent-types";
|
|
import type { ProviderProfileModel } from "@getpaseo/protocol/provider-config";
|
|
|
|
interface ProviderDiagnosticSheetProps {
|
|
provider: string;
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
serverId: string;
|
|
}
|
|
|
|
function rankModels<T>(items: T[], query: string, fields: (item: T) => string[]): T[] {
|
|
if (!query.trim()) return items;
|
|
const scored = items
|
|
.map((item) => ({ item, score: scoreTextFields(query, fields(item)) }))
|
|
.filter(
|
|
(entry): entry is { item: T; score: NonNullable<typeof entry.score> } => entry.score !== null,
|
|
);
|
|
scored.sort((a, b) => compareMatchScores(a.score, b.score));
|
|
return scored.map((entry) => entry.item);
|
|
}
|
|
|
|
function DiscoveredModelRow({ model }: { model: AgentModelDefinition }) {
|
|
return (
|
|
<View style={sheetStyles.modelRow}>
|
|
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
|
{model.label}
|
|
</Text>
|
|
<Text
|
|
style={sheetStyles.monoHint}
|
|
numberOfLines={1}
|
|
selectable
|
|
dataSet={CODE_SURFACE_DATASET}
|
|
>
|
|
{model.id}
|
|
</Text>
|
|
{model.description ? (
|
|
<Text style={sheetStyles.descriptionInline} numberOfLines={1}>
|
|
{model.description}
|
|
</Text>
|
|
) : null}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function CustomModelRow({
|
|
model,
|
|
deleting,
|
|
onDelete,
|
|
}: {
|
|
model: ProviderProfileModel;
|
|
deleting: boolean;
|
|
onDelete: (modelId: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const handleDelete = useCallback(() => onDelete(model.id), [model.id, onDelete]);
|
|
const deleteButtonStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
sheetStyles.iconButton,
|
|
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
|
|
deleting ? sheetStyles.disabled : null,
|
|
],
|
|
[deleting],
|
|
);
|
|
|
|
return (
|
|
<View style={sheetStyles.modelRow}>
|
|
<Text style={sheetStyles.modelTitle} numberOfLines={1}>
|
|
{model.label}
|
|
</Text>
|
|
<Text
|
|
style={sheetStyles.monoHint}
|
|
numberOfLines={1}
|
|
selectable
|
|
dataSet={CODE_SURFACE_DATASET}
|
|
>
|
|
{model.id}
|
|
</Text>
|
|
<View style={sheetStyles.modelRowFiller} />
|
|
<Pressable
|
|
onPress={handleDelete}
|
|
disabled={deleting}
|
|
hitSlop={8}
|
|
style={deleteButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.providers.models.removeModel", { id: model.id })}
|
|
>
|
|
<Trash2 size={theme.iconSize.sm} color={theme.colors.destructive} />
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SectionHeader({ title, count, hint }: { title: string; count?: number; hint?: string }) {
|
|
return (
|
|
<View style={sheetStyles.sectionHeader}>
|
|
<Text style={settingsStyles.sectionHeaderTitle}>{title}</Text>
|
|
<View style={sheetStyles.sectionHeaderMeta}>
|
|
{count !== undefined ? (
|
|
<Text style={settingsStyles.sectionHeaderTitle}>{count}</Text>
|
|
) : null}
|
|
{count !== undefined && hint ? (
|
|
<Text style={settingsStyles.sectionHeaderTitle}>·</Text>
|
|
) : null}
|
|
{hint ? <Text style={settingsStyles.sectionHeaderTitle}>{hint}</Text> : null}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function AddCustomModelSubSheet({
|
|
provider,
|
|
serverId,
|
|
visible,
|
|
onClose,
|
|
refresh,
|
|
}: {
|
|
provider: string;
|
|
serverId: string;
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
refresh: (providers?: AgentProvider[]) => Promise<void>;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const { config, patchConfig } = useDaemonConfig(serverId);
|
|
const [input, setInput] = useState("");
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const additionalModels = useMemo(
|
|
() => config?.providers?.[provider]?.additionalModels ?? [],
|
|
[config?.providers, provider],
|
|
);
|
|
const trimmed = input.trim();
|
|
const canAdd = trimmed.length > 0 && !additionalModels.some((model) => model.id === trimmed);
|
|
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setInput("");
|
|
setError(null);
|
|
}
|
|
}, [visible]);
|
|
|
|
const handleAdd = useCallback(() => {
|
|
if (!canAdd) return;
|
|
setError(null);
|
|
setSaving(true);
|
|
void patchConfig({
|
|
providers: {
|
|
[provider]: {
|
|
additionalModels: [...additionalModels, { id: trimmed, label: trimmed }],
|
|
},
|
|
},
|
|
})
|
|
.then(() => refresh([provider]))
|
|
.then(() => onClose())
|
|
.catch((err) => {
|
|
setError(err instanceof Error ? err.message : t("settings.providers.models.failedToSave"));
|
|
})
|
|
.finally(() => setSaving(false));
|
|
}, [additionalModels, canAdd, onClose, patchConfig, provider, refresh, t, trimmed]);
|
|
|
|
const header = useMemo<SheetHeader>(
|
|
() => ({ title: t("settings.providers.models.addCustomTitle") }),
|
|
[t],
|
|
);
|
|
|
|
return (
|
|
<AdaptiveModalSheet
|
|
header={header}
|
|
visible={visible}
|
|
onClose={onClose}
|
|
desktopMaxWidth={420}
|
|
snapPoints={ADD_SNAP_POINTS}
|
|
testID="add-custom-model-sheet"
|
|
>
|
|
<View style={sheetStyles.formGroup}>
|
|
<Text style={sheetStyles.formLabel}>{t("settings.providers.models.modelId")}</Text>
|
|
<AdaptiveTextInput
|
|
initialValue={input}
|
|
resetKey={`add-custom-${visible}`}
|
|
value={input}
|
|
onChangeText={setInput}
|
|
onSubmitEditing={handleAdd}
|
|
placeholder={t("settings.providers.models.modelIdPlaceholder")}
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
returnKeyType="done"
|
|
// @ts-expect-error - outlineStyle is web-only
|
|
style={FORM_INPUT_STYLE}
|
|
/>
|
|
{error ? <Text style={sheetStyles.errorText}>{error}</Text> : null}
|
|
<View style={sheetStyles.formActions}>
|
|
<Button variant="secondary" size="sm" onPress={onClose} disabled={saving}>
|
|
{t("common.actions.cancel")}
|
|
</Button>
|
|
<Button variant="default" size="sm" onPress={handleAdd} disabled={!canAdd || saving}>
|
|
{saving ? t("settings.providers.models.adding") : t("settings.providers.models.add")}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|
|
|
|
function DiagnosticSubSheet({
|
|
provider,
|
|
serverId,
|
|
visible,
|
|
onClose,
|
|
}: {
|
|
provider: string;
|
|
serverId: string;
|
|
visible: boolean;
|
|
onClose: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const client = useHostRuntimeClient(serverId);
|
|
const [diagnostic, setDiagnostic] = useState<string | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const fetchDiagnostic = useCallback(async () => {
|
|
if (!client) return;
|
|
setLoading(true);
|
|
try {
|
|
const result = await client.getProviderDiagnostic(provider);
|
|
setDiagnostic(result.diagnostic);
|
|
} catch (err) {
|
|
setDiagnostic(
|
|
err instanceof Error ? err.message : t("settings.providers.diagnostic.failedToFetch"),
|
|
);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [client, provider, t]);
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
void fetchDiagnostic();
|
|
} else {
|
|
setDiagnostic(null);
|
|
}
|
|
}, [visible, fetchDiagnostic]);
|
|
|
|
const refreshButtonStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
sheetStyles.iconButton,
|
|
(Boolean(hovered) || pressed) && sheetStyles.iconButtonHovered,
|
|
loading ? sheetStyles.disabled : null,
|
|
],
|
|
[loading],
|
|
);
|
|
|
|
const handleRefreshPress = useCallback(() => {
|
|
void fetchDiagnostic();
|
|
}, [fetchDiagnostic]);
|
|
|
|
const header = useMemo<SheetHeader>(
|
|
() => ({
|
|
title: t("settings.providers.diagnostic.title"),
|
|
actions: (
|
|
<Pressable
|
|
onPress={handleRefreshPress}
|
|
disabled={loading}
|
|
hitSlop={8}
|
|
style={refreshButtonStyle}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={
|
|
loading
|
|
? t("settings.providers.diagnostic.refreshingAccessibility")
|
|
: t("settings.providers.diagnostic.refreshAccessibility")
|
|
}
|
|
>
|
|
{loading ? (
|
|
<LoadingSpinner size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
) : (
|
|
<RotateCw size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
)}
|
|
</Pressable>
|
|
),
|
|
}),
|
|
[
|
|
handleRefreshPress,
|
|
loading,
|
|
refreshButtonStyle,
|
|
t,
|
|
theme.colors.foregroundMuted,
|
|
theme.iconSize.sm,
|
|
],
|
|
);
|
|
|
|
let body: React.ReactNode;
|
|
if (loading && !diagnostic) {
|
|
body = (
|
|
<View style={sheetStyles.codeBlockLoading}>
|
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.running")}</Text>
|
|
</View>
|
|
);
|
|
} else if (diagnostic) {
|
|
body = (
|
|
<ScrollView style={sheetStyles.codeScroll} contentContainerStyle={sheetStyles.codeContent}>
|
|
<ScrollView horizontal showsHorizontalScrollIndicator>
|
|
<Text style={sheetStyles.codeText} selectable dataSet={CODE_SURFACE_DATASET}>
|
|
{diagnostic}
|
|
</Text>
|
|
</ScrollView>
|
|
</ScrollView>
|
|
);
|
|
} else {
|
|
body = (
|
|
<View style={sheetStyles.codeBlockLoading}>
|
|
<Text style={sheetStyles.mutedText}>{t("settings.providers.diagnostic.none")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<AdaptiveModalSheet
|
|
header={header}
|
|
visible={visible}
|
|
onClose={onClose}
|
|
snapPoints={DIAGNOSTIC_SNAP_POINTS}
|
|
scrollable={false}
|
|
testID="provider-diagnostic-sheet"
|
|
>
|
|
<View style={DIAGNOSTIC_CARD_STYLE}>{body}</View>
|
|
</AdaptiveModalSheet>
|
|
);
|
|
}
|
|
|
|
interface ProviderModalBodyProps {
|
|
discoveredCount: number;
|
|
additionalCount: number;
|
|
providerSnapshotRefreshing: boolean;
|
|
providerErrorMessage: string | null;
|
|
modelsRefreshing: boolean;
|
|
searchActive: boolean;
|
|
filteredDiscovered: AgentModelDefinition[];
|
|
filteredCustom: ProviderProfileModel[];
|
|
deletingModelId: string | null;
|
|
onRefresh: () => void;
|
|
onDeleteCustom: (modelId: string) => void;
|
|
theme: { iconSize: { md: number }; colors: { foregroundMuted: string } };
|
|
}
|
|
|
|
interface ProviderSheetFooterInput {
|
|
fetchedAtLabel: string | null;
|
|
isCompact: boolean;
|
|
modelsRefreshing: boolean;
|
|
t: TFunction;
|
|
onOpenAddSheet: () => void;
|
|
onOpenDiagSheet: () => void;
|
|
onRefreshModels: () => void;
|
|
}
|
|
|
|
function renderProviderSheetFooter({
|
|
fetchedAtLabel,
|
|
isCompact,
|
|
modelsRefreshing,
|
|
t,
|
|
onOpenAddSheet,
|
|
onOpenDiagSheet,
|
|
onRefreshModels,
|
|
}: ProviderSheetFooterInput) {
|
|
const contentStyle = isCompact ? sheetStyles.compactFooterContent : sheetStyles.footerContent;
|
|
const actionsStyle = isCompact ? sheetStyles.compactFooterActions : sheetStyles.footerActions;
|
|
const buttonStyle = isCompact ? sheetStyles.compactFooterButton : null;
|
|
const metaStyle = isCompact ? COMPACT_FOOTER_META_STYLE : sheetStyles.footerMeta;
|
|
|
|
return (
|
|
<View style={contentStyle}>
|
|
{fetchedAtLabel || !isCompact ? (
|
|
<Text style={metaStyle} numberOfLines={1}>
|
|
{fetchedAtLabel ? t("settings.providers.models.updated", { time: fetchedAtLabel }) : ""}
|
|
</Text>
|
|
) : null}
|
|
<View style={actionsStyle}>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
leftIcon={Plus}
|
|
onPress={onOpenAddSheet}
|
|
style={buttonStyle}
|
|
>
|
|
{t("settings.providers.models.addModel")}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
leftIcon={FileText}
|
|
onPress={onOpenDiagSheet}
|
|
style={buttonStyle}
|
|
>
|
|
{t("settings.providers.diagnostic.button")}
|
|
</Button>
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
leftIcon={modelsRefreshing ? undefined : RotateCw}
|
|
onPress={onRefreshModels}
|
|
disabled={modelsRefreshing}
|
|
style={buttonStyle}
|
|
>
|
|
{modelsRefreshing
|
|
? t("settings.providers.diagnostic.refreshing")
|
|
: t("settings.providers.diagnostic.refresh")}
|
|
</Button>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function ProviderModalBody(props: ProviderModalBodyProps) {
|
|
const { t } = useTranslation();
|
|
const {
|
|
discoveredCount,
|
|
additionalCount,
|
|
providerSnapshotRefreshing,
|
|
providerErrorMessage,
|
|
modelsRefreshing,
|
|
searchActive,
|
|
filteredDiscovered,
|
|
filteredCustom,
|
|
deletingModelId,
|
|
onRefresh,
|
|
onDeleteCustom,
|
|
theme,
|
|
} = props;
|
|
|
|
if (discoveredCount === 0 && additionalCount === 0 && providerSnapshotRefreshing) {
|
|
return (
|
|
<View style={sheetStyles.emptyState}>
|
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
|
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.loading")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (discoveredCount === 0 && additionalCount === 0 && providerErrorMessage) {
|
|
return (
|
|
<View style={sheetStyles.emptyState}>
|
|
<AlertTriangle size={theme.iconSize.md} color={theme.colors.foregroundMuted} />
|
|
<Text style={sheetStyles.mutedText}>{providerErrorMessage}</Text>
|
|
<Button variant="default" size="sm" onPress={onRefresh} disabled={modelsRefreshing}>
|
|
{modelsRefreshing
|
|
? t("settings.providers.models.retrying")
|
|
: t("settings.providers.models.retry")}
|
|
</Button>
|
|
</View>
|
|
);
|
|
}
|
|
if (filteredDiscovered.length === 0 && filteredCustom.length === 0 && searchActive) {
|
|
return (
|
|
<View style={sheetStyles.emptyState}>
|
|
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noSearchMatches")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
if (discoveredCount === 0 && additionalCount === 0) {
|
|
return (
|
|
<View style={sheetStyles.emptyState}>
|
|
<Text style={sheetStyles.mutedText}>{t("settings.providers.models.noneDetected")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{filteredDiscovered.length > 0 ? (
|
|
<View style={sheetStyles.section}>
|
|
<SectionHeader
|
|
title={t("settings.providers.models.discovered")}
|
|
count={filteredDiscovered.length}
|
|
/>
|
|
<View style={settingsStyles.card}>
|
|
{filteredDiscovered.map((model) => (
|
|
<DiscoveredModelRow key={model.id} model={model} />
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
{filteredCustom.length > 0 ? (
|
|
<View style={sheetStyles.section}>
|
|
<SectionHeader
|
|
title={t("settings.providers.models.custom")}
|
|
count={filteredCustom.length}
|
|
/>
|
|
<View style={settingsStyles.card}>
|
|
{filteredCustom.map((model) => (
|
|
<CustomModelRow
|
|
key={model.id}
|
|
model={model}
|
|
deleting={deletingModelId === model.id}
|
|
onDelete={onDeleteCustom}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ProviderDiagnosticSheet({
|
|
provider,
|
|
visible,
|
|
onClose,
|
|
serverId,
|
|
}: ProviderDiagnosticSheetProps) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const isCompact = useIsCompactFormFactor();
|
|
const { entries: snapshotEntries, refresh, isRefreshing } = useProvidersSnapshot(serverId);
|
|
const { config, patchConfig } = useDaemonConfig(serverId);
|
|
const [query, setQuery] = useState("");
|
|
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
|
const [diagSheetOpen, setDiagSheetOpen] = useState(false);
|
|
const [deletingModelId, setDeletingModelId] = useState<string | null>(null);
|
|
|
|
const providerLabel = resolveProviderLabel(provider, snapshotEntries);
|
|
const providerEntry = useMemo(
|
|
() => snapshotEntries?.find((entry) => entry.provider === provider),
|
|
[snapshotEntries, provider],
|
|
);
|
|
const additionalModels = useMemo(
|
|
() => config?.providers?.[provider]?.additionalModels ?? [],
|
|
[config?.providers, provider],
|
|
);
|
|
const providerSnapshotRefreshing = providerEntry?.status === "loading";
|
|
const providerErrorMessage =
|
|
providerEntry?.status === "error"
|
|
? (providerEntry.error ?? t("settings.providers.diagnostic.unknownError"))
|
|
: null;
|
|
const modelsRefreshing = isRefreshing || providerSnapshotRefreshing;
|
|
|
|
const stableDiscoveredRef = useRef<AgentModelDefinition[]>([]);
|
|
if (providerEntry?.models && providerEntry.models.length > 0) {
|
|
stableDiscoveredRef.current = providerEntry.models;
|
|
}
|
|
const discoveredModels =
|
|
providerEntry?.models && providerEntry.models.length > 0
|
|
? providerEntry.models
|
|
: stableDiscoveredRef.current;
|
|
|
|
const [clockTick, setClockTick] = useState(0);
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
const id = setInterval(() => setClockTick((tick) => tick + 1), 10_000);
|
|
return () => clearInterval(id);
|
|
}, [visible]);
|
|
const fetchedAtLabel = useMemo(() => {
|
|
if (!providerEntry?.fetchedAt) return null;
|
|
void clockTick;
|
|
return formatTimeAgo(new Date(providerEntry.fetchedAt));
|
|
}, [providerEntry?.fetchedAt, clockTick]);
|
|
|
|
useEffect(() => {
|
|
if (!visible) {
|
|
setQuery("");
|
|
setAddSheetOpen(false);
|
|
setDiagSheetOpen(false);
|
|
}
|
|
}, [visible]);
|
|
|
|
const q = query.trim();
|
|
const filteredDiscovered = useMemo(
|
|
() => rankModels(discoveredModels, q, (m) => [m.label, m.id, m.description ?? ""]),
|
|
[discoveredModels, q],
|
|
);
|
|
const filteredCustom = useMemo(
|
|
() => rankModels(additionalModels, q, (m) => [m.label, m.id]),
|
|
[additionalModels, q],
|
|
);
|
|
|
|
const handleRefreshModels = useCallback(() => {
|
|
void refresh([provider]);
|
|
}, [provider, refresh]);
|
|
|
|
const handleOpenAddSheet = useCallback(() => setAddSheetOpen(true), []);
|
|
const handleCloseAddSheet = useCallback(() => setAddSheetOpen(false), []);
|
|
const handleOpenDiagSheet = useCallback(() => setDiagSheetOpen(true), []);
|
|
const handleCloseDiagSheet = useCallback(() => setDiagSheetOpen(false), []);
|
|
|
|
const handleDeleteCustom = useCallback(
|
|
(modelId: string) => {
|
|
setDeletingModelId(modelId);
|
|
void patchConfig({
|
|
providers: {
|
|
[provider]: {
|
|
additionalModels: additionalModels.filter((model) => model.id !== modelId),
|
|
},
|
|
},
|
|
})
|
|
.then(() => refresh([provider]))
|
|
.finally(() => {
|
|
setDeletingModelId((current) => (current === modelId ? null : current));
|
|
});
|
|
},
|
|
[additionalModels, patchConfig, provider, refresh],
|
|
);
|
|
|
|
const sheetHeader = useMemo<SheetHeader>(
|
|
() => ({
|
|
title: providerLabel,
|
|
search: {
|
|
onChange: setQuery,
|
|
placeholder: t("settings.providers.models.searchPlaceholder"),
|
|
testID: "provider-settings-search",
|
|
},
|
|
}),
|
|
[providerLabel, t],
|
|
);
|
|
|
|
return (
|
|
<>
|
|
<AdaptiveModalSheet
|
|
header={sheetHeader}
|
|
visible={visible}
|
|
onClose={onClose}
|
|
testID="provider-settings-sheet"
|
|
footer={renderProviderSheetFooter({
|
|
fetchedAtLabel,
|
|
isCompact,
|
|
modelsRefreshing,
|
|
t,
|
|
onOpenAddSheet: handleOpenAddSheet,
|
|
onOpenDiagSheet: handleOpenDiagSheet,
|
|
onRefreshModels: handleRefreshModels,
|
|
})}
|
|
snapPoints={MAIN_SNAP_POINTS}
|
|
>
|
|
<ProviderModalBody
|
|
discoveredCount={discoveredModels.length}
|
|
additionalCount={additionalModels.length}
|
|
providerSnapshotRefreshing={providerSnapshotRefreshing}
|
|
providerErrorMessage={providerErrorMessage}
|
|
modelsRefreshing={modelsRefreshing}
|
|
searchActive={Boolean(q)}
|
|
filteredDiscovered={filteredDiscovered}
|
|
filteredCustom={filteredCustom}
|
|
deletingModelId={deletingModelId}
|
|
onRefresh={handleRefreshModels}
|
|
onDeleteCustom={handleDeleteCustom}
|
|
theme={theme}
|
|
/>
|
|
</AdaptiveModalSheet>
|
|
<AddCustomModelSubSheet
|
|
provider={provider}
|
|
serverId={serverId}
|
|
visible={addSheetOpen}
|
|
onClose={handleCloseAddSheet}
|
|
refresh={refresh}
|
|
/>
|
|
<DiagnosticSubSheet
|
|
provider={provider}
|
|
serverId={serverId}
|
|
visible={diagSheetOpen}
|
|
onClose={handleCloseDiagSheet}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const sheetStyles = StyleSheet.create((theme) => ({
|
|
mutedText: {
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
monoHint: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
color: theme.colors.foregroundMuted,
|
|
flexShrink: 0,
|
|
},
|
|
descriptionInline: {
|
|
flex: 1,
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
errorText: {
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.destructive,
|
|
},
|
|
formInput: {
|
|
backgroundColor: theme.colors.surface2,
|
|
borderRadius: theme.borderRadius.lg,
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
color: theme.colors.foreground,
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
iconButton: {
|
|
width: 28,
|
|
height: 28,
|
|
borderRadius: theme.borderRadius.full,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
iconButtonHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
disabled: {
|
|
opacity: 0.5,
|
|
},
|
|
section: {
|
|
marginBottom: theme.spacing[4],
|
|
},
|
|
sectionHeader: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
marginBottom: theme.spacing[2],
|
|
marginLeft: theme.spacing[1],
|
|
},
|
|
sectionHeaderMeta: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
},
|
|
modelRow: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
paddingVertical: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[4],
|
|
gap: theme.spacing[3],
|
|
borderTopWidth: 1,
|
|
borderTopColor: theme.colors.border,
|
|
},
|
|
modelTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.sm,
|
|
flexShrink: 0,
|
|
},
|
|
modelRowFiller: {
|
|
flex: 1,
|
|
},
|
|
emptyState: {
|
|
paddingVertical: theme.spacing[8],
|
|
alignItems: "center",
|
|
gap: theme.spacing[3],
|
|
},
|
|
footerContent: {
|
|
flex: 1,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
justifyContent: "space-between",
|
|
gap: theme.spacing[2],
|
|
},
|
|
compactFooterContent: {
|
|
flex: 1,
|
|
gap: theme.spacing[2],
|
|
},
|
|
footerMeta: {
|
|
flex: 1,
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
compactFooterMeta: {
|
|
flex: 0,
|
|
},
|
|
footerActions: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
compactFooterActions: {
|
|
gap: theme.spacing[2],
|
|
},
|
|
compactFooterButton: {
|
|
alignSelf: "stretch",
|
|
},
|
|
formGroup: {
|
|
gap: theme.spacing[3],
|
|
},
|
|
formLabel: {
|
|
fontSize: theme.fontSize.sm,
|
|
fontWeight: theme.fontWeight.medium,
|
|
color: theme.colors.foreground,
|
|
},
|
|
formActions: {
|
|
flexDirection: "row",
|
|
justifyContent: "flex-end",
|
|
gap: theme.spacing[2],
|
|
},
|
|
diagnosticCard: {
|
|
overflow: "hidden",
|
|
},
|
|
codeScroll: {
|
|
maxHeight: 480,
|
|
},
|
|
codeContent: {
|
|
paddingVertical: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
codeText: {
|
|
fontFamily: theme.fontFamily.mono,
|
|
fontSize: theme.fontSize.code,
|
|
color: theme.colors.foreground,
|
|
lineHeight: 18,
|
|
},
|
|
codeBlockLoading: {
|
|
paddingVertical: theme.spacing[4],
|
|
paddingHorizontal: theme.spacing[4],
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
},
|
|
}));
|
|
|
|
const FORM_INPUT_STYLE = [sheetStyles.formInput, isWeb && { outlineStyle: "none" }];
|
|
const COMPACT_FOOTER_META_STYLE = [sheetStyles.footerMeta, sheetStyles.compactFooterMeta];
|
|
|
|
const MAIN_SNAP_POINTS = ["65%", "92%"];
|
|
const ADD_SNAP_POINTS = ["40%"];
|
|
const DIAGNOSTIC_SNAP_POINTS = ["50%", "85%"];
|
|
const DIAGNOSTIC_CARD_STYLE = [settingsStyles.card, sheetStyles.diagnosticCard];
|