diff --git a/packages/mobile-voice/README.md b/packages/mobile-voice/README.md index 8b054ad4e4..20153db656 100644 --- a/packages/mobile-voice/README.md +++ b/packages/mobile-voice/README.md @@ -22,7 +22,7 @@ When adding a server, provide: - APN relay URL - Relay shared secret -Default APN relay URL: `https://relay.opencode.ai` +Default APN relay URL: `https://apn.dev.opencode.ai` The app uses these values to: diff --git a/packages/mobile-voice/src/app/index.tsx b/packages/mobile-voice/src/app/index.tsx index d5dbdaf0db..2c731906ef 100644 --- a/packages/mobile-voice/src/app/index.tsx +++ b/packages/mobile-voice/src/app/index.tsx @@ -27,6 +27,7 @@ import { StatusBar } from "expo-status-bar" import * as Haptics from "expo-haptics" import { useAudioPlayer } from "expo-audio" import { useSpeechToText, WHISPER_BASE_EN } from "react-native-executorch" +import { ExpoResourceFetcher } from "react-native-executorch-expo-resource-fetcher" import { AudioManager, AudioRecorder } from "react-native-audio-api" import * as Notifications from "expo-notifications" import Constants from "expo-constants" @@ -56,7 +57,7 @@ const WAVEFORM_CELL_GAP = 2 const DROPDOWN_VISIBLE_ROWS = 6 // If the press duration is shorter than this, treat it as a tap (toggle) const TAP_THRESHOLD_MS = 300 -const DEFAULT_RELAY_URL = "https://relay.opencode.ai" +const DEFAULT_RELAY_URL = "https://apn.dev.opencode.ai" type ServerItem = { id: string @@ -107,8 +108,10 @@ function formatSessionUpdated(updatedMs: number): string { type DropdownMode = "none" | "server" | "session" export default function DictationScreen() { + const [modelReset, setModelReset] = useState(false) const model = useSpeechToText({ model: WHISPER_BASE_EN, + preventLoad: modelReset, }) const [transcribedText, setTranscribedText] = useState("") @@ -497,6 +500,35 @@ export default function DictationScreen() { setIsSending(false) }, [clearIconRotation, sendOutProgress, stopRecording]) + const handleDeleteModel = useCallback(async () => { + if (modelReset) return + + if (isRecordingRef.current) { + stopRecording() + } + + setModelReset(true) + accumulatedRef.current = "" + baseTextRef.current = "" + setTranscribedText("") + setHasCompletedSession(false) + const cleared = new Array(waveformLevelsRef.current.length).fill(0) + waveformLevelsRef.current = cleared + setWaveformLevels(cleared) + setWaveformTick(Date.now()) + sendOutProgress.value = 0 + setIsSending(false) + Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium).catch(() => {}) + + try { + await ExpoResourceFetcher.deleteResources(WHISPER_BASE_EN.modelSource, WHISPER_BASE_EN.tokenizerSource) + } catch (err) { + console.error("Failed to delete model resources:", err) + } + + setModelReset(false) + }, [modelReset, sendOutProgress, stopRecording]) + const resetTranscriptState = useCallback(() => { if (isRecordingRef.current) { stopRecording() @@ -759,6 +791,9 @@ export default function DictationScreen() { }, [stopRecording]) const modelLoading = !model.isReady + const prog = model.downloadProgress > 1 ? model.downloadProgress / 100 : model.downloadProgress + const load = Math.max(0, Math.min(1, Number.isFinite(prog) ? prog : 0)) + const pct = Math.round(load * 100) const hasTranscript = transcribedText.trim().length > 0 const shouldShowSend = hasCompletedSession && hasTranscript const activeServer = servers.find((s) => s.id === activeServerId) ?? null @@ -1352,6 +1387,16 @@ export default function DictationScreen() { {/* Transcription area */} + { + void handleDeleteModel() + }} + style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]} + hitSlop={8} + disabled={modelLoading || modelReset} + > + DL + [styles.clearButton, pressed && styles.clearButtonPressed]} @@ -1403,11 +1448,22 @@ export default function DictationScreen() { onPressIn={handlePressIn} onPressOut={handlePressOut} disabled={!permissionGranted || modelLoading} - style={[styles.recordPressable, (!permissionGranted || modelLoading) && styles.recordButtonDisabled]} + style={[styles.recordPressable, !permissionGranted && styles.recordButtonDisabled]} > - - + {modelLoading ? ( + <> + + + {`Downloading model ${pct}%`} + + + ) : ( + <> + + + + )} @@ -1673,8 +1729,17 @@ const styles = StyleSheet.create({ transcriptionTopActions: { position: "absolute", top: 10, + left: 10, right: 10, zIndex: 4, + flexDirection: "row", + justifyContent: "space-between", + }, + modelDeleteIcon: { + color: "#8FB4FF", + fontSize: 14, + fontWeight: "800", + letterSpacing: 0.6, }, monitorBadge: { alignSelf: "flex-start", @@ -1745,6 +1810,25 @@ const styles = StyleSheet.create({ width: "100%", overflow: "hidden", }, + loadFill: { + position: "absolute", + left: 0, + top: 0, + bottom: 0, + backgroundColor: "#FF5B47", + }, + loadOverlay: { + ...StyleSheet.absoluteFillObject, + alignItems: "center", + justifyContent: "center", + paddingHorizontal: 18, + }, + loadText: { + color: "#FFF6F4", + fontSize: 14, + fontWeight: "700", + letterSpacing: 0.2, + }, sendSlot: { height: CONTROL_HEIGHT, overflow: "hidden", diff --git a/packages/opencode/src/cli/cmd/serve.ts b/packages/opencode/src/cli/cmd/serve.ts index 3d06cef4a8..cdff6e82be 100644 --- a/packages/opencode/src/cli/cmd/serve.ts +++ b/packages/opencode/src/cli/cmd/serve.ts @@ -31,7 +31,7 @@ export const ServeCommand = cmd({ const relayURL = ( args["relay-url"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_URL ?? - "https://relay.opencode.ai" + "https://apn.dev.opencode.ai" ).trim() const relaySecret = (args["relay-secret"] ?? process.env.OPENCODE_EXPERIMENTAL_PUSH_RELAY_SECRET ?? "").trim() if (relayURL && relaySecret) { diff --git a/specs/apn-relay-mvp-layout.md b/specs/apn-relay-mvp-layout.md index 17339160de..238a091bba 100644 --- a/specs/apn-relay-mvp-layout.md +++ b/specs/apn-relay-mvp-layout.md @@ -142,7 +142,7 @@ This is the minimum setup to get reliable mobile background notifications workin - Add serve options: - `--relay-url` - `--relay-secret` (optional; generate random if missing) -- Default relay URL: `https://relay.opencode.ai` +- Default relay URL: `https://apn.dev.opencode.ai` - If relay is configured, print QR payload in terminal: - `hosts` (local LAN and configured host, including Tailscale IP when present) - `relayURL`