Fresh history cut from cynarAI/evoo antigravity-handoff/project-timeline (Voice First + project timeline). Brand docs/titles as evOo; keep @rakazo/* packages as tech debt. Excludes node_modules, caches, secrets, voice blobs.
1184 lines
42 KiB
TypeScript
1184 lines
42 KiB
TypeScript
import type { ModelOAuthBegin, ThinkingLevel } from "@rakazo/contracts";
|
|
import {
|
|
DEFAULT_MODEL_CONTEXT_WINDOW,
|
|
DEFAULT_MODEL_MAX_TOKENS,
|
|
MAX_MODEL_CONTEXT_WINDOW,
|
|
MAX_MODEL_MAX_TOKENS,
|
|
OPENAI_COMPATIBLE_BASE_URL_HINT,
|
|
OPENAI_COMPATIBLE_PROVIDER_ID,
|
|
openAiCompatibleConnectReady,
|
|
parseModelContextWindow,
|
|
parseModelMaxImagesPerPrompt,
|
|
parseModelMaxTokens,
|
|
} from "@rakazo/contracts";
|
|
import { createModelProbe, featuredModelProviders, initialModelProbeState } from "@rakazo/core";
|
|
import { useFocusEffect } from "expo-router";
|
|
import { useCallback, useMemo, useRef, useState } from "react";
|
|
import {
|
|
ActivityIndicator,
|
|
Linking,
|
|
Pressable,
|
|
ScrollView,
|
|
StyleSheet,
|
|
Switch,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
} from "react-native";
|
|
import { SafeAreaView } from "react-native-safe-area-context";
|
|
import { type MobileMe, type MobileModel, type MobileModelCredential, rpc } from "../lib/api";
|
|
import { mobileTokens } from "../lib/appearance";
|
|
import { useI18n } from "../lib/i18n";
|
|
import { presentMessageActionSheet } from "../lib/message-action-sheet";
|
|
import {
|
|
cancelModelOAuthAttempt,
|
|
finishModelOAuthAttempt,
|
|
waitForModelOAuth,
|
|
} from "../lib/model-auth";
|
|
import { native, useResolvedAppearance, useThemedStyles } from "../lib/native";
|
|
|
|
const THINKING_LEVEL_OPTIONS: ThinkingLevel[] = [
|
|
"minimal",
|
|
"low",
|
|
"medium",
|
|
"high",
|
|
"xhigh",
|
|
"max",
|
|
];
|
|
|
|
function thinkingLevelLabel(level: ThinkingLevel, t: (message: string) => string): string {
|
|
if (level === "xhigh") return t("Extra high");
|
|
if (level === "low") return t("Low");
|
|
if (level === "medium") return t("Medium");
|
|
if (level === "high") return t("High");
|
|
if (level === "minimal") return t("Minimal");
|
|
if (level === "max") return t("Max");
|
|
return level;
|
|
}
|
|
|
|
type ModelSelection = {
|
|
provider?: string;
|
|
modelId?: string;
|
|
};
|
|
|
|
export default function Models() {
|
|
const styles = useThemedStyles(createModelsStyles);
|
|
const { t } = useI18n();
|
|
const colorScheme = useResolvedAppearance();
|
|
const [catalog, setCatalog] = useState<MobileModel[]>([]);
|
|
const [credentials, setCredentials] = useState<MobileModelCredential[]>([]);
|
|
const [me, setMe] = useState<MobileMe | null>(null);
|
|
const [provider, setProvider] = useState("");
|
|
const [showAllProviders, setShowAllProviders] = useState(false);
|
|
const [modelId, setModelId] = useState("");
|
|
const [apiKey, setApiKey] = useState("");
|
|
const [baseUrl, setBaseUrl] = useState("");
|
|
const [reasoning, setReasoning] = useState(false);
|
|
const [thinkingLevel, setThinkingLevel] = useState<ThinkingLevel | null>(null);
|
|
const [maxTokens, setMaxTokens] = useState(String(DEFAULT_MODEL_MAX_TOKENS));
|
|
const [contextWindow, setContextWindow] = useState(String(DEFAULT_MODEL_CONTEXT_WINDOW));
|
|
const [supportsImages, setSupportsImages] = useState(false);
|
|
const [maxImagesPerPrompt, setMaxImagesPerPrompt] = useState("");
|
|
const [showEndpointHelp, setShowEndpointHelp] = useState(false);
|
|
const [showAdvanced, setShowAdvanced] = useState(false);
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [{ models: probeModels, probing }, setProbe] = useState(initialModelProbeState);
|
|
const [modelProbe] = useState(() => createModelProbe(setProbe));
|
|
const resetOpenAiCompatibleProbe = modelProbe.reset;
|
|
const [oauth, setOauth] = useState<ModelOAuthBegin | null>(null);
|
|
const [pasteCode, setPasteCode] = useState("");
|
|
const [loading, setLoading] = useState(true);
|
|
const [pending, setPending] = useState<"connect" | "default" | null>(null);
|
|
const [oauthPending, setOauthPending] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [notice, setNotice] = useState<string | null>(null);
|
|
const oauthAbortRef = useRef<AbortController | null>(null);
|
|
const oauthLoginIdRef = useRef<string | null>(null);
|
|
const oauthCodeSubmittingRef = useRef(false);
|
|
|
|
const cancelOAuth = useCallback(() => {
|
|
const loginId = oauthLoginIdRef.current;
|
|
oauthLoginIdRef.current = null;
|
|
cancelModelOAuthAttempt(oauthAbortRef, () => {
|
|
setOauth(null);
|
|
setOauthPending(false);
|
|
});
|
|
if (loginId) void rpc("models/cancelOAuth", { loginId }).catch(() => undefined);
|
|
}, []);
|
|
|
|
const load = useCallback(async (preferred: ModelSelection = {}) => {
|
|
setError(null);
|
|
const [nextMe, nextCatalog, nextCredentials] = await Promise.all([
|
|
rpc<MobileMe>("me"),
|
|
rpc<MobileModel[]>("models/list"),
|
|
rpc<MobileModelCredential[]>("models/credentials"),
|
|
]);
|
|
const nextProvider =
|
|
(preferred.provider && nextCatalog.some((entry) => entry.provider === preferred.provider)
|
|
? preferred.provider
|
|
: nextMe.defaultProvider) ??
|
|
nextCatalog[0]?.provider ??
|
|
"";
|
|
const nextCredential = nextCredentials.find((entry) => entry.provider === nextProvider);
|
|
const nextModel =
|
|
nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID
|
|
? preferred.modelId?.trim() ||
|
|
nextCredential?.modelId ||
|
|
(nextMe.defaultProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? nextMe.defaultModel : "") ||
|
|
""
|
|
: (nextCatalog.find(
|
|
(entry) => entry.provider === nextProvider && entry.id === preferred.modelId,
|
|
)?.id ??
|
|
nextCatalog.find(
|
|
(entry) => entry.provider === nextProvider && entry.id === nextMe.defaultModel,
|
|
)?.id ??
|
|
nextCatalog.find((entry) => entry.provider === nextProvider)?.id ??
|
|
"");
|
|
setMe(nextMe);
|
|
setCatalog(nextCatalog);
|
|
setCredentials(nextCredentials);
|
|
resetOpenAiCompatibleProbe();
|
|
setProvider(nextProvider);
|
|
setModelId(nextModel);
|
|
if (nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID) {
|
|
setBaseUrl(nextCredential?.baseUrl ?? "");
|
|
setReasoning(nextCredential?.reasoning ?? false);
|
|
setThinkingLevel(nextCredential?.thinkingLevel ?? null);
|
|
setMaxTokens(String(nextCredential?.maxTokens ?? DEFAULT_MODEL_MAX_TOKENS));
|
|
setContextWindow(String(nextCredential?.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW));
|
|
setSupportsImages(nextCredential?.supportsImages ?? false);
|
|
setMaxImagesPerPrompt(String(nextCredential?.maxImagesPerPrompt ?? ""));
|
|
}
|
|
}, []);
|
|
|
|
useFocusEffect(
|
|
useCallback(() => {
|
|
void load()
|
|
.catch((err: unknown) =>
|
|
setError(err instanceof Error ? err.message : t("Could not load model settings")),
|
|
)
|
|
.finally(() => setLoading(false));
|
|
return () => {
|
|
modelProbe.invalidate();
|
|
cancelOAuth();
|
|
};
|
|
}, [cancelOAuth, load]),
|
|
);
|
|
|
|
const groups = useMemo(() => {
|
|
const grouped = new Map<string, MobileModel[]>();
|
|
for (const entry of catalog) {
|
|
const entries = grouped.get(entry.provider) ?? [];
|
|
entries.push(entry);
|
|
grouped.set(entry.provider, entries);
|
|
}
|
|
return [...grouped].map(([id, entries]) => ({
|
|
id,
|
|
name: entries[0]?.providerName ?? id,
|
|
entries,
|
|
}));
|
|
}, [catalog]);
|
|
const featuredProviders = useMemo(
|
|
() =>
|
|
featuredModelProviders(
|
|
groups.map((group) => group.entries[0]!),
|
|
provider,
|
|
),
|
|
[groups, provider],
|
|
);
|
|
const visibleGroups = useMemo(() => {
|
|
if (showAllProviders) return groups;
|
|
const byId = new Map(groups.map((group) => [group.id, group]));
|
|
return featuredProviders.map((entry) => byId.get(entry.provider)!);
|
|
}, [groups, featuredProviders, showAllProviders]);
|
|
const modelsForProvider = catalog.filter((entry) => entry.provider === provider);
|
|
const selected = modelsForProvider.find((entry) => entry.id === modelId) ?? modelsForProvider[0];
|
|
const isOpenAiCompatible = provider === OPENAI_COMPATIBLE_PROVIDER_ID;
|
|
const credential = credentials.find((entry) => entry.provider === provider);
|
|
const currentEntry = catalog.find(
|
|
(entry) => entry.provider === me?.defaultProvider && entry.id === me?.defaultModel,
|
|
);
|
|
const isActive =
|
|
me?.defaultProvider === selected?.provider &&
|
|
me?.defaultModel === (isOpenAiCompatible ? modelId.trim() : selected?.id);
|
|
const acceptsKey = selected?.auth !== "oauth";
|
|
const subscriptionSignIn = selected?.signIn !== undefined;
|
|
const busy = pending !== null || oauthPending;
|
|
const effectiveBaseUrl = baseUrl.trim();
|
|
const openAiCompatibleReady = openAiCompatibleConnectReady({
|
|
baseUrl: effectiveBaseUrl,
|
|
modelId,
|
|
});
|
|
|
|
function updateBaseUrl(nextBaseUrl: string) {
|
|
setBaseUrl(nextBaseUrl);
|
|
resetOpenAiCompatibleProbe();
|
|
setError(null);
|
|
setNotice(null);
|
|
}
|
|
|
|
function updateApiKey(nextApiKey: string) {
|
|
setApiKey(nextApiKey);
|
|
resetOpenAiCompatibleProbe();
|
|
}
|
|
|
|
function chooseProvider(nextProvider: string) {
|
|
cancelOAuth();
|
|
const nextCredential = credentials.find((entry) => entry.provider === nextProvider);
|
|
setProvider(nextProvider);
|
|
setReasoning(nextCredential?.reasoning ?? false);
|
|
setThinkingLevel(nextCredential?.thinkingLevel ?? null);
|
|
setMaxTokens(String(nextCredential?.maxTokens ?? DEFAULT_MODEL_MAX_TOKENS));
|
|
setContextWindow(String(nextCredential?.contextWindow ?? DEFAULT_MODEL_CONTEXT_WINDOW));
|
|
setSupportsImages(nextCredential?.supportsImages ?? false);
|
|
setMaxImagesPerPrompt(String(nextCredential?.maxImagesPerPrompt ?? ""));
|
|
setModelId(
|
|
nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID
|
|
? (nextCredential?.modelId ?? "")
|
|
: (catalog.find((entry) => entry.provider === nextProvider)?.id ?? ""),
|
|
);
|
|
setBaseUrl(
|
|
nextProvider === OPENAI_COMPATIBLE_PROVIDER_ID ? (nextCredential?.baseUrl ?? "") : "",
|
|
);
|
|
setApiKey("");
|
|
resetOpenAiCompatibleProbe();
|
|
setError(null);
|
|
setNotice(null);
|
|
}
|
|
|
|
async function probeServerModels() {
|
|
if (!baseUrl.trim()) return;
|
|
setError(null);
|
|
setNotice(null);
|
|
await modelProbe.probe({
|
|
baseUrl,
|
|
apiKey,
|
|
request: (input) => rpc<{ models: string[] }>("models/probeOpenAiCompatible", input),
|
|
onSuccess: (models) => {
|
|
setModelId((current) => current.trim() || models[0] || "");
|
|
setNotice(
|
|
models.length === 0
|
|
? t("Server found. Enter a model name.")
|
|
: models.length === 1
|
|
? t("Found {count} model.", { count: 1 })
|
|
: t("Found {count} models.", { count: models.length }),
|
|
);
|
|
},
|
|
onError: (err) =>
|
|
setError(err instanceof Error ? err.message : t("Could not reach this model server")),
|
|
});
|
|
}
|
|
|
|
async function setModelDefault() {
|
|
if (!selected || !credential) return;
|
|
const activeModelId = isOpenAiCompatible ? modelId.trim() : selected.id;
|
|
if (isOpenAiCompatible && !activeModelId) return;
|
|
setError(null);
|
|
setNotice(null);
|
|
setPending("default");
|
|
try {
|
|
await rpc("models/setDefault", { provider: selected.provider, modelId: activeModelId });
|
|
await load({ provider, modelId: activeModelId });
|
|
setNotice(
|
|
isOpenAiCompatible
|
|
? t("Model updated.")
|
|
: t("Now using {label}.", { label: selected.label }),
|
|
);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("Could not change the default model"));
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
async function connectKey() {
|
|
if (!selected) return;
|
|
if (isOpenAiCompatible) {
|
|
if (!effectiveBaseUrl || !modelId.trim()) return;
|
|
} else if (!apiKey.trim()) {
|
|
return;
|
|
}
|
|
const parsedMaxImagesPerPrompt = parseModelMaxImagesPerPrompt(
|
|
maxImagesPerPrompt,
|
|
supportsImages,
|
|
);
|
|
if (supportsImages && maxImagesPerPrompt.trim() && parsedMaxImagesPerPrompt === undefined) {
|
|
setError(t("Enter a whole number from 1 to 1000 for the image limit."));
|
|
return;
|
|
}
|
|
const maxImagesPerPromptInput =
|
|
supportsImages && !maxImagesPerPrompt.trim() ? null : parsedMaxImagesPerPrompt;
|
|
|
|
const parsedMaxTokens = parseModelMaxTokens(maxTokens);
|
|
if (parsedMaxTokens === undefined) {
|
|
setError(
|
|
t("Enter a whole number from 1 to {max} for maximum output tokens.", {
|
|
max: MAX_MODEL_MAX_TOKENS,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
const parsedContextWindow = parseModelContextWindow(contextWindow);
|
|
if (parsedContextWindow === undefined) {
|
|
setError(
|
|
t("Enter a whole number from 1 to {max} for the context limit.", {
|
|
max: MAX_MODEL_CONTEXT_WINDOW,
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
setError(null);
|
|
setNotice(null);
|
|
setPending("connect");
|
|
try {
|
|
await rpc(
|
|
"models/connect",
|
|
isOpenAiCompatible
|
|
? {
|
|
provider: selected.provider,
|
|
baseUrl: effectiveBaseUrl,
|
|
modelId: modelId.trim(),
|
|
reasoning,
|
|
thinkingLevel: reasoning ? thinkingLevel : null,
|
|
maxTokens: parsedMaxTokens,
|
|
contextWindow: parsedContextWindow,
|
|
supportsImages,
|
|
maxImagesPerPrompt: maxImagesPerPromptInput,
|
|
apiKey: apiKey.trim() || undefined,
|
|
label: selected.providerName ?? selected.provider,
|
|
}
|
|
: {
|
|
provider: selected.provider,
|
|
apiKey: apiKey.trim(),
|
|
modelId: selected.id,
|
|
label: selected.providerName ?? selected.provider,
|
|
},
|
|
);
|
|
setApiKey("");
|
|
await load({ provider, modelId });
|
|
setNotice(
|
|
isOpenAiCompatible
|
|
? t("Saved.")
|
|
: t("Connected and using {label}.", { label: selected.label }),
|
|
);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t("Could not connect this provider"));
|
|
} finally {
|
|
setPending(null);
|
|
}
|
|
}
|
|
|
|
async function finishSubscriptionSignIn(loginId: string, controller: AbortController) {
|
|
await waitForModelOAuth(loginId, controller.signal);
|
|
if (controller.signal.aborted) return;
|
|
await rpc("models/finishOAuth", { loginId }, { signal: controller.signal });
|
|
if (controller.signal.aborted) return;
|
|
oauthLoginIdRef.current = null;
|
|
setOauth(null);
|
|
await load({ provider, modelId });
|
|
if (controller.signal.aborted) return;
|
|
setNotice(t("Connected and using {label}.", { label: selected?.label ?? t("this model") }));
|
|
}
|
|
|
|
async function startSubscriptionSignIn() {
|
|
if (!selected) return;
|
|
setError(null);
|
|
setNotice(null);
|
|
setOauthPending(true);
|
|
const controller = new AbortController();
|
|
oauthAbortRef.current = controller;
|
|
let waitingForCode = false;
|
|
try {
|
|
const started = await rpc<ModelOAuthBegin>(
|
|
"models/beginOAuth",
|
|
{
|
|
provider: selected.provider,
|
|
modelId: selected.id,
|
|
label: selected.providerName ?? selected.provider,
|
|
},
|
|
{ signal: controller.signal },
|
|
);
|
|
if (controller.signal.aborted) return;
|
|
oauthLoginIdRef.current = started.loginId;
|
|
setPasteCode("");
|
|
setOauth(started);
|
|
await Linking.openURL(started.verificationUri);
|
|
waitingForCode = started.mode === "auth-url";
|
|
if (!waitingForCode) await finishSubscriptionSignIn(started.loginId, controller);
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
const loginId = oauthLoginIdRef.current;
|
|
oauthLoginIdRef.current = null;
|
|
if (loginId) void rpc("models/cancelOAuth", { loginId }).catch(() => undefined);
|
|
setError(err instanceof Error ? err.message : t("Could not start sign-in"));
|
|
setOauth(null);
|
|
} finally {
|
|
if (!waitingForCode) {
|
|
finishModelOAuthAttempt(oauthAbortRef, controller, () => setOauthPending(false));
|
|
}
|
|
}
|
|
}
|
|
|
|
async function submitOAuthCode() {
|
|
if (oauth?.mode !== "auth-url" || oauthCodeSubmittingRef.current) return;
|
|
const controller = oauthAbortRef.current;
|
|
const code = pasteCode.trim();
|
|
if (!controller || !code) return;
|
|
oauthCodeSubmittingRef.current = true;
|
|
setPasteCode("");
|
|
setError(null);
|
|
let submitted = false;
|
|
let retryable = false;
|
|
try {
|
|
await rpc(
|
|
"models/submitOAuthCode",
|
|
{ loginId: oauth.loginId, code },
|
|
{
|
|
signal: controller.signal,
|
|
},
|
|
);
|
|
submitted = true;
|
|
await finishSubscriptionSignIn(oauth.loginId, controller);
|
|
} catch (err) {
|
|
if (controller.signal.aborted) return;
|
|
if (submitted) {
|
|
oauthLoginIdRef.current = null;
|
|
setOauth(null);
|
|
void rpc("models/cancelOAuth", { loginId: oauth.loginId }).catch(() => undefined);
|
|
} else {
|
|
retryable = true;
|
|
setPasteCode(code);
|
|
}
|
|
setError(err instanceof Error ? err.message : t("Could not finish sign-in"));
|
|
} finally {
|
|
oauthCodeSubmittingRef.current = false;
|
|
if (!retryable) {
|
|
finishModelOAuthAttempt(oauthAbortRef, controller, () => setOauthPending(false));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (loading && catalog.length === 0) {
|
|
return (
|
|
<SafeAreaView edges={["bottom"]} style={[styles.screen, styles.centered]}>
|
|
<ActivityIndicator color={native.secondaryLabel} />
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<SafeAreaView edges={["bottom"]} style={styles.screen}>
|
|
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
|
<View style={styles.activeCard}>
|
|
<Text style={styles.eyebrow}>{t("Active model")}</Text>
|
|
<Text style={styles.activeModel}>
|
|
{currentEntry?.label ?? me?.defaultModel ?? t("Deployment default")}
|
|
</Text>
|
|
<Text style={styles.secondary}>
|
|
{currentEntry?.providerName ?? me?.defaultProvider ?? t("Configured by deployment")}
|
|
</Text>
|
|
</View>
|
|
|
|
{error ? <Text style={styles.error}>{error}</Text> : null}
|
|
{notice ? <Text style={styles.notice}>{notice}</Text> : null}
|
|
|
|
<Text style={styles.sectionTitle}>{t("Providers")}</Text>
|
|
<View style={styles.card}>
|
|
{visibleGroups.map((group) => {
|
|
const connected = credentials.some((entry) => entry.provider === group.id);
|
|
return (
|
|
<Pressable
|
|
key={group.id}
|
|
accessibilityRole="button"
|
|
accessibilityState={{ selected: group.id === provider }}
|
|
onPress={() => chooseProvider(group.id)}
|
|
style={({ pressed }) => [
|
|
styles.providerRow,
|
|
group.id === provider && styles.selectedRow,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<View style={styles.providerCopy}>
|
|
<Text style={styles.providerName}>{group.name}</Text>
|
|
<Text style={styles.secondary}>
|
|
{t(group.entries.length === 1 ? "{count} model" : "{count} models", {
|
|
count: group.entries.length,
|
|
})}
|
|
</Text>
|
|
</View>
|
|
{connected ? <Text style={styles.connected}>{t("Connected")}</Text> : null}
|
|
</Pressable>
|
|
);
|
|
})}
|
|
{groups.length > featuredProviders.length ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded: showAllProviders }}
|
|
onPress={() => setShowAllProviders((value) => !value)}
|
|
style={styles.providerRow}
|
|
>
|
|
<Text style={styles.providerName}>
|
|
{showAllProviders ? t("Show less") : t("Show more")}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
|
|
{selected ? (
|
|
<>
|
|
{!isOpenAiCompatible ? <Text style={styles.sectionTitle}>{t("Model")}</Text> : null}
|
|
{isOpenAiCompatible ? (
|
|
<>
|
|
<Text style={styles.sectionTitle}>{t("Server URL")}</Text>
|
|
<TextInput
|
|
accessibilityLabel={t("OpenAI-compatible server URL")}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
editable={!busy}
|
|
onChangeText={updateBaseUrl}
|
|
placeholder={t("http://127.0.0.1:8000/v1")}
|
|
placeholderTextColor={native.tertiaryLabel}
|
|
style={styles.keyInput}
|
|
value={baseUrl}
|
|
/>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded: showEndpointHelp }}
|
|
onPress={() => setShowEndpointHelp((visible) => !visible)}
|
|
>
|
|
<Text style={styles.helpLabel}>{t("Setup help")}</Text>
|
|
</Pressable>
|
|
{showEndpointHelp ? (
|
|
<Text style={styles.hint}>{t(OPENAI_COMPATIBLE_BASE_URL_HINT)}</Text>
|
|
) : null}
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={busy || probing || !effectiveBaseUrl}
|
|
onPress={() => void probeServerModels()}
|
|
style={({ pressed }) => [
|
|
styles.outlineButton,
|
|
(busy || probing || !effectiveBaseUrl) && styles.disabled,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Text style={styles.outlineLabel}>
|
|
{probing ? t("Finding…") : t("Find models")}
|
|
</Text>
|
|
</Pressable>
|
|
<Text style={[styles.sectionTitle, { marginTop: 12 }]}>{t("Model")}</Text>
|
|
{probeModels.length && probeModels.includes(modelId) ? (
|
|
<View style={styles.card}>
|
|
{probeModels.map((entry) => (
|
|
<Pressable
|
|
key={entry}
|
|
accessibilityRole="radio"
|
|
accessibilityState={{ selected: entry === modelId }}
|
|
disabled={probing}
|
|
onPress={() => setModelId(entry)}
|
|
style={({ pressed }) => [
|
|
styles.modelRow,
|
|
entry === modelId && styles.selectedRow,
|
|
probing && styles.disabled,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<View style={styles.radio}>
|
|
{entry === modelId ? <View style={styles.radioDot} /> : null}
|
|
</View>
|
|
<Text style={styles.modelLabel}>{entry}</Text>
|
|
</Pressable>
|
|
))}
|
|
<Pressable
|
|
accessibilityRole="radio"
|
|
accessibilityState={{ selected: false }}
|
|
disabled={probing}
|
|
onPress={() => setModelId("")}
|
|
style={({ pressed }) => [
|
|
styles.modelRow,
|
|
probing && styles.disabled,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<View style={styles.radio} />
|
|
<Text style={styles.modelLabel}>{t("Other model…")}</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : (
|
|
<>
|
|
<TextInput
|
|
accessibilityLabel={t("Model id")}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
editable={!busy && !probing}
|
|
onChangeText={setModelId}
|
|
placeholder={t("exact-model-id")}
|
|
placeholderTextColor={native.tertiaryLabel}
|
|
style={styles.keyInput}
|
|
value={modelId}
|
|
/>
|
|
{probeModels.length ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={() => setModelId(probeModels[0] ?? "")}
|
|
>
|
|
<Text style={styles.helpLabel}>{t("Use a found model")}</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</>
|
|
)}
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded: showAdvanced }}
|
|
onPress={() => setShowAdvanced((visible) => !visible)}
|
|
>
|
|
<Text style={styles.helpLabel}>{t("Advanced")}</Text>
|
|
</Pressable>
|
|
{showAdvanced ? (
|
|
<View style={styles.modelRow}>
|
|
<Text style={styles.modelLabel}>{t("Supports thinking")}</Text>
|
|
<Switch
|
|
accessibilityLabel={t("Supports thinking")}
|
|
value={reasoning}
|
|
onValueChange={(value) => {
|
|
setReasoning(value);
|
|
if (!value) setThinkingLevel(null);
|
|
}}
|
|
disabled={busy}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
{showAdvanced && reasoning ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("Reasoning effort")}
|
|
disabled={busy}
|
|
onPress={() => {
|
|
presentMessageActionSheet({
|
|
title: t("Reasoning effort"),
|
|
cancel: t("Cancel"),
|
|
more: t("More"),
|
|
colorScheme,
|
|
actions: [
|
|
{
|
|
text: t("Default"),
|
|
onPress: () => setThinkingLevel(null),
|
|
},
|
|
...THINKING_LEVEL_OPTIONS.map((level) => ({
|
|
text: thinkingLevelLabel(level, t),
|
|
onPress: () => setThinkingLevel(level),
|
|
})),
|
|
],
|
|
});
|
|
}}
|
|
style={styles.modelRow}
|
|
>
|
|
<Text style={styles.modelLabel}>{t("Reasoning effort")}</Text>
|
|
<Text style={styles.helpLabel}>
|
|
{thinkingLevel ? thinkingLevelLabel(thinkingLevel, t) : t("Default")}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
{showAdvanced ? (
|
|
<View style={styles.modelRow}>
|
|
<Text style={styles.modelLabel}>{t("Context limit")}</Text>
|
|
<TextInput
|
|
accessibilityLabel={t("Context limit")}
|
|
editable={!busy}
|
|
keyboardType="number-pad"
|
|
maxLength={7}
|
|
onChangeText={setContextWindow}
|
|
style={[styles.keyInput, styles.maxImagesInput]}
|
|
value={contextWindow}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
{showAdvanced ? (
|
|
<View style={styles.modelRow}>
|
|
<Text style={styles.modelLabel}>{t("Maximum output tokens")}</Text>
|
|
<TextInput
|
|
accessibilityLabel={t("Maximum output tokens")}
|
|
editable={!busy}
|
|
keyboardType="number-pad"
|
|
maxLength={6}
|
|
onChangeText={setMaxTokens}
|
|
style={[styles.keyInput, styles.maxImagesInput]}
|
|
value={maxTokens}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
{showAdvanced ? (
|
|
<View style={styles.modelRow}>
|
|
<Text style={styles.modelLabel}>{t("Supports images")}</Text>
|
|
<Switch
|
|
accessibilityLabel={t("Supports images")}
|
|
value={supportsImages}
|
|
onValueChange={setSupportsImages}
|
|
disabled={busy}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
{showAdvanced && supportsImages ? (
|
|
<View style={styles.modelRow}>
|
|
<Text style={styles.modelLabel}>{t("Maximum images per request")}</Text>
|
|
<TextInput
|
|
accessibilityLabel={t("Maximum images per request")}
|
|
editable={!busy}
|
|
keyboardType="number-pad"
|
|
maxLength={4}
|
|
onChangeText={setMaxImagesPerPrompt}
|
|
style={[styles.keyInput, styles.maxImagesInput]}
|
|
value={maxImagesPerPrompt}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<View style={styles.card}>
|
|
{modelsForProvider.map((entry) => (
|
|
<Pressable
|
|
key={`${entry.provider}:${entry.id}`}
|
|
accessibilityRole="radio"
|
|
accessibilityState={{ selected: entry.id === selected.id }}
|
|
onPress={() => {
|
|
cancelOAuth();
|
|
setModelId(entry.id);
|
|
setError(null);
|
|
setNotice(null);
|
|
}}
|
|
style={({ pressed }) => [
|
|
styles.modelRow,
|
|
entry.id === selected.id && styles.selectedRow,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<View style={styles.radio}>
|
|
{entry.id === selected.id ? <View style={styles.radioDot} /> : null}
|
|
</View>
|
|
<Text style={styles.modelLabel}>{entry.label}</Text>
|
|
</Pressable>
|
|
))}
|
|
</View>
|
|
)}
|
|
{!isOpenAiCompatible && selected.billing ? (
|
|
<Text style={styles.billing}>{selected.billing}</Text>
|
|
) : null}
|
|
|
|
{!isOpenAiCompatible ? (
|
|
<View style={styles.credentialCard}>
|
|
<Text style={styles.eyebrow}>{t("Personal credential")}</Text>
|
|
<Text style={styles.credentialTitle}>
|
|
{credential
|
|
? t("Connected · {label}", { label: credential.label })
|
|
: t("Not connected")}
|
|
</Text>
|
|
<Text style={styles.secondary}>
|
|
{credential
|
|
? t("Stored securely. Never shown here.")
|
|
: t("Connect this provider to use it as your personal model.")}
|
|
</Text>
|
|
</View>
|
|
) : null}
|
|
|
|
{subscriptionSignIn ? (
|
|
oauth ? (
|
|
<View style={styles.oauthCard}>
|
|
{oauth.mode === "auth-url" ? (
|
|
<>
|
|
<Text style={styles.secondary}>
|
|
{t("Finish signing in in your browser:")}
|
|
</Text>
|
|
<Pressable onPress={() => void Linking.openURL(oauth.verificationUri)}>
|
|
<Text style={styles.link}>{oauth.verificationUri}</Text>
|
|
</Pressable>
|
|
<Text style={styles.secondary}>
|
|
{t("The final page may not load. Paste its URL or code here.")}
|
|
</Text>
|
|
<TextInput
|
|
accessibilityLabel={t("Authorization code")}
|
|
value={pasteCode}
|
|
onChangeText={setPasteCode}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
placeholder={t("http://localhost:53692/callback?code=…")}
|
|
placeholderTextColor={native.secondaryLabel}
|
|
style={styles.keyInput}
|
|
/>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={!pasteCode.trim()}
|
|
onPress={() => void submitOAuthCode()}
|
|
style={({ pressed }) => [
|
|
styles.outlineButton,
|
|
pressed && styles.pressed,
|
|
!pasteCode.trim() && styles.disabled,
|
|
]}
|
|
>
|
|
<Text style={styles.outlineLabel}>{t("Submit")}</Text>
|
|
</Pressable>
|
|
<Text style={styles.secondary}>{t("Waiting for sign-in…")}</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text style={styles.secondary}>{t("Enter this code in your browser:")}</Text>
|
|
<Pressable onPress={() => void Linking.openURL(oauth.verificationUri)}>
|
|
<Text style={styles.link}>{oauth.verificationUri}</Text>
|
|
</Pressable>
|
|
<Text style={styles.code}>{oauth.userCode}</Text>
|
|
<Text style={styles.secondary}>{t("Waiting for sign-in…")}</Text>
|
|
</>
|
|
)}
|
|
</View>
|
|
) : (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={busy}
|
|
onPress={() => void startSubscriptionSignIn()}
|
|
style={({ pressed }) => [
|
|
styles.outlineButton,
|
|
pressed && styles.pressed,
|
|
busy && styles.disabled,
|
|
]}
|
|
>
|
|
<Text style={styles.outlineLabel}>
|
|
{oauthPending ? t("Starting…") : (selected.oauthLabel ?? t("Sign in"))}
|
|
</Text>
|
|
</Pressable>
|
|
)
|
|
) : null}
|
|
|
|
{acceptsKey ? (
|
|
<View style={styles.keySection}>
|
|
{isOpenAiCompatible ? (
|
|
<>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityState={{ expanded: showApiKey }}
|
|
onPress={() => setShowApiKey((visible) => !visible)}
|
|
>
|
|
<Text style={styles.helpLabel}>{t("API key")}</Text>
|
|
</Pressable>
|
|
{showApiKey ? (
|
|
<TextInput
|
|
accessibilityLabel={t("API key")}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
autoComplete="off"
|
|
editable={!busy}
|
|
importantForAutofill="no"
|
|
onChangeText={updateApiKey}
|
|
placeholder={t("Optional")}
|
|
placeholderTextColor={native.tertiaryLabel}
|
|
secureTextEntry
|
|
style={styles.keyInput}
|
|
textContentType="none"
|
|
value={apiKey}
|
|
/>
|
|
) : null}
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text style={styles.sectionTitle}>
|
|
{credential
|
|
? t("Replace API key")
|
|
: subscriptionSignIn
|
|
? t("Or connect an API key")
|
|
: t("API key")}
|
|
</Text>
|
|
<TextInput
|
|
accessibilityLabel={t("API key")}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
autoComplete="off"
|
|
editable={!busy}
|
|
importantForAutofill="no"
|
|
onChangeText={updateApiKey}
|
|
placeholder={t("sk-…")}
|
|
placeholderTextColor={native.tertiaryLabel}
|
|
secureTextEntry
|
|
style={styles.keyInput}
|
|
textContentType="none"
|
|
value={apiKey}
|
|
/>
|
|
</>
|
|
)}
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={
|
|
busy || (isOpenAiCompatible ? !openAiCompatibleReady : apiKey.trim().length < 8)
|
|
}
|
|
onPress={() => void connectKey()}
|
|
style={({ pressed }) => [
|
|
styles.primaryButton,
|
|
(busy ||
|
|
(isOpenAiCompatible ? !openAiCompatibleReady : apiKey.trim().length < 8)) &&
|
|
styles.disabled,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Text style={styles.primaryLabel}>
|
|
{pending === "connect"
|
|
? t("Saving…")
|
|
: isOpenAiCompatible
|
|
? t("Save")
|
|
: credential
|
|
? t("Replace API key")
|
|
: t("Connect API key")}
|
|
</Text>
|
|
</Pressable>
|
|
</View>
|
|
) : null}
|
|
|
|
{selected.auth === "oauth" && !subscriptionSignIn ? (
|
|
<Text style={styles.secondary}>
|
|
{t(
|
|
"This subscription sign-in is not available in Rakazo yet. Use a deployment credential or choose another provider.",
|
|
)}
|
|
</Text>
|
|
) : null}
|
|
|
|
{credential && !isActive ? (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
disabled={busy || (isOpenAiCompatible && !modelId.trim())}
|
|
onPress={() => void setModelDefault()}
|
|
style={({ pressed }) => [
|
|
styles.primaryButton,
|
|
busy && styles.disabled,
|
|
pressed && styles.pressed,
|
|
]}
|
|
>
|
|
<Text style={styles.primaryLabel}>
|
|
{pending === "default" ? t("Switching…") : t("Use this model")}
|
|
</Text>
|
|
</Pressable>
|
|
) : null}
|
|
</>
|
|
) : null}
|
|
</ScrollView>
|
|
</SafeAreaView>
|
|
);
|
|
}
|
|
|
|
function createModelsStyles() {
|
|
const tokens = mobileTokens();
|
|
return StyleSheet.create({
|
|
screen: {
|
|
flex: 1,
|
|
backgroundColor: native.page,
|
|
},
|
|
centered: {
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
content: {
|
|
padding: 20,
|
|
gap: 12,
|
|
paddingBottom: 40,
|
|
},
|
|
activeCard: {
|
|
borderRadius: 16,
|
|
backgroundColor: native.fill,
|
|
padding: 18,
|
|
marginBottom: 8,
|
|
},
|
|
eyebrow: {
|
|
color: native.tertiaryLabel,
|
|
fontSize: 12,
|
|
textTransform: "uppercase",
|
|
letterSpacing: 1,
|
|
},
|
|
activeModel: {
|
|
color: native.label,
|
|
fontSize: 19,
|
|
fontWeight: "600",
|
|
marginTop: 6,
|
|
},
|
|
secondary: {
|
|
color: native.secondaryLabel,
|
|
fontSize: 14,
|
|
lineHeight: 20,
|
|
marginTop: 4,
|
|
},
|
|
sectionTitle: {
|
|
color: native.secondaryLabel,
|
|
fontSize: 14,
|
|
marginTop: 8,
|
|
marginBottom: 2,
|
|
},
|
|
card: {
|
|
borderRadius: 14,
|
|
backgroundColor: native.fill,
|
|
overflow: "hidden",
|
|
},
|
|
providerRow: {
|
|
minHeight: 62,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: native.fillPressed,
|
|
},
|
|
providerCopy: {
|
|
flex: 1,
|
|
},
|
|
providerName: {
|
|
color: native.label,
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
connected: {
|
|
color: tokens.success,
|
|
fontSize: 13,
|
|
},
|
|
modelRow: {
|
|
minHeight: 54,
|
|
paddingHorizontal: 16,
|
|
paddingVertical: 10,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: 12,
|
|
borderBottomWidth: StyleSheet.hairlineWidth,
|
|
borderBottomColor: native.fillPressed,
|
|
},
|
|
radio: {
|
|
width: 20,
|
|
height: 20,
|
|
borderRadius: 10,
|
|
borderWidth: 1,
|
|
borderColor: native.secondaryLabel,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
radioDot: {
|
|
width: 10,
|
|
height: 10,
|
|
borderRadius: 5,
|
|
backgroundColor: native.label,
|
|
},
|
|
modelLabel: {
|
|
flex: 1,
|
|
color: native.label,
|
|
fontSize: 15,
|
|
},
|
|
selectedRow: {
|
|
backgroundColor: tokens.accent,
|
|
},
|
|
billing: {
|
|
color: native.secondaryLabel,
|
|
fontSize: 13,
|
|
lineHeight: 19,
|
|
marginTop: 2,
|
|
},
|
|
hint: {
|
|
color: native.secondaryLabel,
|
|
fontSize: 13,
|
|
lineHeight: 19,
|
|
marginTop: 4,
|
|
},
|
|
helpLabel: {
|
|
color: native.secondaryLabel,
|
|
fontSize: 13,
|
|
marginTop: 8,
|
|
textDecorationLine: "underline",
|
|
},
|
|
credentialCard: {
|
|
borderRadius: 14,
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: native.fillPressed,
|
|
padding: 16,
|
|
marginTop: 8,
|
|
},
|
|
credentialTitle: {
|
|
color: native.label,
|
|
fontSize: 16,
|
|
marginTop: 6,
|
|
},
|
|
oauthCard: {
|
|
borderRadius: 14,
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: native.fillPressed,
|
|
padding: 16,
|
|
marginTop: 8,
|
|
},
|
|
link: {
|
|
color: native.label,
|
|
fontSize: 14,
|
|
textDecorationLine: "underline",
|
|
marginTop: 6,
|
|
},
|
|
code: {
|
|
color: native.label,
|
|
fontFamily: "monospace",
|
|
fontSize: 24,
|
|
letterSpacing: 3,
|
|
marginTop: 10,
|
|
marginBottom: 2,
|
|
},
|
|
keySection: {
|
|
marginTop: 4,
|
|
},
|
|
keyInput: {
|
|
height: 48,
|
|
borderRadius: 12,
|
|
backgroundColor: native.fill,
|
|
color: native.label,
|
|
paddingHorizontal: 14,
|
|
marginTop: 4,
|
|
fontSize: 16,
|
|
},
|
|
maxImagesInput: {
|
|
width: 72,
|
|
height: 40,
|
|
marginTop: 0,
|
|
textAlign: "center",
|
|
},
|
|
primaryButton: {
|
|
minHeight: 48,
|
|
borderRadius: 12,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
backgroundColor: native.label,
|
|
marginTop: 12,
|
|
paddingHorizontal: 16,
|
|
},
|
|
primaryLabel: {
|
|
color: native.page,
|
|
fontSize: 16,
|
|
fontWeight: "700",
|
|
},
|
|
outlineButton: {
|
|
minHeight: 48,
|
|
borderRadius: 12,
|
|
borderWidth: StyleSheet.hairlineWidth,
|
|
borderColor: native.fillPressed,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
marginTop: 12,
|
|
paddingHorizontal: 16,
|
|
},
|
|
outlineLabel: {
|
|
color: native.label,
|
|
fontSize: 16,
|
|
fontWeight: "600",
|
|
},
|
|
error: {
|
|
color: tokens.destructive,
|
|
fontSize: 14,
|
|
marginTop: 4,
|
|
},
|
|
notice: {
|
|
color: tokens.success,
|
|
fontSize: 14,
|
|
marginTop: 4,
|
|
},
|
|
disabled: {
|
|
opacity: 0.45,
|
|
},
|
|
pressed: {
|
|
opacity: 0.7,
|
|
},
|
|
});
|
|
}
|