Prompt is too long

This commit is contained in:
Mohamed Boudra
2025-10-22 09:06:18 +02:00
parent 8235d8d1b1
commit e646f410be
128 changed files with 9346 additions and 7090 deletions
+43
View File
@@ -0,0 +1,43 @@
# Learn more https://docs.github.com/en/get-started/getting-started-with-git/ignoring-files
# dependencies
node_modules/
# Expo
.expo/
dist/
web-build/
expo-env.d.ts
# Native
.kotlin/
*.orig.*
*.jks
*.p8
*.p12
*.key
*.mobileprovision
# Metro
.metro-health-check*
# debug
npm-debug.*
yarn-debug.*
yarn-error.*
# macOS
.DS_Store
*.pem
# local env files
.env*.local
# typescript
*.tsbuildinfo
app-example
# generated native folders
/ios
/android
+50
View File
@@ -0,0 +1,50 @@
# Welcome to your Expo app 👋
This is an [Expo](https://expo.dev) project created with [`create-expo-app`](https://www.npmjs.com/package/create-expo-app).
## Get started
1. Install dependencies
```bash
npm install
```
2. Start the app
```bash
npx expo start
```
In the output, you'll find options to open the app in a
- [development build](https://docs.expo.dev/develop/development-builds/introduction/)
- [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/)
- [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/)
- [Expo Go](https://expo.dev/go), a limited sandbox for trying out app development with Expo
You can start developing by editing the files inside the **app** directory. This project uses [file-based routing](https://docs.expo.dev/router/introduction).
## Get a fresh project
When you're ready, run:
```bash
npm run reset-project
```
This command will move the starter code to the **app-example** directory and create a blank **app** directory where you can start developing.
## Learn more
To learn more about developing your project with Expo, look at the following resources:
- [Expo documentation](https://docs.expo.dev/): Learn fundamentals, or go into advanced topics with our [guides](https://docs.expo.dev/guides).
- [Learn Expo tutorial](https://docs.expo.dev/tutorial/introduction/): Follow a step-by-step tutorial where you'll create a project that runs on Android, iOS, and the web.
## Join the community
Join our community of developers creating universal apps.
- [Expo on GitHub](https://github.com/expo/expo): View our open source platform and contribute.
- [Discord community](https://chat.expo.dev): Chat with Expo users and ask questions.
+81
View File
@@ -0,0 +1,81 @@
{
"expo": {
"name": "Voice Dev",
"slug": "voice-dev",
"version": "1.0.0",
"orientation": "portrait",
"icon": "./assets/images/icon.png",
"scheme": "voicedev",
"userInterfaceStyle": "automatic",
"newArchEnabled": true,
"runtimeVersion": {
"policy": "appVersion"
},
"updates": {
"url": "https://u.expo.dev/0e7f65ce-0367-46c8-a238-2b65963d235a"
},
"ios": {
"supportsTablet": true,
"infoPlist": {
"NSMicrophoneUsageDescription": "This app needs access to the microphone for voice commands."
},
"bundleIdentifier": "com.moboudra.voicedev"
},
"android": {
"adaptiveIcon": {
"backgroundColor": "#E6F4FE",
"foregroundImage": "./assets/images/android-icon-foreground.png",
"backgroundImage": "./assets/images/android-icon-background.png",
"monochromeImage": "./assets/images/android-icon-monochrome.png"
},
"edgeToEdgeEnabled": true,
"predictiveBackGestureEnabled": false,
"softwareKeyboardLayoutMode": "pan",
"permissions": [
"RECORD_AUDIO",
"android.permission.RECORD_AUDIO",
"android.permission.MODIFY_AUDIO_SETTINGS"
],
"package": "com.moboudra.voicedev"
},
"web": {
"output": "static",
"favicon": "./assets/images/favicon.png"
},
"plugins": [
"expo-router",
[
"expo-splash-screen",
{
"image": "./assets/images/splash-icon.png",
"imageWidth": 200,
"resizeMode": "contain",
"backgroundColor": "#ffffff",
"dark": {
"backgroundColor": "#000000"
}
}
],
"expo-audio",
[
"expo-build-properties",
{
"android": {
"minSdkVersion": 29
}
}
]
],
"experiments": {
"typedRoutes": true,
"reactCompiler": false
},
"extra": {
"router": {},
"eas": {
"projectId": "0e7f65ce-0367-46c8-a238-2b65963d235a"
}
},
"owner": "moboudra"
}
}
+17
View File
@@ -0,0 +1,17 @@
import '../global.css';
import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
export default function RootLayout() {
return (
<SafeAreaProvider>
<Stack screenOptions={{ headerShown: false }}>
<Stack.Screen name="index" />
<Stack.Screen name="settings" />
<Stack.Screen name="audio-test" />
</Stack>
<StatusBar style="light" />
</SafeAreaProvider>
);
}
+299
View File
@@ -0,0 +1,299 @@
import { View, Text, Pressable, ScrollView, Alert } from 'react-native';
import { useState, useEffect } from 'react';
import { useAudioRecorder } from '../hooks/use-audio-recorder';
import { useAudioPlayer } from '../hooks/use-audio-player';
import { getRecordingPermissionsAsync, requestRecordingPermissionsAsync } from 'expo-audio';
export default function AudioTestScreen() {
const [permissionGranted, setPermissionGranted] = useState(false);
const [recordingStatus, setRecordingStatus] = useState<'idle' | 'recording' | 'processing'>('idle');
const [lastRecordingSize, setLastRecordingSize] = useState<number | null>(null);
const [playbackStatus, setPlaybackStatus] = useState<string>('No audio playing');
const [lastRecordedBlob, setLastRecordedBlob] = useState<Blob | null>(null);
const audioRecorder = useAudioRecorder({
sampleRate: 16000,
numberOfChannels: 1,
});
const audioPlayer = useAudioPlayer();
useEffect(() => {
checkAndRequestPermissions();
}, []);
async function checkAndRequestPermissions() {
try {
const { status } = await getRecordingPermissionsAsync();
if (status === 'granted') {
setPermissionGranted(true);
} else {
const { granted } = await requestRecordingPermissionsAsync();
setPermissionGranted(granted);
if (!granted) {
Alert.alert(
'Permission Required',
'Microphone permission is required to test audio recording.'
);
}
}
} catch (error) {
console.error('[AudioTest] Permission error:', error);
Alert.alert('Error', 'Failed to check/request microphone permissions');
}
}
async function handleStartRecording() {
if (!permissionGranted) {
Alert.alert('Permission Required', 'Please grant microphone permission first');
return;
}
try {
setRecordingStatus('recording');
setLastRecordingSize(null);
await audioRecorder.start();
} catch (error: any) {
console.error('[AudioTest] Start recording error:', error);
Alert.alert('Recording Error', error.message);
setRecordingStatus('idle');
}
}
async function handleStopRecording() {
try {
setRecordingStatus('processing');
const audioBlob = await audioRecorder.stop();
setLastRecordingSize(audioBlob.size);
setLastRecordedBlob(audioBlob);
setRecordingStatus('idle');
Alert.alert(
'Recording Complete',
`Audio recorded successfully!\nSize: ${audioBlob.size} bytes\nType: ${audioBlob.type}`
);
} catch (error: any) {
console.error('[AudioTest] Stop recording error:', error);
Alert.alert('Recording Error', error.message);
setRecordingStatus('idle');
}
}
async function handlePlayLastRecording() {
if (!lastRecordedBlob) {
Alert.alert('No Recording', 'Please record audio first');
return;
}
try {
setPlaybackStatus('Playing last recording...');
const duration = await audioPlayer.play(lastRecordedBlob);
setPlaybackStatus(`Playback complete (${duration.toFixed(2)}s)`);
} catch (error: any) {
console.error('[AudioTest] Playback error:', error);
Alert.alert('Playback Error', error.message);
setPlaybackStatus('Playback failed');
}
}
async function handlePlayTestAudio() {
// Create a test audio blob (silent audio for testing)
// In a real scenario, this would be audio from the server
try {
setPlaybackStatus('Playing test audio...');
// For now, we'll show an alert since we don't have a test audio file
Alert.alert(
'Test Audio',
'This would play a test audio file. Try recording and playing back instead.'
);
setPlaybackStatus('Test audio not implemented');
} catch (error: any) {
console.error('[AudioTest] Test playback error:', error);
Alert.alert('Playback Error', error.message);
setPlaybackStatus('Test playback failed');
}
}
function handleStopPlayback() {
audioPlayer.stop();
setPlaybackStatus('Playback stopped');
}
return (
<ScrollView className="flex-1 bg-white dark:bg-black">
<View className="p-6">
{/* Header */}
<Text className="text-3xl font-bold text-gray-900 dark:text-white mb-2">
Audio Test
</Text>
<Text className="text-base text-gray-600 dark:text-gray-400 mb-8">
Test audio recording and playback functionality
</Text>
{/* Permission Status */}
<View className="mb-8">
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-2">
Permission Status
</Text>
<View className={`p-4 rounded-lg ${permissionGranted ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
<Text className={`text-base ${permissionGranted ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'}`}>
{permissionGranted ? '✓ Microphone permission granted' : '✗ Microphone permission denied'}
</Text>
</View>
</View>
{/* Recording Section */}
<View className="mb-8">
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Audio Recording
</Text>
{/* Recording Status */}
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</Text>
<Text className="text-base text-gray-900 dark:text-white">
{recordingStatus === 'idle' && 'Ready to record'}
{recordingStatus === 'recording' && '🔴 Recording...'}
{recordingStatus === 'processing' && 'Processing...'}
</Text>
</View>
{/* Recording Controls */}
<View className="flex-row gap-3 mb-4">
<Pressable
onPress={handleStartRecording}
disabled={recordingStatus !== 'idle' || !permissionGranted}
className={`flex-1 p-4 rounded-lg items-center ${
recordingStatus !== 'idle' || !permissionGranted
? 'bg-gray-300 dark:bg-gray-700'
: 'bg-blue-500 dark:bg-blue-600'
}`}
>
<Text className={`text-base font-semibold ${
recordingStatus !== 'idle' || !permissionGranted
? 'text-gray-500 dark:text-gray-400'
: 'text-white'
}`}>
Start Recording
</Text>
</Pressable>
<Pressable
onPress={handleStopRecording}
disabled={recordingStatus !== 'recording'}
className={`flex-1 p-4 rounded-lg items-center ${
recordingStatus !== 'recording'
? 'bg-gray-300 dark:bg-gray-700'
: 'bg-red-500 dark:bg-red-600'
}`}
>
<Text className={`text-base font-semibold ${
recordingStatus !== 'recording'
? 'text-gray-500 dark:text-gray-400'
: 'text-white'
}`}>
Stop Recording
</Text>
</Pressable>
</View>
{/* Last Recording Info */}
{lastRecordingSize !== null && (
<View className="p-4 bg-blue-50 dark:bg-blue-950 rounded-lg">
<Text className="text-sm font-medium text-blue-700 dark:text-blue-300 mb-1">
Last Recording
</Text>
<Text className="text-base text-blue-900 dark:text-blue-100">
Size: {lastRecordingSize} bytes
</Text>
<Text className="text-sm text-blue-600 dark:text-blue-400 mt-1">
Type: audio/m4a
</Text>
</View>
)}
</View>
{/* Playback Section */}
<View className="mb-8">
<Text className="text-lg font-semibold text-gray-900 dark:text-white mb-4">
Audio Playback
</Text>
{/* Playback Status */}
<View className="mb-4 p-4 bg-gray-100 dark:bg-gray-800 rounded-lg">
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Status
</Text>
<Text className="text-base text-gray-900 dark:text-white">
{playbackStatus}
</Text>
</View>
{/* Playback Controls */}
<View className="gap-3">
<Pressable
onPress={handlePlayLastRecording}
disabled={!lastRecordedBlob}
className={`p-4 rounded-lg items-center ${
!lastRecordedBlob
? 'bg-gray-300 dark:bg-gray-700'
: 'bg-green-500 dark:bg-green-600'
}`}
>
<Text className={`text-base font-semibold ${
!lastRecordedBlob
? 'text-gray-500 dark:text-gray-400'
: 'text-white'
}`}>
Play Last Recording
</Text>
</Pressable>
<Pressable
onPress={handlePlayTestAudio}
className="p-4 rounded-lg items-center bg-purple-500 dark:bg-purple-600"
>
<Text className="text-base font-semibold text-white">
Play Test Audio
</Text>
</Pressable>
<Pressable
onPress={handleStopPlayback}
className="p-4 rounded-lg items-center bg-orange-500 dark:bg-orange-600"
>
<Text className="text-base font-semibold text-white">
Stop Playback
</Text>
</Pressable>
</View>
</View>
{/* Audio Settings Info */}
<View className="p-4 bg-gray-50 dark:bg-gray-900 rounded-lg">
<Text className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Audio Configuration
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
• Sample Rate: 16000 Hz
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
• Channels: 1 (Mono)
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400 mb-1">
• Format: M4A/AAC
</Text>
<Text className="text-sm text-gray-600 dark:text-gray-400">
• Optimized for voice/speech
</Text>
</View>
</View>
</ScrollView>
);
}
File diff suppressed because it is too large Load Diff
+367
View File
@@ -0,0 +1,367 @@
import { useState, useEffect } from 'react';
import { View, Text, ScrollView, TextInput, Switch, Pressable, Alert, ActivityIndicator } from 'react-native';
import { router } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import { useSettings } from '@/hooks/use-settings';
export default function SettingsScreen() {
const { settings, isLoading, updateSettings, resetSettings } = useSettings();
const insets = useSafeAreaInsets();
const [serverUrl, setServerUrl] = useState(settings.serverUrl);
const [useSpeaker, setUseSpeaker] = useState(settings.useSpeaker);
const [keepScreenOn, setKeepScreenOn] = useState(settings.keepScreenOn);
const [theme, setTheme] = useState<'dark' | 'light' | 'auto'>(settings.theme);
const [hasChanges, setHasChanges] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
// Update local state when settings load
useEffect(() => {
setServerUrl(settings.serverUrl);
setUseSpeaker(settings.useSpeaker);
setKeepScreenOn(settings.keepScreenOn);
setTheme(settings.theme);
}, [settings]);
// Track changes
useEffect(() => {
const changed =
serverUrl !== settings.serverUrl ||
useSpeaker !== settings.useSpeaker ||
keepScreenOn !== settings.keepScreenOn ||
theme !== settings.theme;
setHasChanges(changed);
}, [serverUrl, useSpeaker, keepScreenOn, theme, settings]);
function validateServerUrl(url: string): boolean {
try {
const urlObj = new URL(url);
return urlObj.protocol === 'ws:' || urlObj.protocol === 'wss:';
} catch {
return false;
}
}
async function handleSave() {
// Validate server URL
if (!validateServerUrl(serverUrl)) {
Alert.alert(
'Invalid URL',
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
[{ text: 'OK' }]
);
return;
}
try {
await updateSettings({
serverUrl,
useSpeaker,
keepScreenOn,
theme,
});
Alert.alert(
'Settings Saved',
'Your settings have been saved successfully.',
[
{
text: 'OK',
onPress: () => router.back(),
},
]
);
} catch (error) {
Alert.alert(
'Error',
'Failed to save settings. Please try again.',
[{ text: 'OK' }]
);
}
}
async function handleReset() {
Alert.alert(
'Reset Settings',
'Are you sure you want to reset all settings to defaults?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Reset',
style: 'destructive',
onPress: async () => {
try {
await resetSettings();
Alert.alert('Settings Reset', 'All settings have been reset to defaults.');
} catch (error) {
Alert.alert('Error', 'Failed to reset settings. Please try again.');
}
},
},
]
);
}
function handleCancel() {
if (hasChanges) {
Alert.alert(
'Discard Changes',
'You have unsaved changes. Are you sure you want to go back?',
[
{ text: 'Stay', style: 'cancel' },
{
text: 'Discard',
style: 'destructive',
onPress: () => router.back(),
},
]
);
} else {
router.back();
}
}
async function handleTestConnection() {
if (!validateServerUrl(serverUrl)) {
Alert.alert(
'Invalid URL',
'Server URL must be a valid WebSocket URL (ws:// or wss://)',
[{ text: 'OK' }]
);
return;
}
setIsTesting(true);
setTestResult(null);
try {
const ws = new WebSocket(serverUrl);
const timeout = setTimeout(() => {
ws.close();
setTestResult({
success: false,
message: 'Connection timeout - server did not respond',
});
setIsTesting(false);
}, 5000);
ws.onopen = () => {
clearTimeout(timeout);
ws.close();
setTestResult({
success: true,
message: 'Connection successful',
});
setIsTesting(false);
};
ws.onerror = () => {
clearTimeout(timeout);
setTestResult({
success: false,
message: 'Connection failed - check URL and network',
});
setIsTesting(false);
};
} catch (error) {
setTestResult({
success: false,
message: 'Failed to create connection',
});
setIsTesting(false);
}
}
if (isLoading) {
return (
<View className="flex-1 bg-black items-center justify-center">
<Text className="text-white text-lg">Loading settings...</Text>
</View>
);
}
return (
<View className="flex-1 bg-black">
{/* Header */}
<View className="px-6 pb-4 border-b border-gray-800" style={{ paddingTop: insets.top + 16 }}>
<View className="flex-row items-center justify-between">
<Text className="text-white text-3xl font-bold">Settings</Text>
<Pressable onPress={handleCancel}>
<Text className="text-blue-500 text-base font-semibold">Cancel</Text>
</Pressable>
</View>
</View>
<ScrollView className="flex-1">
<View className="p-6">
{/* Server Configuration */}
<View className="mb-8">
<Text className="text-white text-lg font-semibold mb-4">
Server Configuration
</Text>
<Text className="text-gray-400 text-sm mb-2">WebSocket URL</Text>
<TextInput
className="bg-zinc-900 text-white p-4 rounded-lg mb-2"
placeholder="wss://example.com/ws"
placeholderTextColor="#6b7280"
value={serverUrl}
onChangeText={(text) => {
setServerUrl(text);
setTestResult(null);
}}
autoCapitalize="none"
autoCorrect={false}
keyboardType="url"
/>
<Text className="text-gray-500 text-xs mb-3">
Must be a valid WebSocket URL (ws:// or wss://)
</Text>
{/* Test Connection Button */}
<Pressable
onPress={handleTestConnection}
disabled={isTesting || !validateServerUrl(serverUrl)}
className={`p-3 rounded-lg mb-3 flex-row items-center justify-center ${
isTesting || !validateServerUrl(serverUrl) ? 'bg-zinc-800' : 'bg-blue-600 active:bg-blue-700'
}`}
>
{isTesting ? (
<>
<ActivityIndicator size="small" color="#fff" />
<Text className="text-white font-semibold ml-2">Testing...</Text>
</>
) : (
<Text className="text-white font-semibold">Test Connection</Text>
)}
</Pressable>
{/* Test Result */}
{testResult && (
<View
className={`p-3 rounded-lg ${
testResult.success
? 'bg-green-900/30 border border-green-700'
: 'bg-red-900/30 border border-red-700'
}`}
>
<Text className={testResult.success ? 'text-green-400' : 'text-red-400'}>
{testResult.message}
</Text>
</View>
)}
</View>
{/* Audio Settings */}
<View className="mb-8">
<Text className="text-white text-lg font-semibold mb-4">
Audio
</Text>
<View className="bg-zinc-900 rounded-lg p-4 mb-3">
<View className="flex-row justify-between items-center">
<View className="flex-1">
<Text className="text-white text-base mb-1">Use Speaker</Text>
<Text className="text-gray-400 text-sm">
Play audio through speaker instead of earpiece
</Text>
</View>
<Switch
value={useSpeaker}
onValueChange={setUseSpeaker}
trackColor={{ false: '#374151', true: '#3b82f6' }}
thumbColor={useSpeaker ? '#60a5fa' : '#d1d5db'}
/>
</View>
</View>
<View className="bg-zinc-900 rounded-lg p-4">
<View className="flex-row justify-between items-center">
<View className="flex-1">
<Text className="text-white text-base mb-1">Keep Screen On</Text>
<Text className="text-gray-400 text-sm">
Prevent screen from sleeping during voice sessions
</Text>
</View>
<Switch
value={keepScreenOn}
onValueChange={setKeepScreenOn}
trackColor={{ false: '#374151', true: '#3b82f6' }}
thumbColor={keepScreenOn ? '#60a5fa' : '#d1d5db'}
/>
</View>
</View>
</View>
{/* Theme Settings */}
<View className="mb-8">
<Text className="text-white text-lg font-semibold mb-4">
Theme
</Text>
<View className="bg-zinc-900 rounded-lg p-4 opacity-50">
<Text className="text-gray-400 text-sm mb-3">
Theme selection (coming soon)
</Text>
{(['dark', 'light', 'auto'] as const).map((themeOption) => (
<Pressable
key={themeOption}
disabled
className="flex-row items-center py-2"
>
<View className={`w-5 h-5 rounded-full border-2 ${
theme === themeOption ? 'border-blue-500' : 'border-gray-600'
} mr-3 items-center justify-center`}>
{theme === themeOption && (
<View className="w-3 h-3 rounded-full bg-blue-500" />
)}
</View>
<Text className="text-gray-400 text-base capitalize">
{themeOption}
</Text>
</Pressable>
))}
</View>
</View>
{/* Action Buttons */}
<View className="mb-8">
<Pressable
className={`p-4 rounded-lg mb-3 ${
hasChanges ? 'bg-blue-500' : 'bg-blue-500/50'
}`}
onPress={handleSave}
disabled={!hasChanges}
>
<Text className="text-white text-center text-base font-semibold">
Save Settings
</Text>
</Pressable>
<Pressable
className="p-4 rounded-lg border border-red-500/30"
onPress={handleReset}
>
<Text className="text-red-500 text-center text-base font-semibold">
Reset to Defaults
</Text>
</Pressable>
</View>
{/* App Info */}
<View className="border-t border-gray-800 pt-6">
<Text className="text-gray-500 text-sm text-center">
Voice Assistant Mobile
</Text>
<Text className="text-gray-600 text-xs text-center mt-1">
Version 1.0.0
</Text>
</View>
</View>
</ScrollView>
</View>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 384 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@@ -0,0 +1,169 @@
import { View, Text, Pressable, ScrollView } from 'react-native';
import type { AgentStatus } from '@server/server/acp/types';
export interface ActiveProcessesProps {
agents: Array<{
id: string;
status: AgentStatus;
type: 'claude';
currentModeId?: string;
availableModes?: Array<{ id: string; name: string; description?: string | null }>;
}>;
commands: Array<{
id: string;
name: string;
workingDirectory: string;
currentCommand: string;
isDead: boolean;
exitCode: number | null;
}>;
activeProcessId: string | null;
activeProcessType: 'agent' | null;
onSelectAgent: (id: string) => void;
onBackToOrchestrator: () => void;
}
function getAgentStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b'; // orange
case 'ready':
return '#3b82f6'; // blue
case 'processing':
return '#fbbf24'; // yellow
case 'completed':
return '#22c55e'; // green
case 'failed':
return '#ef4444'; // red
case 'killed':
return '#9ca3af'; // gray
default:
return '#9ca3af';
}
}
function getModeName(modeId?: string, availableModes?: Array<{ id: string; name: string }>): string {
if (!modeId) return 'unknown';
const mode = availableModes?.find((m) => m.id === modeId);
return mode?.name || modeId;
}
function getModeColor(modeId?: string): string {
if (!modeId) return '#9ca3af'; // gray
// Color based on common mode types
if (modeId.includes('ask')) return '#f59e0b'; // orange - asks permission
if (modeId.includes('code')) return '#22c55e'; // green - writes code
if (modeId.includes('architect') || modeId.includes('plan')) return '#3b82f6'; // blue - plans
return '#9ca3af'; // gray - unknown
}
export function ActiveProcesses({
agents,
commands,
activeProcessId,
activeProcessType,
onSelectAgent,
onBackToOrchestrator,
}: ActiveProcessesProps) {
const hasActiveProcess = activeProcessId !== null && activeProcessType !== null;
if (agents.length === 0 && commands.length === 0) {
return null;
}
return (
<View className="bg-zinc-900 border-b border-zinc-800">
{/* Header with Back button */}
{hasActiveProcess && (
<View className="px-4 py-3 border-b border-zinc-800">
<Pressable
onPress={onBackToOrchestrator}
className="bg-zinc-800 px-4 py-2 rounded-lg active:bg-zinc-700"
>
<Text className="text-white text-sm font-semibold text-center">
Back to Chat
</Text>
</Pressable>
</View>
)}
{/* Scrollable process list */}
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
className="px-4 py-3"
contentContainerStyle={{ gap: 8 }}
>
{/* Agents */}
{agents.map((agent) => {
const modeName = getModeName(agent.currentModeId, agent.availableModes);
const isActive = activeProcessType === 'agent' && activeProcessId === agent.id;
return (
<Pressable
key={`agent-${agent.id}`}
onPress={() => onSelectAgent(agent.id)}
className={`flex-row items-center gap-2 px-3 py-2 rounded-lg ${
isActive ? 'bg-blue-600' : 'bg-zinc-800'
} active:opacity-70`}
>
{/* Agent icon */}
<View className="w-3 h-3 rounded-full bg-blue-500" />
{/* Agent ID (shortened) */}
<Text className="text-white text-xs font-medium">
{agent.id.substring(0, 8)}
</Text>
{/* Status indicator */}
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: getAgentStatusColor(agent.status) }}
/>
{/* Mode indicator */}
{agent.currentModeId && (
<View
className="w-1.5 h-1.5 rounded-full opacity-30"
style={{ backgroundColor: getModeColor(agent.currentModeId) }}
/>
)}
</Pressable>
);
})}
{/* Commands */}
{commands.map((command) => {
const statusColor = command.isDead
? command.exitCode === 0
? '#22c55e'
: '#ef4444'
: '#3b82f6';
return (
<View
key={`command-${command.id}`}
className="flex-row items-center gap-2 px-3 py-2 rounded-lg bg-zinc-800"
>
{/* Command icon */}
<View className="w-3 h-3 rounded-sm bg-purple-500" />
{/* Command name */}
<Text className="text-white text-xs font-medium" numberOfLines={1}>
{command.name || command.currentCommand.substring(0, 20)}
</Text>
{/* Status indicator */}
<View
className="w-2 h-2 rounded-full"
style={{ backgroundColor: statusColor }}
/>
</View>
);
})}
</ScrollView>
</View>
);
}
@@ -0,0 +1,286 @@
import { useState, useEffect, useRef } from 'react';
import { View, Text, ScrollView, Pressable } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { AgentStatus } from '@server/server/acp/types';
import type { SessionNotification } from '@agentclientprotocol/sdk';
export interface AgentStreamViewProps {
agentId: string;
agent: {
id: string;
status: AgentStatus;
createdAt: Date;
type: 'claude';
};
updates: Array<{
timestamp: Date;
notification: SessionNotification;
}>;
onBack: () => void;
onKillAgent: (agentId: string) => void;
onCancelAgent: (agentId: string) => void;
}
function getStatusColor(status: AgentStatus): string {
switch (status) {
case 'initializing':
return '#f59e0b';
case 'ready':
return '#3b82f6';
case 'processing':
return '#fbbf24';
case 'completed':
return '#22c55e';
case 'failed':
return '#ef4444';
case 'killed':
return '#9ca3af';
default:
return '#9ca3af';
}
}
function getStatusIcon(status: AgentStatus): string {
switch (status) {
case 'initializing':
case 'processing':
return '⏳';
case 'ready':
case 'completed':
return '✓';
case 'failed':
case 'killed':
return '✗';
default:
return '•';
}
}
function formatTimestamp(date: Date): string {
return new Intl.DateTimeFormat('en-US', {
hour: 'numeric',
minute: '2-digit',
second: '2-digit',
hour12: true,
}).format(date);
}
function AgentUpdate({
update,
}: {
update: { timestamp: Date; notification: SessionNotification };
}) {
const [isExpanded, setIsExpanded] = useState(true);
const notification = update.notification as any;
// Parse different notification types
if (notification.type === 'sessionUpdate') {
const sessionUpdate = notification.sessionUpdate || {};
// Handle message chunks
if (sessionUpdate.kind === 'agent_message_chunk') {
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-white text-sm">{sessionUpdate.chunk || ''}</Text>
</View>
);
}
// Handle tool calls
if (sessionUpdate.kind === 'tool_call') {
return (
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className="px-3 py-2 active:bg-zinc-700"
>
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-blue-400 text-sm font-medium">
Tool Call: {sessionUpdate.toolName || 'unknown'}
</Text>
</Pressable>
{isExpanded && sessionUpdate.arguments && (
<View className="px-3 pb-3 border-t border-zinc-700">
<Text className="text-zinc-400 text-xs font-mono mt-2">
{JSON.stringify(sessionUpdate.arguments, null, 2)}
</Text>
</View>
)}
</View>
);
}
// Handle tool call updates
if (sessionUpdate.kind === 'tool_call_update') {
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-yellow-400 text-sm">
{sessionUpdate.status || 'updating'}
</Text>
</View>
);
}
// Handle available commands
if (sessionUpdate.kind === 'available_commands_update') {
const commands = sessionUpdate.commands || [];
return (
<View className="bg-zinc-800 rounded-lg mb-2 overflow-hidden">
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className="px-3 py-2 active:bg-zinc-700"
>
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-green-400 text-sm font-medium">
Available Commands ({commands.length})
</Text>
</Pressable>
{isExpanded && (
<View className="px-3 pb-3 border-t border-zinc-700">
{commands.map((cmd: any, idx: number) => (
<Text key={idx} className="text-zinc-400 text-xs mt-1">
• {cmd.name || cmd}
</Text>
))}
</View>
)}
</View>
);
}
// Generic session update
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-zinc-400 text-xs font-mono">
{JSON.stringify(sessionUpdate, null, 2)}
</Text>
</View>
);
}
// Fallback for unknown notification types
return (
<View className="bg-zinc-800 rounded-lg p-3 mb-2">
<Text className="text-zinc-500 text-xs mb-1">
{formatTimestamp(update.timestamp)}
</Text>
<Text className="text-zinc-400 text-xs font-mono">
{JSON.stringify(notification, null, 2)}
</Text>
</View>
);
}
export function AgentStreamView({
agentId,
agent,
updates,
onBack,
onKillAgent,
onCancelAgent,
}: AgentStreamViewProps) {
const scrollViewRef = useRef<ScrollView>(null);
const insets = useSafeAreaInsets();
// Auto-scroll to bottom when new updates arrive
useEffect(() => {
scrollViewRef.current?.scrollToEnd({ animated: true });
}, [updates]);
const canCancel = agent.status === 'processing';
const canKill = agent.status !== 'killed' && agent.status !== 'completed';
return (
<View className="flex-1 bg-black">
{/* Header */}
<View className="bg-zinc-900 border-b border-zinc-800 px-4 pb-4" style={{ paddingTop: insets.top + 16 }}>
{/* Back button */}
<Pressable
onPress={onBack}
className="bg-zinc-800 px-4 py-2 rounded-lg mb-4 active:bg-zinc-700"
>
<Text className="text-white text-sm font-semibold text-center">
← Back to Chat
</Text>
</Pressable>
{/* Agent info */}
<View className="flex-row items-center justify-between mb-3">
<View className="flex-row items-center gap-2">
<Text className="text-zinc-400 text-sm">Agent:</Text>
<Text className="text-white text-sm font-mono">
{agentId.substring(0, 8)}
</Text>
</View>
<View
className="flex-row items-center gap-2 px-3 py-1 rounded-full"
style={{ backgroundColor: getStatusColor(agent.status) }}
>
<Text className="text-white text-xs">{getStatusIcon(agent.status)}</Text>
<Text className="text-white text-xs font-medium">{agent.status}</Text>
</View>
</View>
<Text className="text-zinc-500 text-xs">
Created: {formatTimestamp(agent.createdAt)}
</Text>
{/* Control buttons */}
{(canCancel || canKill) && (
<View className="flex-row gap-2 mt-4">
{canCancel && (
<Pressable
onPress={() => onCancelAgent(agentId)}
className="flex-1 bg-orange-600 px-4 py-2 rounded-lg active:bg-orange-700"
>
<Text className="text-white text-sm font-semibold text-center">
Cancel
</Text>
</Pressable>
)}
{canKill && (
<Pressable
onPress={() => onKillAgent(agentId)}
className="flex-1 bg-red-600 px-4 py-2 rounded-lg active:bg-red-700"
>
<Text className="text-white text-sm font-semibold text-center">
Kill
</Text>
</Pressable>
)}
</View>
)}
</View>
{/* Updates list */}
<ScrollView
ref={scrollViewRef}
className="flex-1 px-4"
contentContainerStyle={{ paddingTop: 16, paddingBottom: Math.max(insets.bottom, 32) }}
>
{updates.length === 0 ? (
<View className="flex-1 items-center justify-center py-12">
<Text className="text-zinc-500 text-sm text-center">
No updates yet.{'\n'}Waiting for agent activity...
</Text>
</View>
) : (
updates.map((update, idx) => <AgentUpdate key={idx} update={update} />)
)}
</ScrollView>
</View>
);
}
+127
View File
@@ -0,0 +1,127 @@
import { View, Text, ScrollView, Pressable, Modal } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useEffect } from 'react';
export interface Artifact {
id: string;
type: 'markdown' | 'diff' | 'image' | 'code';
title: string;
content: string;
isBase64: boolean;
}
interface ArtifactDrawerProps {
artifact: Artifact | null;
onClose: () => void;
}
export function ArtifactDrawer({ artifact, onClose }: ArtifactDrawerProps) {
useEffect(() => {
if (!artifact) return;
console.log('[ArtifactDrawer] Showing artifact:', artifact.id, artifact.type, artifact.title);
}, [artifact]);
if (!artifact) {
return null;
}
// Decode content if base64
const content = artifact.isBase64 ? atob(artifact.content) : artifact.content;
// Type badge colors
const typeColors = {
markdown: 'bg-blue-600',
diff: 'bg-purple-600',
image: 'bg-green-600',
code: 'bg-orange-600',
};
return (
<Modal
visible={!!artifact}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
>
<SafeAreaView edges={['top', 'bottom']} className="flex-1 bg-black">
{/* Header */}
<View className="pb-4 px-4 border-b border-zinc-800">
<View className="flex-row items-center justify-between">
<View className="flex-1 mr-4">
<Text className="text-white text-xl font-bold" numberOfLines={2}>
{artifact.title}
</Text>
</View>
<View className="flex-row items-center gap-2">
<View className={`${typeColors[artifact.type]} px-3 py-1 rounded-full`}>
<Text className="text-white text-xs font-semibold uppercase">
{artifact.type}
</Text>
</View>
<Pressable
onPress={onClose}
className="bg-zinc-800 w-10 h-10 rounded-full items-center justify-center"
>
<Text className="text-white text-xl font-bold">×</Text>
</Pressable>
</View>
</View>
</View>
{/* Content */}
<ScrollView className="flex-1" contentContainerClassName="p-4">
{artifact.type === 'image' ? (
<View className="items-center justify-center">
<Text className="text-zinc-400 text-sm">
Image viewing not yet implemented
</Text>
<Text className="text-zinc-600 text-xs mt-2">
Base64 image data received
</Text>
</View>
) : (
<View className="bg-zinc-900 rounded-lg p-4 border border-zinc-800">
<ScrollView horizontal showsHorizontalScrollIndicator={true}>
<Text
className="text-zinc-300 text-sm font-mono"
style={{ fontFamily: 'monospace' }}
>
{content}
</Text>
</ScrollView>
</View>
)}
{/* Metadata */}
<View className="mt-4 bg-zinc-900 rounded-lg p-3 border border-zinc-800">
<Text className="text-zinc-500 text-xs font-semibold mb-2">METADATA</Text>
<View className="space-y-1">
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">ID:</Text>
<Text className="text-zinc-400 text-xs flex-1 font-mono">
{artifact.id}
</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Type:</Text>
<Text className="text-zinc-400 text-xs">{artifact.type}</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Encoding:</Text>
<Text className="text-zinc-400 text-xs">
{artifact.isBase64 ? 'Base64' : 'Plain text'}
</Text>
</View>
<View className="flex-row">
<Text className="text-zinc-500 text-xs w-20">Size:</Text>
<Text className="text-zinc-400 text-xs">
{content.length.toLocaleString()} characters
</Text>
</View>
</View>
</View>
</ScrollView>
</SafeAreaView>
</Modal>
);
}
@@ -0,0 +1,18 @@
import { View, Text } from 'react-native';
interface ConnectionStatusProps {
isConnected: boolean;
}
export function ConnectionStatus({ isConnected }: ConnectionStatusProps) {
return (
<View className="px-4 py-3 border-b border-zinc-800">
<View className="flex-row items-center">
<View className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-green-500' : 'bg-red-500'}`} />
<Text className={isConnected ? 'text-green-500 text-sm' : 'text-red-500 text-sm'}>
{isConnected ? 'Connected' : 'Disconnected'}
</Text>
</View>
</View>
);
}
@@ -0,0 +1,290 @@
import { useState, useEffect } from 'react';
import { Modal, View, Text, Pressable, ScrollView, Alert, KeyboardAvoidingView, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { MessageSquare, X, Plus, Trash2 } from 'lucide-react-native';
import AsyncStorage from '@react-native-async-storage/async-storage';
import type { UseWebSocketReturn } from '../hooks/use-websocket';
const STORAGE_KEY = '@voice-dev:conversation-id';
interface Conversation {
id: string;
lastUpdated: string;
messageCount: number;
}
interface ConversationSelectorProps {
currentConversationId: string | null;
onSelectConversation: (conversationId: string | null) => void;
websocket: UseWebSocketReturn;
}
export function ConversationSelector({
currentConversationId,
onSelectConversation,
websocket,
}: ConversationSelectorProps) {
const [conversations, setConversations] = useState<Conversation[]>([]);
const [isOpen, setIsOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
// Listen for conversation list responses
useEffect(() => {
const unsubscribe = websocket.on('list_conversations_response', (message) => {
if (message.type !== 'list_conversations_response') return;
setConversations(message.payload.conversations);
setIsLoading(false);
});
return unsubscribe;
}, [websocket]);
// Listen for delete conversation responses
useEffect(() => {
const unsubscribe = websocket.on('delete_conversation_response', (message) => {
if (message.type !== 'delete_conversation_response') return;
console.log('[ConversationSelector] Delete response:', message.payload);
if (message.payload.success) {
// Refresh conversations list
fetchConversations();
// If we deleted the current conversation, start a new one
if (message.payload.conversationId === currentConversationId) {
handleNewConversation();
}
} else {
Alert.alert('Error', `Failed to delete conversation: ${message.payload.error}`);
}
});
return unsubscribe;
}, [websocket, currentConversationId]);
// Fetch conversations when modal opens
useEffect(() => {
if (isOpen) {
fetchConversations();
}
}, [isOpen]);
function fetchConversations() {
setIsLoading(true);
console.log('[ConversationSelector] Requesting conversations via WebSocket');
websocket.send({
type: 'session',
message: {
type: 'list_conversations_request',
},
});
}
function handleDeleteConversation(id: string) {
Alert.alert(
'Delete Conversation',
'Are you sure you want to delete this conversation?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Delete',
style: 'destructive',
onPress: () => {
console.log('[ConversationSelector] Deleting conversation via WebSocket:', id);
websocket.send({
type: 'session',
message: {
type: 'delete_conversation_request',
conversationId: id,
},
});
},
},
]
);
}
function handleClearAll() {
Alert.alert(
'Clear All Conversations',
'Are you sure you want to delete all conversations?',
[
{ text: 'Cancel', style: 'cancel' },
{
text: 'Clear All',
style: 'destructive',
onPress: () => {
console.log('[ConversationSelector] Clearing all conversations via WebSocket');
// Delete all conversations
conversations.forEach((conv) => {
websocket.send({
type: 'session',
message: {
type: 'delete_conversation_request',
conversationId: conv.id,
},
});
});
// Clear local state
setConversations([]);
// Start new conversation
handleNewConversation();
setIsOpen(false);
},
},
]
);
}
async function handleSelectConversation(id: string) {
try {
// Save to AsyncStorage for persistence
await AsyncStorage.setItem(STORAGE_KEY, id);
onSelectConversation(id);
setIsOpen(false);
} catch (error) {
console.error('[ConversationSelector] Failed to save conversation ID:', error);
}
}
async function handleNewConversation() {
try {
// Clear saved conversation ID
await AsyncStorage.removeItem(STORAGE_KEY);
onSelectConversation(null);
setIsOpen(false);
} catch (error) {
console.error('[ConversationSelector] Failed to clear conversation ID:', error);
}
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMs / 3600000);
const diffDays = Math.floor(diffMs / 86400000);
if (diffMins < 1) return 'Just now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
return (
<View>
<Pressable
onPress={() => setIsOpen(true)}
className="bg-zinc-800 px-4 py-2 rounded-lg"
>
<MessageSquare size={20} color="white" />
</Pressable>
<Modal
visible={isOpen}
animationType="slide"
transparent={true}
onRequestClose={() => setIsOpen(false)}
>
<View style={{ flex: 1, backgroundColor: 'rgba(0,0,0,0.5)' }}>
<Pressable
style={{ flex: 1 }}
onPress={() => setIsOpen(false)}
/>
<View style={{ backgroundColor: '#18181b', borderTopLeftRadius: 24, borderTopRightRadius: 24, height: '80%' }}>
<View style={{ flex: 1 }}>
{/* Header */}
<View className="flex-row items-center justify-between p-6 border-b border-zinc-800">
<Text className="text-white text-lg font-semibold">
Conversations {conversations.length > 0 && `(${conversations.length})`}
</Text>
<Pressable onPress={() => setIsOpen(false)}>
<X size={24} color="white" />
</Pressable>
</View>
{/* New Conversation Button */}
<View className="p-4 border-b border-zinc-800">
<Pressable
onPress={handleNewConversation}
className="flex-row items-center justify-center gap-2 bg-zinc-800 py-3 rounded-lg"
>
<Plus size={20} color="white" />
<Text className="text-white font-semibold">New Conversation</Text>
</Pressable>
</View>
{/* Conversations List */}
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 20 }}
keyboardShouldPersistTaps="handled"
keyboardDismissMode="on-drag"
>
{isLoading && (
<View className="p-8 items-center">
<Text className="text-zinc-400">Loading...</Text>
</View>
)}
{!isLoading && conversations.length === 0 && (
<View className="p-8 items-center">
<Text className="text-zinc-400">No saved conversations</Text>
</View>
)}
{!isLoading && conversations.map((conversation) => (
<View
key={conversation.id}
style={{
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: 16,
borderBottomWidth: 1,
borderBottomColor: '#27272a',
backgroundColor: conversation.id === currentConversationId ? '#27272a' : 'transparent'
}}
>
<Pressable
style={{ flex: 1 }}
onPress={() => handleSelectConversation(conversation.id)}
>
<Text style={{ color: 'white', fontWeight: '600', fontSize: 16 }}>
{conversation.messageCount} messages
</Text>
<Text style={{ color: '#a1a1aa', fontSize: 14, marginTop: 4 }}>
{formatDate(conversation.lastUpdated)}
</Text>
</Pressable>
<Pressable
onPress={() => handleDeleteConversation(conversation.id)}
style={{ padding: 8 }}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Trash2 size={20} color="#ef4444" />
</Pressable>
</View>
))}
{!isLoading && conversations.length > 0 && (
<View className="p-4">
<Pressable
onPress={handleClearAll}
className="flex-row items-center justify-center gap-2 bg-red-900/20 py-3 rounded-lg border border-red-900"
>
<Trash2 size={18} color="#ef4444" />
<Text className="text-red-500 font-semibold">Clear All</Text>
</Pressable>
</View>
)}
</ScrollView>
</View>
</View>
</View>
</Modal>
</View>
);
}
+333
View File
@@ -0,0 +1,333 @@
import { View, Text, Pressable, Animated } from 'react-native';
import { useState, useEffect, useRef } from 'react';
import Markdown from 'react-native-markdown-display';
import { Circle, Info, CheckCircle, XCircle, FileText, ChevronRight, ChevronDown, RefreshCw } from 'lucide-react-native';
interface UserMessageProps {
message: string;
timestamp: number;
}
export function UserMessage({ message, timestamp }: UserMessageProps) {
return (
<View className="flex-row justify-end mb-3 px-4">
<View className="bg-blue-600 rounded-2xl rounded-tr-sm px-4 py-3 max-w-[80%]">
<Text className="text-white text-base leading-6">{message}</Text>
</View>
</View>
);
}
interface AssistantMessageProps {
message: string;
timestamp: number;
isStreaming?: boolean;
}
export function AssistantMessage({ message, timestamp, isStreaming = false }: AssistantMessageProps) {
const fadeAnim = useRef(new Animated.Value(0.3)).current;
useEffect(() => {
if (isStreaming) {
Animated.loop(
Animated.sequence([
Animated.timing(fadeAnim, {
toValue: 1,
duration: 600,
useNativeDriver: true,
}),
Animated.timing(fadeAnim, {
toValue: 0.3,
duration: 600,
useNativeDriver: true,
}),
])
).start();
} else {
fadeAnim.stopAnimation();
fadeAnim.setValue(1);
}
}, [isStreaming, fadeAnim]);
const markdownStyles = {
body: {
color: '#f0fdfa',
fontSize: 16,
lineHeight: 24,
},
paragraph: {
marginTop: 0,
marginBottom: 8,
},
strong: {
fontWeight: '700',
},
em: {
fontStyle: 'italic',
},
code_inline: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
paddingHorizontal: 4,
paddingVertical: 2,
borderRadius: 3,
fontFamily: 'monospace',
},
code_block: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
padding: 12,
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 14,
},
fence: {
backgroundColor: '#134e4a',
color: '#ccfbf1',
padding: 12,
borderRadius: 6,
fontFamily: 'monospace',
fontSize: 14,
},
link: {
color: '#5eead4',
textDecorationLine: 'underline',
},
bullet_list: {
marginBottom: 8,
},
ordered_list: {
marginBottom: 8,
},
list_item: {
marginBottom: 4,
},
};
return (
<View className="mb-3 px-4 py-3">
<Markdown style={markdownStyles}>{message}</Markdown>
{isStreaming && (
<Animated.View style={{ opacity: fadeAnim }} className="mt-1">
<Text className="text-teal-200 text-xs font-bold">...</Text>
</Animated.View>
)}
</View>
);
}
interface ActivityLogProps {
type: 'system' | 'info' | 'success' | 'error' | 'artifact';
message: string;
timestamp: number;
metadata?: Record<string, unknown>;
artifactId?: string;
artifactType?: string;
title?: string;
onArtifactClick?: (artifactId: string) => void;
}
export function ActivityLog({
type,
message,
timestamp,
metadata,
artifactId,
artifactType,
title,
onArtifactClick
}: ActivityLogProps) {
const [isExpanded, setIsExpanded] = useState(false);
const typeConfig = {
system: { bg: 'bg-zinc-800/50', color: '#a1a1aa', Icon: Circle },
info: { bg: 'bg-blue-900/30', color: '#60a5fa', Icon: Info },
success: { bg: 'bg-green-900/30', color: '#4ade80', Icon: CheckCircle },
error: { bg: 'bg-red-900/30', color: '#f87171', Icon: XCircle },
artifact: { bg: 'bg-blue-900/40', color: '#93c5fd', Icon: FileText },
};
const config = typeConfig[type];
const IconComponent = config.Icon;
const handlePress = () => {
if (type === 'artifact' && artifactId && onArtifactClick) {
onArtifactClick(artifactId);
} else if (metadata) {
setIsExpanded(!isExpanded);
}
};
const displayMessage = type === 'artifact' && artifactType && title
? `${artifactType}: ${title}`
: message;
return (
<Pressable
onPress={handlePress}
disabled={type !== 'artifact' && !metadata}
className={`mx-2 mb-1 rounded-md overflow-hidden ${config.bg} ${
(type === 'artifact' || metadata) ? 'active:opacity-70' : ''
}`}
>
<View className="px-3 py-2.5">
<View className="flex-row items-start gap-2">
<IconComponent size={16} color={config.color} />
<View className="flex-1">
<Text style={{ color: config.color }} className="text-sm leading-5">
{displayMessage}
</Text>
{metadata && (
<View className="flex-row items-center mt-1">
<Text className="text-zinc-500 text-xs mr-1">Details</Text>
{isExpanded ? (
<ChevronDown size={12} color="#71717a" />
) : (
<ChevronRight size={12} color="#71717a" />
)}
</View>
)}
</View>
</View>
{isExpanded && metadata && (
<View className="mt-2 bg-black/50 rounded p-2 border border-zinc-700">
<Text className="text-zinc-300 text-xs font-mono leading-4">
{JSON.stringify(metadata, null, 2)}
</Text>
</View>
)}
</View>
</Pressable>
);
}
interface ToolCallProps {
toolName: string;
args: any;
result?: any;
error?: any;
status: 'executing' | 'completed' | 'failed';
}
export function ToolCall({ toolName, args, result, error, status }: ToolCallProps) {
const [isExpanded, setIsExpanded] = useState(false);
const spinAnim = useRef(new Animated.Value(0)).current;
useEffect(() => {
if (status === 'executing') {
Animated.loop(
Animated.timing(spinAnim, {
toValue: 1,
duration: 1000,
useNativeDriver: true,
})
).start();
} else {
spinAnim.stopAnimation();
spinAnim.setValue(0);
}
}, [status, spinAnim]);
const spin = spinAnim.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '360deg'],
});
const statusConfig = {
executing: {
border: 'border-amber-500',
bg: 'bg-amber-500/20',
color: '#fcd34d',
label: 'executing',
},
completed: {
border: 'border-green-500',
bg: 'bg-green-500/20',
color: '#86efac',
label: 'completed',
},
failed: {
border: 'border-red-500',
bg: 'bg-red-500/20',
color: '#fca5a5',
label: 'failed',
},
};
const config = statusConfig[status];
return (
<Pressable
onPress={() => setIsExpanded(!isExpanded)}
className={`mx-2 mb-2 bg-zinc-900 rounded-lg border ${config.border} overflow-hidden active:opacity-80`}
>
<View className="p-3">
<View className="flex-row items-center">
{isExpanded ? (
<ChevronDown size={16} color="#9ca3af" className="mr-2" />
) : (
<ChevronRight size={16} color="#9ca3af" className="mr-2" />
)}
<Text className="text-slate-200 font-mono font-medium text-sm flex-1">
{toolName}
</Text>
<View className={`flex-row items-center gap-1.5 px-2 py-1 rounded ${config.bg}`}>
{status === 'executing' ? (
<Animated.View style={{ transform: [{ rotate: spin }] }}>
<RefreshCw size={14} color={config.color} />
</Animated.View>
) : status === 'completed' ? (
<CheckCircle size={14} color={config.color} />
) : (
<XCircle size={14} color={config.color} />
)}
<Text style={{ color: config.color }} className="text-xs font-medium uppercase">
{config.label}
</Text>
</View>
</View>
{isExpanded && (
<View className="mt-3 gap-2">
<View>
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
Arguments
</Text>
<View className="bg-black rounded border border-zinc-700 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
{JSON.stringify(args, null, 2)}
</Text>
</View>
</View>
{result !== undefined && (
<View>
<Text className="text-zinc-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
Result
</Text>
<View className="bg-black rounded border border-zinc-700 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
{JSON.stringify(result, null, 2)}
</Text>
</View>
</View>
)}
{error !== undefined && (
<View>
<Text className="text-red-400 text-xs font-semibold uppercase tracking-wide mb-1.5">
Error
</Text>
<View className="bg-black rounded border border-red-800 p-2">
<Text className="text-slate-200 text-xs font-mono leading-4">
{JSON.stringify(error, null, 2)}
</Text>
</View>
</View>
)}
</View>
)}
</View>
</Pressable>
);
}
+109
View File
@@ -0,0 +1,109 @@
import { Pressable, View, Text, Animated } from 'react-native';
import { useEffect, useRef } from 'react';
interface VoiceButtonProps {
state: 'idle' | 'recording' | 'processing' | 'playing';
onPress: () => void;
disabled?: boolean;
}
export function VoiceButton({ state, onPress, disabled = false }: VoiceButtonProps) {
const pulseAnim = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (state === 'recording') {
Animated.loop(
Animated.sequence([
Animated.timing(pulseAnim, {
toValue: 1.2,
duration: 800,
useNativeDriver: true,
}),
Animated.timing(pulseAnim, {
toValue: 1,
duration: 800,
useNativeDriver: true,
}),
])
).start();
} else {
pulseAnim.setValue(1);
}
}, [state, pulseAnim]);
const getButtonStyle = () => {
switch (state) {
case 'recording':
return 'bg-red-500';
case 'processing':
return 'bg-blue-500';
case 'playing':
return 'bg-green-500';
default:
return 'bg-zinc-700';
}
};
const getIcon = () => {
switch (state) {
case 'recording':
return (
<View className="w-6 h-6 bg-white rounded" />
);
case 'processing':
return (
<View className="w-6 h-6">
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-0 left-3" />
<View className="w-1.5 h-1.5 bg-white rounded-full absolute top-3 right-0" />
<View className="w-1.5 h-1.5 bg-white rounded-full absolute bottom-0 left-3" />
</View>
);
case 'playing':
return (
<View className="flex-row items-center gap-1">
<View className="w-1 h-4 bg-white rounded" />
<View className="w-1 h-6 bg-white rounded" />
<View className="w-1 h-3 bg-white rounded" />
</View>
);
default:
return (
<View className="w-6 h-8 relative">
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-4 h-6 bg-white rounded-t-full" />
<View className="absolute bottom-0 left-1/2 -translate-x-1/2 w-6 h-1.5 bg-white rounded-full" />
</View>
);
}
};
const getLabel = () => {
switch (state) {
case 'recording':
return 'Recording...';
case 'processing':
return 'Processing...';
case 'playing':
return 'Playing...';
default:
return 'Tap to speak';
}
};
return (
<View className="items-center gap-4">
<Pressable
onPress={onPress}
disabled={disabled}
className={`${disabled ? 'opacity-50' : 'opacity-100'}`}
>
<Animated.View
style={{ transform: [{ scale: pulseAnim }] }}
className={`w-20 h-20 rounded-full ${getButtonStyle()} items-center justify-center shadow-lg`}
>
{getIcon()}
</Animated.View>
</Pressable>
<Text className="text-zinc-400 text-sm">{getLabel()}</Text>
</View>
);
}
+53
View File
@@ -0,0 +1,53 @@
/**
* Below are the colors that are used in the app. The colors are defined in the light and dark mode.
* There are many other ways to style your app. For example, [Nativewind](https://www.nativewind.dev/), [Tamagui](https://tamagui.dev/), [unistyles](https://reactnativeunistyles.vercel.app), etc.
*/
import { Platform } from 'react-native';
const tintColorLight = '#0a7ea4';
const tintColorDark = '#fff';
export const Colors = {
light: {
text: '#11181C',
background: '#fff',
tint: tintColorLight,
icon: '#687076',
tabIconDefault: '#687076',
tabIconSelected: tintColorLight,
},
dark: {
text: '#ECEDEE',
background: '#151718',
tint: tintColorDark,
icon: '#9BA1A6',
tabIconDefault: '#9BA1A6',
tabIconSelected: tintColorDark,
},
};
export const Fonts = Platform.select({
ios: {
/** iOS `UIFontDescriptorSystemDesignDefault` */
sans: 'system-ui',
/** iOS `UIFontDescriptorSystemDesignSerif` */
serif: 'ui-serif',
/** iOS `UIFontDescriptorSystemDesignRounded` */
rounded: 'ui-rounded',
/** iOS `UIFontDescriptorSystemDesignMonospaced` */
mono: 'ui-monospace',
},
default: {
sans: 'normal',
serif: 'serif',
rounded: 'normal',
mono: 'monospace',
},
web: {
sans: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif",
serif: "Georgia, 'Times New Roman', serif",
rounded: "'SF Pro Rounded', 'Hiragino Maru Gothic ProN', Meiryo, 'MS PGothic', sans-serif",
mono: "SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace",
},
});
+22
View File
@@ -0,0 +1,22 @@
{
"cli": {
"version": ">= 14.0.0"
},
"build": {
"development": {
"developmentClient": true,
"distribution": "internal",
"channel": "development"
},
"preview": {
"distribution": "internal",
"channel": "preview"
},
"production": {
"channel": "production"
}
},
"submit": {
"production": {}
}
}
+10
View File
@@ -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/*'],
},
]);
+4
View File
@@ -0,0 +1,4 @@
@import "tailwindcss/theme.css" layer(theme);
@import "tailwindcss/preflight.css" layer(base);
@import "tailwindcss/utilities.css";
@import "nativewind/theme";
+259
View File
@@ -0,0 +1,259 @@
import { useAudioPlayer as useExpoAudioPlayer, setAudioModeAsync } from 'expo-audio';
import { Paths, File } from 'expo-file-system';
import { useState, useRef, useEffect } from 'react';
import { Platform } from 'react-native';
interface QueuedAudio {
audioData: Blob;
resolve: (duration: number) => void;
reject: (error: Error) => void;
}
interface AudioPlayerOptions {
useSpeaker?: boolean;
}
/**
* Convert Blob to file URI for expo-audio playback
*/
async function blobToFile(blob: Blob): Promise<File> {
// Read blob as array buffer
const arrayBuffer = await blob.arrayBuffer();
// Create temporary file in cache directory
const fileName = `audio_${Date.now()}.mp3`;
const file = new File(Paths.cache, fileName);
// Write array buffer to file
const uint8Array = new Uint8Array(arrayBuffer);
file.create();
const stream = file.writableStream();
const writer = stream.getWriter();
await writer.write(uint8Array);
await writer.close();
return file;
}
/**
* Hook for audio playback with queue system matching web version functionality
*/
export function useAudioPlayer(options?: AudioPlayerOptions) {
const player = useExpoAudioPlayer();
const [isPlaying, setIsPlaying] = useState(false);
const [isPaused, setIsPaused] = useState(false);
const queueRef = useRef<QueuedAudio[]>([]);
const isProcessingQueueRef = useRef(false);
const currentRejectRef = useRef<((error: Error) => void) | null>(null);
const currentFileRef = useRef<File | null>(null);
// Configure audio mode based on speaker/earpiece preference
useEffect(() => {
async function configureAudioMode() {
try {
if (Platform.OS === 'android') {
// On Android, use shouldRouteThroughEarpiece
await setAudioModeAsync({
shouldRouteThroughEarpiece: !options?.useSpeaker,
});
} else if (Platform.OS === 'ios') {
// On iOS, the audio routing is controlled by the AVAudioSession category
// The default category should work, but we can configure it if needed
// For now, we rely on the iOS default behavior which routes to the receiver
// unless the user has changed the route (e.g., via speaker button in Control Center)
}
} catch (error) {
console.warn('[AudioPlayer] Failed to configure audio mode:', error);
}
}
configureAudioMode();
}, [options?.useSpeaker]);
// Monitor playback status
useEffect(() => {
if (!player.playing && isPlaying && !isPaused) {
// Playback finished
setIsPlaying(false);
processNextInQueue();
}
}, [player.playing, isPlaying, isPaused]);
async function play(audioData: Blob): Promise<number> {
return new Promise((resolve, reject) => {
// Add to queue with its promise handlers
queueRef.current.push({ audioData, resolve, reject });
// Start processing queue if not already processing
if (!isProcessingQueueRef.current) {
processQueue();
}
});
}
async function processQueue(): Promise<void> {
if (isProcessingQueueRef.current || queueRef.current.length === 0) {
return;
}
isProcessingQueueRef.current = true;
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
}
isProcessingQueueRef.current = false;
}
async function processNextInQueue(): Promise<void> {
if (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
try {
const duration = await playAudio(item.audioData);
item.resolve(duration);
} catch (error) {
item.reject(error as Error);
}
} else {
isProcessingQueueRef.current = false;
}
}
async function playAudio(audioData: Blob): Promise<number> {
return new Promise(async (resolve, reject) => {
currentRejectRef.current = reject;
try {
console.log(
`[AudioPlayer] Playing audio (${audioData.size} bytes, type: ${audioData.type})`
);
// Convert blob to file
const file = await blobToFile(audioData);
currentFileRef.current = file;
setIsPlaying(true);
setIsPaused(false);
// Load and play audio
await player.replace({ uri: file.uri });
player.play();
// Wait for playback to finish
const checkInterval = setInterval(() => {
if (!player.playing && player.duration > 0) {
clearInterval(checkInterval);
const duration = player.duration;
console.log(`[AudioPlayer] Playback finished (duration: ${duration}s)`);
setIsPlaying(false);
currentRejectRef.current = null;
// Clean up temporary file
if (currentFileRef.current) {
try {
currentFileRef.current.delete();
} catch (err: any) {
console.warn('[AudioPlayer] Failed to delete temp file:', err);
}
currentFileRef.current = null;
}
resolve(duration);
}
}, 100);
} catch (error) {
console.error('[AudioPlayer] Error playing audio:', error);
setIsPlaying(false);
currentRejectRef.current = null;
// Clean up temporary file
if (currentFileRef.current) {
try {
currentFileRef.current.delete();
} catch (err: any) {
console.warn('[AudioPlayer] Failed to delete temp file:', err);
}
currentFileRef.current = null;
}
reject(error);
}
});
}
function stop(): void {
// Stop currently playing audio
if (player) {
player.pause();
}
setIsPlaying(false);
setIsPaused(false);
// Clean up temporary file
if (currentFileRef.current) {
try {
currentFileRef.current.delete();
} catch (err: any) {
console.warn('[AudioPlayer] Failed to delete temp file:', err);
}
currentFileRef.current = null;
}
// Reject the current playing promise if it exists
if (currentRejectRef.current) {
currentRejectRef.current(new Error('Playback stopped'));
currentRejectRef.current = null;
}
// Reject all pending promises in the queue
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
item.reject(new Error('Playback stopped'));
}
isProcessingQueueRef.current = false;
}
function pause(): void {
if (player && isPlaying && !isPaused) {
player.pause();
setIsPaused(true);
console.log('[AudioPlayer] Playback paused');
}
}
function resume(): void {
if (player && isPaused) {
player.play();
setIsPaused(false);
console.log('[AudioPlayer] Playback resumed');
}
}
function clearQueue(): void {
// Reject all pending promises in the queue
while (queueRef.current.length > 0) {
const item = queueRef.current.shift()!;
item.reject(new Error('Queue cleared'));
}
}
return {
play,
stop,
pause,
resume,
isPlaying: () => isPlaying,
isPaused: () => isPaused,
clearQueue,
};
}
+299
View File
@@ -0,0 +1,299 @@
import { useAudioRecorder as useExpoAudioRecorder, RecordingOptions, RecordingPresets, requestRecordingPermissionsAsync, setAudioModeAsync } from 'expo-audio';
import { Paths, File, Directory, FileInfo } from 'expo-file-system';
import { useState, useEffect } from 'react';
export interface AudioCaptureConfig {
sampleRate?: number;
numberOfChannels?: number;
bitRate?: number;
onAudioLevel?: (level: number) => void;
onSpeechSegment?: (audioBlob: Blob) => void;
enableContinuousRecording?: boolean;
}
/**
* Workaround for Expo SDK 54 Android bug where audioRecorder.uri returns empty/zero-byte file
* https://github.com/expo/expo/issues/39646
*/
async function getActualRecordingUri(createdAt: Date): Promise<string | null> {
try {
console.log('[AudioRecorder] Searching for recording file created at:', createdAt.toISOString());
const audioDir = new Directory(Paths.cache, 'Audio');
console.log('[AudioRecorder] Audio cache directory URI:', audioDir.uri);
console.log('[AudioRecorder] Directory exists:', audioDir.exists);
if (!audioDir.exists) {
console.log('[AudioRecorder] Audio cache directory does not exist');
return null;
}
const files = audioDir.list();
console.log('[AudioRecorder] Found files in Audio cache:', files.length);
if (!files.length) {
console.log('[AudioRecorder] No files found in Audio cache directory');
return null;
}
const validFiles = files
.map(file => {
const info = file.info();
console.log('[AudioRecorder] File info:', {
uri: info.uri,
size: info.size,
creationTime: info.creationTime ? new Date(info.creationTime).toISOString() : null,
});
return info;
})
.filter(f => f.size && f.size > 0);
console.log('[AudioRecorder] Valid files (size > 0):', validFiles.length);
if (validFiles.length === 0) {
console.log('[AudioRecorder] No valid files found (all are zero-byte)');
return null;
}
let closest: FileInfo | null = null;
let minDiff = Infinity;
for (const file of validFiles) {
if (!file.creationTime || !file.uri) continue;
const diff = Math.abs(file.creationTime - createdAt.getTime());
console.log('[AudioRecorder] Time diff for file:', {
uri: file.uri,
diffMs: diff,
});
if (diff < minDiff) {
closest = file;
minDiff = diff;
}
}
if (closest) {
const resultUri = closest.uri?.slice(0, -1) ?? null;
console.log('[AudioRecorder] Found closest file:', {
uri: resultUri,
size: closest.size,
timeDiffMs: minDiff,
});
return resultUri;
}
console.log('[AudioRecorder] No closest file found');
return null;
} catch (e) {
console.error('[AudioRecorder] Error finding actual recording file:', e);
return null;
}
}
/**
* Convert audio file URI to Blob for WebSocket transmission
* Returns a Blob-like object that works in React Native
*/
async function uriToBlob(uri: string): Promise<Blob> {
// Use expo-file-system to read the file
const file = new File(uri);
const base64 = await file.base64();
const size = file.size;
// React Native doesn't support creating Blobs from binary data
// Create a Blob-like object that has the methods we need
const blobLike = {
type: 'audio/m4a',
size: size,
arrayBuffer: async () => {
// Convert base64 to ArrayBuffer
const binaryString = atob(base64);
const bytes = new Uint8Array(binaryString.length);
for (let i = 0; i < binaryString.length; i++) {
bytes[i] = binaryString.charCodeAt(i);
}
return bytes.buffer;
},
} as Blob;
return blobLike;
}
/**
* Hook for audio recording with configuration matching web version
* Matches the web app's audio constraints:
* - 16000 sample rate (optimal for speech/Whisper)
* - 1 channel (mono)
* - Echo cancellation, noise suppression, auto gain control (voice_communication on Android)
*/
export function useAudioRecorder(config?: AudioCaptureConfig) {
const [isRecording, setIsRecording] = useState(false);
const [recordingStartTime, setRecordingStartTime] = useState<Date | null>(null);
// Create recording options matching web app constraints
const recordingOptions: RecordingOptions = {
...RecordingPresets.HIGH_QUALITY,
sampleRate: config?.sampleRate || 16000,
numberOfChannels: config?.numberOfChannels || 1,
bitRate: config?.bitRate || 128000,
extension: '.m4a',
isMeteringEnabled: !!config?.onAudioLevel, // Enable metering if callback provided
android: {
extension: '.m4a',
outputFormat: 'mpeg4',
audioEncoder: 'aac',
sampleRate: config?.sampleRate || 16000,
audioSource: 'voice_communication', // Enables echo cancellation, noise suppression, auto gain control
},
ios: {
extension: '.m4a',
audioQuality: 127, // High quality
sampleRate: config?.sampleRate || 16000,
linearPCMBitDepth: 16,
linearPCMIsBigEndian: false,
linearPCMIsFloat: false,
},
web: {
mimeType: 'audio/webm;codecs=opus',
bitsPerSecond: config?.bitRate || 128000,
},
};
const audioRecorder = useExpoAudioRecorder(recordingOptions);
// Monitor audio levels if metering is enabled
useEffect(() => {
if (!config?.onAudioLevel || !isRecording) return;
const interval = setInterval(() => {
const metering = audioRecorder.state?.metering;
if (metering !== undefined && metering !== null) {
// Normalize metering value (typically ranges from -160 to 0 dB)
// Convert to 0-1 range where 0 is silence and 1 is loud
// We'll use -40 dB as the threshold for "loud"
const normalized = Math.max(0, Math.min(1, (metering + 40) / 40));
config.onAudioLevel(normalized);
}
}, 100); // Check every 100ms
return () => clearInterval(interval);
}, [isRecording, config, audioRecorder.state?.metering]);
async function start(): Promise<void> {
if (isRecording) {
throw new Error('Already recording');
}
try {
// Request microphone permissions
console.log('[AudioRecorder] Requesting recording permissions...');
const permissionResponse = await requestRecordingPermissionsAsync();
if (!permissionResponse.granted) {
throw new Error('Microphone permission denied. Please enable microphone access in your device settings.');
}
// Configure audio mode for recording
console.log('[AudioRecorder] Configuring audio mode...');
await setAudioModeAsync({
playsInSilentMode: true,
allowsRecording: true,
});
console.log('[AudioRecorder] Starting recording with options:', {
sampleRate: recordingOptions.sampleRate,
numberOfChannels: recordingOptions.numberOfChannels,
bitRate: recordingOptions.bitRate,
});
const startTime = new Date();
setRecordingStartTime(startTime);
// Prepare the recorder before recording (required step)
console.log('[AudioRecorder] Preparing recorder...');
await audioRecorder.prepareToRecordAsync();
console.log('[AudioRecorder] Starting recording...');
await audioRecorder.record();
setIsRecording(true);
console.log('[AudioRecorder] Recording started at:', startTime.toISOString());
console.log('[AudioRecorder] Recorder isRecording:', audioRecorder.isRecording);
} catch (error: any) {
console.error('[AudioRecorder] Failed to start recording:', error);
throw new Error(`Failed to start audio recording: ${error.message}`);
}
}
async function stop(): Promise<Blob> {
if (!isRecording) {
throw new Error('Not recording');
}
try {
// Stop recording
await audioRecorder.stop();
setIsRecording(false);
// Get URI from recorder
let uri = audioRecorder.uri;
console.log('[AudioRecorder] Initial URI from recorder:', uri);
// Workaround for Expo SDK 54 Android bug - find actual recording file
if (recordingStartTime && (!uri || uri === '')) {
console.log('[AudioRecorder] Using workaround to find actual recording file...');
const actualUri = await getActualRecordingUri(recordingStartTime);
if (actualUri) {
uri = actualUri;
console.log('[AudioRecorder] Found actual recording URI:', uri);
}
}
if (!uri || uri === '') {
throw new Error('No recording URI found');
}
// Get file info
const file = new File(uri);
const exists = file.exists;
console.log('[AudioRecorder] File exists:', exists);
if (!exists) {
throw new Error('Recording file does not exist');
}
// Convert URI to Blob
const audioBlob = await uriToBlob(uri);
console.log('[AudioRecorder] Recording converted to blob:', {
size: audioBlob.size,
type: audioBlob.type,
});
// Clean up the temporary file
file.delete();
// Reset start time
setRecordingStartTime(null);
return audioBlob;
} catch (error: any) {
setIsRecording(false);
setRecordingStartTime(null);
console.error('[AudioRecorder] Failed to stop recording:', error);
throw new Error(`Failed to stop audio recording: ${error.message}`);
}
}
function getSupportedMimeType(): string | null {
// On native platforms, expo-audio uses m4a/AAC
// On web, it can use webm/opus
return 'audio/m4a';
}
return {
start,
stop,
isRecording: () => isRecording,
getSupportedMimeType,
};
}
+1
View File
@@ -0,0 +1 @@
export { useColorScheme } from 'react-native';
@@ -0,0 +1,21 @@
import { useEffect, useState } from 'react';
import { useColorScheme as useRNColorScheme } from 'react-native';
/**
* To support static rendering, this value needs to be re-calculated on the client side for web
*/
export function useColorScheme() {
const [hasHydrated, setHasHydrated] = useState(false);
useEffect(() => {
setHasHydrated(true);
}, []);
const colorScheme = useRNColorScheme();
if (hasHydrated) {
return colorScheme;
}
return 'light';
}
+210
View File
@@ -0,0 +1,210 @@
import { useState, useEffect, useRef } from 'react';
import { Platform } from 'react-native';
import { useAudioRecorder } from '@siteed/expo-audio-studio';
import { check, request, PERMISSIONS, RESULTS } from 'react-native-permissions';
export interface RealtimeAudioConfig {
onAudioSegment?: (audioData: string) => void;
onSpeechStart?: () => void;
onSpeechEnd?: () => void;
onError?: (error: Error) => void;
volumeThreshold?: number; // RMS threshold for speech detection (0-1)
silenceDuration?: number; // ms of silence before ending segment
}
export interface RealtimeAudio {
start: () => Promise<void>;
stop: () => Promise<void>;
isActive: boolean;
isSpeaking: boolean;
}
/**
* Hook for realtime audio streaming with volume-based VAD
* Uses expo-audio-studio for recording with echo cancellation and noise suppression
*/
export function useRealtimeAudio(config: RealtimeAudioConfig): RealtimeAudio {
const [isActive, setIsActive] = useState(false);
const [isSpeaking, setIsSpeaking] = useState(false);
const audioRecorder = useAudioRecorder();
const audioBufferRef = useRef<string[]>([]);
const silenceStartRef = useRef<number | null>(null);
const isSpeakingRef = useRef(false);
const lastVolumeCheckRef = useRef<number>(Date.now());
const VOLUME_THRESHOLD = config.volumeThreshold ?? 0.02;
const SILENCE_DURATION_MS = config.silenceDuration ?? 1000;
const VOLUME_CHECK_INTERVAL = 200; // Check volume every 200ms
async function requestMicrophonePermission(): Promise<boolean> {
try {
const permission = Platform.OS === 'ios'
? PERMISSIONS.IOS.MICROPHONE
: PERMISSIONS.ANDROID.RECORD_AUDIO;
const status = await check(permission);
if (status === RESULTS.GRANTED) {
return true;
}
const result = await request(permission);
return result === RESULTS.GRANTED;
} catch (error) {
console.error('[RealtimeAudio] Permission error:', error);
return false;
}
}
function calculateVolume(base64Audio: string): number {
try {
// Decode base64 to binary
const binary = atob(base64Audio);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
// Calculate RMS from PCM data (assuming 16-bit PCM)
let sum = 0;
for (let i = 0; i < bytes.length - 1; i += 2) {
const sample = (bytes[i] | (bytes[i + 1] << 8)) / 32768.0; // Convert to -1 to 1
sum += sample * sample;
}
const rms = Math.sqrt(sum / (bytes.length / 2));
return rms;
} catch (error) {
console.error('[RealtimeAudio] Volume calculation error:', error);
return 0;
}
}
async function start(): Promise<void> {
if (isActive) {
console.log('[RealtimeAudio] Already active');
return;
}
try {
console.log('[RealtimeAudio] Starting realtime audio...');
// Request permissions
const hasPermission = await requestMicrophonePermission();
if (!hasPermission) {
throw new Error('Microphone permission denied');
}
// Start recording with audio streaming
await audioRecorder.startRecording({
sampleRate: 16000,
channels: 1,
encoding: 'pcm_16bit',
interval: 100, // Get chunks every 100ms
onAudioStream: async (event: any) => {
if (!event.data) return;
const audioData = event.data; // base64 PCM data
// Check volume periodically
const now = Date.now();
if (now - lastVolumeCheckRef.current >= VOLUME_CHECK_INTERVAL) {
lastVolumeCheckRef.current = now;
const volume = calculateVolume(audioData);
const speechDetected = volume > VOLUME_THRESHOLD;
if (speechDetected && !isSpeakingRef.current) {
// Speech START
console.log('[RealtimeAudio] Speech started (volume:', volume.toFixed(4), ')');
isSpeakingRef.current = true;
silenceStartRef.current = null;
audioBufferRef.current = [audioData];
setIsSpeaking(true);
config.onSpeechStart?.();
} else if (speechDetected && isSpeakingRef.current) {
// Continuing speech
audioBufferRef.current.push(audioData);
silenceStartRef.current = null;
} else if (!speechDetected && isSpeakingRef.current) {
// Potential speech END
audioBufferRef.current.push(audioData);
if (silenceStartRef.current === null) {
silenceStartRef.current = Date.now();
} else {
const silenceDuration = Date.now() - silenceStartRef.current;
if (silenceDuration >= SILENCE_DURATION_MS) {
// Speech END confirmed
console.log('[RealtimeAudio] Speech ended after', silenceDuration, 'ms silence');
isSpeakingRef.current = false;
silenceStartRef.current = null;
setIsSpeaking(false);
config.onSpeechEnd?.();
// Send buffered audio segment
if (audioBufferRef.current.length > 0) {
const combinedAudio = audioBufferRef.current.join('');
config.onAudioSegment?.(combinedAudio);
audioBufferRef.current = [];
}
}
}
}
} else if (isSpeakingRef.current) {
// Just buffer if we're speaking but not checking volume yet
audioBufferRef.current.push(audioData);
}
},
});
setIsActive(true);
console.log('[RealtimeAudio] Realtime audio started successfully');
} catch (error) {
console.error('[RealtimeAudio] Start error:', error);
const err = error instanceof Error ? error : new Error(String(error));
config.onError?.(err);
// Cleanup on error
await stop();
throw error;
}
}
async function stop(): Promise<void> {
console.log('[RealtimeAudio] Stopping realtime audio...');
// Stop recording
if (audioRecorder.isRecording) {
try {
await audioRecorder.stopRecording();
} catch (error) {
console.error('[RealtimeAudio] Error stopping recording:', error);
}
}
// Reset state
audioBufferRef.current = [];
isSpeakingRef.current = false;
silenceStartRef.current = null;
setIsActive(false);
setIsSpeaking(false);
console.log('[RealtimeAudio] Realtime audio stopped');
}
// Cleanup on unmount
useEffect(() => {
return () => {
if (isActive) {
stop();
}
};
}, []);
return {
start,
stop,
isActive,
isSpeaking,
};
}
+78
View File
@@ -0,0 +1,78 @@
import { useState, useEffect, useCallback } from 'react';
import AsyncStorage from '@react-native-async-storage/async-storage';
const STORAGE_KEY = '@voice-dev:settings';
export interface Settings {
serverUrl: string;
useSpeaker: boolean;
keepScreenOn: boolean;
theme: 'dark' | 'light' | 'auto';
}
const DEFAULT_SETTINGS: Settings = {
serverUrl: 'wss://mohameds-macbook-pro.tail8fe838.ts.net/ws',
useSpeaker: true,
keepScreenOn: true,
theme: 'dark',
};
export interface UseSettingsReturn {
settings: Settings;
isLoading: boolean;
updateSettings: (updates: Partial<Settings>) => Promise<void>;
resetSettings: () => Promise<void>;
}
export function useSettings(): UseSettingsReturn {
const [settings, setSettings] = useState<Settings>(DEFAULT_SETTINGS);
const [isLoading, setIsLoading] = useState(true);
// Load settings from AsyncStorage on mount
useEffect(() => {
loadSettings();
}, []);
async function loadSettings() {
try {
const stored = await AsyncStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as Partial<Settings>;
setSettings({ ...DEFAULT_SETTINGS, ...parsed });
}
} catch (error) {
console.error('[Settings] Failed to load settings:', error);
// Continue with default settings
} finally {
setIsLoading(false);
}
}
const updateSettings = useCallback(async (updates: Partial<Settings>) => {
try {
const newSettings = { ...settings, ...updates };
setSettings(newSettings);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(newSettings));
} catch (error) {
console.error('[Settings] Failed to save settings:', error);
throw error;
}
}, [settings]);
const resetSettings = useCallback(async () => {
try {
setSettings(DEFAULT_SETTINGS);
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(DEFAULT_SETTINGS));
} catch (error) {
console.error('[Settings] Failed to reset settings:', error);
throw error;
}
}, []);
return {
settings,
isLoading,
updateSettings,
resetSettings,
};
}
+21
View File
@@ -0,0 +1,21 @@
/**
* Learn more about light and dark modes:
* https://docs.expo.dev/guides/color-schemes/
*/
import { Colors } from '@/constants/theme';
import { useColorScheme } from '@/hooks/use-color-scheme';
export function useThemeColor(
props: { light?: string; dark?: string },
colorName: keyof typeof Colors.light & keyof typeof Colors.dark
) {
const theme = useColorScheme() ?? 'light';
const colorFromProps = props[theme];
if (colorFromProps) {
return colorFromProps;
} else {
return Colors[theme][colorName];
}
}
+161
View File
@@ -0,0 +1,161 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import type {
WSInboundMessage,
WSOutboundMessage,
SessionOutboundMessage
} from '@server/server/messages';
export interface UseWebSocketReturn {
isConnected: boolean;
conversationId: string | null;
send: (message: WSInboundMessage) => void;
on: (type: SessionOutboundMessage['type'], handler: (message: SessionOutboundMessage) => void) => () => void;
sendPing: () => void;
sendUserMessage: (message: string) => void;
}
export function useWebSocket(url: string, conversationId?: string | null): UseWebSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [currentConversationId, setCurrentConversationId] = useState<string | null>(null);
const wsRef = useRef<WebSocket | null>(null);
const handlersRef = useRef<Map<SessionOutboundMessage['type'], Set<(message: SessionOutboundMessage) => void>>>(new Map());
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const connect = useCallback(() => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
return;
}
try {
// Add conversation ID to URL if provided
const wsUrl = conversationId ? `${url}?conversationId=${conversationId}` : url;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log('[WS] Connected to server');
setIsConnected(true);
};
ws.onclose = () => {
console.log('[WS] Disconnected from server');
setIsConnected(false);
// Attempt to reconnect after 3 seconds
reconnectTimeoutRef.current = setTimeout(() => {
console.log('[WS] Attempting to reconnect...');
connect();
}, 3000);
};
ws.onerror = (error) => {
console.error('[WS] Error:', error);
};
ws.onmessage = (event) => {
try {
const wsMessage: WSOutboundMessage = JSON.parse(event.data);
// Only session messages trigger handlers
if (wsMessage.type === 'session') {
const sessionMessage = wsMessage.message;
console.log(`[WS] Received session message type: ${sessionMessage.type}`);
// Track conversation ID when loaded
if (sessionMessage.type === 'conversation_loaded') {
setCurrentConversationId(sessionMessage.payload.conversationId);
}
// Call all registered handlers for this message type
const handlers = handlersRef.current.get(sessionMessage.type);
if (handlers) {
handlers.forEach((handler) => {
try {
handler(sessionMessage);
} catch (err) {
console.error(`[WS] Error in handler for ${sessionMessage.type}:`, err);
}
});
}
} else {
// pong - just log
console.log(`[WS] Received ${wsMessage.type}`);
}
} catch (err) {
console.error('[WS] Failed to parse message:', err);
}
};
wsRef.current = ws;
} catch (err) {
console.error('[WS] Failed to create WebSocket:', err);
}
}, [url, conversationId]);
useEffect(() => {
connect();
return () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
if (wsRef.current) {
wsRef.current.close();
}
};
}, [connect]);
const send = useCallback((message: WSInboundMessage) => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify(message));
} else {
console.warn('[WS] Cannot send message - not connected');
}
}, []);
const on = useCallback((
type: SessionOutboundMessage['type'],
handler: (message: SessionOutboundMessage) => void
) => {
if (!handlersRef.current.has(type)) {
handlersRef.current.set(type, new Set());
}
handlersRef.current.get(type)!.add(handler);
// Return cleanup function
return () => {
const handlers = handlersRef.current.get(type);
if (handlers) {
handlers.delete(handler);
if (handlers.size === 0) {
handlersRef.current.delete(type);
}
}
};
}, []);
const sendPing = useCallback(() => {
send({ type: 'ping' });
}, [send]);
const sendUserMessage = useCallback(
(message: string) => {
send({
type: 'session',
message: {
type: 'user_text',
text: message,
},
});
},
[send]
);
return {
isConnected,
conversationId: currentConversationId,
send,
on,
sendPing,
sendUserMessage,
};
}
+6
View File
@@ -0,0 +1,6 @@
const { getDefaultConfig } = require("expo/metro-config");
const { withNativewind } = require("nativewind/metro");
const config = getDefaultConfig(__dirname);
module.exports = withNativewind(config);
+3
View File
@@ -0,0 +1,3 @@
/// <reference types="react-native-css/types" />
// NOTE: This file should not be edited and should be committed with your source code. It is generated by react-native-css. If you need to move or disable this file, please see the documentation.
+18486
View File
File diff suppressed because it is too large Load Diff
+71
View File
@@ -0,0 +1,71 @@
{
"name": "@voice-dev/app",
"main": "expo-router/entry",
"version": "1.0.0",
"scripts": {
"start": "expo start",
"reset-project": "node ./scripts/reset-project.js",
"android": "expo run:android",
"ios": "expo run:ios",
"web": "expo start --web",
"lint": "expo lint"
},
"dependencies": {
"@expo/vector-icons": "^15.0.2",
"@react-native-async-storage/async-storage": "2.2.0",
"@react-navigation/bottom-tabs": "^7.4.0",
"@react-navigation/elements": "^2.6.3",
"@react-navigation/native": "^7.1.8",
"@siteed/expo-audio-studio": "^2.18.1",
"expo": "~54.0.15",
"expo-audio": "~1.0.13",
"expo-av": "^16.0.7",
"expo-constants": "~18.0.9",
"expo-dev-client": "^6.0.15",
"expo-file-system": "~19.0.17",
"expo-font": "~14.0.9",
"expo-haptics": "~15.0.7",
"expo-image": "~3.0.10",
"expo-keep-awake": "^15.0.7",
"expo-linking": "~8.0.8",
"expo-router": "~6.0.13",
"expo-splash-screen": "~31.0.10",
"expo-status-bar": "~3.0.8",
"expo-symbols": "~1.0.7",
"expo-system-ui": "~6.0.7",
"expo-updates": "~29.0.12",
"expo-web-browser": "~15.0.8",
"lucide-react-native": "^0.546.0",
"nativewind": "^5.0.0-preview.2",
"react": "19.1.0",
"react-dom": "19.1.0",
"react-native": "0.81.4",
"react-native-css": "^3.0.1",
"react-native-gesture-handler": "~2.28.0",
"react-native-markdown-display": "^7.0.2",
"react-native-permissions": "^5.4.2",
"react-native-reanimated": "~4.1.1",
"react-native-safe-area-context": "~5.6.0",
"react-native-screens": "~4.16.0",
"react-native-web": "~0.21.0",
"react-native-webrtc": "^124.0.7",
"react-native-worklets": "0.5.1"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.15",
"@types/react": "~19.1.0",
"eas-cli": "^16.24.1",
"eslint": "^9.25.0",
"eslint-config-expo": "~10.0.0",
"postcss": "^8.4.49",
"tailwindcss": "^4.1.15",
"typescript": "~5.9.2"
},
"overrides": {
"lightningcss": "1.30.1",
"@siteed/expo-audio-studio": {
"expo-modules-core": "3.0.22"
}
},
"private": true
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* This script is used to reset the project to a blank state.
* It deletes or moves the /app, /components, /hooks, /scripts, and /constants directories to /app-example based on user input and creates a new /app directory with an index.tsx and _layout.tsx file.
* You can remove the `reset-project` script from package.json and safely delete this file after running it.
*/
const fs = require("fs");
const path = require("path");
const readline = require("readline");
const root = process.cwd();
const oldDirs = ["app", "components", "hooks", "constants", "scripts"];
const exampleDir = "app-example";
const newAppDir = "app";
const exampleDirPath = path.join(root, exampleDir);
const indexContent = `import { Text, View } from "react-native";
export default function Index() {
return (
<View
style={{
flex: 1,
justifyContent: "center",
alignItems: "center",
}}
>
<Text>Edit app/index.tsx to edit this screen.</Text>
</View>
);
}
`;
const layoutContent = `import { Stack } from "expo-router";
export default function RootLayout() {
return <Stack />;
}
`;
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
const moveDirectories = async (userInput) => {
try {
if (userInput === "y") {
// Create the app-example directory
await fs.promises.mkdir(exampleDirPath, { recursive: true });
console.log(`📁 /${exampleDir} directory created.`);
}
// Move old directories to new app-example directory or delete them
for (const dir of oldDirs) {
const oldDirPath = path.join(root, dir);
if (fs.existsSync(oldDirPath)) {
if (userInput === "y") {
const newDirPath = path.join(root, exampleDir, dir);
await fs.promises.rename(oldDirPath, newDirPath);
console.log(`➡️ /${dir} moved to /${exampleDir}/${dir}.`);
} else {
await fs.promises.rm(oldDirPath, { recursive: true, force: true });
console.log(`❌ /${dir} deleted.`);
}
} else {
console.log(`➡️ /${dir} does not exist, skipping.`);
}
}
// Create new /app directory
const newAppDirPath = path.join(root, newAppDir);
await fs.promises.mkdir(newAppDirPath, { recursive: true });
console.log("\n📁 New /app directory created.");
// Create index.tsx
const indexPath = path.join(newAppDirPath, "index.tsx");
await fs.promises.writeFile(indexPath, indexContent);
console.log("📄 app/index.tsx created.");
// Create _layout.tsx
const layoutPath = path.join(newAppDirPath, "_layout.tsx");
await fs.promises.writeFile(layoutPath, layoutContent);
console.log("📄 app/_layout.tsx created.");
console.log("\n✅ Project reset complete. Next steps:");
console.log(
`1. Run \`npx expo start\` to start a development server.\n2. Edit app/index.tsx to edit the main screen.${
userInput === "y"
? `\n3. Delete the /${exampleDir} directory when you're done referencing it.`
: ""
}`
);
} catch (error) {
console.error(`❌ Error during script execution: ${error.message}`);
}
};
rl.question(
"Do you want to move existing files to /app-example instead of deleting them? (Y/n): ",
(answer) => {
const userInput = answer.trim().toLowerCase() || "y";
if (userInput === "y" || userInput === "n") {
moveDirectories(userInput).finally(() => rl.close());
} else {
console.log("❌ Invalid input. Please enter 'Y' or 'N'.");
rl.close();
}
}
);
+21
View File
@@ -0,0 +1,21 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"paths": {
"@/*": [
"./*"
],
"@server/*": [
"../server/src/*"
]
}
},
"include": [
"**/*.ts",
"**/*.tsx",
".expo/types/**/*.ts",
"expo-env.d.ts",
"nativewind-env.d.ts"
]
}