[TEST] Initial setup - NOT PRODUCTION CODE
⚠️ This is test/experimental code for API integration testing. Do not use in production. Includes: - WellNuo API integration (dashboard, patient context) - Playwright tests for API verification - WebView component for dashboard embedding - API documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
2aef0bcf93
commit
4a5331b2e4
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# OpenAI Configuration
|
||||||
|
# Get your API key from https://platform.openai.com/api-keys
|
||||||
|
OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxx
|
||||||
|
|
||||||
|
# Webhook URL for fetching context/instructions (optional)
|
||||||
|
# The webhook should return JSON with: { systemPrompt, voiceSettings, userData }
|
||||||
|
WEBHOOK_URL=https://your-webhook-url.com/context
|
||||||
|
|
||||||
|
# Voice Settings (optional)
|
||||||
|
# Available voices: alloy, echo, shimmer, ash, ballad, coral, sage, verse
|
||||||
|
DEFAULT_VOICE=shimmer
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@ -32,6 +32,7 @@ yarn-error.*
|
|||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env*.local
|
.env*.local
|
||||||
|
.env
|
||||||
|
|
||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
|||||||
443
API_DOCUMENTATION.md
Normal file
443
API_DOCUMENTATION.md
Normal file
@ -0,0 +1,443 @@
|
|||||||
|
# WellNuo API Documentation
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
WellNuo - система мониторинга здоровья пожилых людей. API позволяет получать данные о пациентах, их активности, локации и состоянии здоровья.
|
||||||
|
|
||||||
|
**Дата тестирования:** 2025-12-10
|
||||||
|
**Статус:** Работает
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Endpoints
|
||||||
|
|
||||||
|
### Base URL
|
||||||
|
```
|
||||||
|
https://eluxnetworks.net/function/well-api/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Web Dashboard
|
||||||
|
```
|
||||||
|
https://react.eluxnetworks.net/dashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
```
|
||||||
|
Username: anandk
|
||||||
|
Password: anandk_8
|
||||||
|
Client ID: 001
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение токена
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "function=credentials&clientId=001&user_name=anandk&ps=anandk_8&nonce=$(uuidgen)"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| function | string | Yes | `credentials` |
|
||||||
|
| clientId | string | Yes | `001` |
|
||||||
|
| user_name | string | Yes | Username |
|
||||||
|
| ps | string | Yes | Password |
|
||||||
|
| nonce | string | Yes | Unique UUID for each request |
|
||||||
|
|
||||||
|
**Response (200 OK):**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
|
||||||
|
"privileges": "21,38,29,41,42",
|
||||||
|
"user_id": 43,
|
||||||
|
"max_role": 2,
|
||||||
|
"status": "200 OK"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Token Format:** JWT (JSON Web Token)
|
||||||
|
- Algorithm: HS256
|
||||||
|
- Expiration: ~7 days (exp claim in payload)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API Endpoints
|
||||||
|
|
||||||
|
### 1. Dashboard List (Список пациентов)
|
||||||
|
|
||||||
|
**Request:**
|
||||||
|
```bash
|
||||||
|
curl -X POST "https://eluxnetworks.net/function/well-api/api" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "function=dashboard_list&user_name=anandk&token=<JWT_TOKEN>&date=2025-12-10&nonce=<UUID>"
|
||||||
|
```
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
| Parameter | Type | Required | Description |
|
||||||
|
|-----------|------|----------|-------------|
|
||||||
|
| function | string | Yes | `dashboard_list` |
|
||||||
|
| user_name | string | Yes | Username |
|
||||||
|
| token | string | Yes | JWT access token |
|
||||||
|
| date | string | Yes | Date in YYYY-MM-DD format |
|
||||||
|
| nonce | string | Yes | Unique UUID |
|
||||||
|
|
||||||
|
**Response:**
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"result_list": [
|
||||||
|
{
|
||||||
|
"user_id": 25,
|
||||||
|
"name": "Ferdinand Zmrzli",
|
||||||
|
"address": "661 Encore Way",
|
||||||
|
"time_zone": "America/Los_Angeles",
|
||||||
|
"picture": "/",
|
||||||
|
"bathroom_at": "2025-12-10T13:52:00",
|
||||||
|
"kitchen_at": "2025-12-10T14:48:00",
|
||||||
|
"bedroom_at": "2025-12-10T09:14:00",
|
||||||
|
"temperature": 72.77,
|
||||||
|
"smell": "clean",
|
||||||
|
"bathroom_delayed": [6, 12],
|
||||||
|
"kitchen_delayed": [6, 12],
|
||||||
|
"bedroom_delayed": [13, 16],
|
||||||
|
"last_location": "Kitchen",
|
||||||
|
"last_detected_time": "2025-12-10T14:47:00",
|
||||||
|
"before_last_location": "Living Room",
|
||||||
|
"last_present_duration": 1,
|
||||||
|
"wellness_score_percent": 90,
|
||||||
|
"wellness_descriptor": "Great!",
|
||||||
|
"wellness_descriptor_color": "bg-green-100 text-green-700",
|
||||||
|
"bedroom_temperature": 15.22,
|
||||||
|
"sleep_bathroom_visit_count": 0,
|
||||||
|
"bedroom_co2": 500,
|
||||||
|
"shower_detected_time": "2025-12-10T13:52:00",
|
||||||
|
"breakfast_detected_time": 0,
|
||||||
|
"living_room_time_spent": 0,
|
||||||
|
"outside_hours": 0,
|
||||||
|
"most_time_spent_in": "Bedroom",
|
||||||
|
"sleep_hours": 11.12,
|
||||||
|
"units": "°F",
|
||||||
|
"deployment_id": 21,
|
||||||
|
"location_list": ["Living Room", "Bathroom Small", "Bedroom", "Dining Room", "Bathroom Main", "Kitchen", "Office"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "200 OK"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Available Patients (Тестовые данные)
|
||||||
|
|
||||||
|
| User ID | Name | Address | Timezone | Deployment ID |
|
||||||
|
|---------|------|---------|----------|---------------|
|
||||||
|
| 25 | Ferdinand Zmrzli | 661 Encore Way | America/Los_Angeles | 21 |
|
||||||
|
| 34 | Helga Kleine | - | Europe/Berlin | 29 |
|
||||||
|
| 48 | Đurđica Božičević-Radić | A.T. Mimare 38 | Europe/Zagreb | 38 |
|
||||||
|
| 50 | Tarik Hubana | - | Europe/Sarajevo | 41 |
|
||||||
|
| 54 | Jamie Rivera-Vallestero | 2088 Trafalgar Ave | US/Pacific | 42 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Structure
|
||||||
|
|
||||||
|
### Patient Object Fields
|
||||||
|
|
||||||
|
| Field | Type | Description |
|
||||||
|
|-------|------|-------------|
|
||||||
|
| user_id | int | Unique patient ID |
|
||||||
|
| name | string | Patient full name |
|
||||||
|
| address | string | Home address |
|
||||||
|
| time_zone | string | IANA timezone |
|
||||||
|
| temperature | float | Current room temperature |
|
||||||
|
| smell | string | Air quality (clean/etc) |
|
||||||
|
| last_location | string | Current room |
|
||||||
|
| last_detected_time | datetime | Last activity timestamp |
|
||||||
|
| wellness_score_percent | int | Health score 0-100 |
|
||||||
|
| wellness_descriptor | string | Status text |
|
||||||
|
| sleep_hours | float | Hours slept |
|
||||||
|
| bathroom_at | datetime | Last bathroom visit |
|
||||||
|
| kitchen_at | datetime | Last kitchen visit |
|
||||||
|
| bedroom_at | datetime | Last bedroom visit |
|
||||||
|
| location_list | array | Available rooms in home |
|
||||||
|
| units | string | Temperature units (°F/°C) |
|
||||||
|
| deployment_id | int | Sensor deployment ID |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Notes
|
||||||
|
|
||||||
|
### WebView Integration (React Native)
|
||||||
|
|
||||||
|
Для встраивания dashboard в React Native приложение:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
import { WebView } from 'react-native-webview';
|
||||||
|
|
||||||
|
// Inject credentials and auto-login
|
||||||
|
const injectedJS = `
|
||||||
|
localStorage.setItem('auth2', JSON.stringify({
|
||||||
|
username: 'anandk',
|
||||||
|
token: '${token}',
|
||||||
|
user_id: 43
|
||||||
|
}));
|
||||||
|
window.location.reload();
|
||||||
|
true;
|
||||||
|
`;
|
||||||
|
|
||||||
|
<WebView
|
||||||
|
source={{ uri: 'https://react.eluxnetworks.net/dashboard' }}
|
||||||
|
injectedJavaScript={injectedJS}
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
domStorageEnabled={true}
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request Headers
|
||||||
|
|
||||||
|
All requests use:
|
||||||
|
```
|
||||||
|
Content-Type: application/x-www-form-urlencoded
|
||||||
|
```
|
||||||
|
|
||||||
|
### Nonce Generation
|
||||||
|
|
||||||
|
Each request requires a unique nonce (UUID):
|
||||||
|
```javascript
|
||||||
|
const nonce = crypto.randomUUID();
|
||||||
|
// or
|
||||||
|
const nonce = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
||||||
|
const r = Math.random() * 16 | 0;
|
||||||
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
| Status | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| 200 OK | Success |
|
||||||
|
| 401 | Invalid token |
|
||||||
|
| 403 | Access denied |
|
||||||
|
| 404 | Resource not found |
|
||||||
|
| 405 | Method not allowed (use POST) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quick Test Commands
|
||||||
|
|
||||||
|
### 1. Get Token
|
||||||
|
```bash
|
||||||
|
curl -s -X POST "https://eluxnetworks.net/function/well-api/api" \
|
||||||
|
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||||
|
-d "function=credentials&clientId=001&user_name=anandk&ps=anandk_8&nonce=test-123"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Get Dashboard
|
||||||
|
```bash
|
||||||
|
TOKEN="<paste_token_here>"
|
||||||
|
curl -s -X POST "https://eluxnetworks.net/function/well-api/api" \
|
||||||
|
-d "function=dashboard_list&user_name=anandk&token=$TOKEN&date=$(date +%Y-%m-%d)&nonce=test-456"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patient Context API (для AI чата)
|
||||||
|
|
||||||
|
### Endpoint
|
||||||
|
```
|
||||||
|
https://wellnuo.smartlaunchhub.com/api/patient/context
|
||||||
|
```
|
||||||
|
|
||||||
|
### Request
|
||||||
|
```bash
|
||||||
|
curl -s "https://wellnuo.smartlaunchhub.com/api/patient/context"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Response Structure
|
||||||
|
|
||||||
|
API возвращает детальные данные о пациенте для использования в AI чате:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"patient": {
|
||||||
|
"id": "dad",
|
||||||
|
"name": "John Smith",
|
||||||
|
"age": 78,
|
||||||
|
"relationship": "Father",
|
||||||
|
"address": "123 Oak Street, San Francisco, CA",
|
||||||
|
"timezone": "America/Los_Angeles",
|
||||||
|
"emergency_contact": { "name": "Sarah Smith", "phone": "+1 (555) 123-4567" }
|
||||||
|
},
|
||||||
|
"current_status": {
|
||||||
|
"location": "Living Room",
|
||||||
|
"presence_detected": true,
|
||||||
|
"is_moving": false,
|
||||||
|
"last_movement": "2025-12-10T22:51:43.316Z",
|
||||||
|
"estimated_activity": "resting",
|
||||||
|
"time_in_current_room_minutes": 45
|
||||||
|
},
|
||||||
|
"environment": {
|
||||||
|
"living_room": {
|
||||||
|
"temperature_c": 22.2, "temperature_f": 72,
|
||||||
|
"humidity_percent": 45, "co2_ppm": 650,
|
||||||
|
"voc_index": 85, "light_lux": 320,
|
||||||
|
"air_quality_status": "good",
|
||||||
|
"presence": true, "motion_level": "low"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sleep_analysis": {
|
||||||
|
"last_night": {
|
||||||
|
"bed_time_detected": "2025-11-30T22:18:00Z",
|
||||||
|
"wake_time_detected": "2025-12-01T05:45:00Z",
|
||||||
|
"total_hours": 7.45,
|
||||||
|
"quality_score": 78,
|
||||||
|
"bathroom_visits": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"activity_patterns": {
|
||||||
|
"today": {
|
||||||
|
"total_active_minutes": 185,
|
||||||
|
"sedentary_minutes": 240,
|
||||||
|
"rooms_visited": ["Bedroom", "Bathroom", "Kitchen", "Living Room"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"recent_events": [
|
||||||
|
{
|
||||||
|
"time": "2025-12-01T10:45:00Z",
|
||||||
|
"type": "presence",
|
||||||
|
"event": "Continued presence in living room",
|
||||||
|
"room": "Living Room"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"alerts": {
|
||||||
|
"active": [],
|
||||||
|
"recent": [
|
||||||
|
{
|
||||||
|
"id": "alert_001",
|
||||||
|
"type": "possible_fall",
|
||||||
|
"severity": "high",
|
||||||
|
"timestamp": "2025-11-15T14:22:00Z",
|
||||||
|
"room": "Bathroom",
|
||||||
|
"resolved": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Доступные данные для чата:
|
||||||
|
|
||||||
|
| Раздел | Описание |
|
||||||
|
|--------|----------|
|
||||||
|
| patient | Информация о пациенте |
|
||||||
|
| current_status | Текущее местоположение и активность |
|
||||||
|
| environment | Данные сенсоров по комнатам |
|
||||||
|
| environment_history | История показателей за день |
|
||||||
|
| sleep_analysis | Анализ сна |
|
||||||
|
| activity_patterns | Паттерны активности |
|
||||||
|
| recent_events | Последние события (до 90 записей) |
|
||||||
|
| alerts | Активные и недавние алерты |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
- `tests/api-check.spec.js` - Playwright tests for API
|
||||||
|
- `tests/network-capture.spec.js` - Network capture for debugging
|
||||||
|
- `tests/api-discovery.spec.js` - API endpoint discovery
|
||||||
|
- `tests/screenshots/` - Visual verification screenshots
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## React Native WebView Integration
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
```bash
|
||||||
|
npm install react-native-webview
|
||||||
|
```
|
||||||
|
|
||||||
|
### Usage Example
|
||||||
|
```tsx
|
||||||
|
import { DashboardWebView } from './src/components';
|
||||||
|
|
||||||
|
function DashboardScreen() {
|
||||||
|
const handlePatientSelect = (patientId: number, patientName: string) => {
|
||||||
|
console.log('Selected patient:', patientId, patientName);
|
||||||
|
// Navigate to patient details or start voice chat
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardWebView
|
||||||
|
onPatientSelect={handlePatientSelect}
|
||||||
|
onError={(error) => console.error(error)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Component: `src/components/DashboardWebView.tsx`
|
||||||
|
|
||||||
|
Компонент автоматически:
|
||||||
|
1. Получает JWT токен через API
|
||||||
|
2. Автоматически логинится в веб-интерфейс
|
||||||
|
3. Отображает dashboard внутри приложения
|
||||||
|
4. Перехватывает клики на пациентах
|
||||||
|
|
||||||
|
### Credentials в коде:
|
||||||
|
```typescript
|
||||||
|
const CREDENTIALS = {
|
||||||
|
username: 'anandk',
|
||||||
|
password: 'anandk_8',
|
||||||
|
clientId: '001'
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Gitea Repository Access
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
```
|
||||||
|
URL: https://gitea.wellnua.com
|
||||||
|
Username: sergei_t
|
||||||
|
Password: WellNuo2025!Secure
|
||||||
|
Email: serter2069@gmail.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
```
|
||||||
|
https://gitea.wellnua.com/robert/MobileApp_react_native
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clone Command
|
||||||
|
```bash
|
||||||
|
git clone https://sergei_t:WellNuo2025%21Secure@gitea.wellnua.com/robert/MobileApp_react_native.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Local Clone
|
||||||
|
```
|
||||||
|
~/Desktop/Wellnuo/wellnuo-mobile-repo/
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Contact / Support
|
||||||
|
|
||||||
|
- Dashboard: https://react.eluxnetworks.net/dashboard
|
||||||
|
- API Base: https://eluxnetworks.net/function/well-api/api
|
||||||
|
- Patient Context: https://wellnuo.smartlaunchhub.com/api/patient/context
|
||||||
|
- Gitea: https://gitea.wellnua.com
|
||||||
428
App.tsx
428
App.tsx
@ -1,11 +1,295 @@
|
|||||||
import { StatusBar } from 'expo-status-bar';
|
import React, { useState, useEffect, useCallback } from 'react';
|
||||||
import { StyleSheet, Text, View } from 'react-native';
|
import {
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StatusBar,
|
||||||
|
TouchableOpacity,
|
||||||
|
Alert,
|
||||||
|
} from 'react-native';
|
||||||
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { OPENAI_API_KEY, WEBHOOK_URL } from '@env';
|
||||||
|
import { VoiceButton, StatusIndicator, TranscriptView } from './src/components';
|
||||||
|
import { useVoiceAssistant, PermissionStatus } from './src/hooks/useVoiceAssistant';
|
||||||
|
import { fetchWebhookContext, getDefaultContext } from './src/services/webhookService';
|
||||||
|
import { WebhookContext } from './src/types';
|
||||||
|
|
||||||
|
// Wellnuo brand colors
|
||||||
|
const COLORS = {
|
||||||
|
primary: '#0074be',
|
||||||
|
primaryDark: '#005a94',
|
||||||
|
teal: '#5db1a8',
|
||||||
|
purple: '#ab5b8d',
|
||||||
|
white: '#ffffff',
|
||||||
|
background: '#f4f6f8',
|
||||||
|
textDark: '#515b69',
|
||||||
|
textLight: '#7f8795',
|
||||||
|
error: '#dc3545',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const [context, setContext] = useState<WebhookContext>(getDefaultContext());
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [currentTranscript, setCurrentTranscript] = useState('');
|
||||||
|
const [assistantText, setAssistantText] = useState('');
|
||||||
|
const [isInitialized, setIsInitialized] = useState(false);
|
||||||
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
|
const [apiKey] = useState(OPENAI_API_KEY || '');
|
||||||
|
|
||||||
|
// Fetch context from webhook on mount
|
||||||
|
useEffect(() => {
|
||||||
|
async function initialize() {
|
||||||
|
if (WEBHOOK_URL) {
|
||||||
|
const webhookContext = await fetchWebhookContext(WEBHOOK_URL);
|
||||||
|
setContext(webhookContext);
|
||||||
|
}
|
||||||
|
setIsInitialized(true);
|
||||||
|
}
|
||||||
|
initialize();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleTranscript = useCallback((text: string, isFinal: boolean) => {
|
||||||
|
if (isFinal) {
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'user', content: text, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
setCurrentTranscript('');
|
||||||
|
} else {
|
||||||
|
setCurrentTranscript(text);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleAssistantResponse = useCallback((text: string) => {
|
||||||
|
setAssistantText(prev => prev + text);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const {
|
||||||
|
state,
|
||||||
|
connectionStatus,
|
||||||
|
permissionStatus,
|
||||||
|
isInConversation,
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
startContinuousListening,
|
||||||
|
stopContinuousListening,
|
||||||
|
interrupt,
|
||||||
|
openSettings,
|
||||||
|
} = useVoiceAssistant({
|
||||||
|
apiKey,
|
||||||
|
context,
|
||||||
|
onTranscript: handleTranscript,
|
||||||
|
onAssistantResponse: handleAssistantResponse,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save assistant response when done
|
||||||
|
useEffect(() => {
|
||||||
|
if (!state.isSpeaking && !state.isProcessing && assistantText) {
|
||||||
|
setMessages(prev => [
|
||||||
|
...prev,
|
||||||
|
{ role: 'assistant', content: assistantText, timestamp: new Date() },
|
||||||
|
]);
|
||||||
|
setAssistantText('');
|
||||||
|
}
|
||||||
|
}, [state.isSpeaking, state.isProcessing, assistantText]);
|
||||||
|
|
||||||
|
// Toggle mute
|
||||||
|
const handleToggleMute = useCallback(async () => {
|
||||||
|
if (isMuted) {
|
||||||
|
// Unmute - resume listening
|
||||||
|
setIsMuted(false);
|
||||||
|
if (connectionStatus === 'connected') {
|
||||||
|
await startContinuousListening();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mute - stop listening but keep connection
|
||||||
|
setIsMuted(true);
|
||||||
|
await stopContinuousListening();
|
||||||
|
}
|
||||||
|
}, [isMuted, connectionStatus, startContinuousListening, stopContinuousListening]);
|
||||||
|
|
||||||
|
// Toggle conversation on/off with single tap
|
||||||
|
const handleToggleConversation = useCallback(async () => {
|
||||||
|
if (!apiKey) {
|
||||||
|
Alert.alert(
|
||||||
|
'API Key Required',
|
||||||
|
'Please add your OpenAI API key in the app configuration.',
|
||||||
|
[{ text: 'OK' }]
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If in conversation, end it
|
||||||
|
if (isInConversation || connectionStatus === 'connected') {
|
||||||
|
await stopContinuousListening();
|
||||||
|
disconnect();
|
||||||
|
setIsMuted(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start new conversation
|
||||||
|
await connect();
|
||||||
|
}, [apiKey, isInConversation, connectionStatus, connect, disconnect, stopContinuousListening]);
|
||||||
|
|
||||||
|
// Start continuous listening after connected (if not muted)
|
||||||
|
useEffect(() => {
|
||||||
|
if (connectionStatus === 'connected' && !isInConversation && !isMuted) {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
startContinuousListening();
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [connectionStatus, isInConversation, isMuted, startContinuousListening]);
|
||||||
|
|
||||||
|
// Interrupt AI when tapping during speech
|
||||||
|
const handleInterrupt = useCallback(() => {
|
||||||
|
if (state.isSpeaking) {
|
||||||
|
interrupt();
|
||||||
|
}
|
||||||
|
}, [state.isSpeaking, interrupt]);
|
||||||
|
|
||||||
|
// Main button: Start/End conversation
|
||||||
|
const handleMainButtonPress = useCallback(() => {
|
||||||
|
handleToggleConversation();
|
||||||
|
}, [handleToggleConversation]);
|
||||||
|
|
||||||
|
// For legacy VoiceButton compatibility
|
||||||
|
const handlePressIn = useCallback(() => {
|
||||||
|
// Main button now only starts/stops conversation
|
||||||
|
handleMainButtonPress();
|
||||||
|
}, [handleMainButtonPress]);
|
||||||
|
|
||||||
|
const handlePressOut = useCallback(() => {
|
||||||
|
// No longer needed for toggle mode
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleClearHistory = useCallback(() => {
|
||||||
|
Alert.alert(
|
||||||
|
'Clear History',
|
||||||
|
'Are you sure you want to clear the conversation history?',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{
|
||||||
|
text: 'Clear',
|
||||||
|
style: 'destructive',
|
||||||
|
onPress: () => setMessages([]),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!isInitialized) {
|
||||||
|
return (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<Text style={styles.loadingText}>Initializing...</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<Text>Open up App.tsx to start working on your app!</Text>
|
<StatusBar barStyle="dark-content" backgroundColor={COLORS.white} />
|
||||||
<StatusBar style="auto" />
|
<SafeAreaView style={styles.safeArea}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.title}>Wellnuo</Text>
|
||||||
|
<Text style={styles.subtitle}>Your AI Health Assistant</Text>
|
||||||
|
{messages.length > 0 && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.clearButton}
|
||||||
|
onPress={handleClearHistory}
|
||||||
|
>
|
||||||
|
<Ionicons name="trash-outline" size={18} color={COLORS.textLight} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Transcript View */}
|
||||||
|
<View style={styles.transcriptContainer}>
|
||||||
|
<TranscriptView
|
||||||
|
messages={messages}
|
||||||
|
currentTranscript={currentTranscript || assistantText}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Voice Controls */}
|
||||||
|
<View style={styles.controlsContainer}>
|
||||||
|
{/* Permission Denied Message */}
|
||||||
|
{permissionStatus === 'denied' && (
|
||||||
|
<View style={styles.permissionDenied}>
|
||||||
|
<Ionicons name="mic-off" size={48} color={COLORS.error} />
|
||||||
|
<Text style={styles.permissionTitle}>Microphone Access Required</Text>
|
||||||
|
<Text style={styles.permissionText}>
|
||||||
|
To use voice chat, please enable microphone access in Settings.
|
||||||
|
</Text>
|
||||||
|
<TouchableOpacity style={styles.settingsButton} onPress={openSettings}>
|
||||||
|
<Text style={styles.settingsButtonText}>Open Settings</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{permissionStatus !== 'denied' && (
|
||||||
|
<>
|
||||||
|
<StatusIndicator
|
||||||
|
connectionStatus={connectionStatus}
|
||||||
|
isListening={state.isListening && !isMuted}
|
||||||
|
isSpeaking={state.isSpeaking}
|
||||||
|
isProcessing={state.isProcessing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.buttonsRow}>
|
||||||
|
{/* Main Call Button - Start/End Call */}
|
||||||
|
<VoiceButton
|
||||||
|
isListening={(state.isListening || isInConversation) && !isMuted}
|
||||||
|
isSpeaking={state.isSpeaking}
|
||||||
|
isProcessing={state.isProcessing}
|
||||||
|
isInCall={isInConversation || connectionStatus === 'connected'}
|
||||||
|
disabled={!apiKey}
|
||||||
|
onPressIn={handlePressIn}
|
||||||
|
onPressOut={handlePressOut}
|
||||||
|
style={styles.voiceButton}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Mute Button - only during call */}
|
||||||
|
{(isInConversation || connectionStatus === 'connected') && (
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.muteButton, isMuted && styles.muteButtonActive]}
|
||||||
|
onPress={handleToggleMute}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={isMuted ? 'mic-off' : 'mic'}
|
||||||
|
size={20}
|
||||||
|
color={isMuted ? COLORS.white : COLORS.textDark}
|
||||||
|
/>
|
||||||
|
<Text style={[styles.muteButtonText, isMuted && styles.muteButtonTextActive]}>
|
||||||
|
{isMuted ? 'Unmute' : 'Mute'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{state.error && (
|
||||||
|
<Text style={styles.errorText}>{state.error}</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.hint}>
|
||||||
|
{connectionStatus !== 'connected'
|
||||||
|
? 'Tap to call Julia'
|
||||||
|
: isInConversation
|
||||||
|
? 'Tap again to end call'
|
||||||
|
: ''}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</SafeAreaView>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -13,8 +297,142 @@ export default function App() {
|
|||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: '#fff',
|
backgroundColor: COLORS.background,
|
||||||
|
},
|
||||||
|
safeArea: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
backgroundColor: COLORS.background,
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
fontSize: 18,
|
||||||
|
color: COLORS.textDark,
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
paddingTop: 16,
|
||||||
|
paddingBottom: 16,
|
||||||
|
backgroundColor: COLORS.white,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: COLORS.primary,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: 14,
|
||||||
|
color: COLORS.textLight,
|
||||||
|
marginTop: 4,
|
||||||
|
},
|
||||||
|
clearButton: {
|
||||||
|
position: 'absolute',
|
||||||
|
right: 20,
|
||||||
|
top: 20,
|
||||||
|
padding: 8,
|
||||||
|
},
|
||||||
|
transcriptContainer: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: COLORS.white,
|
||||||
|
marginHorizontal: 16,
|
||||||
|
marginVertical: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
overflow: 'hidden',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 2 },
|
||||||
|
shadowOpacity: 0.05,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 2,
|
||||||
|
},
|
||||||
|
controlsContainer: {
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 20,
|
||||||
|
backgroundColor: COLORS.white,
|
||||||
|
alignItems: 'center',
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: '#e5e7eb',
|
||||||
|
},
|
||||||
|
buttonsRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
},
|
},
|
||||||
|
voiceButton: {
|
||||||
|
marginVertical: 8,
|
||||||
|
},
|
||||||
|
muteButton: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
paddingVertical: 10,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: COLORS.background,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
marginTop: 12,
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
muteButtonActive: {
|
||||||
|
backgroundColor: COLORS.error,
|
||||||
|
borderColor: COLORS.error,
|
||||||
|
},
|
||||||
|
muteButtonText: {
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: '500',
|
||||||
|
color: COLORS.textDark,
|
||||||
|
},
|
||||||
|
muteButtonTextActive: {
|
||||||
|
color: COLORS.white,
|
||||||
|
},
|
||||||
|
errorText: {
|
||||||
|
color: COLORS.error,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 8,
|
||||||
|
},
|
||||||
|
hint: {
|
||||||
|
color: COLORS.textLight,
|
||||||
|
fontSize: 13,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginTop: 12,
|
||||||
|
},
|
||||||
|
permissionDenied: {
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingVertical: 20,
|
||||||
|
paddingHorizontal: 16,
|
||||||
|
},
|
||||||
|
permissionTitle: {
|
||||||
|
color: COLORS.textDark,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginTop: 16,
|
||||||
|
marginBottom: 8,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
permissionText: {
|
||||||
|
color: COLORS.textLight,
|
||||||
|
fontSize: 14,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: 20,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
settingsButton: {
|
||||||
|
backgroundColor: COLORS.primary,
|
||||||
|
paddingHorizontal: 24,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: 8,
|
||||||
|
},
|
||||||
|
settingsButtonText: {
|
||||||
|
color: COLORS.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
35
app.json
35
app.json
@ -10,21 +10,46 @@
|
|||||||
"splash": {
|
"splash": {
|
||||||
"image": "./assets/splash-icon.png",
|
"image": "./assets/splash-icon.png",
|
||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#4A90A4"
|
||||||
},
|
},
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true
|
"supportsTablet": true,
|
||||||
|
"bundleIdentifier": "com.kosyakorel1.wellnuo",
|
||||||
|
"infoPlist": {
|
||||||
|
"NSMicrophoneUsageDescription": "Wellnuo needs microphone access to enable voice conversations with your AI health assistant.",
|
||||||
|
"UIBackgroundModes": [
|
||||||
|
"audio"
|
||||||
|
],
|
||||||
|
"ITSAppUsesNonExemptEncryption": false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/adaptive-icon.png",
|
"foregroundImage": "./assets/adaptive-icon.png",
|
||||||
"backgroundColor": "#ffffff"
|
"backgroundColor": "#4A90A4"
|
||||||
},
|
},
|
||||||
"edgeToEdgeEnabled": true,
|
"permissions": [
|
||||||
"predictiveBackGestureEnabled": false
|
"android.permission.RECORD_AUDIO",
|
||||||
|
"android.permission.MODIFY_AUDIO_SETTINGS"
|
||||||
|
],
|
||||||
|
"package": "com.wellnuo.app"
|
||||||
},
|
},
|
||||||
"web": {
|
"web": {
|
||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
[
|
||||||
|
"expo-av",
|
||||||
|
{
|
||||||
|
"microphonePermission": "Wellnuo needs microphone access to enable voice conversations with your AI health assistant."
|
||||||
}
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"extra": {
|
||||||
|
"eas": {
|
||||||
|
"projectId": "c9e68e27-5713-4b6d-a313-cb8e7a8866ee"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"owner": "kosyakorel1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
14
babel.config.js
Normal file
14
babel.config.js
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
module.exports = function (api) {
|
||||||
|
api.cache(true);
|
||||||
|
return {
|
||||||
|
presets: ['babel-preset-expo'],
|
||||||
|
plugins: [
|
||||||
|
['module:react-native-dotenv', {
|
||||||
|
moduleName: '@env',
|
||||||
|
path: '.env',
|
||||||
|
safe: false,
|
||||||
|
allowUndefined: true,
|
||||||
|
}],
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
22
backend/deploy.sh
Executable file
22
backend/deploy.sh
Executable file
@ -0,0 +1,22 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Deploy WellNuo backend to production
|
||||||
|
|
||||||
|
SERVER="root@91.98.205.156"
|
||||||
|
REMOTE_PATH="/var/www/wellnuo"
|
||||||
|
LOCAL_PATH="$(dirname "$0")"
|
||||||
|
|
||||||
|
echo "🚀 Deploying WellNuo backend..."
|
||||||
|
|
||||||
|
# Sync files
|
||||||
|
rsync -avz --delete \
|
||||||
|
--exclude 'node_modules' \
|
||||||
|
--exclude '.DS_Store' \
|
||||||
|
--exclude 'deploy.sh' \
|
||||||
|
-e "ssh" \
|
||||||
|
"$LOCAL_PATH/" "$SERVER:$REMOTE_PATH/"
|
||||||
|
|
||||||
|
echo "📦 Restarting PM2..."
|
||||||
|
ssh $SERVER "pm2 restart wellnuo"
|
||||||
|
|
||||||
|
echo "✅ Deploy complete!"
|
||||||
|
echo "🌐 https://wellnuo.smartlaunchhub.com"
|
||||||
767
backend/server.js
Normal file
767
backend/server.js
Normal file
@ -0,0 +1,767 @@
|
|||||||
|
const http = require("http");
|
||||||
|
|
||||||
|
// WellNuo Mock API - Based on REAL Wellplug sensor capabilities
|
||||||
|
// Sensors: Radar motion/presence, CO2, Smell (CO, VOC, VSC), Humidity, Light, Temperature, Pressure
|
||||||
|
|
||||||
|
const patientData = {
|
||||||
|
patient: {
|
||||||
|
id: "dad",
|
||||||
|
name: "John Smith",
|
||||||
|
age: 78,
|
||||||
|
relationship: "Father",
|
||||||
|
photo: "https://randomuser.me/api/portraits/men/75.jpg",
|
||||||
|
address: "123 Oak Street, San Francisco, CA",
|
||||||
|
timezone: "America/Los_Angeles",
|
||||||
|
emergency_contact: {
|
||||||
|
name: "Sarah Smith",
|
||||||
|
phone: "+1 (555) 123-4567",
|
||||||
|
relationship: "Daughter"
|
||||||
|
},
|
||||||
|
secondary_contact: {
|
||||||
|
name: "Michael Smith",
|
||||||
|
phone: "+1 (555) 987-6543",
|
||||||
|
relationship: "Son"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Current real-time status from sensors
|
||||||
|
current_status: {
|
||||||
|
location: "Living Room",
|
||||||
|
presence_detected: true,
|
||||||
|
is_moving: false,
|
||||||
|
last_movement: "2025-12-01T10:28:00Z",
|
||||||
|
estimated_activity: "resting", // inferred from motion patterns
|
||||||
|
time_in_current_room_minutes: 45
|
||||||
|
},
|
||||||
|
|
||||||
|
// Environment data from Wellplug sensors
|
||||||
|
environment: {
|
||||||
|
living_room: {
|
||||||
|
temperature_c: 22.2,
|
||||||
|
temperature_f: 72,
|
||||||
|
humidity_percent: 45,
|
||||||
|
co2_ppm: 650,
|
||||||
|
voc_index: 85, // Volatile Organic Compounds (0-500 scale)
|
||||||
|
co_detected: false,
|
||||||
|
pressure_hpa: 1013,
|
||||||
|
light_lux: 320,
|
||||||
|
air_quality_status: "good",
|
||||||
|
presence: true,
|
||||||
|
motion_level: "low"
|
||||||
|
},
|
||||||
|
bedroom: {
|
||||||
|
temperature_c: 20.5,
|
||||||
|
temperature_f: 69,
|
||||||
|
humidity_percent: 48,
|
||||||
|
co2_ppm: 520,
|
||||||
|
voc_index: 45,
|
||||||
|
co_detected: false,
|
||||||
|
pressure_hpa: 1013,
|
||||||
|
light_lux: 15,
|
||||||
|
air_quality_status: "excellent",
|
||||||
|
presence: false,
|
||||||
|
motion_level: "none"
|
||||||
|
},
|
||||||
|
kitchen: {
|
||||||
|
temperature_c: 23.8,
|
||||||
|
temperature_f: 75,
|
||||||
|
humidity_percent: 52,
|
||||||
|
co2_ppm: 580,
|
||||||
|
voc_index: 120,
|
||||||
|
co_detected: false,
|
||||||
|
pressure_hpa: 1013,
|
||||||
|
light_lux: 450,
|
||||||
|
air_quality_status: "good",
|
||||||
|
presence: false,
|
||||||
|
motion_level: "none"
|
||||||
|
},
|
||||||
|
bathroom: {
|
||||||
|
temperature_c: 21.0,
|
||||||
|
temperature_f: 70,
|
||||||
|
humidity_percent: 58,
|
||||||
|
co2_ppm: 480,
|
||||||
|
voc_index: 65,
|
||||||
|
co_detected: false,
|
||||||
|
pressure_hpa: 1013,
|
||||||
|
light_lux: 0,
|
||||||
|
air_quality_status: "good",
|
||||||
|
presence: false,
|
||||||
|
motion_level: "none"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Environment history (hourly readings)
|
||||||
|
environment_history: {
|
||||||
|
today: [
|
||||||
|
{ time: "00:00", room: "bedroom", temp_f: 68, humidity: 50, co2: 480, presence: true, motion: "minimal" },
|
||||||
|
{ time: "01:00", room: "bedroom", temp_f: 68, humidity: 51, co2: 520, presence: true, motion: "none" },
|
||||||
|
{ time: "02:00", room: "bedroom", temp_f: 67, humidity: 52, co2: 540, presence: true, motion: "none" },
|
||||||
|
{ time: "03:00", room: "bedroom", temp_f: 67, humidity: 52, co2: 560, presence: true, motion: "minimal" },
|
||||||
|
{ time: "03:15", room: "bathroom", temp_f: 68, humidity: 55, co2: 450, presence: true, motion: "detected" },
|
||||||
|
{ time: "03:20", room: "bedroom", temp_f: 67, humidity: 52, co2: 550, presence: true, motion: "minimal" },
|
||||||
|
{ time: "04:00", room: "bedroom", temp_f: 66, humidity: 53, co2: 580, presence: true, motion: "none" },
|
||||||
|
{ time: "05:00", room: "bedroom", temp_f: 66, humidity: 52, co2: 590, presence: true, motion: "none" },
|
||||||
|
{ time: "05:45", room: "bedroom", temp_f: 67, humidity: 51, co2: 600, presence: true, motion: "active" },
|
||||||
|
{ time: "06:00", room: "bathroom", temp_f: 68, humidity: 65, co2: 480, presence: true, motion: "active" },
|
||||||
|
{ time: "06:15", room: "bedroom", temp_f: 68, humidity: 50, co2: 550, presence: true, motion: "active" },
|
||||||
|
{ time: "07:00", room: "bathroom", temp_f: 70, humidity: 75, co2: 520, presence: true, motion: "active" },
|
||||||
|
{ time: "07:30", room: "kitchen", temp_f: 70, humidity: 48, co2: 580, presence: true, motion: "active" },
|
||||||
|
{ time: "08:00", room: "kitchen", temp_f: 72, humidity: 50, co2: 650, voc: 180, presence: true, motion: "active", note: "cooking detected" },
|
||||||
|
{ time: "08:30", room: "kitchen", temp_f: 74, humidity: 52, co2: 720, voc: 220, presence: true, motion: "moderate" },
|
||||||
|
{ time: "09:00", room: "kitchen", temp_f: 73, humidity: 50, co2: 680, presence: true, motion: "low" },
|
||||||
|
{ time: "09:30", room: "living_room", temp_f: 71, humidity: 46, co2: 620, presence: true, motion: "low" },
|
||||||
|
{ time: "10:00", room: "living_room", temp_f: 72, humidity: 45, co2: 640, presence: true, motion: "minimal" },
|
||||||
|
{ time: "10:30", room: "living_room", temp_f: 72, humidity: 45, co2: 650, presence: true, motion: "low" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Sleep analysis (derived from motion/presence sensors)
|
||||||
|
sleep_analysis: {
|
||||||
|
last_night: {
|
||||||
|
bed_time_detected: "2025-11-30T22:18:00Z",
|
||||||
|
wake_time_detected: "2025-12-01T05:45:00Z",
|
||||||
|
total_hours: 7.45,
|
||||||
|
quality_score: 78, // based on movement patterns
|
||||||
|
night_movements: 3,
|
||||||
|
bathroom_visits: 1,
|
||||||
|
bathroom_visit_times: ["03:15"],
|
||||||
|
restlessness_periods: [
|
||||||
|
{ start: "01:30", end: "01:45", duration_minutes: 15 },
|
||||||
|
{ start: "04:20", end: "04:35", duration_minutes: 15 }
|
||||||
|
],
|
||||||
|
time_to_fall_asleep_minutes: 18,
|
||||||
|
bedroom_co2_peak: 600,
|
||||||
|
bedroom_temp_avg_f: 67
|
||||||
|
},
|
||||||
|
weekly_average: {
|
||||||
|
avg_sleep_hours: 7.2,
|
||||||
|
avg_bathroom_visits: 1.3,
|
||||||
|
avg_quality_score: 75,
|
||||||
|
trend: "stable"
|
||||||
|
},
|
||||||
|
patterns: {
|
||||||
|
typical_bedtime: "22:00-22:30",
|
||||||
|
typical_waketime: "05:30-06:00",
|
||||||
|
weekend_variation: "+45 minutes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Activity patterns (derived from motion sensors across rooms)
|
||||||
|
activity_patterns: {
|
||||||
|
today: {
|
||||||
|
total_active_minutes: 185,
|
||||||
|
sedentary_minutes: 240,
|
||||||
|
rooms_visited: ["Bedroom", "Bathroom", "Kitchen", "Living Room"],
|
||||||
|
room_time_distribution: {
|
||||||
|
bedroom: { minutes: 420, percent: 58 },
|
||||||
|
living_room: { minutes: 180, percent: 25 },
|
||||||
|
kitchen: { minutes: 85, percent: 12 },
|
||||||
|
bathroom: { minutes: 35, percent: 5 }
|
||||||
|
},
|
||||||
|
peak_activity_periods: ["07:00-08:30", "09:30-10:00"],
|
||||||
|
longest_sedentary_period_minutes: 95
|
||||||
|
},
|
||||||
|
weekly_comparison: {
|
||||||
|
avg_active_minutes: 195,
|
||||||
|
trend: "stable",
|
||||||
|
most_active_day: "Saturday",
|
||||||
|
least_active_day: "Sunday"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Real-time events timeline (what sensors actually detect)
|
||||||
|
recent_events: [
|
||||||
|
// December 1st - Today (detailed sensor-based events)
|
||||||
|
{ time: "2025-12-01T10:45:00Z", type: "presence", event: "Continued presence in living room", room: "Living Room", motion_level: "low" },
|
||||||
|
{ time: "2025-12-01T10:30:00Z", type: "activity", event: "Settled in living room - minimal movement detected", room: "Living Room", duration_minutes: 45 },
|
||||||
|
{ time: "2025-12-01T10:15:00Z", type: "environment", event: "Kitchen VOC levels returning to normal after cooking", room: "Kitchen", voc_index: 95 },
|
||||||
|
{ time: "2025-12-01T10:00:00Z", type: "movement", event: "Moved from kitchen to living room", from_room: "Kitchen", to_room: "Living Room" },
|
||||||
|
{ time: "2025-12-01T09:45:00Z", type: "activity", event: "Active movement in kitchen - likely cleaning up", room: "Kitchen", motion_level: "moderate", duration_minutes: 15 },
|
||||||
|
{ time: "2025-12-01T09:30:00Z", type: "environment", event: "Kitchen temperature stabilizing after cooking", room: "Kitchen", temp_f: 73, co2: 680 },
|
||||||
|
{ time: "2025-12-01T09:00:00Z", type: "activity", event: "Reduced movement in kitchen - likely eating", room: "Kitchen", motion_level: "low", duration_minutes: 30 },
|
||||||
|
{ time: "2025-12-01T08:30:00Z", type: "environment", event: "Elevated VOC and temperature - cooking activity detected", room: "Kitchen", temp_f: 74, voc_index: 220, co2: 720 },
|
||||||
|
{ time: "2025-12-01T08:00:00Z", type: "activity", event: "Active movement in kitchen - cooking detected", room: "Kitchen", motion_level: "active", duration_minutes: 30 },
|
||||||
|
{ time: "2025-12-01T07:45:00Z", type: "movement", event: "Moved from bathroom to kitchen", from_room: "Bathroom", to_room: "Kitchen" },
|
||||||
|
{ time: "2025-12-01T07:30:00Z", type: "environment", event: "Bathroom humidity spike - shower detected", room: "Bathroom", humidity: 78 },
|
||||||
|
{ time: "2025-12-01T07:00:00Z", type: "activity", event: "Extended presence in bathroom - morning routine", room: "Bathroom", duration_minutes: 30 },
|
||||||
|
{ time: "2025-12-01T06:45:00Z", type: "movement", event: "Moved from bedroom to bathroom", from_room: "Bedroom", to_room: "Bathroom" },
|
||||||
|
{ time: "2025-12-01T06:15:00Z", type: "activity", event: "Active movement in bedroom - getting ready", room: "Bedroom", motion_level: "active", duration_minutes: 30 },
|
||||||
|
{ time: "2025-12-01T06:00:00Z", type: "environment", event: "Bedroom light level increased - blinds opened", room: "Bedroom", light_lux: 180 },
|
||||||
|
{ time: "2025-12-01T05:45:00Z", type: "wake", event: "Wake-up detected - active movement began", room: "Bedroom", confidence: "high" },
|
||||||
|
{ time: "2025-12-01T05:30:00Z", type: "sleep", event: "Pre-wake restlessness detected", room: "Bedroom", motion_level: "minimal" },
|
||||||
|
{ time: "2025-12-01T03:20:00Z", type: "movement", event: "Returned to bedroom from bathroom", from_room: "Bathroom", to_room: "Bedroom" },
|
||||||
|
{ time: "2025-12-01T03:15:00Z", type: "bathroom_visit", event: "Nighttime bathroom visit detected", room: "Bathroom", duration_minutes: 5 },
|
||||||
|
{ time: "2025-12-01T03:12:00Z", type: "movement", event: "Nighttime movement - went to bathroom", from_room: "Bedroom", to_room: "Bathroom" },
|
||||||
|
{ time: "2025-12-01T01:30:00Z", type: "sleep", event: "Restlessness detected during sleep", room: "Bedroom", duration_minutes: 15 },
|
||||||
|
|
||||||
|
// November 30th - Yesterday
|
||||||
|
{ time: "2025-11-30T22:18:00Z", type: "sleep", event: "Sleep onset detected - minimal movement began", room: "Bedroom", confidence: "high" },
|
||||||
|
{ time: "2025-11-30T22:00:00Z", type: "environment", event: "Bedroom lights turned off", room: "Bedroom", light_lux: 0 },
|
||||||
|
{ time: "2025-11-30T21:45:00Z", type: "movement", event: "Moved from living room to bedroom", from_room: "Living Room", to_room: "Bedroom" },
|
||||||
|
{ time: "2025-11-30T21:30:00Z", type: "activity", event: "Low activity in living room - likely watching TV", room: "Living Room", motion_level: "minimal", duration_minutes: 90 },
|
||||||
|
{ time: "2025-11-30T20:00:00Z", type: "movement", event: "Returned to living room from kitchen", from_room: "Kitchen", to_room: "Living Room" },
|
||||||
|
{ time: "2025-11-30T19:30:00Z", type: "activity", event: "Active in kitchen - dinner preparation and eating", room: "Kitchen", duration_minutes: 45 },
|
||||||
|
{ time: "2025-11-30T19:00:00Z", type: "environment", event: "Kitchen VOC elevated - cooking detected", room: "Kitchen", voc_index: 195, temp_f: 76 },
|
||||||
|
{ time: "2025-11-30T18:30:00Z", type: "movement", event: "Moved to kitchen from living room", from_room: "Living Room", to_room: "Kitchen" },
|
||||||
|
{ time: "2025-11-30T17:00:00Z", type: "activity", event: "Extended sedentary period in living room", room: "Living Room", duration_minutes: 120, motion_level: "minimal" },
|
||||||
|
{ time: "2025-11-30T16:30:00Z", type: "activity", event: "Afternoon rest detected - very low movement", room: "Bedroom", duration_minutes: 45, motion_level: "none" },
|
||||||
|
{ time: "2025-11-30T16:00:00Z", type: "movement", event: "Moved to bedroom", from_room: "Living Room", to_room: "Bedroom" },
|
||||||
|
{ time: "2025-11-30T15:00:00Z", type: "activity", event: "Low activity in living room", room: "Living Room", duration_minutes: 60, motion_level: "low" },
|
||||||
|
{ time: "2025-11-30T14:30:00Z", type: "bathroom_visit", event: "Bathroom visit detected", room: "Bathroom", duration_minutes: 8 },
|
||||||
|
{ time: "2025-11-30T13:00:00Z", type: "activity", event: "Moderate activity in kitchen - lunch", room: "Kitchen", duration_minutes: 35 },
|
||||||
|
{ time: "2025-11-30T12:00:00Z", type: "activity", event: "Low movement in living room", room: "Living Room", duration_minutes: 60 },
|
||||||
|
{ time: "2025-11-30T10:30:00Z", type: "movement", event: "Moving between rooms - general activity", rooms: ["Kitchen", "Living Room", "Bathroom"] },
|
||||||
|
{ time: "2025-11-30T09:00:00Z", type: "activity", event: "Morning routine completed - active movement", room: "Kitchen", duration_minutes: 45 },
|
||||||
|
{ time: "2025-11-30T07:00:00Z", type: "environment", event: "Bathroom humidity spike - shower", room: "Bathroom", humidity: 82 },
|
||||||
|
{ time: "2025-11-30T06:00:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom", confidence: "high" },
|
||||||
|
|
||||||
|
// November 29th
|
||||||
|
{ time: "2025-11-29T22:30:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-29T19:00:00Z", type: "activity", event: "Extended presence with occasional movement - possible visitor", room: "Living Room", duration_minutes: 90, note: "unusual pattern - social activity suspected" },
|
||||||
|
{ time: "2025-11-29T15:00:00Z", type: "activity", event: "Moderate activity period", room: "Living Room", duration_minutes: 45 },
|
||||||
|
{ time: "2025-11-29T12:00:00Z", type: "activity", event: "Active in kitchen - meal preparation", room: "Kitchen", duration_minutes: 40 },
|
||||||
|
{ time: "2025-11-29T06:15:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-29T02:45:00Z", type: "bathroom_visit", event: "Nighttime bathroom visit", room: "Bathroom", duration_minutes: 6 },
|
||||||
|
|
||||||
|
// November 28th - Thanksgiving (unusual patterns)
|
||||||
|
{ time: "2025-11-28T23:00:00Z", type: "sleep", event: "Late sleep onset - unusual", room: "Bedroom", note: "2 hours later than typical" },
|
||||||
|
{ time: "2025-11-28T18:00:00Z", type: "activity", event: "High activity in kitchen - extended cooking", room: "Kitchen", duration_minutes: 180, motion_level: "very_active" },
|
||||||
|
{ time: "2025-11-28T17:00:00Z", type: "environment", event: "Kitchen temperature elevated - 82°F", room: "Kitchen", temp_f: 82, alert_generated: true },
|
||||||
|
{ time: "2025-11-28T16:30:00Z", type: "environment", event: "High VOC levels in kitchen", room: "Kitchen", voc_index: 280, note: "extended cooking" },
|
||||||
|
{ time: "2025-11-28T14:00:00Z", type: "activity", event: "Multiple people presence pattern detected", room: "Living Room", note: "unusual movement patterns - visitors likely" },
|
||||||
|
{ time: "2025-11-28T09:00:00Z", type: "activity", event: "Earlier than usual kitchen activity", room: "Kitchen", note: "holiday preparation" },
|
||||||
|
|
||||||
|
// November 27th
|
||||||
|
{ time: "2025-11-27T22:15:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-27T16:00:00Z", type: "activity", event: "Extended sedentary period", room: "Living Room", duration_minutes: 150 },
|
||||||
|
{ time: "2025-11-27T10:00:00Z", type: "environment", event: "Low CO2 - windows likely opened", room: "Living Room", co2: 380 },
|
||||||
|
{ time: "2025-11-27T06:30:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom" },
|
||||||
|
|
||||||
|
// November 26th
|
||||||
|
{ time: "2025-11-26T22:00:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-26T18:00:00Z", type: "activity", event: "Extended low movement period - likely watching TV", room: "Living Room", duration_minutes: 180 },
|
||||||
|
{ time: "2025-11-26T14:00:00Z", type: "activity", event: "Unusual activity pattern - movement to garage area", note: "outdoor/garage activity suspected" },
|
||||||
|
{ time: "2025-11-26T06:00:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom" },
|
||||||
|
|
||||||
|
// November 25th
|
||||||
|
{ time: "2025-11-25T22:30:00Z", type: "sleep", event: "Late sleep onset", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-25T10:00:00Z", type: "environment", event: "Low humidity alert - 25%", room: "Living Room", humidity: 25, alert_generated: true },
|
||||||
|
{ time: "2025-11-25T05:30:00Z", type: "wake", event: "Early wake-up detected", room: "Bedroom", note: "1 hour earlier than typical" },
|
||||||
|
{ time: "2025-11-25T04:00:00Z", type: "bathroom_visit", event: "Nighttime bathroom visit", room: "Bathroom" },
|
||||||
|
{ time: "2025-11-25T02:30:00Z", type: "bathroom_visit", event: "Nighttime bathroom visit", room: "Bathroom", note: "second visit - unusual" },
|
||||||
|
|
||||||
|
// November 24th
|
||||||
|
{ time: "2025-11-24T22:00:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-24T15:00:00Z", type: "activity", event: "No presence detected for 2 hours", note: "likely out of home" },
|
||||||
|
{ time: "2025-11-24T11:00:00Z", type: "environment", event: "Extended high VOC in kitchen", room: "Kitchen", voc_index: 245, duration_minutes: 90, note: "baking activity" },
|
||||||
|
{ time: "2025-11-24T06:00:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom" },
|
||||||
|
|
||||||
|
// November 23rd
|
||||||
|
{ time: "2025-11-23T22:15:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-23T03:30:00Z", type: "bathroom_visit", event: "Nighttime bathroom visit", room: "Bathroom" },
|
||||||
|
|
||||||
|
// November 22nd
|
||||||
|
{ time: "2025-11-22T23:50:00Z", type: "environment", event: "Back door sensor triggered late", note: "door activity detected at unusual hour" },
|
||||||
|
{ time: "2025-11-22T22:00:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
|
||||||
|
// November 21st
|
||||||
|
{ time: "2025-11-21T12:00:00Z", type: "environment", event: "Poor air quality detected - AQI 95", room: "Kitchen", voc_index: 340, co2: 920, alert_generated: true, note: "burnt food detected" },
|
||||||
|
{ time: "2025-11-21T06:15:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom" },
|
||||||
|
|
||||||
|
// November 20th
|
||||||
|
{ time: "2025-11-20T22:00:00Z", type: "sleep", event: "Sleep onset detected", room: "Bedroom" },
|
||||||
|
{ time: "2025-11-20T15:00:00Z", type: "activity", event: "No movement for 3 hours - extended rest", room: "Bedroom", alert_generated: true },
|
||||||
|
{ time: "2025-11-20T06:00:00Z", type: "wake", event: "Wake-up detected", room: "Bedroom" },
|
||||||
|
|
||||||
|
// November 15th - fall incident
|
||||||
|
{ time: "2025-11-15T14:35:00Z", type: "movement", event: "Normal movement resumed", room: "Bathroom" },
|
||||||
|
{ time: "2025-11-15T14:22:00Z", type: "fall_detected", event: "Sudden movement pattern change - possible fall", room: "Bathroom", alert_generated: true, severity: "high", confidence: "medium" },
|
||||||
|
{ time: "2025-11-15T14:20:00Z", type: "presence", event: "Presence in bathroom", room: "Bathroom" },
|
||||||
|
|
||||||
|
// November 10th
|
||||||
|
{ time: "2025-11-10T22:40:00Z", type: "movement", event: "Returned inside", room: "Living Room" },
|
||||||
|
{ time: "2025-11-10T22:30:00Z", type: "environment", event: "Unusual - presence lost from all indoor sensors", alert_generated: true, note: "left home perimeter at night" }
|
||||||
|
],
|
||||||
|
|
||||||
|
// Alerts generated by the system
|
||||||
|
alerts: {
|
||||||
|
active: [],
|
||||||
|
recent: [
|
||||||
|
{
|
||||||
|
id: "alert_001",
|
||||||
|
type: "possible_fall",
|
||||||
|
severity: "high",
|
||||||
|
timestamp: "2025-11-15T14:22:00Z",
|
||||||
|
room: "Bathroom",
|
||||||
|
description: "Unusual motion pattern detected - sudden downward movement followed by minimal activity",
|
||||||
|
sensor_data: { motion_change: "rapid_decrease", duration_still: "8 minutes" },
|
||||||
|
resolved: true,
|
||||||
|
resolved_at: "2025-11-15T14:35:00Z",
|
||||||
|
resolution_note: "Movement resumed normally, family confirmed via phone call - minor stumble, no injury"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_002",
|
||||||
|
type: "extended_inactivity",
|
||||||
|
severity: "medium",
|
||||||
|
timestamp: "2025-11-20T15:00:00Z",
|
||||||
|
room: "Bedroom",
|
||||||
|
description: "No movement detected for 3 hours during daytime - unusual pattern",
|
||||||
|
sensor_data: { inactive_duration_minutes: 180, typical_max: 90 },
|
||||||
|
resolved: true,
|
||||||
|
resolved_at: "2025-11-20T16:30:00Z",
|
||||||
|
resolution_note: "Extended afternoon nap - had poor sleep previous night"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_003",
|
||||||
|
type: "air_quality",
|
||||||
|
severity: "medium",
|
||||||
|
timestamp: "2025-11-21T12:00:00Z",
|
||||||
|
room: "Kitchen",
|
||||||
|
description: "Poor air quality detected - high VOC and CO2 levels",
|
||||||
|
sensor_data: { voc_index: 340, co2_ppm: 920, normal_voc: 100, normal_co2: 600 },
|
||||||
|
resolved: true,
|
||||||
|
resolved_at: "2025-11-21T13:30:00Z",
|
||||||
|
resolution_note: "Burnt toast - window opened, levels returned to normal"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_004",
|
||||||
|
type: "temperature_anomaly",
|
||||||
|
severity: "medium",
|
||||||
|
timestamp: "2025-11-28T17:00:00Z",
|
||||||
|
room: "Kitchen",
|
||||||
|
description: "Room temperature elevated to 82°F - potential stove/oven left on",
|
||||||
|
sensor_data: { temperature_f: 82, typical_max: 76, voc_elevated: true },
|
||||||
|
resolved: true,
|
||||||
|
resolved_at: "2025-11-28T17:30:00Z",
|
||||||
|
resolution_note: "Thanksgiving cooking - oven in use for extended period, normal activity"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_005",
|
||||||
|
type: "low_humidity",
|
||||||
|
severity: "low",
|
||||||
|
timestamp: "2025-11-25T10:00:00Z",
|
||||||
|
room: "Living Room",
|
||||||
|
description: "Indoor humidity dropped to 25% - very dry conditions",
|
||||||
|
sensor_data: { humidity_percent: 25, recommended_min: 35 },
|
||||||
|
resolved: true,
|
||||||
|
resolved_at: "2025-11-25T14:00:00Z",
|
||||||
|
resolution_note: "Dry winter day with heating - humidifier recommended"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_006",
|
||||||
|
type: "night_wandering",
|
||||||
|
severity: "high",
|
||||||
|
timestamp: "2025-11-10T22:30:00Z",
|
||||||
|
description: "Presence lost from all indoor sensors at unusual hour",
|
||||||
|
sensor_data: { last_presence_room: "Living Room", duration_absent_minutes: 10 },
|
||||||
|
resolved: true,
|
||||||
|
resolved_at: "2025-11-10T22:40:00Z",
|
||||||
|
resolution_note: "Went outside briefly - thought he heard something. Returned safely."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_007",
|
||||||
|
type: "frequent_bathroom_visits",
|
||||||
|
severity: "low",
|
||||||
|
timestamp: "2025-11-25T04:00:00Z",
|
||||||
|
description: "Multiple nighttime bathroom visits detected (2 in one night)",
|
||||||
|
sensor_data: { visits: 2, typical_max: 1 },
|
||||||
|
resolved: true,
|
||||||
|
resolution_note: "Drank extra fluids before bed. Monitoring for pattern."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_008",
|
||||||
|
type: "unusual_schedule",
|
||||||
|
severity: "low",
|
||||||
|
timestamp: "2025-11-28T23:00:00Z",
|
||||||
|
room: "Bedroom",
|
||||||
|
description: "Sleep onset 2 hours later than typical",
|
||||||
|
sensor_data: { actual_time: "23:00", typical_time: "22:00" },
|
||||||
|
resolved: true,
|
||||||
|
resolution_note: "Thanksgiving - family gathering, expected deviation"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "alert_009",
|
||||||
|
type: "elevated_co2",
|
||||||
|
severity: "low",
|
||||||
|
timestamp: "2025-11-30T02:00:00Z",
|
||||||
|
room: "Bedroom",
|
||||||
|
description: "Bedroom CO2 levels elevated during sleep - 720ppm",
|
||||||
|
sensor_data: { co2_ppm: 720, recommended_max: 600 },
|
||||||
|
resolved: true,
|
||||||
|
resolution_note: "Suggest opening window slightly before bed for better ventilation"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
statistics: {
|
||||||
|
total_this_month: 9,
|
||||||
|
by_severity: { high: 2, medium: 3, low: 4 },
|
||||||
|
by_type: {
|
||||||
|
possible_fall: 1,
|
||||||
|
extended_inactivity: 1,
|
||||||
|
air_quality: 1,
|
||||||
|
temperature_anomaly: 1,
|
||||||
|
night_wandering: 1,
|
||||||
|
frequent_bathroom_visits: 1,
|
||||||
|
unusual_schedule: 1,
|
||||||
|
low_humidity: 1,
|
||||||
|
elevated_co2: 1
|
||||||
|
},
|
||||||
|
average_resolution_time_minutes: 45
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Daily patterns derived from sensor data
|
||||||
|
daily_patterns: {
|
||||||
|
typical_schedule: {
|
||||||
|
wake_time: { average: "06:00", range: "05:30-06:30" },
|
||||||
|
morning_routine: { duration_minutes: 90, rooms: ["Bedroom", "Bathroom", "Kitchen"] },
|
||||||
|
breakfast_time: { average: "08:00", duration_minutes: 45 },
|
||||||
|
active_periods: ["07:00-09:00", "11:00-12:00", "17:00-18:00"],
|
||||||
|
sedentary_periods: ["10:00-11:00", "14:00-16:00", "20:00-22:00"],
|
||||||
|
afternoon_rest: { typical_time: "15:00-16:30", frequency: "daily" },
|
||||||
|
dinner_time: { average: "19:00", duration_minutes: 45 },
|
||||||
|
evening_routine: { start: "21:00", rooms: ["Living Room", "Bathroom", "Bedroom"] },
|
||||||
|
bed_time: { average: "22:15", range: "22:00-22:30" }
|
||||||
|
},
|
||||||
|
bathroom_patterns: {
|
||||||
|
typical_daily_visits: 6,
|
||||||
|
typical_night_visits: 1,
|
||||||
|
average_duration_minutes: 8,
|
||||||
|
shower_detected_days: ["daily"],
|
||||||
|
typical_shower_time: "07:00"
|
||||||
|
},
|
||||||
|
cooking_patterns: {
|
||||||
|
breakfast: { detected: true, typical_time: "08:00", voc_spike: "moderate" },
|
||||||
|
lunch: { detected: true, typical_time: "13:00", voc_spike: "low" },
|
||||||
|
dinner: { detected: true, typical_time: "19:00", voc_spike: "moderate" }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Monthly summaries
|
||||||
|
monthly_summaries: [
|
||||||
|
{
|
||||||
|
month: "November 2025",
|
||||||
|
sleep: {
|
||||||
|
avg_hours: 7.2,
|
||||||
|
avg_quality_score: 76,
|
||||||
|
avg_bathroom_visits_per_night: 1.2,
|
||||||
|
nights_with_poor_sleep: 3
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
avg_active_minutes: 190,
|
||||||
|
avg_sedentary_minutes: 480,
|
||||||
|
most_active_room: "Kitchen",
|
||||||
|
days_with_low_activity: 4
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
avg_indoor_temp_f: 71,
|
||||||
|
avg_humidity: 44,
|
||||||
|
avg_co2: 580,
|
||||||
|
air_quality_alerts: 2
|
||||||
|
},
|
||||||
|
alerts_count: 9,
|
||||||
|
patterns: {
|
||||||
|
schedule_consistency: "good",
|
||||||
|
notable_deviations: ["Thanksgiving day - late sleep, high kitchen activity", "Nov 25 - early wake, multiple bathroom visits"]
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
"Consistent morning routine maintained",
|
||||||
|
"One possible fall incident - resolved with no injury",
|
||||||
|
"Thanksgiving showed expected schedule changes",
|
||||||
|
"Sleep quality generally good with occasional restless nights"
|
||||||
|
],
|
||||||
|
concerns: [
|
||||||
|
"Bathroom fall risk - grab bars recommended",
|
||||||
|
"Occasional high CO2 in bedroom at night",
|
||||||
|
"One day with extended inactivity"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
month: "October 2025",
|
||||||
|
sleep: {
|
||||||
|
avg_hours: 7.0,
|
||||||
|
avg_quality_score: 78,
|
||||||
|
avg_bathroom_visits_per_night: 1.0,
|
||||||
|
nights_with_poor_sleep: 2
|
||||||
|
},
|
||||||
|
activity: {
|
||||||
|
avg_active_minutes: 205,
|
||||||
|
avg_sedentary_minutes: 460,
|
||||||
|
most_active_room: "Kitchen",
|
||||||
|
days_with_low_activity: 2
|
||||||
|
},
|
||||||
|
environment: {
|
||||||
|
avg_indoor_temp_f: 70,
|
||||||
|
avg_humidity: 48,
|
||||||
|
avg_co2: 560,
|
||||||
|
air_quality_alerts: 0
|
||||||
|
},
|
||||||
|
alerts_count: 3,
|
||||||
|
patterns: {
|
||||||
|
schedule_consistency: "excellent",
|
||||||
|
notable_deviations: ["Oct 15-17 - visitors present, different patterns"]
|
||||||
|
},
|
||||||
|
highlights: [
|
||||||
|
"Very consistent daily patterns",
|
||||||
|
"Good activity levels",
|
||||||
|
"No significant environmental issues"
|
||||||
|
],
|
||||||
|
concerns: []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
|
||||||
|
// Wellplug device info
|
||||||
|
devices: {
|
||||||
|
wellplugs: [
|
||||||
|
{ id: "wp_001", location: "Living Room", status: "active", last_reading: "2025-12-01T10:45:00Z", firmware: "2.1.4" },
|
||||||
|
{ id: "wp_002", location: "Bedroom", status: "active", last_reading: "2025-12-01T10:45:00Z", firmware: "2.1.4" },
|
||||||
|
{ id: "wp_003", location: "Kitchen", status: "active", last_reading: "2025-12-01T10:45:00Z", firmware: "2.1.4" },
|
||||||
|
{ id: "wp_004", location: "Bathroom", status: "active", last_reading: "2025-12-01T10:45:00Z", firmware: "2.1.4" }
|
||||||
|
],
|
||||||
|
sensors_per_device: ["radar_motion", "radar_presence", "co2", "voc", "co", "humidity", "temperature", "pressure", "light"],
|
||||||
|
connectivity: "wifi",
|
||||||
|
data_sync_interval_seconds: 60
|
||||||
|
},
|
||||||
|
|
||||||
|
// System prompt for Julia AI
|
||||||
|
system_prompt: `You are Julia, a warm and caring AI assistant for the WellNuo family care system. You help family members stay connected with and understand the wellbeing of their elderly loved ones through ambient sensor data.
|
||||||
|
|
||||||
|
IMPORTANT - Data Sources:
|
||||||
|
WellNuo uses ambient sensors (Wellplug devices) that monitor: motion/presence, CO2, air quality (VOC, CO), humidity, temperature, pressure, and light. There are NO wearables, cameras, or microphones.
|
||||||
|
|
||||||
|
What you CAN discuss (sensor-detected):
|
||||||
|
- Movement patterns and room locations
|
||||||
|
- Sleep timing and quality (from motion patterns)
|
||||||
|
- Bathroom visit frequency and timing
|
||||||
|
- Cooking activity (from VOC/temperature)
|
||||||
|
- Air quality, temperature, humidity
|
||||||
|
- Daily routine consistency
|
||||||
|
- Unusual patterns or deviations
|
||||||
|
|
||||||
|
What you CANNOT know (no sensors for this):
|
||||||
|
- Specific activities (reading, watching TV, eating specific foods)
|
||||||
|
- Health vitals (heart rate, blood pressure, blood oxygen)
|
||||||
|
- Medication intake
|
||||||
|
- Phone calls or social interactions (unless visitor presence inferred)
|
||||||
|
- Emotional state or mood
|
||||||
|
- What they ate or drank
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
- Be warm, conversational, and reassuring
|
||||||
|
- Describe what sensors detected, not assumptions about activities
|
||||||
|
- Say "movement patterns suggest..." not "he was watching TV"
|
||||||
|
- Lead with positive patterns before concerns
|
||||||
|
- Provide context for alerts (most are resolved, minor issues)
|
||||||
|
- If asked about something sensors can't detect, explain kindly: "Our sensors monitor movement and environment, so I can't tell you exactly what he's doing, but I can see he's been active in the living room for the past hour"
|
||||||
|
- Keep responses concise but caring`,
|
||||||
|
|
||||||
|
meta: {
|
||||||
|
api_version: "2.0.0",
|
||||||
|
data_source: "Wellplug ambient sensors",
|
||||||
|
sensors: ["radar_motion", "radar_presence", "co2", "voc", "co", "humidity", "temperature", "pressure", "light"],
|
||||||
|
no_wearables: true,
|
||||||
|
no_cameras: true,
|
||||||
|
no_microphones: true,
|
||||||
|
total_events: 68,
|
||||||
|
total_alerts: 9,
|
||||||
|
data_range: "2025-10-01 to 2025-12-01",
|
||||||
|
last_updated: new Date().toISOString()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Landing page
|
||||||
|
const landingPage = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>WellNuo Mock API</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: -apple-system, BlinkMacSystemFont, sans-serif; max-width: 900px; margin: 50px auto; padding: 20px; background: #f5f5f5; color: #333; }
|
||||||
|
h1 { color: #667eea; }
|
||||||
|
h2 { color: #444; margin-top: 30px; }
|
||||||
|
.endpoint { background: white; padding: 20px; border-radius: 10px; margin: 20px 0; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }
|
||||||
|
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 15px; margin: 20px 0; }
|
||||||
|
.stat { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); color: white; padding: 15px; border-radius: 8px; text-align: center; }
|
||||||
|
.stat-value { font-size: 1.8em; font-weight: bold; }
|
||||||
|
.stat-label { font-size: 0.85em; opacity: 0.9; }
|
||||||
|
.sensor-list { display: flex; flex-wrap: wrap; gap: 8px; margin: 15px 0; }
|
||||||
|
.sensor { background: #e8e8e8; padding: 5px 12px; border-radius: 15px; font-size: 13px; }
|
||||||
|
code { background: #e8e8e8; padding: 3px 8px; border-radius: 4px; font-size: 14px; }
|
||||||
|
.method { color: #22c55e; font-weight: bold; }
|
||||||
|
a { color: #667eea; }
|
||||||
|
pre { background: #1e1e1e; color: #d4d4d4; padding: 15px; border-radius: 8px; overflow-x: auto; }
|
||||||
|
.warning { background: #fef3c7; border-left: 4px solid #f59e0b; padding: 15px; margin: 20px 0; border-radius: 0 8px 8px 0; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>WellNuo Mock API v2.0</h1>
|
||||||
|
<p>Realistic demo API based on <strong>actual Wellplug sensor capabilities</strong>.</p>
|
||||||
|
|
||||||
|
<div class="warning">
|
||||||
|
<strong>⚠️ Sensor-Based Data Only</strong><br>
|
||||||
|
This API reflects what Wellplug sensors can actually detect. No wearables, cameras, or microphones.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>Wellplug Sensors</h2>
|
||||||
|
<div class="sensor-list">
|
||||||
|
<span class="sensor">🎯 Radar Motion</span>
|
||||||
|
<span class="sensor">👤 Presence Detection</span>
|
||||||
|
<span class="sensor">💨 CO2</span>
|
||||||
|
<span class="sensor">🌡️ Temperature</span>
|
||||||
|
<span class="sensor">💧 Humidity</span>
|
||||||
|
<span class="sensor">🔬 VOC (Smells)</span>
|
||||||
|
<span class="sensor">⚠️ CO Detection</span>
|
||||||
|
<span class="sensor">📊 Pressure</span>
|
||||||
|
<span class="sensor">💡 Light Level</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats">
|
||||||
|
<div class="stat"><div class="stat-value">68</div><div class="stat-label">Events</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">9</div><div class="stat-label">Alerts</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">4</div><div class="stat-label">Rooms</div></div>
|
||||||
|
<div class="stat"><div class="stat-value">2</div><div class="stat-label">Months</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3><span class="method">GET</span> /api/patient/context</h3>
|
||||||
|
<p>Returns sensor-based patient context for Julia AI.</p>
|
||||||
|
<p><a href="/api/patient/context">Try it →</a></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What's Detected</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<ul>
|
||||||
|
<li><strong>Movement & Presence</strong> - Room-by-room tracking, activity levels</li>
|
||||||
|
<li><strong>Sleep Patterns</strong> - Bed/wake times, night movements, bathroom visits</li>
|
||||||
|
<li><strong>Daily Routine</strong> - Schedule consistency, deviations</li>
|
||||||
|
<li><strong>Cooking Activity</strong> - VOC/temperature spikes in kitchen</li>
|
||||||
|
<li><strong>Air Quality</strong> - CO2, VOC, humidity alerts</li>
|
||||||
|
<li><strong>Environmental</strong> - Temperature, humidity, pressure per room</li>
|
||||||
|
<li><strong>Anomalies</strong> - Falls, inactivity, night wandering</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h2>What's NOT Detected</h2>
|
||||||
|
<div class="endpoint">
|
||||||
|
<ul style="color: #999;">
|
||||||
|
<li>❌ Heart rate, blood pressure, vitals (no wearables)</li>
|
||||||
|
<li>❌ Medication intake</li>
|
||||||
|
<li>❌ Specific activities (reading, TV, phone calls)</li>
|
||||||
|
<li>❌ What they ate or drank</li>
|
||||||
|
<li>❌ Conversations or social interactions</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="endpoint">
|
||||||
|
<h3>Example</h3>
|
||||||
|
<pre>curl https://wellnuo.smartlaunchhub.com/api/patient/context</pre>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const PORT = process.env.PORT || 5011;
|
||||||
|
const CONVERSATIONS_FILE = path.join(__dirname, "conversations.json");
|
||||||
|
|
||||||
|
// Загрузить существующие разговоры
|
||||||
|
function loadConversations() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(CONVERSATIONS_FILE)) {
|
||||||
|
return JSON.parse(fs.readFileSync(CONVERSATIONS_FILE, "utf8"));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error loading conversations:", error);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранить разговоры
|
||||||
|
function saveConversations(conversations) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(CONVERSATIONS_FILE, JSON.stringify(conversations, null, 2));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving conversations:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
||||||
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
||||||
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
|
||||||
|
|
||||||
|
if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
|
||||||
|
|
||||||
|
const url = req.url;
|
||||||
|
|
||||||
|
if (url === "/" || url === "") {
|
||||||
|
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
||||||
|
res.end(landingPage);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url === "/api/patient/context" || url.match(/^\/api\/patient\/[^\/]+\/context$/)) {
|
||||||
|
const response = JSON.parse(JSON.stringify(patientData));
|
||||||
|
const now = new Date();
|
||||||
|
response.current_status.last_movement = new Date(now - 5 * 60000).toISOString();
|
||||||
|
response.meta.last_updated = now.toISOString();
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify(response, null, 2));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/conversations - сохранение истории разговора по схеме
|
||||||
|
if (url === "/api/conversations" && req.method === "POST") {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", chunk => { body += chunk.toString(); });
|
||||||
|
req.on("end", () => {
|
||||||
|
try {
|
||||||
|
const conversation = JSON.parse(body);
|
||||||
|
|
||||||
|
// Валидация
|
||||||
|
if (!conversation.session_id || !conversation.messages) {
|
||||||
|
res.writeHead(400, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Missing required fields: session_id, messages" }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавить timestamp если нет
|
||||||
|
conversation.saved_at = new Date().toISOString();
|
||||||
|
|
||||||
|
// Загрузить, добавить, сохранить
|
||||||
|
const conversations = loadConversations();
|
||||||
|
conversations.push(conversation);
|
||||||
|
saveConversations(conversations);
|
||||||
|
|
||||||
|
console.log(`Conversation saved: ${conversation.session_id}, ${conversation.messages.length} messages`);
|
||||||
|
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
success: true,
|
||||||
|
session_id: conversation.session_id,
|
||||||
|
message: "Conversation saved successfully"
|
||||||
|
}));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving conversation:", error);
|
||||||
|
res.writeHead(500, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Failed to save conversation" }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /api/conversations - получить все разговоры (для отладки)
|
||||||
|
if (url === "/api/conversations" && req.method === "GET") {
|
||||||
|
const conversations = loadConversations();
|
||||||
|
res.writeHead(200, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({
|
||||||
|
count: conversations.length,
|
||||||
|
conversations: conversations.slice(-10) // последние 10
|
||||||
|
}));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(404, { "Content-Type": "application/json" });
|
||||||
|
res.end(JSON.stringify({ error: "Not found", available_endpoints: ["/api/patient/context", "/api/conversations"] }));
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`WellNuo Mock API v2.0 running on port ${PORT}`));
|
||||||
27
eas.json
Normal file
27
eas.json
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 3.0.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal",
|
||||||
|
"ios": {
|
||||||
|
"simulator": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true,
|
||||||
|
"ios": {
|
||||||
|
"resourceClass": "m-medium"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,8 +1,18 @@
|
|||||||
import { registerRootComponent } from 'expo';
|
import { registerRootComponent } from 'expo';
|
||||||
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
|
function Root() {
|
||||||
|
return (
|
||||||
|
<SafeAreaProvider>
|
||||||
|
<App />
|
||||||
|
</SafeAreaProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
// registerRootComponent calls AppRegistry.registerComponent('main', () => App);
|
||||||
// It also ensures that whether you load the app in Expo Go or in a native build,
|
// It also ensures that whether you load the app in Expo Go or in a native build,
|
||||||
// the environment is set up appropriately
|
// the environment is set up appropriately
|
||||||
registerRootComponent(App);
|
registerRootComponent(Root);
|
||||||
4906
package-lock.json
generated
4906
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
19
package.json
19
package.json
@ -1,21 +1,32 @@
|
|||||||
{
|
{
|
||||||
"name": "wellnuo",
|
"name": "wellnuo",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.ts",
|
"main": "index.tsx",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "expo start",
|
"start": "expo start",
|
||||||
"android": "expo start --android",
|
"android": "expo run:android",
|
||||||
"ios": "expo start --ios",
|
"ios": "expo run:ios",
|
||||||
"web": "expo start --web"
|
"web": "expo start --web"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
|
"eas-cli": "^16.28.0",
|
||||||
"expo": "~54.0.25",
|
"expo": "~54.0.25",
|
||||||
|
"expo-av": "~16.0.7",
|
||||||
|
"expo-dev-client": "~6.0.18",
|
||||||
|
"expo-linear-gradient": "~15.0.7",
|
||||||
"expo-status-bar": "~3.0.8",
|
"expo-status-bar": "~3.0.8",
|
||||||
"react": "19.1.0",
|
"react": "19.1.0",
|
||||||
"react-native": "0.81.5"
|
"react-native": "0.81.5",
|
||||||
|
"react-native-dotenv": "^3.4.11",
|
||||||
|
"react-native-safe-area-context": "^5.6.2",
|
||||||
|
"react-native-webview": "^13.16.0",
|
||||||
|
"zaiclient": "^8.2.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.57.0",
|
||||||
"@types/react": "~19.1.0",
|
"@types/react": "~19.1.0",
|
||||||
|
"babel-preset-expo": "^54.0.7",
|
||||||
"typescript": "~5.9.2"
|
"typescript": "~5.9.2"
|
||||||
},
|
},
|
||||||
"private": true
|
"private": true
|
||||||
|
|||||||
23
playwright.config.js
Normal file
23
playwright.config.js
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
const { defineConfig, devices } = require('@playwright/test');
|
||||||
|
|
||||||
|
module.exports = defineConfig({
|
||||||
|
testDir: './tests',
|
||||||
|
fullyParallel: true,
|
||||||
|
forbidOnly: !!process.env.CI,
|
||||||
|
retries: 0,
|
||||||
|
workers: 1,
|
||||||
|
reporter: 'list',
|
||||||
|
use: {
|
||||||
|
baseURL: 'https://react.eluxnetworks.net',
|
||||||
|
trace: 'on-first-retry',
|
||||||
|
screenshot: 'on',
|
||||||
|
video: 'on-first-retry',
|
||||||
|
},
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
outputDir: 'tests/screenshots/',
|
||||||
|
});
|
||||||
245
src/components/DashboardWebView.tsx
Normal file
245
src/components/DashboardWebView.tsx
Normal file
@ -0,0 +1,245 @@
|
|||||||
|
import React, { useRef, useState, useCallback } from 'react';
|
||||||
|
import { View, StyleSheet, ActivityIndicator, Text, TouchableOpacity } from 'react-native';
|
||||||
|
import { WebView, WebViewNavigation } from 'react-native-webview';
|
||||||
|
|
||||||
|
const API_BASE = 'https://eluxnetworks.net/function/well-api/api';
|
||||||
|
const DASHBOARD_URL = 'https://react.eluxnetworks.net/dashboard';
|
||||||
|
|
||||||
|
// Credentials
|
||||||
|
const CREDENTIALS = {
|
||||||
|
username: 'anandk',
|
||||||
|
password: 'anandk_8',
|
||||||
|
clientId: '001'
|
||||||
|
};
|
||||||
|
|
||||||
|
interface DashboardWebViewProps {
|
||||||
|
onPatientSelect?: (patientId: number, patientName: string) => void;
|
||||||
|
onError?: (error: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DashboardWebView: React.FC<DashboardWebViewProps> = ({
|
||||||
|
onPatientSelect,
|
||||||
|
onError
|
||||||
|
}) => {
|
||||||
|
const webViewRef = useRef<WebView>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// JavaScript to inject for auto-login
|
||||||
|
const getAutoLoginScript = (authToken: string) => `
|
||||||
|
(function() {
|
||||||
|
// Save auth to localStorage
|
||||||
|
localStorage.setItem('auth2', JSON.stringify({
|
||||||
|
username: '${CREDENTIALS.username}',
|
||||||
|
token: '${authToken}',
|
||||||
|
user_id: 43
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Check if we're on login page
|
||||||
|
if (window.location.pathname === '/dashboard' || window.location.pathname === '/') {
|
||||||
|
// Find login form
|
||||||
|
const inputs = document.querySelectorAll('input');
|
||||||
|
const usernameInput = inputs[0];
|
||||||
|
const passwordInput = document.querySelector('input[type="password"]');
|
||||||
|
|
||||||
|
if (usernameInput && passwordInput) {
|
||||||
|
// Fill form
|
||||||
|
usernameInput.value = '${CREDENTIALS.username}';
|
||||||
|
passwordInput.value = '${CREDENTIALS.password}';
|
||||||
|
|
||||||
|
// Trigger React state updates
|
||||||
|
usernameInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
passwordInput.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
|
||||||
|
// Click login button
|
||||||
|
setTimeout(() => {
|
||||||
|
const loginBtn = document.querySelector('button');
|
||||||
|
if (loginBtn) loginBtn.click();
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listen for patient card clicks
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const card = e.target.closest('[data-patient-id]');
|
||||||
|
if (card) {
|
||||||
|
const patientId = card.getAttribute('data-patient-id');
|
||||||
|
const patientName = card.querySelector('.patient-name')?.textContent || '';
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||||
|
type: 'PATIENT_SELECTED',
|
||||||
|
patientId: patientId,
|
||||||
|
patientName: patientName
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
true;
|
||||||
|
})();
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Fetch token on mount
|
||||||
|
const fetchToken = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const nonce = `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
function: 'credentials',
|
||||||
|
clientId: CREDENTIALS.clientId,
|
||||||
|
user_name: CREDENTIALS.username,
|
||||||
|
ps: CREDENTIALS.password,
|
||||||
|
nonce: nonce
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = await fetch(API_BASE, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.access_token) {
|
||||||
|
setToken(data.access_token);
|
||||||
|
return data.access_token;
|
||||||
|
} else {
|
||||||
|
throw new Error('No token received');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch token:', error);
|
||||||
|
onError?.('Failed to authenticate');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [onError]);
|
||||||
|
|
||||||
|
// Handle messages from WebView
|
||||||
|
const handleMessage = useCallback((event: { nativeEvent: { data: string } }) => {
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(event.nativeEvent.data);
|
||||||
|
|
||||||
|
if (message.type === 'PATIENT_SELECTED') {
|
||||||
|
onPatientSelect?.(message.patientId, message.patientName);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('WebView message:', event.nativeEvent.data);
|
||||||
|
}
|
||||||
|
}, [onPatientSelect]);
|
||||||
|
|
||||||
|
// Handle navigation state change
|
||||||
|
const handleNavigationStateChange = useCallback((navState: WebViewNavigation) => {
|
||||||
|
console.log('Navigation:', navState.url);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Inject script when page loads
|
||||||
|
const handleLoadEnd = useCallback(() => {
|
||||||
|
setLoading(false);
|
||||||
|
|
||||||
|
if (token && webViewRef.current) {
|
||||||
|
webViewRef.current.injectJavaScript(getAutoLoginScript(token));
|
||||||
|
}
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
// Refresh handler
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
setLoading(true);
|
||||||
|
webViewRef.current?.reload();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Header */}
|
||||||
|
<View style={styles.header}>
|
||||||
|
<Text style={styles.headerTitle}>WellNuo Dashboard</Text>
|
||||||
|
<TouchableOpacity onPress={handleRefresh} style={styles.refreshButton}>
|
||||||
|
<Text style={styles.refreshText}>Refresh</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* WebView */}
|
||||||
|
<WebView
|
||||||
|
ref={webViewRef}
|
||||||
|
source={{ uri: DASHBOARD_URL }}
|
||||||
|
style={styles.webview}
|
||||||
|
onLoadStart={() => setLoading(true)}
|
||||||
|
onLoadEnd={handleLoadEnd}
|
||||||
|
onNavigationStateChange={handleNavigationStateChange}
|
||||||
|
onMessage={handleMessage}
|
||||||
|
javaScriptEnabled={true}
|
||||||
|
domStorageEnabled={true}
|
||||||
|
startInLoadingState={true}
|
||||||
|
scalesPageToFit={true}
|
||||||
|
allowsBackForwardNavigationGestures={true}
|
||||||
|
renderLoading={() => (
|
||||||
|
<View style={styles.loadingContainer}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
<Text style={styles.loadingText}>Loading dashboard...</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
onError={(syntheticEvent) => {
|
||||||
|
const { nativeEvent } = syntheticEvent;
|
||||||
|
console.error('WebView error:', nativeEvent);
|
||||||
|
onError?.(`WebView error: ${nativeEvent.description}`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Loading overlay */}
|
||||||
|
{loading && (
|
||||||
|
<View style={styles.loadingOverlay}>
|
||||||
|
<ActivityIndicator size="large" color="#3B82F6" />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: 16,
|
||||||
|
backgroundColor: '#3B82F6',
|
||||||
|
},
|
||||||
|
headerTitle: {
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: 'bold',
|
||||||
|
color: '#FFFFFF',
|
||||||
|
},
|
||||||
|
refreshButton: {
|
||||||
|
padding: 8,
|
||||||
|
borderRadius: 8,
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
refreshText: {
|
||||||
|
color: '#FFFFFF',
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
webview: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
loadingContainer: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: '#F3F4F6',
|
||||||
|
},
|
||||||
|
loadingText: {
|
||||||
|
marginTop: 12,
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#6B7280',
|
||||||
|
},
|
||||||
|
loadingOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 60,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.8)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default DashboardWebView;
|
||||||
62
src/components/StatusIndicator.tsx
Normal file
62
src/components/StatusIndicator.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { ConnectionStatus } from '../types';
|
||||||
|
|
||||||
|
interface StatusIndicatorProps {
|
||||||
|
connectionStatus: ConnectionStatus;
|
||||||
|
isListening: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatusIndicator({
|
||||||
|
connectionStatus,
|
||||||
|
isListening,
|
||||||
|
isSpeaking,
|
||||||
|
isProcessing,
|
||||||
|
}: StatusIndicatorProps) {
|
||||||
|
const getStatusText = () => {
|
||||||
|
if (connectionStatus === 'error') return 'Connection Error';
|
||||||
|
if (connectionStatus === 'connecting') return 'Connecting...';
|
||||||
|
if (connectionStatus !== 'connected') return null;
|
||||||
|
if (isSpeaking) return 'Julia is speaking...';
|
||||||
|
if (isProcessing) return 'Thinking...';
|
||||||
|
if (isListening) return 'Listening...';
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStatusColor = () => {
|
||||||
|
if (connectionStatus === 'error') return '#EF4444';
|
||||||
|
if (connectionStatus === 'connecting') return '#F59E0B';
|
||||||
|
if (isListening) return '#10B981';
|
||||||
|
if (isProcessing) return '#F59E0B';
|
||||||
|
if (isSpeaking) return '#8B5CF6';
|
||||||
|
return '#4A90A4';
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusText = getStatusText();
|
||||||
|
|
||||||
|
// Only show when there's something meaningful to display
|
||||||
|
if (!statusText) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.container}>
|
||||||
|
<Text style={[styles.statusText, { color: getStatusColor() }]}>
|
||||||
|
{statusText}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: 8,
|
||||||
|
},
|
||||||
|
statusText: {
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
|
});
|
||||||
110
src/components/TranscriptView.tsx
Normal file
110
src/components/TranscriptView.tsx
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import React, { useRef, useEffect } from 'react';
|
||||||
|
import { View, Text, ScrollView, StyleSheet } from 'react-native';
|
||||||
|
|
||||||
|
interface Message {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TranscriptViewProps {
|
||||||
|
messages: Message[];
|
||||||
|
currentTranscript?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TranscriptView({ messages, currentTranscript }: TranscriptViewProps) {
|
||||||
|
const scrollViewRef = useRef<ScrollView>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
scrollViewRef.current?.scrollToEnd({ animated: true });
|
||||||
|
}, [messages, currentTranscript]);
|
||||||
|
|
||||||
|
if (messages.length === 0 && !currentTranscript) {
|
||||||
|
return (
|
||||||
|
<View style={styles.emptyContainer}>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
Your conversation will appear here
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ScrollView
|
||||||
|
ref={scrollViewRef}
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{messages.map((message, index) => (
|
||||||
|
<View
|
||||||
|
key={index}
|
||||||
|
style={[
|
||||||
|
styles.messageContainer,
|
||||||
|
message.role === 'user' ? styles.userMessage : styles.assistantMessage,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Text style={styles.roleLabel}>
|
||||||
|
{message.role === 'user' ? 'You' : 'Wellnuo'}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.messageText}>{message.content}</Text>
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
{currentTranscript && (
|
||||||
|
<View style={[styles.messageContainer, styles.currentTranscript]}>
|
||||||
|
<Text style={styles.roleLabel}>You (listening...)</Text>
|
||||||
|
<Text style={styles.messageText}>{currentTranscript}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
padding: 16,
|
||||||
|
gap: 12,
|
||||||
|
},
|
||||||
|
emptyContainer: {
|
||||||
|
flex: 1,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: 32,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
fontSize: 16,
|
||||||
|
color: '#9CA3AF',
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
messageContainer: {
|
||||||
|
padding: 12,
|
||||||
|
borderRadius: 12,
|
||||||
|
maxWidth: '85%',
|
||||||
|
},
|
||||||
|
userMessage: {
|
||||||
|
backgroundColor: '#E5E7EB',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
},
|
||||||
|
assistantMessage: {
|
||||||
|
backgroundColor: '#4A90A4',
|
||||||
|
alignSelf: 'flex-start',
|
||||||
|
},
|
||||||
|
currentTranscript: {
|
||||||
|
backgroundColor: '#FEF3C7',
|
||||||
|
alignSelf: 'flex-end',
|
||||||
|
opacity: 0.8,
|
||||||
|
},
|
||||||
|
roleLabel: {
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: '600',
|
||||||
|
marginBottom: 4,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
messageText: {
|
||||||
|
fontSize: 15,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
281
src/components/VoiceButton.tsx
Normal file
281
src/components/VoiceButton.tsx
Normal file
@ -0,0 +1,281 @@
|
|||||||
|
import React, { useEffect, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Pressable,
|
||||||
|
StyleSheet,
|
||||||
|
View,
|
||||||
|
Animated,
|
||||||
|
ViewStyle,
|
||||||
|
} from 'react-native';
|
||||||
|
|
||||||
|
interface VoiceButtonProps {
|
||||||
|
isListening: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
isInCall?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onPressIn: () => void;
|
||||||
|
onPressOut: () => void;
|
||||||
|
style?: ViewStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VoiceButton({
|
||||||
|
isListening,
|
||||||
|
isSpeaking,
|
||||||
|
isProcessing,
|
||||||
|
isInCall = false,
|
||||||
|
disabled,
|
||||||
|
onPressIn,
|
||||||
|
onPressOut,
|
||||||
|
style,
|
||||||
|
}: VoiceButtonProps) {
|
||||||
|
// Separate animated values to avoid mixing native/JS drivers
|
||||||
|
const buttonScaleAnim = useRef(new Animated.Value(1)).current;
|
||||||
|
const glowOpacityAnim = useRef(new Animated.Value(0)).current;
|
||||||
|
const pulseAnimRef = useRef<Animated.CompositeAnimation | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isListening) {
|
||||||
|
// Pulse animation while listening
|
||||||
|
pulseAnimRef.current = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(buttonScaleAnim, {
|
||||||
|
toValue: 1.1,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(buttonScaleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 500,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
pulseAnimRef.current.start();
|
||||||
|
|
||||||
|
// Glow animation (separate, non-native)
|
||||||
|
Animated.timing(glowOpacityAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
} else {
|
||||||
|
// Stop pulse animation
|
||||||
|
if (pulseAnimRef.current) {
|
||||||
|
pulseAnimRef.current.stop();
|
||||||
|
pulseAnimRef.current = null;
|
||||||
|
}
|
||||||
|
// Reset scale with native driver
|
||||||
|
Animated.timing(buttonScaleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 150,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
|
||||||
|
// Fade out glow
|
||||||
|
Animated.timing(glowOpacityAnim, {
|
||||||
|
toValue: 0,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
|
}, [isListening, buttonScaleAnim, glowOpacityAnim]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isSpeaking && !isListening) {
|
||||||
|
// Wave animation while speaking
|
||||||
|
pulseAnimRef.current = Animated.loop(
|
||||||
|
Animated.sequence([
|
||||||
|
Animated.timing(buttonScaleAnim, {
|
||||||
|
toValue: 1.05,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
Animated.timing(buttonScaleAnim, {
|
||||||
|
toValue: 1,
|
||||||
|
duration: 300,
|
||||||
|
useNativeDriver: true,
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
);
|
||||||
|
pulseAnimRef.current.start();
|
||||||
|
} else if (!isListening && !isSpeaking) {
|
||||||
|
if (pulseAnimRef.current) {
|
||||||
|
pulseAnimRef.current.stop();
|
||||||
|
pulseAnimRef.current = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isSpeaking, isListening, buttonScaleAnim]);
|
||||||
|
|
||||||
|
const getButtonColor = () => {
|
||||||
|
if (disabled) return '#9CA3AF';
|
||||||
|
if (isInCall) return '#EF4444'; // Red when in call (to indicate "end call")
|
||||||
|
if (isListening) return '#10B981'; // Green when listening
|
||||||
|
if (isSpeaking) return '#8B5CF6';
|
||||||
|
if (isProcessing) return '#F59E0B';
|
||||||
|
return '#4A90A4'; // Blue when ready to start
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, style]}>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.glow,
|
||||||
|
{
|
||||||
|
opacity: glowOpacityAnim,
|
||||||
|
backgroundColor: 'rgba(239, 68, 68, 0.4)',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<Pressable
|
||||||
|
onPressIn={onPressIn}
|
||||||
|
onPressOut={onPressOut}
|
||||||
|
disabled={disabled}
|
||||||
|
style={({ pressed }) => [
|
||||||
|
styles.buttonWrapper,
|
||||||
|
pressed && { opacity: 0.8 },
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Animated.View
|
||||||
|
style={[
|
||||||
|
styles.button,
|
||||||
|
{
|
||||||
|
backgroundColor: getButtonColor(),
|
||||||
|
transform: [{ scale: buttonScaleAnim }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={styles.iconContainer}>
|
||||||
|
{isInCall ? (
|
||||||
|
<EndCallIcon />
|
||||||
|
) : isSpeaking ? (
|
||||||
|
<SpeakingIcon />
|
||||||
|
) : isProcessing ? (
|
||||||
|
<ProcessingIcon />
|
||||||
|
) : (
|
||||||
|
<PhoneIcon />
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhoneIcon() {
|
||||||
|
return (
|
||||||
|
<View style={styles.phoneIcon}>
|
||||||
|
<View style={styles.phoneReceiver} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EndCallIcon() {
|
||||||
|
return (
|
||||||
|
<View style={styles.endCallIcon}>
|
||||||
|
<View style={styles.phoneReceiverRotated} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SpeakingIcon() {
|
||||||
|
return (
|
||||||
|
<View style={styles.speakingIcon}>
|
||||||
|
<View style={[styles.wave, styles.wave1]} />
|
||||||
|
<View style={[styles.wave, styles.wave2]} />
|
||||||
|
<View style={[styles.wave, styles.wave3]} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessingIcon() {
|
||||||
|
return (
|
||||||
|
<View style={styles.processingIcon}>
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<View style={styles.dot} />
|
||||||
|
<View style={styles.dot} />
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
glow: {
|
||||||
|
position: 'absolute',
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
borderRadius: 70,
|
||||||
|
},
|
||||||
|
buttonWrapper: {
|
||||||
|
// Hit area for touch
|
||||||
|
},
|
||||||
|
button: {
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 50,
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
shadowColor: '#000',
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 8,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
phoneIcon: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
phoneReceiver: {
|
||||||
|
width: 36,
|
||||||
|
height: 14,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 7,
|
||||||
|
transform: [{ rotate: '-45deg' }],
|
||||||
|
},
|
||||||
|
endCallIcon: {
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
},
|
||||||
|
phoneReceiverRotated: {
|
||||||
|
width: 36,
|
||||||
|
height: 14,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 7,
|
||||||
|
transform: [{ rotate: '135deg' }],
|
||||||
|
},
|
||||||
|
speakingIcon: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 4,
|
||||||
|
},
|
||||||
|
wave: {
|
||||||
|
width: 4,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 2,
|
||||||
|
},
|
||||||
|
wave1: {
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
wave2: {
|
||||||
|
height: 32,
|
||||||
|
},
|
||||||
|
wave3: {
|
||||||
|
height: 20,
|
||||||
|
},
|
||||||
|
processingIcon: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
gap: 6,
|
||||||
|
},
|
||||||
|
dot: {
|
||||||
|
width: 10,
|
||||||
|
height: 10,
|
||||||
|
backgroundColor: 'white',
|
||||||
|
borderRadius: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
137
src/components/ZAIAnalysis.jsx
Normal file
137
src/components/ZAIAnalysis.jsx
Normal file
@ -0,0 +1,137 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, Image, Alert } from 'react-native';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import ZAIMCPClient from '../utils/zaiMCPClient';
|
||||||
|
|
||||||
|
const ZAIAnalysis = () => {
|
||||||
|
const [selectedImage, setSelectedImage] = useState(null);
|
||||||
|
const [analysis, setAnalysis] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [zaiClient, setZaiClient] = useState(null);
|
||||||
|
|
||||||
|
const initializeZAI = async () => {
|
||||||
|
try {
|
||||||
|
const client = new ZAIMCPClient();
|
||||||
|
await client.start();
|
||||||
|
setZaiClient(client);
|
||||||
|
console.log('Z.AI MCP Client initialized');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize Z.AI:', error);
|
||||||
|
Alert.alert('Error', 'Failed to initialize Z.AI client');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pickImage = async () => {
|
||||||
|
const result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [4, 3],
|
||||||
|
quality: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!result.canceled) {
|
||||||
|
setSelectedImage(result.assets[0].uri);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const analyzeImage = async () => {
|
||||||
|
if (!selectedImage || !zaiClient) {
|
||||||
|
Alert.alert('Error', 'Please select an image and initialize Z.AI');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await zaiClient.analyzeImage(
|
||||||
|
selectedImage,
|
||||||
|
'Describe in detail the layout structure, color style, main components, and interactive elements of the website in this image to facilitate subsequent code generation by the model.'
|
||||||
|
);
|
||||||
|
setAnalysis(result.content || result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Analysis failed:', error);
|
||||||
|
Alert.alert('Error', 'Failed to analyze image');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{ flex: 1, padding: 20 }}>
|
||||||
|
<Text style={{ fontSize: 24, fontWeight: 'bold', marginBottom: 20 }}>
|
||||||
|
Z.AI Image Analysis
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#007AFF',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 20,
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
onPress={initializeZAI}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', fontWeight: 'bold' }}>
|
||||||
|
Initialize Z.AI Client
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#34C759',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 20,
|
||||||
|
alignItems: 'center'
|
||||||
|
}}
|
||||||
|
onPress={pickImage}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', fontWeight: 'bold' }}>
|
||||||
|
Pick Image
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{selectedImage && (
|
||||||
|
<View style={{ marginBottom: 20, alignItems: 'center' }}>
|
||||||
|
<Image
|
||||||
|
source={{ uri: selectedImage }}
|
||||||
|
style={{ width: 300, height: 200, borderRadius: 8 }}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={{
|
||||||
|
backgroundColor: '#FF9500',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginBottom: 20,
|
||||||
|
alignItems: 'center',
|
||||||
|
opacity: loading ? 0.5 : 1
|
||||||
|
}}
|
||||||
|
onPress={analyzeImage}
|
||||||
|
disabled={loading}
|
||||||
|
>
|
||||||
|
<Text style={{ color: 'white', fontWeight: 'bold' }}>
|
||||||
|
{loading ? 'Analyzing...' : 'Analyze Image'}
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
{analysis && (
|
||||||
|
<View style={{
|
||||||
|
backgroundColor: '#F2F2F7',
|
||||||
|
padding: 15,
|
||||||
|
borderRadius: 8,
|
||||||
|
marginTop: 20
|
||||||
|
}}>
|
||||||
|
<Text style={{ fontWeight: 'bold', marginBottom: 10 }}>
|
||||||
|
Analysis Result:
|
||||||
|
</Text>
|
||||||
|
<Text>{analysis}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ZAIAnalysis;
|
||||||
4
src/components/index.ts
Normal file
4
src/components/index.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export { VoiceButton } from './VoiceButton';
|
||||||
|
export { StatusIndicator } from './StatusIndicator';
|
||||||
|
export { TranscriptView } from './TranscriptView';
|
||||||
|
export { DashboardWebView } from './DashboardWebView';
|
||||||
1013
src/hooks/useVoiceAssistant.ts
Normal file
1013
src/hooks/useVoiceAssistant.ts
Normal file
File diff suppressed because it is too large
Load Diff
322
src/services/webhookService.ts
Normal file
322
src/services/webhookService.ts
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
import { WebhookContext } from '../types';
|
||||||
|
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||||
|
|
||||||
|
const FETCH_TIMEOUT = 10000; // 10 секунд по схеме
|
||||||
|
const CONVERSATIONS_STORAGE_KEY = '@wellnuo_pending_conversations';
|
||||||
|
|
||||||
|
// Типы ошибок для обработки по схеме
|
||||||
|
export type ApiErrorType =
|
||||||
|
| 'NETWORK_ERROR'
|
||||||
|
| 'API_KEY_ERROR'
|
||||||
|
| 'PATIENT_NOT_FOUND'
|
||||||
|
| 'TIMEOUT_ERROR'
|
||||||
|
| 'UNKNOWN_ERROR';
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
type: ApiErrorType;
|
||||||
|
statusCode?: number;
|
||||||
|
|
||||||
|
constructor(type: ApiErrorType, message: string, statusCode?: number) {
|
||||||
|
super(message);
|
||||||
|
this.type = type;
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
this.name = 'ApiError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONTEXT: WebhookContext = {
|
||||||
|
systemPrompt: `You are Julia, a caring AI health assistant for the WellNuo family care system.
|
||||||
|
You help family members check on their elderly loved ones. Be warm, concise, and reassuring.
|
||||||
|
|
||||||
|
IMPORTANT: Always speak in English by default. Only switch to another language if the user explicitly requests it or speaks to you in that language first.
|
||||||
|
|
||||||
|
When starting a conversation, greet the user warmly and briefly summarize any important health updates or status information about their loved one.`,
|
||||||
|
voiceSettings: {
|
||||||
|
voice: 'shimmer',
|
||||||
|
speed: 1.0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function formatPatientContext(data: any): string {
|
||||||
|
const parts: string[] = [];
|
||||||
|
|
||||||
|
// System prompt from API
|
||||||
|
if (data.system_prompt) {
|
||||||
|
parts.push(data.system_prompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Patient info
|
||||||
|
if (data.patient) {
|
||||||
|
const p = data.patient;
|
||||||
|
parts.push(`\n\n## Patient Information
|
||||||
|
- Name: ${p.name}
|
||||||
|
- Age: ${p.age}
|
||||||
|
- Relationship: ${p.relationship}
|
||||||
|
- Address: ${p.address}
|
||||||
|
- Emergency Contact: ${p.emergency_contact?.name} (${p.emergency_contact?.relationship}) - ${p.emergency_contact?.phone}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current status
|
||||||
|
if (data.current_status) {
|
||||||
|
const s = data.current_status;
|
||||||
|
parts.push(`\n\n## Current Status
|
||||||
|
- Location: ${s.location}
|
||||||
|
- Mood: ${s.mood}
|
||||||
|
- Is Sleeping: ${s.is_sleeping ? 'Yes' : 'No'}
|
||||||
|
- Last Activity: ${new Date(s.last_activity).toLocaleString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health metrics
|
||||||
|
if (data.health_metrics) {
|
||||||
|
const h = data.health_metrics;
|
||||||
|
parts.push(`\n\n## Health Metrics
|
||||||
|
- Heart Rate: ${h.heart_rate?.current} bpm (resting avg: ${h.heart_rate?.resting_average}, status: ${h.heart_rate?.status})
|
||||||
|
- Blood Pressure: ${h.blood_pressure?.last_reading} (${h.blood_pressure?.status}, trend: ${h.blood_pressure?.trend})
|
||||||
|
- Blood Oxygen: ${h.blood_oxygen?.current}% (${h.blood_oxygen?.status})
|
||||||
|
- Body Temperature: ${h.body_temperature?.current}°C (${h.body_temperature?.status})
|
||||||
|
- Weight: ${h.weight?.current_kg} kg (trend: ${h.weight?.trend})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sleep data
|
||||||
|
if (data.sleep_data?.last_night) {
|
||||||
|
const sl = data.sleep_data.last_night;
|
||||||
|
parts.push(`\n\n## Sleep Data (Last Night)
|
||||||
|
- Duration: ${sl.duration_hours} hours (quality: ${sl.quality})
|
||||||
|
- Deep Sleep: ${sl.deep_sleep_hours}h, REM: ${sl.rem_sleep_hours}h, Light: ${sl.light_sleep_hours}h
|
||||||
|
- Bathroom Visits: ${sl.bathroom_visits}
|
||||||
|
- Sleep Score: ${data.sleep_data.sleep_score}/100`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activity data
|
||||||
|
if (data.activity_data?.today) {
|
||||||
|
const a = data.activity_data.today;
|
||||||
|
parts.push(`\n\n## Today's Activity
|
||||||
|
- Steps: ${a.steps} (goal: ${data.activity_data.goals?.daily_steps})
|
||||||
|
- Active Minutes: ${a.active_minutes}
|
||||||
|
- Calories Burned: ${a.calories_burned}
|
||||||
|
- Rooms Visited: ${a.rooms_visited?.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Medications
|
||||||
|
if (data.medications?.daily_schedule) {
|
||||||
|
const meds = data.medications.daily_schedule
|
||||||
|
.map((m: any) => `${m.name} ${m.dosage} at ${m.time} - ${m.taken_today ? 'Taken' : 'Not taken'}`)
|
||||||
|
.join('\n - ');
|
||||||
|
parts.push(`\n\n## Medications
|
||||||
|
- Adherence: ${data.medications.adherence_rate}%
|
||||||
|
- Today's Schedule:
|
||||||
|
- ${meds}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Recent events
|
||||||
|
if (data.recent_events?.length > 0) {
|
||||||
|
const events = data.recent_events
|
||||||
|
.slice(0, 5)
|
||||||
|
.map((e: any) => `[${e.type}] ${e.description} (${new Date(e.timestamp).toLocaleString()})`)
|
||||||
|
.join('\n - ');
|
||||||
|
parts.push(`\n\n## Recent Events
|
||||||
|
- ${events}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upcoming appointments
|
||||||
|
if (data.upcoming?.appointments?.length > 0) {
|
||||||
|
const appts = data.upcoming.appointments
|
||||||
|
.map((a: any) => `${a.type} - ${new Date(a.date).toLocaleDateString()} at ${a.location}`)
|
||||||
|
.join('\n - ');
|
||||||
|
parts.push(`\n\n## Upcoming Appointments
|
||||||
|
- ${appts}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Caregiver notes
|
||||||
|
if (data.caregiver_notes?.length > 0) {
|
||||||
|
const notes = data.caregiver_notes
|
||||||
|
.slice(0, 3)
|
||||||
|
.map((n: any) => `[${n.date}] ${n.author}: "${n.note}"`)
|
||||||
|
.join('\n - ');
|
||||||
|
parts.push(`\n\n## Recent Caregiver Notes
|
||||||
|
- ${notes}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchWebhookContext(webhookUrl: string): Promise<WebhookContext> {
|
||||||
|
// AbortController для timeout 10 сек по схеме
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(webhookUrl, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Обработка HTTP ошибок по схеме
|
||||||
|
if (response.status === 401 || response.status === 403) {
|
||||||
|
throw new ApiError(
|
||||||
|
'API_KEY_ERROR',
|
||||||
|
'Сервис временно недоступен. Проблема с API ключом.',
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.status === 404) {
|
||||||
|
throw new ApiError(
|
||||||
|
'PATIENT_NOT_FOUND',
|
||||||
|
'Данные пациента не найдены.',
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new ApiError(
|
||||||
|
'NETWORK_ERROR',
|
||||||
|
`Ошибка сервера: ${response.status}`,
|
||||||
|
response.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
const fullContext = formatPatientContext(data);
|
||||||
|
|
||||||
|
return {
|
||||||
|
systemPrompt: fullContext || DEFAULT_CONTEXT.systemPrompt,
|
||||||
|
voiceSettings: {
|
||||||
|
voice: 'shimmer',
|
||||||
|
speed: 1.0,
|
||||||
|
},
|
||||||
|
userData: data,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
// Timeout error
|
||||||
|
if (error instanceof Error && error.name === 'AbortError') {
|
||||||
|
throw new ApiError(
|
||||||
|
'TIMEOUT_ERROR',
|
||||||
|
'Превышено время ожидания. Проверьте подключение к интернету.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Уже обработанная ошибка
|
||||||
|
if (error instanceof ApiError) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Network error (no internet, etc.)
|
||||||
|
console.error('Failed to fetch webhook context:', error);
|
||||||
|
throw new ApiError(
|
||||||
|
'NETWORK_ERROR',
|
||||||
|
'Проверьте подключение к интернету.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDefaultContext(): WebhookContext {
|
||||||
|
return DEFAULT_CONTEXT;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Интерфейс для сохранения истории разговора
|
||||||
|
export interface ConversationMessage {
|
||||||
|
role: 'user' | 'assistant';
|
||||||
|
content: string;
|
||||||
|
timestamp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ConversationData {
|
||||||
|
session_id: string;
|
||||||
|
patient_id: string;
|
||||||
|
messages: ConversationMessage[];
|
||||||
|
duration: number;
|
||||||
|
started_at: string;
|
||||||
|
ended_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение истории разговора на сервер по схеме
|
||||||
|
export async function saveConversation(
|
||||||
|
apiUrl: string,
|
||||||
|
data: ConversationData
|
||||||
|
): Promise<boolean> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeoutId = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data),
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Server error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Conversation saved successfully');
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
console.error('Failed to save conversation:', error);
|
||||||
|
|
||||||
|
// По схеме: если ошибка - сохранить локально для retry позже
|
||||||
|
await savePendingConversation(data);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение в AsyncStorage для retry позже
|
||||||
|
async function savePendingConversation(data: ConversationData): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existing = await AsyncStorage.getItem(CONVERSATIONS_STORAGE_KEY);
|
||||||
|
const pending: ConversationData[] = existing ? JSON.parse(existing) : [];
|
||||||
|
pending.push(data);
|
||||||
|
await AsyncStorage.setItem(CONVERSATIONS_STORAGE_KEY, JSON.stringify(pending));
|
||||||
|
console.log('Conversation saved locally for later sync');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save conversation locally:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Синхронизация отложенных разговоров
|
||||||
|
export async function syncPendingConversations(apiUrl: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const existing = await AsyncStorage.getItem(CONVERSATIONS_STORAGE_KEY);
|
||||||
|
if (!existing) return;
|
||||||
|
|
||||||
|
const pending: ConversationData[] = JSON.parse(existing);
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
console.log(`Syncing ${pending.length} pending conversations...`);
|
||||||
|
const remaining: ConversationData[] = [];
|
||||||
|
|
||||||
|
for (const conversation of pending) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${apiUrl}/api/conversations`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(conversation),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
remaining.push(conversation);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
remaining.push(conversation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await AsyncStorage.setItem(CONVERSATIONS_STORAGE_KEY, JSON.stringify(remaining));
|
||||||
|
console.log(`Sync complete. ${remaining.length} conversations still pending.`);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync pending conversations:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
5
src/types/env.d.ts
vendored
Normal file
5
src/types/env.d.ts
vendored
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
declare module '@env' {
|
||||||
|
export const OPENAI_API_KEY: string;
|
||||||
|
export const WEBHOOK_URL: string;
|
||||||
|
export const DEFAULT_VOICE: string;
|
||||||
|
}
|
||||||
23
src/types/index.ts
Normal file
23
src/types/index.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
export interface WebhookContext {
|
||||||
|
systemPrompt: string;
|
||||||
|
voiceSettings?: {
|
||||||
|
voice?: 'alloy' | 'echo' | 'shimmer' | 'ash' | 'ballad' | 'coral' | 'sage' | 'verse';
|
||||||
|
speed?: number;
|
||||||
|
};
|
||||||
|
userData?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VoiceAssistantState {
|
||||||
|
isConnected: boolean;
|
||||||
|
isListening: boolean;
|
||||||
|
isSpeaking: boolean;
|
||||||
|
isProcessing: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConnectionStatus = 'disconnected' | 'connecting' | 'connected' | 'error';
|
||||||
|
|
||||||
|
export interface RealtimeEvent {
|
||||||
|
type: string;
|
||||||
|
[key: string]: any;
|
||||||
|
}
|
||||||
131
src/utils/audioConverter.ts
Normal file
131
src/utils/audioConverter.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* PCM16 to WAV Converter
|
||||||
|
* OpenAI Realtime API returns raw PCM16 audio (24kHz, mono)
|
||||||
|
* expo-av requires WAV format with proper headers
|
||||||
|
*/
|
||||||
|
|
||||||
|
// WAV file constants for OpenAI Realtime audio
|
||||||
|
const SAMPLE_RATE = 24000;
|
||||||
|
const NUM_CHANNELS = 1;
|
||||||
|
const BITS_PER_SAMPLE = 16;
|
||||||
|
const BYTE_RATE = SAMPLE_RATE * NUM_CHANNELS * (BITS_PER_SAMPLE / 8);
|
||||||
|
const BLOCK_ALIGN = NUM_CHANNELS * (BITS_PER_SAMPLE / 8);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert base64 PCM16 audio to WAV format with proper headers
|
||||||
|
* @param pcm16Base64 - Base64 encoded PCM16 audio data from OpenAI
|
||||||
|
* @returns Base64 encoded WAV audio ready for playback
|
||||||
|
*/
|
||||||
|
export function pcm16ToWav(pcm16Base64: string): string {
|
||||||
|
// Decode base64 to binary
|
||||||
|
const pcmData = base64ToUint8Array(pcm16Base64);
|
||||||
|
const dataLength = pcmData.length;
|
||||||
|
|
||||||
|
// WAV header is 44 bytes
|
||||||
|
const wavHeader = createWavHeader(dataLength);
|
||||||
|
|
||||||
|
// Combine header + PCM data
|
||||||
|
const wavData = new Uint8Array(44 + dataLength);
|
||||||
|
wavData.set(wavHeader, 0);
|
||||||
|
wavData.set(pcmData, 44);
|
||||||
|
|
||||||
|
// Convert back to base64
|
||||||
|
return uint8ArrayToBase64(wavData);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create WAV header for PCM16 24kHz mono audio
|
||||||
|
*/
|
||||||
|
function createWavHeader(dataLength: number): Uint8Array {
|
||||||
|
const header = new ArrayBuffer(44);
|
||||||
|
const view = new DataView(header);
|
||||||
|
|
||||||
|
// RIFF chunk descriptor
|
||||||
|
writeString(view, 0, 'RIFF');
|
||||||
|
view.setUint32(4, 36 + dataLength, true); // File size - 8
|
||||||
|
writeString(view, 8, 'WAVE');
|
||||||
|
|
||||||
|
// fmt sub-chunk
|
||||||
|
writeString(view, 12, 'fmt ');
|
||||||
|
view.setUint32(16, 16, true); // Subchunk1Size (16 for PCM)
|
||||||
|
view.setUint16(20, 1, true); // AudioFormat (1 = PCM)
|
||||||
|
view.setUint16(22, NUM_CHANNELS, true); // NumChannels
|
||||||
|
view.setUint32(24, SAMPLE_RATE, true); // SampleRate
|
||||||
|
view.setUint32(28, BYTE_RATE, true); // ByteRate
|
||||||
|
view.setUint16(32, BLOCK_ALIGN, true); // BlockAlign
|
||||||
|
view.setUint16(34, BITS_PER_SAMPLE, true); // BitsPerSample
|
||||||
|
|
||||||
|
// data sub-chunk
|
||||||
|
writeString(view, 36, 'data');
|
||||||
|
view.setUint32(40, dataLength, true); // Subchunk2Size
|
||||||
|
|
||||||
|
return new Uint8Array(header);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write string to DataView at offset
|
||||||
|
*/
|
||||||
|
function writeString(view: DataView, offset: number, str: string): void {
|
||||||
|
for (let i = 0; i < str.length; i++) {
|
||||||
|
view.setUint8(offset + i, str.charCodeAt(i));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert base64 string to Uint8Array
|
||||||
|
*/
|
||||||
|
function base64ToUint8Array(base64: string): Uint8Array {
|
||||||
|
// Handle both browser and React Native environments
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Uint8Array to base64 string
|
||||||
|
*/
|
||||||
|
function uint8ArrayToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
|
binary += String.fromCharCode(bytes[i]);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a data URI for WAV audio that can be played by expo-av
|
||||||
|
* @param pcm16Base64 - Base64 encoded PCM16 audio data from OpenAI
|
||||||
|
* @returns Data URI string ready for Audio.Sound.createAsync
|
||||||
|
*/
|
||||||
|
export function createWavDataUri(pcm16Base64: string): string {
|
||||||
|
const wavBase64 = pcm16ToWav(pcm16Base64);
|
||||||
|
return `data:audio/wav;base64,${wavBase64}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip WAV header from recorded audio to get raw PCM16 data
|
||||||
|
* expo-av records in WAV format (44-byte header + PCM data)
|
||||||
|
* OpenAI Realtime API expects raw PCM16 without headers
|
||||||
|
* @param wavBase64 - Base64 encoded WAV file from recording
|
||||||
|
* @returns Base64 encoded raw PCM16 data
|
||||||
|
*/
|
||||||
|
export function wavToPcm16Base64(wavBase64: string): string {
|
||||||
|
// Decode base64 to binary
|
||||||
|
const wavData = base64ToUint8Array(wavBase64);
|
||||||
|
|
||||||
|
// WAV header is 44 bytes for standard PCM format
|
||||||
|
// Skip first 44 bytes to get raw PCM data
|
||||||
|
if (wavData.length <= 44) {
|
||||||
|
console.log('[audioConverter] WAV data too short, returning as-is');
|
||||||
|
return wavBase64;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract PCM data (skip 44-byte WAV header)
|
||||||
|
const pcmData = wavData.slice(44);
|
||||||
|
|
||||||
|
// Convert back to base64
|
||||||
|
return uint8ArrayToBase64(pcmData);
|
||||||
|
}
|
||||||
1
src/utils/index.ts
Normal file
1
src/utils/index.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { pcm16ToWav, createWavDataUri } from './audioConverter';
|
||||||
87
src/utils/zaiMCPClient.js
Normal file
87
src/utils/zaiMCPClient.js
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import { spawn } from 'child_process';
|
||||||
|
import { createInterface } from 'readline';
|
||||||
|
|
||||||
|
class ZAIMCPClient {
|
||||||
|
constructor() {
|
||||||
|
this.process = null;
|
||||||
|
this.requestId = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
this.process = spawn('zai-mcp-server', [], {
|
||||||
|
stdio: ['pipe', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
|
||||||
|
this.rl = createInterface({
|
||||||
|
input: this.process.stdout,
|
||||||
|
output: this.process.stdin
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.process.on('spawn', () => {
|
||||||
|
console.log('Z.AI MCP Server started');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
this.process.on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async callTool(toolName, args) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: ++this.requestId,
|
||||||
|
method: "tools/call",
|
||||||
|
params: {
|
||||||
|
name: toolName,
|
||||||
|
arguments: args
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.process.stdin.write(JSON.stringify(request) + '\n');
|
||||||
|
|
||||||
|
let output = '';
|
||||||
|
const onData = (data) => {
|
||||||
|
output += data;
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(output);
|
||||||
|
if (response.id === this.requestId) {
|
||||||
|
this.process.stdout.removeListener('data', onData);
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve(response.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Still collecting data
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
this.process.stdout.on('data', onData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeImage(imagePath, prompt) {
|
||||||
|
return this.callTool('analyze_image', {
|
||||||
|
image_source: imagePath,
|
||||||
|
prompt: prompt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyzeVideo(videoPath, prompt) {
|
||||||
|
return this.callTool('analyze_video', {
|
||||||
|
video_source: videoPath,
|
||||||
|
prompt: prompt
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
stop() {
|
||||||
|
if (this.process) {
|
||||||
|
this.process.kill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ZAIMCPClient;
|
||||||
164
tests/api-check.spec.js
Normal file
164
tests/api-check.spec.js
Normal file
@ -0,0 +1,164 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
// API credentials from client
|
||||||
|
const API_BASE = 'https://react.eluxnetworks.net';
|
||||||
|
const DASHBOARD_URL = `${API_BASE}/dashboard`;
|
||||||
|
const LOGIN_CREDENTIALS = {
|
||||||
|
username: 'anandk',
|
||||||
|
password: 'anandk_8'
|
||||||
|
};
|
||||||
|
|
||||||
|
test.describe('WellNuo API Check', () => {
|
||||||
|
|
||||||
|
test('should load main page', async ({ page }) => {
|
||||||
|
const response = await page.goto(API_BASE);
|
||||||
|
expect(response.status()).toBe(200);
|
||||||
|
|
||||||
|
// Check if it's the WellNuo app
|
||||||
|
await expect(page).toHaveTitle(/WellNuo/);
|
||||||
|
|
||||||
|
// Take screenshot
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/main-page.png', fullPage: true });
|
||||||
|
console.log('Main page loaded successfully');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should navigate to dashboard and login', async ({ page }) => {
|
||||||
|
// Go to dashboard
|
||||||
|
await page.goto(DASHBOARD_URL);
|
||||||
|
|
||||||
|
// Wait for page to load
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Take screenshot of login page
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/dashboard-initial.png', fullPage: true });
|
||||||
|
|
||||||
|
// Look for login form elements
|
||||||
|
const usernameField = page.locator('input[name="username"], input[name="user_name"], input[type="text"]').first();
|
||||||
|
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
|
||||||
|
const submitButton = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Sign in")').first();
|
||||||
|
|
||||||
|
// Check if login form exists
|
||||||
|
const hasLoginForm = await usernameField.isVisible().catch(() => false);
|
||||||
|
|
||||||
|
if (hasLoginForm) {
|
||||||
|
console.log('Login form found, attempting to login...');
|
||||||
|
|
||||||
|
// Fill credentials
|
||||||
|
await usernameField.fill(LOGIN_CREDENTIALS.username);
|
||||||
|
await passwordField.fill(LOGIN_CREDENTIALS.password);
|
||||||
|
|
||||||
|
// Take screenshot before submit
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/login-filled.png', fullPage: true });
|
||||||
|
|
||||||
|
// Click login
|
||||||
|
await submitButton.click();
|
||||||
|
|
||||||
|
// Wait for navigation or response
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Take screenshot after login
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/after-login.png', fullPage: true });
|
||||||
|
|
||||||
|
// Check URL to verify login success
|
||||||
|
const currentUrl = page.url();
|
||||||
|
console.log('Current URL after login:', currentUrl);
|
||||||
|
|
||||||
|
// Check page content
|
||||||
|
const pageContent = await page.content();
|
||||||
|
console.log('Page contains "dashboard":', pageContent.toLowerCase().includes('dashboard'));
|
||||||
|
console.log('Page contains "error":', pageContent.toLowerCase().includes('error'));
|
||||||
|
console.log('Page contains "invalid":', pageContent.toLowerCase().includes('invalid'));
|
||||||
|
|
||||||
|
} else {
|
||||||
|
console.log('No login form found, checking page state...');
|
||||||
|
const pageContent = await page.content();
|
||||||
|
console.log('Page HTML (first 500 chars):', pageContent.substring(0, 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should check API endpoints directly', async ({ request }) => {
|
||||||
|
// Try common API endpoints
|
||||||
|
const endpoints = [
|
||||||
|
'/api/auth/login',
|
||||||
|
'/api/login',
|
||||||
|
'/auth/login',
|
||||||
|
'/api/token',
|
||||||
|
'/api/v1/auth/login'
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const endpoint of endpoints) {
|
||||||
|
const url = `${API_BASE}${endpoint}`;
|
||||||
|
console.log(`Checking endpoint: ${url}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Try POST with credentials
|
||||||
|
const response = await request.post(url, {
|
||||||
|
data: {
|
||||||
|
user_name: LOGIN_CREDENTIALS.username,
|
||||||
|
password: LOGIN_CREDENTIALS.password
|
||||||
|
},
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(` Status: ${response.status()}`);
|
||||||
|
|
||||||
|
if (response.status() === 200) {
|
||||||
|
const body = await response.json().catch(() => ({}));
|
||||||
|
console.log(` Response:`, JSON.stringify(body, null, 2));
|
||||||
|
|
||||||
|
if (body.token || body.access_token) {
|
||||||
|
console.log('TOKEN FOUND!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should try login with various body formats', async ({ request }) => {
|
||||||
|
const loginEndpoint = `${API_BASE}/api/auth/login`;
|
||||||
|
|
||||||
|
// Format 1: user_name / password
|
||||||
|
console.log('Trying format 1: user_name/password');
|
||||||
|
let response = await request.post(loginEndpoint, {
|
||||||
|
data: {
|
||||||
|
user_name: LOGIN_CREDENTIALS.username,
|
||||||
|
password: LOGIN_CREDENTIALS.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Status: ${response.status()}`);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
console.log('Response:', await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 2: username / password
|
||||||
|
console.log('Trying format 2: username/password');
|
||||||
|
response = await request.post(loginEndpoint, {
|
||||||
|
data: {
|
||||||
|
username: LOGIN_CREDENTIALS.username,
|
||||||
|
password: LOGIN_CREDENTIALS.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Status: ${response.status()}`);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
console.log('Response:', await response.text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format 3: email-style
|
||||||
|
console.log('Trying format 3: email/password');
|
||||||
|
response = await request.post(loginEndpoint, {
|
||||||
|
data: {
|
||||||
|
email: LOGIN_CREDENTIALS.username,
|
||||||
|
password: LOGIN_CREDENTIALS.password
|
||||||
|
}
|
||||||
|
});
|
||||||
|
console.log(`Status: ${response.status()}`);
|
||||||
|
if (response.status() === 200) {
|
||||||
|
console.log('Response:', await response.text());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
185
tests/api-discovery.spec.js
Normal file
185
tests/api-discovery.spec.js
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const API_BASE = 'https://eluxnetworks.net/function/well-api/api';
|
||||||
|
const TOKEN = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFuYW5kayIsImV4cCI6MTc2NTQ5MzI5N30.Jon7IJtBscErkt4-8qjas45Irwg_MQTRyS1ASrPGb1U';
|
||||||
|
const USER_NAME = 'anandk';
|
||||||
|
const PATIENT_ID = 25; // Ferdinand Zmrzli
|
||||||
|
|
||||||
|
test.describe('API Discovery - Find all available functions', () => {
|
||||||
|
|
||||||
|
// Common API functions to try
|
||||||
|
const functionsToTry = [
|
||||||
|
// Patient related
|
||||||
|
'patient_details',
|
||||||
|
'patient_info',
|
||||||
|
'get_patient',
|
||||||
|
'patient_data',
|
||||||
|
'patient_history',
|
||||||
|
'patient_context',
|
||||||
|
|
||||||
|
// Health data
|
||||||
|
'health_data',
|
||||||
|
'health_metrics',
|
||||||
|
'vitals',
|
||||||
|
'wellness',
|
||||||
|
'wellness_data',
|
||||||
|
|
||||||
|
// Activity
|
||||||
|
'activity',
|
||||||
|
'activity_log',
|
||||||
|
'activity_history',
|
||||||
|
'location_history',
|
||||||
|
|
||||||
|
// Chat/Messages
|
||||||
|
'chat',
|
||||||
|
'messages',
|
||||||
|
'send_message',
|
||||||
|
'get_messages',
|
||||||
|
'conversation',
|
||||||
|
'conversations',
|
||||||
|
|
||||||
|
// Alerts
|
||||||
|
'alerts',
|
||||||
|
'notifications',
|
||||||
|
'alert_history',
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
'settings',
|
||||||
|
'user_settings',
|
||||||
|
'preferences',
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
'report',
|
||||||
|
'daily_report',
|
||||||
|
'weekly_report',
|
||||||
|
|
||||||
|
// Other
|
||||||
|
'status',
|
||||||
|
'system_status',
|
||||||
|
'api_list',
|
||||||
|
'functions',
|
||||||
|
'help'
|
||||||
|
];
|
||||||
|
|
||||||
|
test('discover available API functions', async ({ request }) => {
|
||||||
|
console.log('\\n=== TESTING API FUNCTIONS ===\\n');
|
||||||
|
|
||||||
|
const workingFunctions = [];
|
||||||
|
|
||||||
|
for (const func of functionsToTry) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
function: func,
|
||||||
|
user_name: USER_NAME,
|
||||||
|
token: TOKEN,
|
||||||
|
patient_id: PATIENT_ID.toString(),
|
||||||
|
user_id: PATIENT_ID.toString(),
|
||||||
|
date: '2025-12-10',
|
||||||
|
nonce: `test-${Date.now()}`
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.post(API_BASE, {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
data: params.toString()
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = response.status();
|
||||||
|
const text = await response.text();
|
||||||
|
|
||||||
|
// Check if function returned valid data
|
||||||
|
if (status === 200 && text && !text.includes('error') && !text.includes('Error')) {
|
||||||
|
console.log(`[OK] ${func}: ${text.substring(0, 200)}`);
|
||||||
|
workingFunctions.push({ function: func, response: text.substring(0, 500) });
|
||||||
|
} else if (status === 200) {
|
||||||
|
console.log(`[??] ${func}: ${text.substring(0, 100)}`);
|
||||||
|
} else {
|
||||||
|
console.log(`[${status}] ${func}`);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`[ERR] ${func}: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\\n=== WORKING FUNCTIONS ===');
|
||||||
|
console.log(JSON.stringify(workingFunctions, null, 2));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('explore web interface for more endpoints', async ({ page }) => {
|
||||||
|
const apiCalls = [];
|
||||||
|
|
||||||
|
// Intercept all API calls
|
||||||
|
page.on('request', req => {
|
||||||
|
if (req.url().includes('eluxnetworks.net')) {
|
||||||
|
apiCalls.push({
|
||||||
|
url: req.url(),
|
||||||
|
method: req.method(),
|
||||||
|
postData: req.postData()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login and navigate
|
||||||
|
await page.goto('https://react.eluxnetworks.net/dashboard');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Fill login
|
||||||
|
await page.locator('input').first().fill('anandk');
|
||||||
|
await page.locator('input[type="password"]').fill('anandk_8');
|
||||||
|
await page.locator('button:has-text("Log In")').click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Click on first patient card to see details
|
||||||
|
const patientCard = page.locator('.bg-white').first();
|
||||||
|
if (await patientCard.isVisible()) {
|
||||||
|
await patientCard.click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/patient-details.png', fullPage: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to find and click on different sections
|
||||||
|
const sections = ['Health', 'Activity', 'Sleep', 'Alerts', 'Settings', 'Chat', 'Messages'];
|
||||||
|
for (const section of sections) {
|
||||||
|
const link = page.locator(`text=${section}`).first();
|
||||||
|
if (await link.isVisible().catch(() => false)) {
|
||||||
|
console.log(`Found section: ${section}`);
|
||||||
|
await link.click().catch(() => {});
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\\n=== ALL API CALLS CAPTURED ===');
|
||||||
|
apiCalls.forEach((call, i) => {
|
||||||
|
console.log(`\\n[${i + 1}] ${call.method} ${call.url}`);
|
||||||
|
if (call.postData) {
|
||||||
|
console.log(' Data:', call.postData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('test patient context API for chat', async ({ request }) => {
|
||||||
|
// Test the webhook URL from .env
|
||||||
|
console.log('\\n=== Testing Patient Context API ===\\n');
|
||||||
|
|
||||||
|
const contextUrl = 'https://wellnuo.smartlaunchhub.com/api/patient/context';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request.get(contextUrl);
|
||||||
|
console.log(`Status: ${response.status()}`);
|
||||||
|
const body = await response.text();
|
||||||
|
console.log(`Response: ${body.substring(0, 1000)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try with patient ID
|
||||||
|
try {
|
||||||
|
const response = await request.get(`${contextUrl}?patient_id=${PATIENT_ID}`);
|
||||||
|
console.log(`\\nWith patient_id - Status: ${response.status()}`);
|
||||||
|
const body = await response.text();
|
||||||
|
console.log(`Response: ${body.substring(0, 1000)}`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`Error: ${e.message}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
18
tests/check-repo.spec.js
Normal file
18
tests/check-repo.spec.js
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
const { test } = require('@playwright/test');
|
||||||
|
|
||||||
|
test('check repo content', async ({ page }) => {
|
||||||
|
// Login first
|
||||||
|
await page.goto('https://gitea.wellnua.com/user/login');
|
||||||
|
await page.locator('input#user_name').fill('sergei_t');
|
||||||
|
await page.locator('input#password').fill('WellNuo2025!Secure');
|
||||||
|
await page.locator('button:has-text("Sign In")').click();
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
// Go to repo
|
||||||
|
await page.goto('https://gitea.wellnua.com/robert/MobileApp_react_native');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/repo-content.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('Page title:', await page.title());
|
||||||
|
console.log('URL:', page.url());
|
||||||
|
});
|
||||||
76
tests/gitea-complete.spec.js
Normal file
76
tests/gitea-complete.spec.js
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const GITEA_URL = 'https://gitea.wellnua.com';
|
||||||
|
const EMAIL = 'serter2069@gmail.com';
|
||||||
|
const USERNAME = 'serter2069';
|
||||||
|
const PASSWORD = 'WellNuo2025!Secure';
|
||||||
|
|
||||||
|
test('complete Gitea registration', async ({ page }) => {
|
||||||
|
console.log('=== GITEA REGISTRATION ===\n');
|
||||||
|
|
||||||
|
// Go to register page directly
|
||||||
|
await page.goto(`${GITEA_URL}/user/sign_up`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
console.log('1. On registration page');
|
||||||
|
|
||||||
|
// Fill form
|
||||||
|
await page.locator('input#user_name').fill(USERNAME);
|
||||||
|
await page.locator('input#email').fill(EMAIL);
|
||||||
|
await page.locator('input#password').fill(PASSWORD);
|
||||||
|
await page.locator('input#retype').fill(PASSWORD);
|
||||||
|
|
||||||
|
console.log('2. Form filled');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-form.png' });
|
||||||
|
|
||||||
|
// Click Register Account button
|
||||||
|
await page.locator('button.ui.primary.button:has-text("Register Account")').click();
|
||||||
|
console.log('3. Clicked Register button');
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-after-register.png', fullPage: true });
|
||||||
|
|
||||||
|
const currentUrl = page.url();
|
||||||
|
console.log('4. Current URL:', currentUrl);
|
||||||
|
|
||||||
|
// Check for errors or success
|
||||||
|
const pageText = await page.textContent('body');
|
||||||
|
if (pageText.includes('error') || pageText.includes('Error')) {
|
||||||
|
console.log(' Error detected on page');
|
||||||
|
}
|
||||||
|
if (pageText.includes('confirm') || pageText.includes('email')) {
|
||||||
|
console.log(' Email confirmation may be required');
|
||||||
|
}
|
||||||
|
if (pageText.includes('success') || pageText.includes('Success')) {
|
||||||
|
console.log(' Registration successful!');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already registered - try login
|
||||||
|
if (currentUrl.includes('sign_up') || pageText.includes('already')) {
|
||||||
|
console.log('5. User may already exist, trying login...');
|
||||||
|
await page.goto(`${GITEA_URL}/user/login`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('input#user_name').fill(USERNAME);
|
||||||
|
await page.locator('input#password').fill(PASSWORD);
|
||||||
|
await page.locator('button.ui.primary.button').click();
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-after-login.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log(' After login URL:', page.url());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try accessing repo
|
||||||
|
console.log('6. Accessing repository...');
|
||||||
|
await page.goto(`${GITEA_URL}/robert/MobileApp_react_native`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-repo-final.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log(' Repo page title:', await page.title());
|
||||||
|
|
||||||
|
console.log('\n=== DONE ===');
|
||||||
|
});
|
||||||
75
tests/gitea-final.spec.js
Normal file
75
tests/gitea-final.spec.js
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const RESET_URL = 'https://gitea.wellnua.com/user/recover_account?code=202512111241000180718f2083d36c90ef12f385dc56ccea121af2b2437365726765695f74';
|
||||||
|
const NEW_PASSWORD = 'WellNuo2025!Secure';
|
||||||
|
const USERNAME = 'sergei_t';
|
||||||
|
|
||||||
|
test('set password and access repo', async ({ page }) => {
|
||||||
|
console.log('=== GITEA FINAL SETUP ===\n');
|
||||||
|
|
||||||
|
// Step 1: Set new password
|
||||||
|
await page.goto(RESET_URL);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
console.log('1. On recovery page');
|
||||||
|
|
||||||
|
// Fill password
|
||||||
|
await page.locator('input[name="password"]').fill(NEW_PASSWORD);
|
||||||
|
console.log('2. Password filled');
|
||||||
|
|
||||||
|
// Click Recover Account button
|
||||||
|
await page.locator('button:has-text("Recover Account")').click();
|
||||||
|
console.log('3. Clicked Recover Account');
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-final-1.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('4. After recovery URL:', page.url());
|
||||||
|
|
||||||
|
// Step 2: Login
|
||||||
|
console.log('\n5. Logging in...');
|
||||||
|
await page.goto('https://gitea.wellnua.com/user/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('input#user_name').fill(USERNAME);
|
||||||
|
await page.locator('input#password').fill(NEW_PASSWORD);
|
||||||
|
await page.locator('button:has-text("Sign In")').click();
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-final-2.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('6. After login URL:', page.url());
|
||||||
|
|
||||||
|
// Step 3: Access repo
|
||||||
|
console.log('\n7. Accessing repository...');
|
||||||
|
await page.goto('https://gitea.wellnua.com/robert/MobileApp_react_native');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-final-3.png', fullPage: true });
|
||||||
|
|
||||||
|
const title = await page.title();
|
||||||
|
console.log('8. Repo title:', title);
|
||||||
|
|
||||||
|
// Check if repo is accessible
|
||||||
|
const is404 = title.includes('Not Found') || title.includes('404');
|
||||||
|
if (!is404) {
|
||||||
|
console.log('\nSUCCESS! Repository is accessible!');
|
||||||
|
|
||||||
|
// Get clone URL
|
||||||
|
const cloneUrl = await page.locator('input[value*="git"]').first().inputValue().catch(() => '');
|
||||||
|
if (cloneUrl) {
|
||||||
|
console.log('Clone URL:', cloneUrl);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('\nRepository still not accessible');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== CREDENTIALS ===');
|
||||||
|
console.log('Gitea URL: https://gitea.wellnua.com');
|
||||||
|
console.log('Username:', USERNAME);
|
||||||
|
console.log('Password:', NEW_PASSWORD);
|
||||||
|
console.log('Email: serter2069@gmail.com');
|
||||||
|
|
||||||
|
console.log('\n=== DONE ===');
|
||||||
|
});
|
||||||
107
tests/gitea-register.spec.js
Normal file
107
tests/gitea-register.spec.js
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const GITEA_URL = 'https://gitea.wellnua.com';
|
||||||
|
const EMAIL = 'serter2069@gmail.com';
|
||||||
|
const USERNAME = 'serter2069';
|
||||||
|
const PASSWORD = 'WellNuo2025!Secure';
|
||||||
|
|
||||||
|
test('register on Gitea and accept invitation', async ({ page }) => {
|
||||||
|
console.log('=== GITEA REGISTRATION ===\n');
|
||||||
|
|
||||||
|
// Step 1: Go to Gitea
|
||||||
|
console.log('1. Opening Gitea...');
|
||||||
|
await page.goto(GITEA_URL);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-01-home.png', fullPage: true });
|
||||||
|
|
||||||
|
// Check current page
|
||||||
|
const pageContent = await page.content();
|
||||||
|
console.log(' Page title:', await page.title());
|
||||||
|
|
||||||
|
// Step 2: Find register/login link
|
||||||
|
const registerLink = page.locator('a:has-text("Register"), a:has-text("Sign Up"), a[href*="register"]').first();
|
||||||
|
const loginLink = page.locator('a:has-text("Sign In"), a:has-text("Login"), a[href*="login"]').first();
|
||||||
|
|
||||||
|
if (await registerLink.isVisible().catch(() => false)) {
|
||||||
|
console.log('2. Found Register link, clicking...');
|
||||||
|
await registerLink.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-02-register.png', fullPage: true });
|
||||||
|
|
||||||
|
// Fill registration form
|
||||||
|
console.log('3. Filling registration form...');
|
||||||
|
|
||||||
|
const usernameField = page.locator('input[name="user_name"], input[id="user_name"], input[placeholder*="Username"]').first();
|
||||||
|
const emailField = page.locator('input[name="email"], input[type="email"]').first();
|
||||||
|
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
|
||||||
|
const confirmField = page.locator('input[name="retype"], input[name="confirm_password"], input[type="password"]').nth(1);
|
||||||
|
|
||||||
|
if (await usernameField.isVisible()) {
|
||||||
|
await usernameField.fill(USERNAME);
|
||||||
|
console.log(' - Username filled');
|
||||||
|
}
|
||||||
|
if (await emailField.isVisible()) {
|
||||||
|
await emailField.fill(EMAIL);
|
||||||
|
console.log(' - Email filled');
|
||||||
|
}
|
||||||
|
if (await passwordField.isVisible()) {
|
||||||
|
await passwordField.fill(PASSWORD);
|
||||||
|
console.log(' - Password filled');
|
||||||
|
}
|
||||||
|
if (await confirmField.isVisible()) {
|
||||||
|
await confirmField.fill(PASSWORD);
|
||||||
|
console.log(' - Confirm password filled');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-03-form-filled.png', fullPage: true });
|
||||||
|
|
||||||
|
// Submit registration
|
||||||
|
const submitBtn = page.locator('button[type="submit"], input[type="submit"]').first();
|
||||||
|
if (await submitBtn.isVisible()) {
|
||||||
|
console.log('4. Submitting registration...');
|
||||||
|
await submitBtn.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-04-after-submit.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log(' Current URL:', page.url());
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (await loginLink.isVisible().catch(() => false)) {
|
||||||
|
console.log('2. Found Login link (maybe already registered), trying to login...');
|
||||||
|
await loginLink.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-02-login.png', fullPage: true });
|
||||||
|
|
||||||
|
// Try to login
|
||||||
|
const usernameField = page.locator('input[name="user_name"], input[id="user_name"]').first();
|
||||||
|
const passwordField = page.locator('input[name="password"], input[type="password"]').first();
|
||||||
|
|
||||||
|
if (await usernameField.isVisible()) {
|
||||||
|
await usernameField.fill(USERNAME);
|
||||||
|
await passwordField.fill(PASSWORD);
|
||||||
|
|
||||||
|
const submitBtn = page.locator('button[type="submit"]').first();
|
||||||
|
await submitBtn.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-03-after-login.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log(' Current URL:', page.url());
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('2. Cannot find register/login links');
|
||||||
|
console.log(' Page HTML:', pageContent.substring(0, 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Try to access the repository
|
||||||
|
console.log('5. Trying to access repository...');
|
||||||
|
await page.goto(`${GITEA_URL}/robert/MobileApp_react_native`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-05-repo.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log(' Repo URL:', page.url());
|
||||||
|
console.log(' Page title:', await page.title());
|
||||||
|
|
||||||
|
console.log('\n=== DONE ===');
|
||||||
|
});
|
||||||
44
tests/gitea-reset-password.spec.js
Normal file
44
tests/gitea-reset-password.spec.js
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const GITEA_URL = 'https://gitea.wellnua.com';
|
||||||
|
const EMAIL = 'serter2069@gmail.com';
|
||||||
|
|
||||||
|
test('reset Gitea password', async ({ page }) => {
|
||||||
|
console.log('=== GITEA PASSWORD RESET ===\n');
|
||||||
|
|
||||||
|
// Go to forgot password page
|
||||||
|
await page.goto(`${GITEA_URL}/user/forgot_password`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-forgot-1.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('1. On forgot password page');
|
||||||
|
|
||||||
|
// Fill email
|
||||||
|
const emailField = page.locator('input[name="email"], input#email, input[type="email"]').first();
|
||||||
|
if (await emailField.isVisible()) {
|
||||||
|
await emailField.fill(EMAIL);
|
||||||
|
console.log('2. Email filled:', EMAIL);
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-forgot-2.png', fullPage: true });
|
||||||
|
|
||||||
|
// Click submit
|
||||||
|
const submitBtn = page.locator('button[type="submit"], button:has-text("Send"), button:has-text("Reset")').first();
|
||||||
|
if (await submitBtn.isVisible()) {
|
||||||
|
await submitBtn.click();
|
||||||
|
console.log('3. Clicked submit button');
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-forgot-3.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('4. Current URL:', page.url());
|
||||||
|
console.log('5. Check email for reset link!');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('Email field not found');
|
||||||
|
const content = await page.content();
|
||||||
|
console.log('Page content:', content.substring(0, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== DONE ===');
|
||||||
|
});
|
||||||
78
tests/gitea-set-password.spec.js
Normal file
78
tests/gitea-set-password.spec.js
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const RESET_URL = 'https://gitea.wellnua.com/user/recover_account?code=202512111241000180718f2083d36c90ef12f385dc56ccea121af2b2437365726765695f74';
|
||||||
|
const NEW_PASSWORD = 'WellNuo2025!Secure';
|
||||||
|
const USERNAME = 'sergei_t';
|
||||||
|
|
||||||
|
test('set new Gitea password and login', async ({ page }) => {
|
||||||
|
console.log('=== SET NEW PASSWORD ===\n');
|
||||||
|
|
||||||
|
// Go to reset link
|
||||||
|
await page.goto(RESET_URL);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-reset-1.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('1. Opened reset link');
|
||||||
|
console.log(' URL:', page.url());
|
||||||
|
|
||||||
|
// Look for password fields
|
||||||
|
const passwordField = page.locator('input[name="password"], input#password, input[type="password"]').first();
|
||||||
|
const confirmField = page.locator('input[name="retype"], input[name="confirm"], input[type="password"]').nth(1);
|
||||||
|
|
||||||
|
if (await passwordField.isVisible()) {
|
||||||
|
await passwordField.fill(NEW_PASSWORD);
|
||||||
|
console.log('2. Password filled');
|
||||||
|
|
||||||
|
if (await confirmField.isVisible()) {
|
||||||
|
await confirmField.fill(NEW_PASSWORD);
|
||||||
|
console.log('3. Confirm password filled');
|
||||||
|
}
|
||||||
|
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-reset-2.png', fullPage: true });
|
||||||
|
|
||||||
|
// Submit
|
||||||
|
const submitBtn = page.locator('button[type="submit"]').first();
|
||||||
|
await submitBtn.click();
|
||||||
|
console.log('4. Submitted new password');
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-reset-3.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('5. Current URL:', page.url());
|
||||||
|
} else {
|
||||||
|
console.log('Password field not found on page');
|
||||||
|
const text = await page.textContent('body');
|
||||||
|
console.log('Page text:', text.substring(0, 500));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to login with new password
|
||||||
|
console.log('\n6. Trying to login...');
|
||||||
|
await page.goto('https://gitea.wellnua.com/user/login');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
await page.locator('input#user_name').fill(USERNAME);
|
||||||
|
await page.locator('input#password').fill(NEW_PASSWORD);
|
||||||
|
await page.locator('button:has-text("Sign In")').click();
|
||||||
|
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(2000);
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-logged-in.png', fullPage: true });
|
||||||
|
|
||||||
|
console.log('7. After login URL:', page.url());
|
||||||
|
|
||||||
|
// Access repo
|
||||||
|
console.log('\n8. Accessing repository...');
|
||||||
|
await page.goto('https://gitea.wellnua.com/robert/MobileApp_react_native');
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/gitea-repo-access.png', fullPage: true });
|
||||||
|
|
||||||
|
const title = await page.title();
|
||||||
|
console.log('9. Repo page title:', title);
|
||||||
|
|
||||||
|
if (!title.includes('Not Found')) {
|
||||||
|
console.log('SUCCESS! Repository is accessible!');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\n=== DONE ===');
|
||||||
|
});
|
||||||
104
tests/network-capture.spec.js
Normal file
104
tests/network-capture.spec.js
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
const API_BASE = 'https://react.eluxnetworks.net';
|
||||||
|
const LOGIN_CREDENTIALS = {
|
||||||
|
username: 'anandk',
|
||||||
|
password: 'anandk_8'
|
||||||
|
};
|
||||||
|
|
||||||
|
test('capture network requests during login', async ({ page }) => {
|
||||||
|
// Collect all network requests
|
||||||
|
const requests = [];
|
||||||
|
const responses = [];
|
||||||
|
|
||||||
|
page.on('request', request => {
|
||||||
|
if (request.url().includes('eluxnetworks') || request.url().includes('api')) {
|
||||||
|
requests.push({
|
||||||
|
url: request.url(),
|
||||||
|
method: request.method(),
|
||||||
|
postData: request.postData(),
|
||||||
|
headers: request.headers()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
page.on('response', async response => {
|
||||||
|
if (response.url().includes('eluxnetworks') || response.url().includes('api')) {
|
||||||
|
let body = '';
|
||||||
|
try {
|
||||||
|
body = await response.text();
|
||||||
|
} catch (e) {
|
||||||
|
body = 'Could not read body';
|
||||||
|
}
|
||||||
|
responses.push({
|
||||||
|
url: response.url(),
|
||||||
|
status: response.status(),
|
||||||
|
body: body.substring(0, 1000)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Go to dashboard (will redirect to login)
|
||||||
|
await page.goto(`${API_BASE}/dashboard`);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
|
// Fill login form
|
||||||
|
const usernameField = page.locator('input').first();
|
||||||
|
const passwordField = page.locator('input[type="password"]');
|
||||||
|
const submitButton = page.locator('button:has-text("Log In")');
|
||||||
|
|
||||||
|
await usernameField.fill(LOGIN_CREDENTIALS.username);
|
||||||
|
await passwordField.fill(LOGIN_CREDENTIALS.password);
|
||||||
|
|
||||||
|
console.log('\\n=== CLICKING LOGIN BUTTON ===\\n');
|
||||||
|
|
||||||
|
// Click login and wait for network
|
||||||
|
await submitButton.click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
await page.waitForTimeout(3000);
|
||||||
|
|
||||||
|
console.log('\\n=== ALL REQUESTS ===');
|
||||||
|
requests.forEach((req, i) => {
|
||||||
|
console.log(`\\n[${i + 1}] ${req.method} ${req.url}`);
|
||||||
|
if (req.postData) {
|
||||||
|
console.log(' POST Data:', req.postData);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\\n=== ALL RESPONSES ===');
|
||||||
|
responses.forEach((res, i) => {
|
||||||
|
console.log(`\\n[${i + 1}] ${res.status} ${res.url}`);
|
||||||
|
if (res.body && res.body !== 'Could not read body' && !res.body.includes('<!doctype')) {
|
||||||
|
console.log(' Body:', res.body.substring(0, 500));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check localStorage for token
|
||||||
|
const localStorage = await page.evaluate(() => {
|
||||||
|
const items = {};
|
||||||
|
for (let i = 0; i < window.localStorage.length; i++) {
|
||||||
|
const key = window.localStorage.key(i);
|
||||||
|
items[key] = window.localStorage.getItem(key);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\\n=== LOCAL STORAGE ===');
|
||||||
|
console.log(JSON.stringify(localStorage, null, 2));
|
||||||
|
|
||||||
|
// Check sessionStorage
|
||||||
|
const sessionStorage = await page.evaluate(() => {
|
||||||
|
const items = {};
|
||||||
|
for (let i = 0; i < window.sessionStorage.length; i++) {
|
||||||
|
const key = window.sessionStorage.key(i);
|
||||||
|
items[key] = window.sessionStorage.getItem(key);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('\\n=== SESSION STORAGE ===');
|
||||||
|
console.log(JSON.stringify(sessionStorage, null, 2));
|
||||||
|
|
||||||
|
// Take final screenshot
|
||||||
|
await page.screenshot({ path: 'tests/screenshots/network-test-final.png', fullPage: true });
|
||||||
|
});
|
||||||
6
tests/screenshots/.last-run.json
Normal file
6
tests/screenshots/.last-run.json
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": [
|
||||||
|
"703d711b83804a97c7aa-3618f1d342369de44ba0"
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -0,0 +1,126 @@
|
|||||||
|
# Page snapshot
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- generic [active] [ref=e1]:
|
||||||
|
- generic [ref=e2]:
|
||||||
|
- navigation "Navigation Bar" [ref=e3]:
|
||||||
|
- generic [ref=e4]:
|
||||||
|
- link "Dashboard" [ref=e5] [cursor=pointer]:
|
||||||
|
- /url: /
|
||||||
|
- img [ref=e6]
|
||||||
|
- link "Issues" [ref=e7] [cursor=pointer]:
|
||||||
|
- /url: /issues
|
||||||
|
- link "Pull Requests" [ref=e8] [cursor=pointer]:
|
||||||
|
- /url: /pulls
|
||||||
|
- link "Milestones" [ref=e9] [cursor=pointer]:
|
||||||
|
- /url: /milestones
|
||||||
|
- link "Explore" [ref=e10] [cursor=pointer]:
|
||||||
|
- /url: /explore/repos
|
||||||
|
- generic [ref=e11]:
|
||||||
|
- link "Notifications" [ref=e12] [cursor=pointer]:
|
||||||
|
- /url: /notifications
|
||||||
|
- img [ref=e14]
|
||||||
|
- menu "Create…" [ref=e16] [cursor=pointer]:
|
||||||
|
- generic [ref=e17]:
|
||||||
|
- img [ref=e18]
|
||||||
|
- img [ref=e21]
|
||||||
|
- menu "Profile and Settings…" [ref=e23] [cursor=pointer]:
|
||||||
|
- generic [ref=e24]:
|
||||||
|
- img "sergei_t" [ref=e25]
|
||||||
|
- img [ref=e27]
|
||||||
|
- 'main "robert/MobileApp_react_native: Scaled down mobile app written in React Native" [ref=e29]':
|
||||||
|
- generic [ref=e30]:
|
||||||
|
- generic [ref=e32]:
|
||||||
|
- generic [ref=e33]:
|
||||||
|
- img [ref=e35]
|
||||||
|
- generic [ref=e38]:
|
||||||
|
- link "robert" [ref=e39] [cursor=pointer]:
|
||||||
|
- /url: /robert
|
||||||
|
- text: /
|
||||||
|
- link "MobileApp_react_native" [ref=e40] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native
|
||||||
|
- generic [ref=e42]: Private
|
||||||
|
- generic [ref=e43]:
|
||||||
|
- link "RSS Feed" [ref=e44] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native.rss
|
||||||
|
- img [ref=e45]
|
||||||
|
- generic [ref=e48] [cursor=pointer]:
|
||||||
|
- button "Watch" [ref=e49]:
|
||||||
|
- img [ref=e50]
|
||||||
|
- generic [ref=e52]: Watch
|
||||||
|
- link "1" [ref=e53]:
|
||||||
|
- /url: /robert/MobileApp_react_native/watchers
|
||||||
|
- generic [ref=e55] [cursor=pointer]:
|
||||||
|
- button "Star" [ref=e56]:
|
||||||
|
- img [ref=e57]
|
||||||
|
- generic [ref=e59]: Star
|
||||||
|
- link "0" [ref=e60]:
|
||||||
|
- /url: /robert/MobileApp_react_native/stars
|
||||||
|
- navigation [ref=e62]:
|
||||||
|
- generic [ref=e63]:
|
||||||
|
- link "Code" [ref=e64] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native
|
||||||
|
- img [ref=e65]
|
||||||
|
- generic [ref=e67]: Code
|
||||||
|
- link "Issues" [ref=e68] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native/issues
|
||||||
|
- img [ref=e69]
|
||||||
|
- generic [ref=e72]: Issues
|
||||||
|
- link "Packages" [ref=e73] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native/packages
|
||||||
|
- img [ref=e74]
|
||||||
|
- generic [ref=e76]: Packages
|
||||||
|
- link "Projects" [ref=e77] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native/projects
|
||||||
|
- img [ref=e78]
|
||||||
|
- generic [ref=e80]: Projects
|
||||||
|
- link "Wiki" [ref=e81] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native/wiki
|
||||||
|
- img [ref=e82]
|
||||||
|
- generic [ref=e84]: Wiki
|
||||||
|
- generic [ref=e88]:
|
||||||
|
- heading "Quick Guide" [level=4] [ref=e89]
|
||||||
|
- generic [ref=e90]:
|
||||||
|
- generic [ref=e91]:
|
||||||
|
- heading "Clone this repository Need help cloning? Visit Help." [level=3] [ref=e92]:
|
||||||
|
- text: Clone this repository
|
||||||
|
- generic [ref=e93]:
|
||||||
|
- text: Need help cloning? Visit
|
||||||
|
- link "Help" [ref=e94] [cursor=pointer]:
|
||||||
|
- /url: http://git-scm.com/book/en/v2/Git-Basics-Getting-a-Git-Repository
|
||||||
|
- text: .
|
||||||
|
- generic [ref=e95]:
|
||||||
|
- link "New File" [ref=e96] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native/_new/main/
|
||||||
|
- link "Upload File" [ref=e97] [cursor=pointer]:
|
||||||
|
- /url: /robert/MobileApp_react_native/_upload/main/
|
||||||
|
- generic [ref=e98]:
|
||||||
|
- button "HTTPS" [ref=e99] [cursor=pointer]
|
||||||
|
- button "SSH" [ref=e100] [cursor=pointer]
|
||||||
|
- textbox [ref=e101]: https://gitea.wellnua.com/robert/MobileApp_react_native.git
|
||||||
|
- button "Copy URL" [ref=e102] [cursor=pointer]:
|
||||||
|
- img [ref=e103]
|
||||||
|
- generic [ref=e107]:
|
||||||
|
- heading "Creating a new repository on the command line" [level=3] [ref=e108]
|
||||||
|
- code [ref=e111]: touch README.md git init git checkout -b main git add README.md git commit -m "first commit" git remote add origin https://gitea.wellnua.com/robert/MobileApp_react_native.git git push -u origin main
|
||||||
|
- generic [ref=e113]:
|
||||||
|
- heading "Pushing an existing repository from the command line" [level=3] [ref=e114]
|
||||||
|
- code [ref=e117]: git remote add origin https://gitea.wellnua.com/robert/MobileApp_react_native.git git push -u origin main
|
||||||
|
- group "Footer" [ref=e118]:
|
||||||
|
- contentinfo "About Software" [ref=e119]:
|
||||||
|
- link "Powered by Gitea" [ref=e120] [cursor=pointer]:
|
||||||
|
- /url: https://about.gitea.com
|
||||||
|
- text: "Version: 1.23.6 Page:"
|
||||||
|
- strong [ref=e121]: 87ms
|
||||||
|
- text: "Template:"
|
||||||
|
- strong [ref=e122]: 5ms
|
||||||
|
- group "Links" [ref=e123]:
|
||||||
|
- menu [ref=e124] [cursor=pointer]:
|
||||||
|
- generic [ref=e125]:
|
||||||
|
- img [ref=e126]
|
||||||
|
- text: English
|
||||||
|
- link "Licenses" [ref=e128] [cursor=pointer]:
|
||||||
|
- /url: /assets/licenses.txt
|
||||||
|
- link "API" [ref=e129] [cursor=pointer]:
|
||||||
|
- /url: /api/swagger
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 84 KiB |
130
tests/test-chat.js
Normal file
130
tests/test-chat.js
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
// Test chat functionality with patient context
|
||||||
|
const https = require('https');
|
||||||
|
|
||||||
|
const OPENAI_API_KEY = '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';
|
||||||
|
const CONTEXT_URL = 'https://wellnuo.smartlaunchhub.com/api/patient/context';
|
||||||
|
|
||||||
|
async function fetchPatientContext() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
https.get(CONTEXT_URL, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(data));
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}).on('error', reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function askAboutPatient(context, question) {
|
||||||
|
// Build system prompt from context
|
||||||
|
const systemPrompt = `You are Julia, a caring AI health assistant for WellNuo family care system.
|
||||||
|
|
||||||
|
Current patient information:
|
||||||
|
- Name: ${context.patient.name}
|
||||||
|
- Age: ${context.patient.age}
|
||||||
|
- Relationship: ${context.patient.relationship}
|
||||||
|
- Location: ${context.current_status.location}
|
||||||
|
- Activity: ${context.current_status.estimated_activity}
|
||||||
|
|
||||||
|
Recent sleep data:
|
||||||
|
- Last night: ${context.sleep_analysis?.last_night?.total_hours || 'N/A'} hours
|
||||||
|
- Quality: ${context.sleep_analysis?.last_night?.quality_score || 'N/A'}/100
|
||||||
|
|
||||||
|
Today's activity:
|
||||||
|
- Active minutes: ${context.activity_patterns?.today?.total_active_minutes || 'N/A'}
|
||||||
|
- Rooms visited: ${context.activity_patterns?.today?.rooms_visited?.join(', ') || 'N/A'}
|
||||||
|
|
||||||
|
Environment (${context.current_status.location}):
|
||||||
|
- Temperature: ${context.environment?.living_room?.temperature_f || 'N/A'}°F
|
||||||
|
- Humidity: ${context.environment?.living_room?.humidity_percent || 'N/A'}%
|
||||||
|
- Air quality: ${context.environment?.living_room?.air_quality_status || 'N/A'}
|
||||||
|
|
||||||
|
Be warm, concise, and reassuring. Answer questions about the patient's wellbeing.`;
|
||||||
|
|
||||||
|
const requestData = JSON.stringify({
|
||||||
|
model: 'gpt-4o-mini',
|
||||||
|
messages: [
|
||||||
|
{ role: 'system', content: systemPrompt },
|
||||||
|
{ role: 'user', content: question }
|
||||||
|
],
|
||||||
|
max_tokens: 300
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const req = https.request({
|
||||||
|
hostname: 'api.openai.com',
|
||||||
|
path: '/v1/chat/completions',
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Authorization': `Bearer ${OPENAI_API_KEY}`,
|
||||||
|
'Content-Length': Buffer.byteLength(requestData)
|
||||||
|
}
|
||||||
|
}, (res) => {
|
||||||
|
let data = '';
|
||||||
|
res.on('data', chunk => data += chunk);
|
||||||
|
res.on('end', () => {
|
||||||
|
try {
|
||||||
|
const response = JSON.parse(data);
|
||||||
|
if (response.error) {
|
||||||
|
reject(new Error(response.error.message));
|
||||||
|
} else {
|
||||||
|
resolve(response.choices[0].message.content);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
req.on('error', reject);
|
||||||
|
req.write(requestData);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
console.log('=== TESTING WELLNUO CHAT ===\n');
|
||||||
|
|
||||||
|
// Step 1: Fetch patient context
|
||||||
|
console.log('1. Fetching patient context...');
|
||||||
|
let context;
|
||||||
|
try {
|
||||||
|
context = await fetchPatientContext();
|
||||||
|
console.log(` Patient: ${context.patient.name}, ${context.patient.age} years old`);
|
||||||
|
console.log(` Location: ${context.current_status.location}`);
|
||||||
|
console.log(` Activity: ${context.current_status.estimated_activity}`);
|
||||||
|
console.log(' Context API: OK\n');
|
||||||
|
} catch (e) {
|
||||||
|
console.log(` ERROR: ${e.message}\n`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Ask questions about patient
|
||||||
|
const questions = [
|
||||||
|
'How is my father doing today?',
|
||||||
|
'Did he sleep well last night?',
|
||||||
|
'Where is he right now?'
|
||||||
|
];
|
||||||
|
|
||||||
|
console.log('2. Testing AI chat responses:\n');
|
||||||
|
|
||||||
|
for (const question of questions) {
|
||||||
|
console.log(`Q: "${question}"`);
|
||||||
|
try {
|
||||||
|
const answer = await askAboutPatient(context, question);
|
||||||
|
console.log(`A: ${answer}\n`);
|
||||||
|
} catch (e) {
|
||||||
|
console.log(`ERROR: ${e.message}\n`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('=== CHAT TEST COMPLETE ===');
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error);
|
||||||
Loading…
x
Reference in New Issue
Block a user