update to the apn and server management
parent
ddd30ef304
commit
eadb0e25da
|
|
@ -19,6 +19,10 @@ type PushResult = {
|
|||
error?: string
|
||||
}
|
||||
|
||||
function tokenSuffix(input: string) {
|
||||
return input.length > 8 ? input.slice(-8) : input
|
||||
}
|
||||
|
||||
let jwt = ""
|
||||
let exp = 0
|
||||
let pk: Awaited<ReturnType<typeof importPKCS8>> | undefined
|
||||
|
|
@ -100,10 +104,27 @@ function post(input: {
|
|||
}
|
||||
|
||||
export async function send(input: PushInput): Promise<PushResult> {
|
||||
const apnsHost = host(input.env)
|
||||
const suffix = tokenSuffix(input.token)
|
||||
|
||||
console.log("[ APN RELAY ] push:start", {
|
||||
env: input.env,
|
||||
host: apnsHost,
|
||||
bundle: input.bundle,
|
||||
tokenSuffix: suffix,
|
||||
})
|
||||
|
||||
const auth = await sign().catch((err) => {
|
||||
return `error:${String(err)}`
|
||||
})
|
||||
if (auth.startsWith("error:")) {
|
||||
console.log("[ APN RELAY ] push:auth-failed", {
|
||||
env: input.env,
|
||||
host: apnsHost,
|
||||
bundle: input.bundle,
|
||||
tokenSuffix: suffix,
|
||||
error: auth,
|
||||
})
|
||||
return {
|
||||
ok: false,
|
||||
code: 0,
|
||||
|
|
@ -117,13 +138,13 @@ export async function send(input: PushInput): Promise<PushResult> {
|
|||
title: input.title,
|
||||
body: input.body,
|
||||
},
|
||||
sound: "default",
|
||||
sound: "alert.wav",
|
||||
},
|
||||
...input.data,
|
||||
})
|
||||
|
||||
const out = await post({
|
||||
host: host(input.env),
|
||||
host: apnsHost,
|
||||
token: input.token,
|
||||
auth,
|
||||
bundle: input.bundle,
|
||||
|
|
@ -134,12 +155,28 @@ export async function send(input: PushInput): Promise<PushResult> {
|
|||
}))
|
||||
|
||||
if (out.code === 200) {
|
||||
console.log("[ APN RELAY ] push:sent", {
|
||||
env: input.env,
|
||||
host: apnsHost,
|
||||
bundle: input.bundle,
|
||||
tokenSuffix: suffix,
|
||||
code: out.code,
|
||||
})
|
||||
return {
|
||||
ok: true,
|
||||
code: 200,
|
||||
}
|
||||
}
|
||||
|
||||
console.log("[ APN RELAY ] push:failed", {
|
||||
env: input.env,
|
||||
host: apnsHost,
|
||||
bundle: input.bundle,
|
||||
tokenSuffix: suffix,
|
||||
code: out.code,
|
||||
error: out.body,
|
||||
})
|
||||
|
||||
return {
|
||||
ok: false,
|
||||
code: out.code,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"expo": {
|
||||
"name": "Control",
|
||||
"slug": "mobile-voice",
|
||||
"slug": "control",
|
||||
"version": "1.0.0",
|
||||
"orientation": "portrait",
|
||||
"icon": "./assets/images/icon.png",
|
||||
|
|
@ -66,7 +66,8 @@
|
|||
[
|
||||
"expo-notifications",
|
||||
{
|
||||
"enableBackgroundRemoteNotifications": true
|
||||
"enableBackgroundRemoteNotifications": true,
|
||||
"sounds": ["./assets/sounds/alert.wav"]
|
||||
}
|
||||
]
|
||||
],
|
||||
|
|
@ -77,8 +78,9 @@
|
|||
"extra": {
|
||||
"router": {},
|
||||
"eas": {
|
||||
"projectId": "89248f34-51fc-49e9-acb3-728497520c5a"
|
||||
"projectId": "50b3dac3-8b5e-4142-b749-65ecf7b2904d"
|
||||
}
|
||||
}
|
||||
},
|
||||
"owner": "anomaly-co"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
|
|
@ -1,7 +0,0 @@
|
|||
module.exports = function (api) {
|
||||
api.cache(true);
|
||||
return {
|
||||
presets: ['babel-preset-expo'],
|
||||
plugins: ['react-native-reanimated/plugin'],
|
||||
};
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// https://docs.expo.dev/guides/using-eslint/
|
||||
const { defineConfig } = require('eslint/config');
|
||||
const expoConfig = require("eslint-config-expo/flat");
|
||||
|
||||
module.exports = defineConfig([
|
||||
expoConfig,
|
||||
{
|
||||
ignores: ["dist/*"],
|
||||
}
|
||||
]);
|
||||
|
|
@ -1 +1,4 @@
|
|||
- While the model is loading for the first time, there should be some fun little like onboarding sequence that you can go through that makes sure the model is automated properly.
|
||||
- When a permission/session complete notification is sent, if you click on it, the session/server should auto be selected.
|
||||
- We need some sort of permissions UI in the top half of the generation.
|
||||
- Need to figure out a good way to start new sessions.
|
||||
|
|
@ -246,6 +246,51 @@ function mergeTranscriptChunk(previous: string, chunk: string): string {
|
|||
return `${cleanPrevious} ${normalizedChunk}`
|
||||
}
|
||||
|
||||
type SessionMessageInfo = {
|
||||
role?: unknown
|
||||
time?: unknown
|
||||
}
|
||||
|
||||
type SessionMessagePart = {
|
||||
type?: unknown
|
||||
text?: unknown
|
||||
}
|
||||
|
||||
type SessionMessagePayload = {
|
||||
info?: unknown
|
||||
parts?: unknown
|
||||
}
|
||||
|
||||
function findLatestAssistantCompletionText(payload: unknown): string {
|
||||
if (!Array.isArray(payload)) return ""
|
||||
|
||||
for (let index = payload.length - 1; index >= 0; index -= 1) {
|
||||
const candidate = payload[index] as SessionMessagePayload
|
||||
if (!candidate || typeof candidate !== "object") continue
|
||||
|
||||
const info = candidate.info as SessionMessageInfo
|
||||
if (!info || typeof info !== "object") continue
|
||||
if (info.role !== "assistant") continue
|
||||
|
||||
const time = info.time as { completed?: unknown } | undefined
|
||||
if (!time || typeof time !== "object") continue
|
||||
if (typeof time.completed !== "number") continue
|
||||
|
||||
const parts = Array.isArray(candidate.parts) ? (candidate.parts as SessionMessagePart[]) : []
|
||||
const text = parts
|
||||
.filter((part) => part && part.type === "text" && typeof part.text === "string")
|
||||
.map((part) => cleanSessionText(part.text as string))
|
||||
.filter((part) => part.length > 0)
|
||||
.join("\n\n")
|
||||
|
||||
if (text.length > 0) {
|
||||
return text
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
type ServerItem = {
|
||||
id: string
|
||||
name: string
|
||||
|
|
@ -354,20 +399,48 @@ function parsePair(input: string): Pair | undefined {
|
|||
}
|
||||
}
|
||||
|
||||
function pickHost(list: string[]): string | undefined {
|
||||
const next = list.find((item) => {
|
||||
function isLoopback(hostname: string): boolean {
|
||||
return hostname === "127.0.0.1" || hostname === "localhost" || hostname === "0.0.0.0" || hostname === "::1"
|
||||
}
|
||||
|
||||
/**
|
||||
* Race all non-loopback hosts in parallel by hitting /health.
|
||||
* Returns the first one that responds with 200, or falls back to the
|
||||
* first non-loopback entry (preserving server-side ordering) if none respond.
|
||||
*/
|
||||
async function pickHost(list: string[]): Promise<string | undefined> {
|
||||
const candidates = list.filter((item) => {
|
||||
try {
|
||||
const url = new URL(item)
|
||||
if (url.hostname === "127.0.0.1") return false
|
||||
if (url.hostname === "localhost") return false
|
||||
if (url.hostname === "0.0.0.0") return false
|
||||
if (url.hostname === "::1") return false
|
||||
return true
|
||||
return !isLoopback(new URL(item).hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
})
|
||||
return next ?? list[0]
|
||||
|
||||
if (!candidates.length) return list[0]
|
||||
|
||||
const controller = new AbortController()
|
||||
const timeout = setTimeout(() => controller.abort(), 3000)
|
||||
|
||||
try {
|
||||
const winner = await Promise.any(
|
||||
candidates.map(async (host) => {
|
||||
const res = await fetch(`${host.replace(/\/+$/, "")}/health`, {
|
||||
method: "GET",
|
||||
signal: controller.signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${res.status}`)
|
||||
return host
|
||||
}),
|
||||
)
|
||||
return winner
|
||||
} catch {
|
||||
// all failed or timed out — fall back to first candidate (server already orders by reachability)
|
||||
return candidates[0]
|
||||
} finally {
|
||||
clearTimeout(timeout)
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
function serverBases(input: string) {
|
||||
|
|
@ -473,6 +546,8 @@ export default function DictationScreen() {
|
|||
const [isSending, setIsSending] = useState(false)
|
||||
const [monitorJob, setMonitorJob] = useState<MonitorJob | null>(null)
|
||||
const [monitorStatus, setMonitorStatus] = useState<string>("")
|
||||
const [latestAssistantResponse, setLatestAssistantResponse] = useState("")
|
||||
const [agentStateDismissed, setAgentStateDismissed] = useState(false)
|
||||
const [devicePushToken, setDevicePushToken] = useState<string | null>(null)
|
||||
const [appState, setAppState] = useState<AppStateStatus>(AppState.currentState)
|
||||
const [dropdownMode, setDropdownMode] = useState<DropdownMode>("none")
|
||||
|
|
@ -488,6 +563,7 @@ export default function DictationScreen() {
|
|||
const serversRef = useRef<ServerItem[]>([])
|
||||
const lastWaveformCommitRef = useRef(0)
|
||||
const sendPlayer = useAudioPlayer(require("../../assets/sounds/send-whoosh.mp3"))
|
||||
const completePlayer = useAudioPlayer(require("../../assets/sounds/complete.wav"))
|
||||
|
||||
const isRecordingRef = useRef(false)
|
||||
const isStartingRef = useRef(false)
|
||||
|
|
@ -513,6 +589,8 @@ export default function DictationScreen() {
|
|||
const restoredRef = useRef(false)
|
||||
const whisperRestoredRef = useRef(false)
|
||||
const refreshSeqRef = useRef<Record<string, number>>({})
|
||||
const activeSessionIdRef = useRef<string | null>(null)
|
||||
const latestAssistantRequestRef = useRef(0)
|
||||
|
||||
useEffect(() => {
|
||||
serversRef.current = servers
|
||||
|
|
@ -600,6 +678,10 @@ export default function DictationScreen() {
|
|||
monitorJobRef.current = monitorJob
|
||||
}, [monitorJob])
|
||||
|
||||
useEffect(() => {
|
||||
activeSessionIdRef.current = activeSessionId
|
||||
}, [activeSessionId])
|
||||
|
||||
const modelPath = useCallback((modelID: WhisperModelID) => `${WHISPER_MODELS_DIR}/${modelID}`, [])
|
||||
|
||||
const refreshInstalledWhisperModels = useCallback(async () => {
|
||||
|
|
@ -931,20 +1013,22 @@ export default function DictationScreen() {
|
|||
|
||||
useEffect(() => {
|
||||
const notificationSub = Notifications.addNotificationReceivedListener((notification: unknown) => {
|
||||
const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
const eventType = data.eventType
|
||||
const data = (notification as { request?: { content?: { data?: unknown } } }).request?.content?.data
|
||||
if (!data || typeof data !== "object") return
|
||||
const eventType = (data as { eventType?: unknown }).eventType
|
||||
if (eventType === "complete" || eventType === "permission" || eventType === "error") {
|
||||
setMonitorStatus(formatMonitorEventLabel(eventType))
|
||||
}
|
||||
if (eventType === "complete" || eventType === "error") {
|
||||
if (eventType === "complete") {
|
||||
completePlayer.seekTo(0)
|
||||
completePlayer.play()
|
||||
setMonitorJob(null)
|
||||
} else if (eventType === "error") {
|
||||
setMonitorJob(null)
|
||||
}
|
||||
})
|
||||
return () => notificationSub.remove()
|
||||
}, [])
|
||||
}, [completePlayer])
|
||||
|
||||
const finalizeRecordingState = useCallback(() => {
|
||||
isRecordingRef.current = false
|
||||
|
|
@ -1254,6 +1338,11 @@ export default function DictationScreen() {
|
|||
setIsSending(false)
|
||||
}, [clearIconRotation, clearWaveform, sendOutProgress, stopRecording])
|
||||
|
||||
const handleHideAgentState = useCallback(() => {
|
||||
Haptics.selectionAsync().catch(() => {})
|
||||
setAgentStateDismissed(true)
|
||||
}, [])
|
||||
|
||||
const resetTranscriptState = useCallback(() => {
|
||||
if (isRecordingRef.current) {
|
||||
stopRecording()
|
||||
|
|
@ -1451,8 +1540,36 @@ export default function DictationScreen() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
const loadLatestAssistantResponse = useCallback(async (baseURL: string, sessionID: string) => {
|
||||
const requestID = latestAssistantRequestRef.current + 1
|
||||
latestAssistantRequestRef.current = requestID
|
||||
|
||||
const base = baseURL.replace(/\/+$/, "")
|
||||
|
||||
try {
|
||||
const response = await fetch(`${base}/session/${sessionID}/message?limit=60`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Session messages failed (${response.status})`)
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as unknown
|
||||
const text = findLatestAssistantCompletionText(payload)
|
||||
|
||||
if (latestAssistantRequestRef.current !== requestID) return
|
||||
if (activeSessionIdRef.current !== sessionID) return
|
||||
setLatestAssistantResponse(text)
|
||||
if (text) {
|
||||
setAgentStateDismissed(false)
|
||||
}
|
||||
} catch {
|
||||
if (latestAssistantRequestRef.current !== requestID) return
|
||||
if (activeSessionIdRef.current !== sessionID) return
|
||||
setLatestAssistantResponse("")
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleMonitorEvent = useCallback(
|
||||
(eventType: MonitorEventType) => {
|
||||
(eventType: MonitorEventType, job: MonitorJob) => {
|
||||
setMonitorStatus(formatMonitorEventLabel(eventType))
|
||||
|
||||
if (eventType === "permission") {
|
||||
|
|
@ -1462,8 +1579,11 @@ export default function DictationScreen() {
|
|||
|
||||
if (eventType === "complete") {
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
|
||||
completePlayer.seekTo(0)
|
||||
completePlayer.play()
|
||||
stopForegroundMonitor()
|
||||
setMonitorJob(null)
|
||||
void loadLatestAssistantResponse(job.opencodeBaseURL, job.sessionID)
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -1471,7 +1591,7 @@ export default function DictationScreen() {
|
|||
stopForegroundMonitor()
|
||||
setMonitorJob(null)
|
||||
},
|
||||
[stopForegroundMonitor],
|
||||
[completePlayer, loadLatestAssistantResponse, stopForegroundMonitor],
|
||||
)
|
||||
|
||||
const startForegroundMonitor = useCallback(
|
||||
|
|
@ -1514,7 +1634,7 @@ export default function DictationScreen() {
|
|||
|
||||
const active = monitorJobRef.current
|
||||
if (!active || active.id !== job.id) return
|
||||
handleMonitorEvent(eventType)
|
||||
handleMonitorEvent(eventType, job)
|
||||
}
|
||||
} catch {
|
||||
if (abortController.signal.aborted) return
|
||||
|
|
@ -1555,6 +1675,16 @@ export default function DictationScreen() {
|
|||
setMonitorStatus("")
|
||||
}, [activeSessionId, stopForegroundMonitor])
|
||||
|
||||
useEffect(() => {
|
||||
setLatestAssistantResponse("")
|
||||
setAgentStateDismissed(false)
|
||||
if (!activeServerId || !activeSessionId) return
|
||||
|
||||
const server = serversRef.current.find((item) => item.id === activeServerId)
|
||||
if (!server || server.status !== "online") return
|
||||
void loadLatestAssistantResponse(server.url, activeSessionId)
|
||||
}, [activeServerId, activeSessionId, loadLatestAssistantResponse])
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
stopForegroundMonitor()
|
||||
|
|
@ -1685,6 +1815,11 @@ export default function DictationScreen() {
|
|||
? WHISPER_MODEL_LABELS[downloadingModelID]
|
||||
: WHISPER_MODEL_LABELS[defaultWhisperModel]
|
||||
const hasTranscript = transcribedText.trim().length > 0
|
||||
const hasAssistantResponse = latestAssistantResponse.trim().length > 0
|
||||
const hasAgentActivity = hasAssistantResponse || monitorStatus.trim().length > 0 || monitorJob !== null
|
||||
const shouldShowAgentStateCard = hasAgentActivity && !agentStateDismissed
|
||||
const agentStateIcon = monitorJob !== null ? "loading" : hasAssistantResponse ? "done" : "loading"
|
||||
const agentStateText = hasAssistantResponse ? latestAssistantResponse : "Waiting for agent…"
|
||||
const shouldShowSend = hasCompletedSession && hasTranscript
|
||||
const activeServer = servers.find((s) => s.id === activeServerId) ?? null
|
||||
const activeSession = activeServer?.sessions.find((s) => s.id === activeSessionId) ?? null
|
||||
|
|
@ -1742,8 +1877,8 @@ export default function DictationScreen() {
|
|||
easing: Easing.bezier(0.2, 0.8, 0.2, 1),
|
||||
})
|
||||
: withTiming(0, {
|
||||
duration: 220,
|
||||
easing: Easing.bezier(0.4, 0, 0.2, 1),
|
||||
duration: 360,
|
||||
easing: Easing.bezier(0.22, 0.61, 0.36, 1),
|
||||
})
|
||||
}, [shouldShowSend, sendVisibility])
|
||||
|
||||
|
|
@ -2241,20 +2376,21 @@ export default function DictationScreen() {
|
|||
return
|
||||
}
|
||||
|
||||
const host = pickHost(pair.hosts)
|
||||
if (!host) {
|
||||
scanLockRef.current = false
|
||||
return
|
||||
}
|
||||
void pickHost(pair.hosts).then((host) => {
|
||||
if (!host) {
|
||||
scanLockRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
const ok = addServer(host, pair.relayURL, pair.relaySecret)
|
||||
if (!ok) {
|
||||
scanLockRef.current = false
|
||||
return
|
||||
}
|
||||
const ok = addServer(host, pair.relayURL, pair.relaySecret)
|
||||
if (!ok) {
|
||||
scanLockRef.current = false
|
||||
return
|
||||
}
|
||||
|
||||
setScanOpen(false)
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
|
||||
setScanOpen(false)
|
||||
Haptics.notificationAsync(Haptics.NotificationFeedbackType.Success).catch(() => {})
|
||||
})
|
||||
},
|
||||
[addServer],
|
||||
)
|
||||
|
|
@ -2273,11 +2409,22 @@ export default function DictationScreen() {
|
|||
return () => clearInterval(timer)
|
||||
}, [activeServerId, refreshServerStatusAndSessions])
|
||||
|
||||
// Stable key that only changes when relay-relevant server properties change
|
||||
// (id, relayURL, relaySecret), not on status/session/sessionsLoading updates.
|
||||
const relayServersKey = useMemo(
|
||||
() =>
|
||||
servers
|
||||
.filter((s) => s.relaySecret.trim().length > 0)
|
||||
.map((s) => `${s.id}:${s.relayURL}:${s.relaySecret.trim()}`)
|
||||
.join("|"),
|
||||
[servers],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "ios") return
|
||||
if (!devicePushToken) return
|
||||
|
||||
const list = servers.filter((server) => server.relaySecret.trim().length > 0)
|
||||
const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
|
||||
if (!list.length) return
|
||||
|
||||
const bundleId = Constants.expoConfig?.ios?.bundleIdentifier ?? "com.anomalyco.mobilevoice"
|
||||
|
|
@ -2322,7 +2469,7 @@ export default function DictationScreen() {
|
|||
}
|
||||
}),
|
||||
).catch(() => {})
|
||||
}, [devicePushToken, servers])
|
||||
}, [devicePushToken, relayServersKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== "ios") return
|
||||
|
|
@ -2331,7 +2478,7 @@ export default function DictationScreen() {
|
|||
previousPushTokenRef.current = devicePushToken
|
||||
if (!previous || previous === devicePushToken) return
|
||||
|
||||
const list = servers.filter((server) => server.relaySecret.trim().length > 0)
|
||||
const list = serversRef.current.filter((server) => server.relaySecret.trim().length > 0)
|
||||
if (!list.length) return
|
||||
console.log("[Relay] unregister:batch", {
|
||||
previousSuffix: previous.slice(-8),
|
||||
|
|
@ -2365,7 +2512,7 @@ export default function DictationScreen() {
|
|||
}
|
||||
}),
|
||||
).catch(() => {})
|
||||
}, [devicePushToken, servers])
|
||||
}, [devicePushToken, relayServersKey])
|
||||
|
||||
const defaultModelInstalled = installedWhisperModels.includes(defaultWhisperModel)
|
||||
const onboardingProgressRaw = downloadingModelID
|
||||
|
|
@ -2628,68 +2775,152 @@ export default function DictationScreen() {
|
|||
|
||||
{/* Transcription area */}
|
||||
<View style={styles.transcriptionArea}>
|
||||
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
||||
<Pressable
|
||||
onPress={handleOpenWhisperSettings}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<SymbolView
|
||||
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
|
||||
size={18}
|
||||
weight="semibold"
|
||||
tintColor="#B8BDC9"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleClearTranscript}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Animated.Text style={[styles.clearIcon, animatedClearIconStyle]}>↻</Animated.Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{monitorStatus ? (
|
||||
<View style={styles.monitorBadge}>
|
||||
<Text style={styles.monitorBadgeText}>{monitorStatus}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{whisperError ? (
|
||||
<View style={styles.modelErrorBadge}>
|
||||
<Text style={styles.modelErrorText}>{whisperError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.transcriptionScroll}
|
||||
contentContainerStyle={styles.transcriptionContent}
|
||||
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
<Animated.View style={animatedTranscriptSendStyle}>
|
||||
{transcribedText ? (
|
||||
<Text style={styles.transcriptionText}>{transcribedText}</Text>
|
||||
) : (
|
||||
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
||||
pointerEvents="none"
|
||||
onLayout={handleWaveformLayout}
|
||||
>
|
||||
{Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
|
||||
<View key={`row-${row}`} style={styles.waveformGridRow}>
|
||||
{waveformLevels.map((_, col) => (
|
||||
<View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
|
||||
))}
|
||||
{shouldShowAgentStateCard ? (
|
||||
<View style={styles.splitCardStack}>
|
||||
<View style={[styles.splitCard, styles.replyCard]}>
|
||||
<View style={styles.agentStateHeaderRow}>
|
||||
<View style={styles.agentStateTitleWrap}>
|
||||
<View style={styles.agentStateIconWrap}>
|
||||
{agentStateIcon === "loading" ? (
|
||||
<ActivityIndicator size="small" color="#91A0C0" />
|
||||
) : (
|
||||
<SymbolView
|
||||
name={{ ios: "checkmark.circle.fill", android: "check_circle", web: "check_circle" }}
|
||||
size={16}
|
||||
tintColor="#91C29D"
|
||||
/>
|
||||
)}
|
||||
</View>
|
||||
<Text style={styles.replyCardLabel}>Agent</Text>
|
||||
</View>
|
||||
<Pressable onPress={handleHideAgentState} hitSlop={8}>
|
||||
<Text style={styles.agentStateClose}>✕</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
<ScrollView style={styles.replyScroll} contentContainerStyle={styles.replyContent}>
|
||||
<Text style={styles.replyText}>{agentStateText}</Text>
|
||||
</ScrollView>
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.transcriptionPanel}>
|
||||
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
||||
<Pressable
|
||||
onPress={handleOpenWhisperSettings}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<SymbolView
|
||||
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
|
||||
size={18}
|
||||
weight="semibold"
|
||||
tintColor="#B8BDC9"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleClearTranscript}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Animated.Text style={[styles.clearIcon, animatedClearIconStyle]}>↻</Animated.Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{whisperError ? (
|
||||
<View style={styles.modelErrorBadge}>
|
||||
<Text style={styles.modelErrorText}>{whisperError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.transcriptionScroll}
|
||||
contentContainerStyle={styles.transcriptionContent}
|
||||
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
<Animated.View style={animatedTranscriptSendStyle}>
|
||||
{transcribedText ? (
|
||||
<Text style={styles.transcriptionText}>{transcribedText}</Text>
|
||||
) : (
|
||||
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
||||
pointerEvents="none"
|
||||
onLayout={handleWaveformLayout}
|
||||
>
|
||||
{Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
|
||||
<View key={`row-${row}`} style={styles.waveformGridRow}>
|
||||
{waveformLevels.map((_, col) => (
|
||||
<View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.transcriptionPanel}>
|
||||
<View style={styles.transcriptionTopActions} pointerEvents="box-none">
|
||||
<Pressable
|
||||
onPress={handleOpenWhisperSettings}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<SymbolView
|
||||
name={{ ios: "gearshape.fill", android: "settings", web: "settings" }}
|
||||
size={18}
|
||||
weight="semibold"
|
||||
tintColor="#B8BDC9"
|
||||
/>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={handleClearTranscript}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
hitSlop={8}
|
||||
>
|
||||
<Animated.Text style={[styles.clearIcon, animatedClearIconStyle]}>↻</Animated.Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{whisperError ? (
|
||||
<View style={styles.modelErrorBadge}>
|
||||
<Text style={styles.modelErrorText}>{whisperError}</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
style={styles.transcriptionScroll}
|
||||
contentContainerStyle={styles.transcriptionContent}
|
||||
onContentSizeChange={() => scrollViewRef.current?.scrollToEnd({ animated: true })}
|
||||
>
|
||||
<Animated.View style={animatedTranscriptSendStyle}>
|
||||
{transcribedText ? (
|
||||
<Text style={styles.transcriptionText}>{transcribedText}</Text>
|
||||
) : (
|
||||
<Text style={styles.placeholderText}>Your transcription will appear here…</Text>
|
||||
)}
|
||||
</Animated.View>
|
||||
</ScrollView>
|
||||
|
||||
<Animated.View
|
||||
style={[styles.waveformBoxesRow, animatedWaveformRowStyle]}
|
||||
pointerEvents="none"
|
||||
onLayout={handleWaveformLayout}
|
||||
>
|
||||
{Array.from({ length: WAVEFORM_ROWS }).map((_, row) => (
|
||||
<View key={`row-${row}`} style={styles.waveformGridRow}>
|
||||
{waveformLevels.map((_, col) => (
|
||||
<View key={`cell-${row}-${col}`} style={[styles.waveformBox, getWaveformCellStyle(row, col)]} />
|
||||
))}
|
||||
</View>
|
||||
))}
|
||||
</Animated.View>
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
|
||||
{/* Record button */}
|
||||
|
|
@ -3223,6 +3454,13 @@ const styles = StyleSheet.create({
|
|||
flex: 1,
|
||||
marginHorizontal: 6,
|
||||
marginTop: 6,
|
||||
},
|
||||
splitCardStack: {
|
||||
flex: 1,
|
||||
gap: 8,
|
||||
},
|
||||
splitCard: {
|
||||
flex: 1,
|
||||
backgroundColor: "#151515",
|
||||
borderRadius: 20,
|
||||
borderWidth: 3,
|
||||
|
|
@ -3230,6 +3468,57 @@ const styles = StyleSheet.create({
|
|||
overflow: "hidden",
|
||||
position: "relative",
|
||||
},
|
||||
replyCard: {
|
||||
paddingTop: 16,
|
||||
},
|
||||
transcriptionPanel: {
|
||||
flex: 1,
|
||||
position: "relative",
|
||||
overflow: "hidden",
|
||||
},
|
||||
replyCardLabel: {
|
||||
color: "#AAB5CC",
|
||||
fontSize: 15,
|
||||
fontWeight: "600",
|
||||
},
|
||||
agentStateHeaderRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginHorizontal: 20,
|
||||
marginBottom: 8,
|
||||
},
|
||||
agentStateTitleWrap: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: 8,
|
||||
},
|
||||
agentStateIconWrap: {
|
||||
width: 16,
|
||||
height: 16,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
agentStateClose: {
|
||||
color: "#8D97AB",
|
||||
fontSize: 18,
|
||||
fontWeight: "700",
|
||||
lineHeight: 18,
|
||||
},
|
||||
replyScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
replyContent: {
|
||||
paddingHorizontal: 20,
|
||||
paddingBottom: 18,
|
||||
flexGrow: 1,
|
||||
},
|
||||
replyText: {
|
||||
fontSize: 22,
|
||||
fontWeight: "500",
|
||||
lineHeight: 32,
|
||||
color: "#F4F7FF",
|
||||
},
|
||||
transcriptionScroll: {
|
||||
flex: 1,
|
||||
},
|
||||
|
|
@ -3266,24 +3555,6 @@ const styles = StyleSheet.create({
|
|||
fontWeight: "600",
|
||||
letterSpacing: 0.1,
|
||||
},
|
||||
monitorBadge: {
|
||||
alignSelf: "flex-start",
|
||||
marginLeft: 14,
|
||||
marginTop: 12,
|
||||
marginBottom: 4,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 5,
|
||||
borderRadius: 999,
|
||||
backgroundColor: "#1B2438",
|
||||
borderWidth: 1,
|
||||
borderColor: "#2B3D66",
|
||||
},
|
||||
monitorBadgeText: {
|
||||
color: "#BFD0FA",
|
||||
fontSize: 12,
|
||||
fontWeight: "600",
|
||||
letterSpacing: 0.2,
|
||||
},
|
||||
transcriptionText: {
|
||||
fontSize: 28,
|
||||
fontWeight: "500",
|
||||
|
|
|
|||
|
|
@ -23,6 +23,8 @@ TaskManager.defineTask(BACKGROUND_TASK_NAME, async ({ data }: { data?: unknown }
|
|||
title,
|
||||
body,
|
||||
data: payload ?? {},
|
||||
sound: "alert.wav",
|
||||
...(Platform.OS === "android" ? { channelId: "monitoring" } : {}),
|
||||
},
|
||||
trigger: null,
|
||||
})
|
||||
|
|
@ -55,6 +57,7 @@ export async function ensureNotificationPermissions(): Promise<boolean> {
|
|||
await Notifications.setNotificationChannelAsync("monitoring", {
|
||||
name: "OpenCode Monitoring",
|
||||
importance: Notifications.AndroidImportance.HIGH,
|
||||
sound: "alert.wav",
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,13 +10,30 @@ import { Installation } from "../../installation"
|
|||
import { PushRelay } from "../../server/push-relay"
|
||||
import * as QRCode from "qrcode"
|
||||
|
||||
function ipTier(address: string): number {
|
||||
const parts = address.split(".")
|
||||
if (parts.length !== 4) return 4
|
||||
const a = Number(parts[0])
|
||||
const b = Number(parts[1])
|
||||
if (a === 127) return 4
|
||||
if (a === 169 && b === 254) return 3
|
||||
if (a === 10) return 2
|
||||
if (a === 172 && b >= 16 && b <= 31) return 2
|
||||
if (a === 192 && b === 168) return 2
|
||||
if (a === 100 && b >= 64 && b <= 127) return 1
|
||||
return 0
|
||||
}
|
||||
|
||||
function hosts(hostname: string, port: number) {
|
||||
const list = new Set<string>()
|
||||
const seen = new Set<string>()
|
||||
const entries: Array<{ url: string; tier: number }> = []
|
||||
const add = (item: string) => {
|
||||
if (!item) return
|
||||
if (item === "0.0.0.0") return
|
||||
if (item === "::") return
|
||||
list.add(`http://${item}:${port}`)
|
||||
if (seen.has(item)) return
|
||||
seen.add(item)
|
||||
entries.push({ url: `http://${item}:${port}`, tier: ipTier(item) })
|
||||
}
|
||||
add(hostname)
|
||||
add("127.0.0.1")
|
||||
|
|
@ -25,7 +42,8 @@ function hosts(hostname: string, port: number) {
|
|||
.filter((item) => item.family === "IPv4" && !item.internal)
|
||||
.map((item) => item.address)
|
||||
.forEach(add)
|
||||
return [...list]
|
||||
entries.sort((a, b) => a.tier - b.tier)
|
||||
return entries.map((item) => item.url)
|
||||
}
|
||||
|
||||
export const ServeCommand = cmd({
|
||||
|
|
|
|||
|
|
@ -56,13 +56,48 @@ function norm(input: string) {
|
|||
return input.replace(/\/+$/, "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Classify an IPv4 address into a reachability tier.
|
||||
* Lower number = more likely reachable from an external/overlay network device.
|
||||
*
|
||||
* 0 – public / routable
|
||||
* 1 – CGNAT / shared (100.64.0.0/10) – used by Tailscale, Cloudflare WARP, carrier NAT, etc.
|
||||
* 2 – private LAN (10.0.0.0/8, 172.16-31.x, 192.168.x)
|
||||
* 3 – link-local (169.254.x)
|
||||
* 4 – loopback (127.x)
|
||||
*/
|
||||
function ipTier(address: string): number {
|
||||
const parts = address.split(".")
|
||||
if (parts.length !== 4) return 4
|
||||
const a = Number(parts[0])
|
||||
const b = Number(parts[1])
|
||||
|
||||
// loopback 127.0.0.0/8
|
||||
if (a === 127) return 4
|
||||
// link-local 169.254.0.0/16
|
||||
if (a === 169 && b === 254) return 3
|
||||
// private 10.0.0.0/8
|
||||
if (a === 10) return 2
|
||||
// private 172.16.0.0/12
|
||||
if (a === 172 && b >= 16 && b <= 31) return 2
|
||||
// private 192.168.0.0/16
|
||||
if (a === 192 && b === 168) return 2
|
||||
// CGNAT / shared address space 100.64.0.0/10 (100.64.x – 100.127.x)
|
||||
if (a === 100 && b >= 64 && b <= 127) return 1
|
||||
// everything else is routable
|
||||
return 0
|
||||
}
|
||||
|
||||
function list(hostname: string, port: number) {
|
||||
const urls = new Set<string>()
|
||||
const seen = new Set<string>()
|
||||
const hosts: Array<{ url: string; tier: number }> = []
|
||||
const add = (host: string) => {
|
||||
if (!host) return
|
||||
if (host === "0.0.0.0") return
|
||||
if (host === "::") return
|
||||
urls.add(`http://${host}:${port}`)
|
||||
if (seen.has(host)) return
|
||||
seen.add(host)
|
||||
hosts.push({ url: `http://${host}:${port}`, tier: ipTier(host) })
|
||||
}
|
||||
|
||||
add(hostname)
|
||||
|
|
@ -75,7 +110,10 @@ function list(hostname: string, port: number) {
|
|||
|
||||
nets.forEach(add)
|
||||
|
||||
return [...urls]
|
||||
// sort: most externally reachable first, loopback last
|
||||
hosts.sort((a, b) => a.tier - b.tier)
|
||||
|
||||
return hosts.map((item) => item.url)
|
||||
}
|
||||
|
||||
function map(event: Event): { type: Type; sessionID: string } | undefined {
|
||||
|
|
@ -216,6 +254,20 @@ async function post(input: { type: Type; sessionID: string }) {
|
|||
|
||||
const content = await notify(input)
|
||||
|
||||
console.log("[ APN RELAY ] posting event", {
|
||||
relayURL: next.relayURL,
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
})
|
||||
|
||||
log.info("[ APN RELAY ] posting event", {
|
||||
relayURL: next.relayURL,
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
})
|
||||
|
||||
void fetch(`${next.relayURL}/v1/event`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
|
@ -230,7 +282,22 @@ async function post(input: { type: Type; sessionID: string }) {
|
|||
}),
|
||||
})
|
||||
.then(async (res) => {
|
||||
if (res.ok) return
|
||||
if (res.ok) {
|
||||
console.log("[ APN RELAY ] relay accepted event", {
|
||||
status: res.status,
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
})
|
||||
|
||||
log.info("[ APN RELAY ] relay accepted event", {
|
||||
status: res.status,
|
||||
type: input.type,
|
||||
sessionID: input.sessionID,
|
||||
title: content.title,
|
||||
})
|
||||
return
|
||||
}
|
||||
const error = await res.text().catch(() => "")
|
||||
log.warn("relay post failed", {
|
||||
status: res.status,
|
||||
|
|
|
|||
Loading…
Reference in New Issue