feat: Samsung lock screen face widget, voice assistant services, PiP mode and gitignore installers
Add Samsung proprietary Face Widget (lock screen/AOD) with terminal status display. Add voice interaction services (AgentVoiceInteractionService, RecognitionService) for digital assistant registration. Add PiP mode with voice/expand actions. Add session-state proxy, voice transcript routes, window controls component. Ignore installers/ directory.
This commit is contained in:
@@ -28,6 +28,8 @@ import {
|
||||
handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
|
||||
handleHooksApprovalIgnore, handleHooksApprovalList
|
||||
} from './hooks-approval'
|
||||
import { handleSessionStateProxy } from './session-state-proxy'
|
||||
import { handleVoiceTranscript } from './voice-transcript'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -343,6 +345,17 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url)
|
||||
}
|
||||
|
||||
// Voice Transcript (Android voice assistant sends transcribed text here)
|
||||
if (path === '/api/voice-transcript') {
|
||||
const res = await handleVoiceTranscript(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Session State (proxy to terminal server for external clients like Android widget)
|
||||
if (path === '/api/session-state' && req.method === 'GET') {
|
||||
return handleSessionStateProxy(url)
|
||||
}
|
||||
|
||||
// Hooks Approval (long-poll for permission/plan decisions)
|
||||
if (path === '/api/hooks-approval') {
|
||||
if (req.method === 'GET') {
|
||||
|
||||
26
server/routes/session-state-proxy.ts
Normal file
26
server/routes/session-state-proxy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
import { PORT_TERMINAL } from '../config'
|
||||
|
||||
/**
|
||||
* Proxy GET /api/session-state → terminal server.
|
||||
* Returns session-state + terminal-registry combined,
|
||||
* so external clients (Android widget) get everything in one call.
|
||||
*/
|
||||
export async function handleSessionStateProxy(url: URL): Promise<Response> {
|
||||
try {
|
||||
const [stateResp, registryResp] = await Promise.all([
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/session-state`),
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
|
||||
])
|
||||
|
||||
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
|
||||
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
|
||||
|
||||
return jsonResponse({
|
||||
agents: stateData.agents ?? {},
|
||||
registry: registryData.registry ?? []
|
||||
})
|
||||
} catch (e: any) {
|
||||
return errorResponse(`Failed to reach terminal server: ${e.message}`, 502)
|
||||
}
|
||||
}
|
||||
105
server/routes/voice-transcript.ts
Normal file
105
server/routes/voice-transcript.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { jsonResponse } from '../utils/cors'
|
||||
import { PORT_TERMINAL } from '../config'
|
||||
|
||||
export async function handleVoiceTranscript(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const body = await req.json() as { text?: string; timestamp?: string; source?: string }
|
||||
const { text, timestamp, source } = body
|
||||
|
||||
if (!text) {
|
||||
return jsonResponse({ error: 'Missing "text" field' }, 400)
|
||||
}
|
||||
|
||||
const ts = timestamp || new Date().toISOString()
|
||||
const src = source || 'android-voice'
|
||||
|
||||
console.log(`\n🎙️ [VOICE TRANSCRIPT] ────────────────────────`)
|
||||
console.log(` Source: ${src}`)
|
||||
console.log(` Time: ${ts}`)
|
||||
console.log(` Text: "${text}"`)
|
||||
|
||||
// Find first alive terminal and send the text as input
|
||||
const result = await sendToFirstTerminal(text)
|
||||
|
||||
console.log(` Terminal: ${result.terminal || 'none found'}`)
|
||||
console.log(` Status: ${result.sent ? 'sent ✓' : result.error || 'no terminal'}`)
|
||||
console.log(` ──────────────────────────────────────────────\n`)
|
||||
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
received: text,
|
||||
timestamp: ts,
|
||||
source: src,
|
||||
sentToTerminal: result.sent,
|
||||
terminal: result.terminal || null,
|
||||
ephemeralSessionId: result.ephemeralSessionId || null
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[voice-transcript] Parse error:', e.message)
|
||||
return jsonResponse({ error: 'Invalid JSON body' }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToFirstTerminal(text: string): Promise<{ sent: boolean; terminal?: string; ephemeralSessionId?: string; error?: string }> {
|
||||
try {
|
||||
// Fetch terminal registry to find alive terminals
|
||||
const res = await fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
|
||||
if (!res.ok) {
|
||||
return { sent: false, error: `registry fetch failed: ${res.status}` }
|
||||
}
|
||||
|
||||
const registry = await res.json() as Array<{
|
||||
ephemeralSessionId: string
|
||||
agent: string
|
||||
label: string
|
||||
alive: boolean
|
||||
}>
|
||||
|
||||
// Find first alive terminal
|
||||
const target = registry.find(t => t.alive)
|
||||
if (!target) {
|
||||
return { sent: false, error: 'no alive terminals' }
|
||||
}
|
||||
|
||||
// Connect via WebSocket and send the text as input
|
||||
const wsUrl = `ws://localhost:${PORT_TERMINAL}/ws/terminal?session=${target.ephemeralSessionId}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
try { ws.close() } catch {}
|
||||
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws timeout' })
|
||||
}, 5000)
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send the transcribed text
|
||||
ws.send(JSON.stringify({ type: 'input', data: text }))
|
||||
|
||||
// Send Enter after a short delay
|
||||
setTimeout(() => {
|
||||
ws.send(JSON.stringify({ type: 'input', data: '\r' }))
|
||||
|
||||
// Close after sending
|
||||
setTimeout(() => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve({
|
||||
sent: true,
|
||||
terminal: `${target.ephemeralSessionId} (${target.agent})`,
|
||||
ephemeralSessionId: target.ephemeralSessionId
|
||||
})
|
||||
}, 150)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws connection error' })
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
return { sent: false, error: e.message }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user