Explorar el Código

feat: improve mobile model download UX and relay defaults

Add in-button model download progress plus a model reset control in mobile-voice, and switch APN relay defaults to apn.dev.opencode.ai in serve and docs.
Ryan Vogel hace 4 meses
padre
commit
0051b605ae

+ 1 - 1
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:
 

+ 88 - 4
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 */}
       <View style={styles.transcriptionArea}>
         <View style={styles.transcriptionTopActions} pointerEvents="box-none">
+          <Pressable
+            onPress={() => {
+              void handleDeleteModel()
+            }}
+            style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
+            hitSlop={8}
+            disabled={modelLoading || modelReset}
+          >
+            <Text style={styles.modelDeleteIcon}>DL</Text>
+          </Pressable>
           <Pressable
             onPress={handleClearTranscript}
             style={({ pressed }) => [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]}
         >
           <View style={styles.recordButton}>
-            <Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
-            <Animated.View style={[styles.recordDot, animatedDotStyle]} />
+            {modelLoading ? (
+              <>
+                <View style={[styles.loadFill, { width: `${Math.max(pct, 3)}%` }]} />
+                <View style={styles.loadOverlay} pointerEvents="none">
+                  <Text style={styles.loadText}>{`Downloading model ${pct}%`}</Text>
+                </View>
+              </>
+            ) : (
+              <>
+                <Animated.View style={[styles.recordBorder, animatedBorderStyle]} pointerEvents="none" />
+                <Animated.View style={[styles.recordDot, animatedDotStyle]} />
+              </>
+            )}
           </View>
         </Pressable>
 
@@ -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",

+ 1 - 1
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) {

+ 1 - 1
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`