mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-19 16:40:48 +00:00
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.
This commit is contained in:
parent
56e0e5ce65
commit
0051b605ae
4 changed files with 91 additions and 7 deletions
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue