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:
15
.gitignore
vendored
15
.gitignore
vendored
@@ -9,6 +9,9 @@ nul
|
|||||||
# Voice recordings (training data)
|
# Voice recordings (training data)
|
||||||
server/recordings/*.webm
|
server/recordings/*.webm
|
||||||
|
|
||||||
|
# Installers / APKs
|
||||||
|
installers/
|
||||||
|
|
||||||
# Tauri build artifacts
|
# Tauri build artifacts
|
||||||
src-tauri/target/
|
src-tauri/target/
|
||||||
src-tauri/installers/
|
src-tauri/installers/
|
||||||
@@ -31,12 +34,24 @@ src-tauri/gen/android/app/src/main/res/*
|
|||||||
!src-tauri/gen/android/app/src/main/res/layout/
|
!src-tauri/gen/android/app/src/main/res/layout/
|
||||||
src-tauri/gen/android/app/src/main/res/layout/*
|
src-tauri/gen/android/app/src/main/res/layout/*
|
||||||
!src-tauri/gen/android/app/src/main/res/layout/widget_transcript.xml
|
!src-tauri/gen/android/app/src/main/res/layout/widget_transcript.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/layout/widget_terminal_item.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/layout/face_widget_lockscreen.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/layout/face_widget_aod.xml
|
||||||
!src-tauri/gen/android/app/src/main/res/xml/
|
!src-tauri/gen/android/app/src/main/res/xml/
|
||||||
src-tauri/gen/android/app/src/main/res/xml/*
|
src-tauri/gen/android/app/src/main/res/xml/*
|
||||||
!src-tauri/gen/android/app/src/main/res/xml/transcript_widget_info.xml
|
!src-tauri/gen/android/app/src/main/res/xml/transcript_widget_info.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/xml/voice_interaction_service.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/xml/recognition_service.xml
|
||||||
!src-tauri/gen/android/app/src/main/res/values/
|
!src-tauri/gen/android/app/src/main/res/values/
|
||||||
src-tauri/gen/android/app/src/main/res/values/*
|
src-tauri/gen/android/app/src/main/res/values/*
|
||||||
!src-tauri/gen/android/app/src/main/res/values/strings.xml
|
!src-tauri/gen/android/app/src/main/res/values/strings.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/raw/
|
||||||
|
src-tauri/gen/android/app/src/main/res/raw/*
|
||||||
|
!src-tauri/gen/android/app/src/main/res/raw/facewidgets.json
|
||||||
|
!src-tauri/gen/android/app/src/main/res/drawable/
|
||||||
|
src-tauri/gen/android/app/src/main/res/drawable/*
|
||||||
|
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_dark.xml
|
||||||
|
!src-tauri/gen/android/app/src/main/res/drawable/face_widget_bg_aod.xml
|
||||||
src-tauri/gen/android/keystore.jks
|
src-tauri/gen/android/keystore.jks
|
||||||
|
|
||||||
# Old frontend Tauri location
|
# Old frontend Tauri location
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue
|
|||||||
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||||
import HooksApprovalModal from './components/HooksApprovalModal.vue'
|
import HooksApprovalModal from './components/HooksApprovalModal.vue'
|
||||||
import ServerConfigDialog from './components/ServerConfigDialog.vue'
|
import ServerConfigDialog from './components/ServerConfigDialog.vue'
|
||||||
|
import WindowControls from './components/WindowControls.vue'
|
||||||
import { useGlobalApproval } from './composables/useGlobalApproval'
|
import { useGlobalApproval } from './composables/useGlobalApproval'
|
||||||
import { initWebMCP, getWebMCP } from './services/webmcp'
|
import { initWebMCP, getWebMCP } from './services/webmcp'
|
||||||
import { initTorch, destroyTorch } from './services/torch'
|
import { initTorch, destroyTorch } from './services/torch'
|
||||||
@@ -20,7 +21,7 @@ import { setResponseControls } from './services/tools/handlers/responseHandlers'
|
|||||||
import { useCanvasStore } from './stores/canvas'
|
import { useCanvasStore } from './stores/canvas'
|
||||||
import { useProjectCanvasStore } from './stores/projectCanvas'
|
import { useProjectCanvasStore } from './stores/projectCanvas'
|
||||||
import { useSessionState } from './stores/session-state'
|
import { useSessionState } from './stores/session-state'
|
||||||
import { isTauri } from './lib/tauri'
|
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
|
||||||
import { useServerConfig } from './stores/server-config'
|
import { useServerConfig } from './stores/server-config'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -35,7 +36,7 @@ const showVoice = ref(false)
|
|||||||
const showTranscriptDebug = ref(false)
|
const showTranscriptDebug = ref(false)
|
||||||
const showDebugConsole = ref(false)
|
const showDebugConsole = ref(false)
|
||||||
const toolbarVisible = ref(true)
|
const toolbarVisible = ref(true)
|
||||||
const forceWco = ref(false)
|
const forceWco = ref(isTauri && !isMobileTauri())
|
||||||
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
|
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
|
||||||
|
|
||||||
// Intercept console.log for debug panel
|
// Intercept console.log for debug panel
|
||||||
@@ -222,6 +223,31 @@ function syncThemeColor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
|
// Bridge for Android widget navigation (called from MainActivity via evaluateJavascript)
|
||||||
|
;(window as any).__WIDGET_NAVIGATE__ = (route: string) => {
|
||||||
|
router.push(route)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bridge for Android voice assistant — opens FloatingTranscriptDebug on the target terminal
|
||||||
|
;(window as any).__VOICE_OPEN_TERMINAL__ = (ephemeralSessionId: string) => {
|
||||||
|
const entry = sessionState.terminalRegistry.find(
|
||||||
|
t => t.ephemeralSessionId === ephemeralSessionId
|
||||||
|
)
|
||||||
|
if (entry && transcriptDebugRef.value) {
|
||||||
|
transcriptDebugRef.value.switchToTerminal(entry.transcriptSessionId)
|
||||||
|
showTranscriptDebug.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Fallback: open on first terminal
|
||||||
|
if (sessionState.terminalRegistry.length && transcriptDebugRef.value) {
|
||||||
|
transcriptDebugRef.value.switchToTerminal(sessionState.terminalRegistry[0].transcriptSessionId)
|
||||||
|
showTranscriptDebug.value = true
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// Sync Windows titlebar color with CSS variable
|
// Sync Windows titlebar color with CSS variable
|
||||||
syncThemeColor()
|
syncThemeColor()
|
||||||
|
|
||||||
@@ -287,6 +313,34 @@ onMounted(async () => {
|
|||||||
await torchReady
|
await torchReady
|
||||||
})
|
})
|
||||||
|
|
||||||
|
async function sendTestNotification() {
|
||||||
|
const title = 'Agent UI'
|
||||||
|
const body = 'Test notification from Agent UI — all platforms!'
|
||||||
|
|
||||||
|
if (isTauri) {
|
||||||
|
try {
|
||||||
|
const { isPermissionGranted, requestPermission, sendNotification } = await getTauriNotification()
|
||||||
|
let granted = await isPermissionGranted()
|
||||||
|
if (!granted) {
|
||||||
|
const perm = await requestPermission()
|
||||||
|
granted = perm === 'granted'
|
||||||
|
}
|
||||||
|
if (granted) {
|
||||||
|
sendNotification({ title, body })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[Notification] Tauri plugin failed:', e)
|
||||||
|
}
|
||||||
|
} else if ('Notification' in window) {
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
new Notification(title, { body })
|
||||||
|
} else if (Notification.permission !== 'denied') {
|
||||||
|
const perm = await Notification.requestPermission()
|
||||||
|
if (perm === 'granted') new Notification(title, { body })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
document.removeEventListener('mousemove', trackMouse)
|
document.removeEventListener('mousemove', trackMouse)
|
||||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||||
@@ -367,6 +421,12 @@ if (serverConfig) {
|
|||||||
</svg>
|
</svg>
|
||||||
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
|
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="refresh-btn" @click="sendTestNotification" title="Test notification">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
|
||||||
|
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
|
<button class="refresh-btn" @click="hardRefresh" title="Hard refresh (Ctrl+F5)">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
|
<path d="M21 12a9 9 0 1 1-9-9c2.52 0 4.93 1 6.74 2.74L21 8"/>
|
||||||
@@ -375,6 +435,7 @@ if (serverConfig) {
|
|||||||
</button>
|
</button>
|
||||||
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
|
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
|
||||||
<TorchButton />
|
<TorchButton />
|
||||||
|
<WindowControls />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="app-main">
|
<main class="app-main">
|
||||||
@@ -503,9 +564,9 @@ if (serverConfig) {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
|
padding-top: calc(0.5rem + var(--sat, env(safe-area-inset-top, 0px)));
|
||||||
padding-left: calc(1rem + env(safe-area-inset-left, 0px));
|
padding-left: calc(1rem + var(--sal, env(safe-area-inset-left, 0px)));
|
||||||
padding-right: calc(1rem + env(safe-area-inset-right, 0px));
|
padding-right: calc(1rem + var(--sar, env(safe-area-inset-right, 0px)));
|
||||||
background: var(--bg-primary);
|
background: var(--bg-primary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -537,6 +598,7 @@ if (serverConfig) {
|
|||||||
min-height: 32px;
|
min-height: 32px;
|
||||||
max-height: 32px;
|
max-height: 32px;
|
||||||
padding: 0 0.5rem;
|
padding: 0 0.5rem;
|
||||||
|
padding-right: 0;
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|||||||
131
frontend/src/components/WindowControls.vue
Normal file
131
frontend/src/components/WindowControls.vue
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted } from 'vue'
|
||||||
|
import { isTauri, isMobileTauri, getTauriWindow } from '../lib/tauri'
|
||||||
|
|
||||||
|
const isDesktopTauri = isTauri && !isMobileTauri()
|
||||||
|
|
||||||
|
const isMaximized = ref(false)
|
||||||
|
let unlisten: (() => void) | null = null
|
||||||
|
|
||||||
|
async function initWindowState() {
|
||||||
|
if (!isDesktopTauri) return
|
||||||
|
try {
|
||||||
|
const win = await getTauriWindow()
|
||||||
|
isMaximized.value = await win.isMaximized()
|
||||||
|
unlisten = await win.onResized(async () => {
|
||||||
|
isMaximized.value = await win.isMaximized()
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WindowControls] init failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function minimizeWindow() {
|
||||||
|
try {
|
||||||
|
const win = await getTauriWindow()
|
||||||
|
await win.minimize()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WindowControls] minimize failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMaximize() {
|
||||||
|
try {
|
||||||
|
const win = await getTauriWindow()
|
||||||
|
await win.toggleMaximize()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WindowControls] toggleMaximize failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeWindow() {
|
||||||
|
try {
|
||||||
|
const win = await getTauriWindow()
|
||||||
|
await win.close()
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('[WindowControls] close failed:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
initWindowState()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (unlisten) {
|
||||||
|
unlisten()
|
||||||
|
unlisten = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="isDesktopTauri" class="window-controls">
|
||||||
|
<button class="wc-btn wc-minimize" @click="minimizeWindow" title="Minimize">
|
||||||
|
<svg width="10" height="1" viewBox="0 0 10 1">
|
||||||
|
<rect width="10" height="1" fill="currentColor" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="wc-btn wc-maximize" @click="toggleMaximize" :title="isMaximized ? 'Restore' : 'Maximize'">
|
||||||
|
<svg v-if="!isMaximized" width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<rect x="0.5" y="0.5" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
<svg v-else width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<rect x="2.5" y="0.5" width="7" height="7" fill="none" stroke="currentColor" stroke-width="1" />
|
||||||
|
<rect x="0.5" y="2.5" width="7" height="7" fill="var(--bg-primary, #0f0f14)" stroke="currentColor" stroke-width="1" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button class="wc-btn wc-close" @click="closeWindow" title="Close">
|
||||||
|
<svg width="10" height="10" viewBox="0 0 10 10">
|
||||||
|
<line x1="0" y1="0" x2="10" y2="10" stroke="currentColor" stroke-width="1.2" />
|
||||||
|
<line x1="10" y1="0" x2="0" y2="10" stroke="currentColor" stroke-width="1.2" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.window-controls {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
height: 100%;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
-webkit-app-region: no-drag;
|
||||||
|
app-region: no-drag;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 46px;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary, #a1a1aa);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.1s ease, color 0.1s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-btn:hover {
|
||||||
|
background: var(--bg-hover, #1e1e28);
|
||||||
|
color: var(--text-primary, #e4e4e7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-btn:active {
|
||||||
|
background: var(--border-color, #2a2a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-close:hover {
|
||||||
|
background: #e81123;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wc-close:active {
|
||||||
|
background: #c50f1f;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -83,3 +83,8 @@ export async function getTauriClipboard() {
|
|||||||
export async function getTauriDialog() {
|
export async function getTauriDialog() {
|
||||||
return import('@tauri-apps/plugin-dialog')
|
return import('@tauri-apps/plugin-dialog')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getTauriWindow() {
|
||||||
|
const { getCurrentWindow } = await import('@tauri-apps/api/window')
|
||||||
|
return getCurrentWindow()
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,38 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
import { useTranscriptDebug } from '@/composables/transcript-debug'
|
||||||
import { useVoiceInput } from '@/composables/useVoiceInput'
|
import { useVoiceInput } from '@/composables/useVoiceInput'
|
||||||
import { SessionSelector, RawJsonViewer, ChatContainer } from '@/components/transcript-debug'
|
import { useSessionState } from '@/stores/session-state'
|
||||||
|
import { ChatContainer, AquaticBackground, AgentBadge, NewSessionModal } from '@/components/transcript-debug'
|
||||||
import type { AgentName } from '@/types/transcript-debug'
|
import type { AgentName } from '@/types/transcript-debug'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const sessionState = useSessionState()
|
||||||
|
|
||||||
const {
|
const {
|
||||||
selectedAgent,
|
selectedAgent,
|
||||||
sessions,
|
sessions,
|
||||||
selectedSessionId,
|
selectedSessionId,
|
||||||
rawContent,
|
|
||||||
conversation,
|
conversation,
|
||||||
loading,
|
loading,
|
||||||
transitioning,
|
transitioning,
|
||||||
|
transitionError,
|
||||||
error,
|
error,
|
||||||
lineCount,
|
|
||||||
isRealtime,
|
isRealtime,
|
||||||
sending,
|
|
||||||
processing,
|
processing,
|
||||||
ephemeral,
|
ephemeral,
|
||||||
terminalReady,
|
terminalReady,
|
||||||
hookMeta,
|
hookMeta,
|
||||||
|
openTerminals,
|
||||||
|
activeTerminalSessionId,
|
||||||
init,
|
init,
|
||||||
switchAgent,
|
switchAgent,
|
||||||
selectSession,
|
selectSession,
|
||||||
createNewSession,
|
createNewSession,
|
||||||
|
switchToTerminal,
|
||||||
|
closeTerminal,
|
||||||
disconnectRealtime,
|
disconnectRealtime,
|
||||||
sendPrompt
|
sendPrompt
|
||||||
} = useTranscriptDebug()
|
} = useTranscriptDebug()
|
||||||
@@ -48,21 +56,117 @@ const agents: { id: AgentName; label: string }[] = [
|
|||||||
{ id: 'claude', label: 'Claude' }
|
{ id: 'claude', label: 'Claude' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const chatRef = ref<InstanceType<typeof ChatContainer> | null>(null)
|
||||||
|
const showSelector = ref(false)
|
||||||
|
const showNewSessionModal = ref(false)
|
||||||
|
|
||||||
|
// Readability overlay
|
||||||
|
const savedOverlay = localStorage.getItem('transcript-overlay-opacity')
|
||||||
|
const overlayOpacity = ref(savedOverlay !== null ? parseFloat(savedOverlay) : 0.55)
|
||||||
|
function setOverlayOpacity(val: number) {
|
||||||
|
overlayOpacity.value = val
|
||||||
|
localStorage.setItem('transcript-overlay-opacity', String(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Input max lines
|
||||||
|
const savedMaxLines = localStorage.getItem('transcript-input-max-lines')
|
||||||
|
const inputMaxLines = ref(savedMaxLines !== null ? parseInt(savedMaxLines) : 6)
|
||||||
|
function setInputMaxLines(val: number) {
|
||||||
|
inputMaxLines.value = val
|
||||||
|
localStorage.setItem('transcript-input-max-lines', String(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll nav mode
|
||||||
|
type ScrollNavMode = 'scrollbar' | 'buttons' | 'none'
|
||||||
|
const savedScrollNav = localStorage.getItem('transcript-scroll-nav') as ScrollNavMode | null
|
||||||
|
const scrollNavMode = ref<ScrollNavMode>(
|
||||||
|
savedScrollNav && ['scrollbar', 'buttons', 'none'].includes(savedScrollNav) ? savedScrollNav : 'buttons'
|
||||||
|
)
|
||||||
|
function setScrollNavMode(val: ScrollNavMode) {
|
||||||
|
scrollNavMode.value = val
|
||||||
|
localStorage.setItem('transcript-scroll-nav', val)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll jump percent
|
||||||
|
const savedScrollJump = localStorage.getItem('transcript-scroll-jump')
|
||||||
|
const scrollJumpPercent = ref(savedScrollJump !== null ? parseInt(savedScrollJump) : 50)
|
||||||
|
function setScrollJumpPercent(val: number) {
|
||||||
|
scrollJumpPercent.value = val
|
||||||
|
localStorage.setItem('transcript-scroll-jump', String(val))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active terminal index label
|
||||||
|
const terminalLabel = computed(() => {
|
||||||
|
if (!activeTerminalSessionId.value) return null
|
||||||
|
const idx = sessionState.terminalRegistry.findIndex(
|
||||||
|
e => e.transcriptSessionId === activeTerminalSessionId.value
|
||||||
|
)
|
||||||
|
return idx >= 0 ? `T${idx + 1}` : null
|
||||||
|
})
|
||||||
|
|
||||||
function handleSend(message: string) {
|
function handleSend(message: string) {
|
||||||
voice.clearTranscript()
|
voice.clearTranscript()
|
||||||
sendPrompt(message)
|
sendPrompt(message)
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleCreateSession() {
|
function handleAgentSwitch(agent: AgentName) {
|
||||||
createNewSession()
|
switchAgent(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
function handleSessionSelect(sessionId: string) {
|
||||||
init()
|
selectSession(sessionId)
|
||||||
await voice.init()
|
showSelector.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCreateSession() {
|
||||||
|
showNewSessionModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleModalCreateNew(agent: AgentName, initialPrompt: string) {
|
||||||
|
showNewSessionModal.value = false
|
||||||
|
if (agent !== selectedAgent.value) {
|
||||||
|
switchAgent(agent)
|
||||||
|
}
|
||||||
|
await createNewSession()
|
||||||
|
if (initialPrompt.trim()) {
|
||||||
|
sendPrompt(initialPrompt.trim())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleTerminalSwitch(sessionId: string) {
|
||||||
|
const idx = sessionState.terminalRegistry.findIndex(
|
||||||
|
e => e.transcriptSessionId === sessionId
|
||||||
|
)
|
||||||
|
if (idx >= 0) {
|
||||||
|
router.push({ name: 'transcript-debug-terminal', params: { terminalIndex: String(idx + 1) } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve terminalIndex param → sessionId and switch
|
||||||
|
function syncTerminalFromRoute() {
|
||||||
|
const param = route.params.terminalIndex
|
||||||
|
if (!param) return
|
||||||
|
const idx = parseInt(param as string) - 1
|
||||||
|
const entry = sessionState.terminalRegistry[idx]
|
||||||
|
if (entry && entry.transcriptSessionId !== activeTerminalSessionId.value) {
|
||||||
|
switchToTerminal(entry.transcriptSessionId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.params.terminalIndex, () => syncTerminalFromRoute())
|
||||||
|
|
||||||
|
// Also react when registry populates (it may arrive after mount)
|
||||||
|
watch(() => sessionState.terminalRegistry.length, () => {
|
||||||
|
if (route.params.terminalIndex) syncTerminalFromRoute()
|
||||||
})
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onMounted(async () => {
|
||||||
|
await init()
|
||||||
|
await voice.init()
|
||||||
|
syncTerminalFromRoute()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
disconnectRealtime()
|
disconnectRealtime()
|
||||||
voice.cleanup()
|
voice.cleanup()
|
||||||
})
|
})
|
||||||
@@ -70,93 +174,166 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="transcript-debug-page">
|
<div class="transcript-debug-page">
|
||||||
<!-- Header -->
|
<!-- Terminal selector strip -->
|
||||||
<header class="page-header">
|
<div class="terminal-strip">
|
||||||
<div class="header-left">
|
<div class="strip-left">
|
||||||
<h2>Transcript Debug</h2>
|
<button
|
||||||
<span :class="['realtime-dot', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
|
v-for="(entry, idx) in sessionState.terminalRegistry"
|
||||||
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
|
:key="entry.transcriptSessionId"
|
||||||
</span>
|
:class="[
|
||||||
|
'strip-terminal-btn',
|
||||||
|
{
|
||||||
|
active: String(idx + 1) === route.params.terminalIndex || (!route.params.terminalIndex && entry.transcriptSessionId === activeTerminalSessionId),
|
||||||
|
dead: !entry.alive
|
||||||
|
}
|
||||||
|
]"
|
||||||
|
@click="handleTerminalSwitch(entry.transcriptSessionId)"
|
||||||
|
:title="entry.label"
|
||||||
|
>
|
||||||
|
<span :class="['strip-dot', { alive: entry.alive, dead: !entry.alive }]"></span>
|
||||||
|
T{{ idx + 1 }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="header-selectors">
|
<div class="strip-right">
|
||||||
<!-- Agent selector -->
|
<AgentBadge
|
||||||
<div class="agent-selector">
|
v-if="selectedAgent"
|
||||||
<button
|
:agent="selectedAgent"
|
||||||
v-for="a in agents"
|
:connected="!!activeTerminalSessionId"
|
||||||
:key="a.id"
|
:terminals="openTerminals"
|
||||||
:class="['agent-btn', { active: selectedAgent === a.id }]"
|
:active-session-id="activeTerminalSessionId"
|
||||||
@click="switchAgent(a.id)"
|
:model="conversation?.model"
|
||||||
>
|
:version="conversation?.version"
|
||||||
{{ a.label }}
|
@switch-terminal="handleTerminalSwitch"
|
||||||
</button>
|
@close-terminal="closeTerminal"
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Session selector -->
|
|
||||||
<SessionSelector
|
|
||||||
:sessions="sessions"
|
|
||||||
:selected-id="selectedSessionId"
|
|
||||||
:loading="loading"
|
|
||||||
@select="selectSession"
|
|
||||||
/>
|
/>
|
||||||
|
<span :class="['realtime-dot', { connected: isRealtime }]">
|
||||||
|
<svg width="6" height="6" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
@click.stop="chatRef?.collapseAllExceptLast()"
|
||||||
|
:class="['strip-btn', { active: chatRef?.allCollapsed }]"
|
||||||
|
title="Collapse all except last"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline v-if="chatRef?.allCollapsed" points="6 9 12 15 18 9"/>
|
||||||
|
<template v-else><polyline points="18 15 12 9 6 15"/></template>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="chatRef?.toggleSelectMode()"
|
||||||
|
:class="['strip-btn', { active: chatRef?.selectMode }]"
|
||||||
|
title="Select messages"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<polyline v-if="chatRef?.selectMode" points="20 6 9 17 4 12"/>
|
||||||
|
<template v-else>
|
||||||
|
<rect x="3" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="3" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="3" y="14" width="7" height="7" rx="1"/>
|
||||||
|
<rect x="14" y="14" width="7" height="7" rx="1"/>
|
||||||
|
</template>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
@click.stop="showSelector = !showSelector"
|
||||||
|
:class="['strip-btn', { active: showSelector }]"
|
||||||
|
title="Agent/Session selector"
|
||||||
|
>
|
||||||
|
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="3"/>
|
||||||
|
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</div>
|
||||||
|
|
||||||
<!-- Error -->
|
<!-- Error -->
|
||||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||||
|
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="content-area">
|
<div :class="['content-area', { 'selector-open': showSelector }]">
|
||||||
<div v-if="!selectedSessionId" :class="['empty-state', { fading: transitioning }]">
|
<AquaticBackground />
|
||||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
|
|
||||||
<polyline points="14 2 14 8 20 8" />
|
<Transition name="terminal-loading">
|
||||||
<line x1="16" y1="13" x2="8" y2="13" />
|
<div v-if="transitioning" class="loading-overlay">
|
||||||
<line x1="16" y1="17" x2="8" y2="17" />
|
<div class="loading-spinner" />
|
||||||
<polyline points="10 9 9 9 8 9" />
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<Transition name="terminal-loading">
|
||||||
|
<div v-if="transitionError" class="error-overlay" @click="transitionError = null">
|
||||||
|
<div class="error-content">
|
||||||
|
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="#f87171" stroke-width="2">
|
||||||
|
<circle cx="12" cy="12" r="10"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/>
|
||||||
|
</svg>
|
||||||
|
<span class="error-msg">{{ transitionError }}</span>
|
||||||
|
<span class="error-hint">Click to dismiss</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Transition>
|
||||||
|
|
||||||
|
<ChatContainer
|
||||||
|
ref="chatRef"
|
||||||
|
v-if="conversation"
|
||||||
|
:conversation="conversation"
|
||||||
|
:processing="processing"
|
||||||
|
:terminal-ready="terminalReady"
|
||||||
|
:terminal="ephemeral"
|
||||||
|
:show-selector="showSelector"
|
||||||
|
:agents="agents"
|
||||||
|
:selected-agent="selectedAgent"
|
||||||
|
:sessions="sessions"
|
||||||
|
:selected-session-id="selectedSessionId"
|
||||||
|
:sessions-loading="loading"
|
||||||
|
:voice-mode="voiceMode"
|
||||||
|
:whisper-status="whisperStatus"
|
||||||
|
:audio-devices="audioDevices"
|
||||||
|
:selected-device-id="selectedDeviceId"
|
||||||
|
:is-recording="voiceRecording"
|
||||||
|
:voice-transcript="voiceTranscript + voiceInterim"
|
||||||
|
:last-audio-url="lastAudioUrl"
|
||||||
|
:is-playing-audio="isPlayingAudio"
|
||||||
|
:overlay-opacity="overlayOpacity"
|
||||||
|
:input-max-lines="inputMaxLines"
|
||||||
|
:scroll-jump-percent="scrollJumpPercent"
|
||||||
|
:scroll-nav-mode="scrollNavMode"
|
||||||
|
:hook-permission-mode="hookMeta.permissionMode"
|
||||||
|
@send="handleSend"
|
||||||
|
@switch-agent="handleAgentSwitch"
|
||||||
|
@select-session="handleSessionSelect"
|
||||||
|
@create-session="handleCreateSession"
|
||||||
|
@close-session="closeTerminal"
|
||||||
|
@start-recording="voice.startRecording()"
|
||||||
|
@stop-recording="voice.stopRecording()"
|
||||||
|
@set-voice-mode="voice.setMode($event)"
|
||||||
|
@select-microphone="voice.selectMicrophone($event)"
|
||||||
|
@play-last-audio="voice.playLastAudio()"
|
||||||
|
@update:overlay-opacity="setOverlayOpacity"
|
||||||
|
@update:input-max-lines="setInputMaxLines"
|
||||||
|
@update:scroll-jump-percent="setScrollJumpPercent"
|
||||||
|
@update:scroll-nav-mode="setScrollNavMode"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div v-else-if="!transitioning" class="empty-state">
|
||||||
|
<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
||||||
</svg>
|
</svg>
|
||||||
<p>Select a transcript session to begin</p>
|
<span>No active terminal</span>
|
||||||
<span>{{ sessions.length }} sessions available</span>
|
<small v-if="!sessionState.terminalRegistry.length">No terminals registered</small>
|
||||||
</div>
|
<small v-else>Select a terminal above to begin</small>
|
||||||
|
|
||||||
<div v-else class="split-panels">
|
|
||||||
<!-- Left: Raw JSONL -->
|
|
||||||
<div :class="['panel-left', { fading: transitioning }]">
|
|
||||||
<RawJsonViewer :content="rawContent" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Resize handle -->
|
|
||||||
<div class="resize-handle"></div>
|
|
||||||
|
|
||||||
<!-- Right: Chat -->
|
|
||||||
<div :class="['panel-right', { fading: transitioning }]">
|
|
||||||
<ChatContainer
|
|
||||||
v-if="conversation"
|
|
||||||
:conversation="conversation"
|
|
||||||
:processing="processing"
|
|
||||||
:terminal-ready="terminalReady"
|
|
||||||
:terminal="ephemeral"
|
|
||||||
:selected-agent="selectedAgent"
|
|
||||||
:voice-mode="voiceMode"
|
|
||||||
:whisper-status="whisperStatus"
|
|
||||||
:audio-devices="audioDevices"
|
|
||||||
:selected-device-id="selectedDeviceId"
|
|
||||||
:is-recording="voiceRecording"
|
|
||||||
:voice-transcript="voiceTranscript + voiceInterim"
|
|
||||||
:last-audio-url="lastAudioUrl"
|
|
||||||
:is-playing-audio="isPlayingAudio"
|
|
||||||
:hook-permission-mode="hookMeta.permissionMode"
|
|
||||||
@send="handleSend"
|
|
||||||
@create-session="handleCreateSession"
|
|
||||||
@start-recording="voice.startRecording()"
|
|
||||||
@stop-recording="voice.stopRecording()"
|
|
||||||
@set-voice-mode="voice.setMode($event)"
|
|
||||||
@select-microphone="voice.selectMicrophone($event)"
|
|
||||||
@play-last-audio="voice.playLastAudio()"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- New session modal -->
|
||||||
|
<NewSessionModal
|
||||||
|
v-if="showNewSessionModal"
|
||||||
|
:agents="agents"
|
||||||
|
:default-agent="selectedAgent"
|
||||||
|
@create="handleModalCreateNew"
|
||||||
|
@close="showNewSessionModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -170,29 +347,70 @@ onUnmounted(() => {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-header {
|
/* ── Terminal strip ── */
|
||||||
|
|
||||||
|
.terminal-strip {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 0.5rem 1rem;
|
padding: 3px 0.75rem;
|
||||||
background: var(--bg-secondary);
|
background: var(--bg-secondary);
|
||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 1rem;
|
gap: 0.5rem;
|
||||||
|
min-height: 32px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.strip-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-terminal-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 10px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-terminal-btn:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border-color: var(--accent, #0ea5e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-terminal-btn.active {
|
||||||
|
background: var(--accent, #0ea5e9);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent, #0ea5e9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-terminal-btn.dead:not(.active) {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.strip-dot {
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
}
|
||||||
|
.strip-dot.alive { background: #22c55e; }
|
||||||
|
.strip-dot.dead { background: #ef4444; }
|
||||||
|
|
||||||
|
.strip-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left h2 {
|
|
||||||
font-size: 15px;
|
|
||||||
font-weight: 600;
|
|
||||||
margin: 0;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.realtime-dot {
|
.realtime-dot {
|
||||||
@@ -200,7 +418,6 @@ onUnmounted(() => {
|
|||||||
opacity: 0.4;
|
opacity: 0.4;
|
||||||
transition: all 0.3s;
|
transition: all 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.realtime-dot.connected {
|
.realtime-dot.connected {
|
||||||
color: #22c55e;
|
color: #22c55e;
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
@@ -212,50 +429,30 @@ onUnmounted(() => {
|
|||||||
50% { filter: drop-shadow(0 0 6px currentColor); }
|
50% { filter: drop-shadow(0 0 6px currentColor); }
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-selectors {
|
.strip-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
justify-content: center;
|
||||||
flex: 1;
|
width: 26px;
|
||||||
min-width: 0;
|
height: 26px;
|
||||||
}
|
|
||||||
|
|
||||||
/* Agent selector */
|
|
||||||
.agent-selector {
|
|
||||||
display: flex;
|
|
||||||
background: var(--bg-primary);
|
|
||||||
border: 1px solid var(--border-color);
|
|
||||||
border-radius: 6px;
|
|
||||||
overflow: hidden;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-btn {
|
|
||||||
padding: 0.35rem 0.75rem;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
font-size: 12px;
|
|
||||||
font-weight: 500;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
.strip-btn:hover {
|
||||||
.agent-btn:not(:last-child) {
|
|
||||||
border-right: 1px solid var(--border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.agent-btn:hover {
|
|
||||||
background: var(--bg-hover);
|
background: var(--bg-hover);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
.strip-btn.active {
|
||||||
.agent-btn.active {
|
background: var(--accent, #0ea5e9);
|
||||||
background: var(--accent);
|
|
||||||
color: white;
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Error bar ── */
|
||||||
|
|
||||||
.error-bar {
|
.error-bar {
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
background: rgba(239, 68, 68, 0.1);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
@@ -265,18 +462,83 @@ onUnmounted(() => {
|
|||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Content area (mirrors FloatingTranscriptDebug .content) ── */
|
||||||
|
|
||||||
.content-area {
|
.content-area {
|
||||||
display: flex;
|
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fading {
|
.readability-overlay {
|
||||||
opacity: 0 !important;
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Loading / Error overlays ── */
|
||||||
|
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.35);
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2.5px solid rgba(255, 255, 255, 0.15);
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.7);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: tl-spin 0.7s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes tl-spin { to { transform: rotate(360deg); } }
|
||||||
|
|
||||||
|
.terminal-loading-enter-active { transition: opacity 0.15s ease; }
|
||||||
|
.terminal-loading-leave-active { transition: opacity 0.25s ease; }
|
||||||
|
.terminal-loading-enter-from,
|
||||||
|
.terminal-loading-leave-to { opacity: 0; }
|
||||||
|
|
||||||
|
.error-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(0, 0, 0, 0.45);
|
||||||
|
backdrop-filter: blur(6px);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.6rem;
|
||||||
|
max-width: 80%;
|
||||||
|
padding: 1.2rem 1.5rem;
|
||||||
|
background: rgba(30, 30, 30, 0.85);
|
||||||
|
border: 1px solid rgba(248, 113, 113, 0.3);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.error-msg { font-size: 12px; color: #fca5a5; text-align: center; line-height: 1.4; word-break: break-word; }
|
||||||
|
.error-hint { font-size: 10px; color: rgba(255, 255, 255, 0.35); }
|
||||||
|
|
||||||
|
/* ── Empty state ── */
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
transition: opacity 0.15s ease;
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -285,103 +547,218 @@ onUnmounted(() => {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
.empty-state svg { opacity: 0.4; }
|
||||||
|
.empty-state span { font-size: 15px; color: var(--text-secondary); }
|
||||||
|
.empty-state small { font-size: 13px; }
|
||||||
|
|
||||||
.empty-state svg {
|
/* ══════════════════════════════════════════════════════════════════════════════
|
||||||
opacity: 0.4;
|
ChatContainer glass-transparent overrides (mirrored from FloatingTranscriptDebug)
|
||||||
|
══════════════════════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
.content-area :deep(.chat-container) {
|
||||||
|
background: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
flex: 1 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
/* Chat header: absolute overlay, floats over messages */
|
||||||
font-size: 15px;
|
.content-area :deep(.chat-header) {
|
||||||
color: var(--text-secondary);
|
position: absolute !important;
|
||||||
margin: 0;
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
z-index: 3 !important;
|
||||||
|
background: rgba(0, 6, 18, 0.5) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(8px) !important;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||||
|
padding: 0.3rem 0.6rem !important;
|
||||||
|
transition: opacity 0.35s ease, transform 0.35s ease !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state span {
|
/* Hidden by default, shown only when selector-open */
|
||||||
font-size: 13px;
|
.content-area:not(.selector-open) :deep(.chat-header) {
|
||||||
|
opacity: 0 !important;
|
||||||
|
transform: translateY(-150%) !important;
|
||||||
|
pointer-events: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.split-panels {
|
/* Messages: fill entire container, pad for overlaid header / input */
|
||||||
display: flex;
|
.content-area :deep(.messages-scroll) {
|
||||||
flex: 1;
|
background: transparent !important;
|
||||||
overflow: hidden;
|
padding-top: 3.5rem !important;
|
||||||
|
padding-bottom: 5rem !important;
|
||||||
|
flex: 1 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-left {
|
/* Bottom overlay: absolute container for lifecycle + input + status */
|
||||||
width: 35%;
|
.content-area :deep(.bottom-overlay) {
|
||||||
min-width: 250px;
|
position: absolute !important;
|
||||||
display: flex;
|
bottom: 0 !important;
|
||||||
flex-direction: column;
|
left: 0 !important;
|
||||||
padding: 0.5rem;
|
right: 0 !important;
|
||||||
padding-right: 0;
|
z-index: 3 !important;
|
||||||
transition: opacity 0.15s ease;
|
display: flex !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
background: rgba(0, 6, 18, 0.5) !important;
|
||||||
|
backdrop-filter: blur(8px) !important;
|
||||||
|
-webkit-backdrop-filter: blur(8px) !important;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.06) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle {
|
.content-area :deep(.status-bar) {
|
||||||
width: 4px;
|
background: transparent !important;
|
||||||
cursor: col-resize;
|
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
background: transparent;
|
border-bottom: none !important;
|
||||||
flex-shrink: 0;
|
padding: 0.15rem 0.5rem !important;
|
||||||
margin: 0.5rem 2px;
|
|
||||||
border-radius: 2px;
|
|
||||||
transition: background 0.15s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle:hover {
|
.content-area :deep(.status-id) {
|
||||||
background: var(--accent);
|
color: rgba(255,255,255,0.35) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-right {
|
.content-area :deep(.status-bar .copy-id-btn) {
|
||||||
flex: 1;
|
color: rgba(255,255,255,0.25) !important;
|
||||||
min-width: 300px;
|
}
|
||||||
display: flex;
|
.content-area :deep(.status-bar .copy-id-btn:hover) {
|
||||||
flex-direction: column;
|
color: rgba(255,255,255,0.6) !important;
|
||||||
padding: 0.5rem;
|
|
||||||
padding-left: 0;
|
|
||||||
transition: opacity 0.15s ease;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
.content-area :deep(.status-bar .meta-badge) {
|
||||||
.page-header {
|
border-radius: 0 !important;
|
||||||
flex-direction: column;
|
font-family: 'Courier New', monospace !important;
|
||||||
align-items: flex-start;
|
}
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.5rem 0.75rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-left h2 {
|
.content-area :deep(.status-bar .meta-badge.model) {
|
||||||
font-size: 14px;
|
background: rgba(99, 102, 241, 0.1) !important;
|
||||||
}
|
color: #a5b4fc !important;
|
||||||
|
}
|
||||||
|
|
||||||
.header-selectors {
|
.content-area :deep(.status-bar .meta-badge.version) {
|
||||||
flex-direction: column;
|
background: rgba(255,255,255,0.04) !important;
|
||||||
align-items: stretch;
|
color: rgba(255,255,255,0.3) !important;
|
||||||
width: 100%;
|
}
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.split-panels {
|
.content-area :deep(.status-bar .meta-count),
|
||||||
flex-direction: column;
|
.content-area :deep(.status-bar .meta-duration) {
|
||||||
}
|
color: rgba(255,255,255,0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
.panel-left {
|
/* UserInput */
|
||||||
width: 100%;
|
.content-area :deep(.user-input) {
|
||||||
height: 40%;
|
background: transparent !important;
|
||||||
min-width: 0;
|
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||||
padding: 0.5rem;
|
padding: 0.3rem 0.5rem !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle {
|
/* Lifecycle ribbon */
|
||||||
width: 100%;
|
.content-area :deep(.lifecycle-ribbon) {
|
||||||
height: 4px;
|
background: transparent !important;
|
||||||
margin: 2px 0.5rem;
|
pointer-events: none !important;
|
||||||
cursor: row-resize;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.panel-right {
|
/* Input container */
|
||||||
flex: 1;
|
.content-area :deep(.input-container) {
|
||||||
min-width: 0;
|
background: rgba(0, 6, 18, 0.8) !important;
|
||||||
min-height: 0;
|
border-color: rgba(14, 165, 233, 0.1) !important;
|
||||||
padding: 0.5rem;
|
border-radius: 0 !important;
|
||||||
}
|
padding: 0.3rem 0.4rem !important;
|
||||||
|
}
|
||||||
|
.content-area :deep(.input-container:focus-within) {
|
||||||
|
border-color: rgba(14, 165, 233, 0.25) !important;
|
||||||
|
background: rgba(0, 6, 18, 0.85) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area :deep(.input-field) {
|
||||||
|
color: rgba(255,255,255,0.85);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Send button: pixel art daytime ocean */
|
||||||
|
.content-area :deep(.send-btn) {
|
||||||
|
background:
|
||||||
|
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='28' height='28' viewBox='0 0 28 28' shape-rendering='crispEdges'%3E%3Crect width='28' height='6' fill='%2387ceeb'/%3E%3Crect y='6' width='28' height='4' fill='%2356b3d9'/%3E%3Crect y='10' width='28' height='4' fill='%232d9abf'/%3E%3Crect y='14' width='28' height='4' fill='%231a7fa5'/%3E%3Crect y='18' width='28' height='4' fill='%23106888'/%3E%3Crect y='22' width='28' height='6' fill='%23c2b280'/%3E%3Crect x='24' y='4' width='3' height='3' fill='%23fffde0' opacity='0.8'/%3E%3Crect x='25' y='3' width='2' height='1' fill='%23fffde0' opacity='0.5'/%3E%3Crect x='2' y='5' width='4' height='2' fill='white' opacity='0.35'/%3E%3Crect x='10' y='4' width='6' height='2' fill='white' opacity='0.25'/%3E%3Crect x='20' y='6' width='3' height='1' fill='white' opacity='0.2'/%3E%3Crect x='5' y='12' width='3' height='2' fill='%23f97316' opacity='0.7'/%3E%3Crect x='4' y='13' width='1' height='1' fill='%23fdba74' opacity='0.5'/%3E%3Crect x='18' y='16' width='2' height='1' fill='%232563eb' opacity='0.5'/%3E%3Crect x='20' y='16' width='1' height='1' fill='%2393c5fd' opacity='0.4'/%3E%3Crect x='8' y='18' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='22' y='12' width='1' height='1' fill='white' opacity='0.2'/%3E%3Crect x='14' y='20' width='1' height='1' fill='white' opacity='0.15'/%3E%3Crect x='3' y='23' width='4' height='3' fill='%23059669' opacity='0.5'/%3E%3Crect x='4' y='22' width='2' height='1' fill='%2310b981' opacity='0.4'/%3E%3Crect x='20' y='24' width='3' height='2' fill='%23059669' opacity='0.4'/%3E%3Crect x='12' y='25' width='3' height='2' fill='%23ec4899' opacity='0.45'/%3E%3Crect x='13' y='24' width='2' height='1' fill='%23f472b6' opacity='0.35'/%3E%3C/svg%3E") !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 0 !important;
|
||||||
|
width: 28px !important;
|
||||||
|
height: 28px !important;
|
||||||
|
color: white !important;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
box-shadow: none !important;
|
||||||
|
transition: color 0.15s ease !important;
|
||||||
|
}
|
||||||
|
.content-area :deep(.send-btn:hover:not(:disabled)) {
|
||||||
|
color: #fffde0 !important;
|
||||||
|
filter: none !important;
|
||||||
|
box-shadow: 0 0 12px rgba(135, 206, 235, 0.5), 0 0 4px rgba(255, 253, 224, 0.3) !important;
|
||||||
|
}
|
||||||
|
.content-area :deep(.send-btn:disabled) {
|
||||||
|
opacity: 0.25 !important;
|
||||||
|
filter: saturate(0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Meta badges */
|
||||||
|
.content-area :deep(.meta-badge) {
|
||||||
|
background: rgba(255,255,255,0.04);
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
border-radius: 0;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.content-area :deep(.meta-badge.model) {
|
||||||
|
background: rgba(99, 102, 241, 0.1);
|
||||||
|
color: #a5b4fc;
|
||||||
|
}
|
||||||
|
.content-area :deep(.meta-cwd),
|
||||||
|
.content-area :deep(.meta-duration),
|
||||||
|
.content-area :deep(.meta-count) {
|
||||||
|
color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-area :deep(.copy-id-btn) {
|
||||||
|
color: rgba(255,255,255,0.25);
|
||||||
|
}
|
||||||
|
.content-area :deep(.copy-id-btn:hover) {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Select mode */
|
||||||
|
.content-area :deep(.select-mode-btn) {
|
||||||
|
border-color: rgba(255,255,255,0.08);
|
||||||
|
color: rgba(255,255,255,0.35);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.content-area :deep(.select-mode-btn:hover) {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.7);
|
||||||
|
}
|
||||||
|
.content-area :deep(.select-mode-btn.active) {
|
||||||
|
background: rgba(99, 102, 241, 0.25);
|
||||||
|
border-color: rgba(99, 102, 241, 0.35);
|
||||||
|
color: #c7d2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Selection bar */
|
||||||
|
.content-area :deep(.selection-bar) {
|
||||||
|
background: rgba(8, 8, 12, 0.92);
|
||||||
|
border-color: rgba(255,255,255,0.06);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.content-area :deep(.selection-count) {
|
||||||
|
color: rgba(255,255,255,0.4);
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
}
|
||||||
|
.content-area :deep(.selection-btn.toggle-all) {
|
||||||
|
background: rgba(255,255,255,0.06);
|
||||||
|
color: rgba(255,255,255,0.5);
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
.content-area :deep(.message-wrapper.selected) {
|
||||||
|
background: rgba(99, 102, 241, 0.06);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -58,6 +58,11 @@ const router = createRouter({
|
|||||||
path: '/transcript-debug',
|
path: '/transcript-debug',
|
||||||
name: 'transcript-debug',
|
name: 'transcript-debug',
|
||||||
component: () => import('../pages/TranscriptDebugPage.vue')
|
component: () => import('../pages/TranscriptDebugPage.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/transcript-debug/:terminalIndex',
|
||||||
|
name: 'transcript-debug-terminal',
|
||||||
|
component: () => import('../pages/TranscriptDebugPage.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,7 +12,10 @@
|
|||||||
"tauri:dev": "npx --prefix frontend tauri dev",
|
"tauri:dev": "npx --prefix frontend tauri dev",
|
||||||
"tauri:build": "npx --prefix frontend tauri build",
|
"tauri:build": "npx --prefix frontend tauri build",
|
||||||
"tauri:android:init": "npx --prefix frontend tauri android init",
|
"tauri:android:init": "npx --prefix frontend tauri android init",
|
||||||
"tauri:android:build": "npx --prefix frontend tauri android build"
|
"tauri:android:build": "npx --prefix frontend tauri android build",
|
||||||
|
"build:android:tauri": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",
|
||||||
|
"build:tauri:android": "cd src-tauri/gen/android && ./gradlew assembleRelease -x rustBuildArm64Release -x rustBuildArmRelease -x rustBuildX86Release -x rustBuildX86_64Release",
|
||||||
|
"start:tauri": "frontend/node_modules/.bin/tauri dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"concurrently": "^9.2.1"
|
"concurrently": "^9.2.1"
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import {
|
|||||||
handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
|
handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
|
||||||
handleHooksApprovalIgnore, handleHooksApprovalList
|
handleHooksApprovalIgnore, handleHooksApprovalList
|
||||||
} from './hooks-approval'
|
} from './hooks-approval'
|
||||||
|
import { handleSessionStateProxy } from './session-state-proxy'
|
||||||
|
import { handleVoiceTranscript } from './voice-transcript'
|
||||||
|
|
||||||
export async function handleRequest(req: Request): Promise<Response> {
|
export async function handleRequest(req: Request): Promise<Response> {
|
||||||
const url = new URL(req.url)
|
const url = new URL(req.url)
|
||||||
@@ -343,6 +345,17 @@ export async function handleRequest(req: Request): Promise<Response> {
|
|||||||
return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url)
|
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)
|
// Hooks Approval (long-poll for permission/plan decisions)
|
||||||
if (path === '/api/hooks-approval') {
|
if (path === '/api/hooks-approval') {
|
||||||
if (req.method === 'GET') {
|
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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,12 @@
|
|||||||
"store:default",
|
"store:default",
|
||||||
"notification:default",
|
"notification:default",
|
||||||
"clipboard-manager:default",
|
"clipboard-manager:default",
|
||||||
"dialog:default"
|
"dialog:default",
|
||||||
|
"core:window:default",
|
||||||
|
"core:window:allow-minimize",
|
||||||
|
"core:window:allow-maximize",
|
||||||
|
"core:window:allow-unmaximize",
|
||||||
|
"core:window:allow-toggle-maximize",
|
||||||
|
"core:window:allow-close"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,8 +45,9 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
getByName("release") {
|
getByName("release") {
|
||||||
|
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||||
signingConfig = signingConfigs.getByName("release")
|
signingConfig = signingConfigs.getByName("release")
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = false
|
||||||
proguardFiles(
|
proguardFiles(
|
||||||
*fileTree(".") { include("**/*.pro") }
|
*fileTree(".") { include("**/*.pro") }
|
||||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
@@ -80,7 +81,8 @@ dependencies {
|
|||||||
|
|
||||||
apply(from = "tauri.build.gradle.kts")
|
apply(from = "tauri.build.gradle.kts")
|
||||||
|
|
||||||
// Copy APK to src-tauri/installers after build
|
// Copy APK after build: local installers/ + network backup
|
||||||
|
// Only the universal signed variant gets copied as agent-ui.apk
|
||||||
android.applicationVariants.all {
|
android.applicationVariants.all {
|
||||||
val variant = this
|
val variant = this
|
||||||
variant.outputs.all {
|
variant.outputs.all {
|
||||||
@@ -88,9 +90,36 @@ android.applicationVariants.all {
|
|||||||
variant.assembleProvider.get().doLast {
|
variant.assembleProvider.get().doLast {
|
||||||
val src = output.outputFile
|
val src = output.outputFile
|
||||||
if (src.exists()) {
|
if (src.exists()) {
|
||||||
val dest = file("../../../../installers/AgentUI-${variant.versionName}-${variant.name}.apk")
|
val localDir = file("../../../../installers")
|
||||||
src.copyTo(dest, overwrite = true)
|
localDir.mkdirs()
|
||||||
println(">> Copied APK to ${dest.absolutePath}")
|
|
||||||
|
// Always save versioned copy per variant
|
||||||
|
val localVersioned = File(localDir, "AgentUI-${variant.versionName}-${variant.name}.apk")
|
||||||
|
src.copyTo(localVersioned, overwrite = true)
|
||||||
|
println(">> Copied APK to ${localVersioned.absolutePath}")
|
||||||
|
|
||||||
|
// agent-ui.apk = only the universal signed build
|
||||||
|
val isUniversal = variant.name.contains("universal", ignoreCase = true)
|
||||||
|
val isSigned = variant.signingConfig != null
|
||||||
|
if (isUniversal && isSigned) {
|
||||||
|
val localFixed = File(localDir, "agent-ui.apk")
|
||||||
|
src.copyTo(localFixed, overwrite = true)
|
||||||
|
println(">> Copied universal signed APK to ${localFixed.absolutePath}")
|
||||||
|
|
||||||
|
// Network backup
|
||||||
|
val networkDir = File("\\\\Memoria-1\\ActiveBackupforBusiness\\Nucleo v3\\agent-ui-apk")
|
||||||
|
try {
|
||||||
|
if (networkDir.exists() || networkDir.mkdirs()) {
|
||||||
|
val networkFile = File(networkDir, "agent-ui.apk")
|
||||||
|
src.copyTo(networkFile, overwrite = true)
|
||||||
|
println(">> Copied APK to network: ${networkFile.absolutePath}")
|
||||||
|
} else {
|
||||||
|
println(">> WARNING: Network path not available: $networkDir")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
println(">> WARNING: Failed to copy to network: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="com.samsung.systemui.permission.FACE_WIDGET" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
android:launchMode="singleTask"
|
android:launchMode="singleTask"
|
||||||
android:label="@string/main_activity_title"
|
android:label="@string/main_activity_title"
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true">
|
android:exported="true"
|
||||||
|
android:supportsPictureInPicture="true">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
@@ -37,7 +39,7 @@
|
|||||||
android:resource="@xml/transcript_widget_info" />
|
android:resource="@xml/transcript_widget_info" />
|
||||||
</receiver>
|
</receiver>
|
||||||
|
|
||||||
<!-- Voice Command / Share Activity -->
|
<!-- Voice Command / Share / Assist Activity -->
|
||||||
<activity
|
<activity
|
||||||
android:name=".VoiceCommandActivity"
|
android:name=".VoiceCommandActivity"
|
||||||
android:label="Agent UI Voice"
|
android:label="Agent UI Voice"
|
||||||
@@ -48,8 +50,70 @@
|
|||||||
<category android:name="android.intent.category.DEFAULT" />
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
<data android:mimeType="text/plain" />
|
<data android:mimeType="text/plain" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.ASSIST" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VOICE_COMMAND" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<!-- Digital Assistant Service (enables Samsung side-key, long-press home, etc.) -->
|
||||||
|
<service
|
||||||
|
android:name=".AgentVoiceInteractionService"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||||
|
android:exported="true">
|
||||||
|
<meta-data
|
||||||
|
android:name="android.voice_interaction"
|
||||||
|
android:resource="@xml/voice_interaction_service" />
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.service.voice.VoiceInteractionService" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".AgentVoiceInteractionSessionService"
|
||||||
|
android:permission="android.permission.BIND_VOICE_INTERACTION"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- RecognitionService: required by Android to fully register as digital assistant -->
|
||||||
|
<service
|
||||||
|
android:name=".AgentRecognitionService"
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:permission="android.permission.BIND_RECOGNITION_SERVICE"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.speech.RecognitionService" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
</intent-filter>
|
||||||
|
<meta-data
|
||||||
|
android:name="android.speech"
|
||||||
|
android:resource="@xml/recognition_service" />
|
||||||
|
</service>
|
||||||
|
|
||||||
|
<!-- Widget ListView adapter service -->
|
||||||
|
<service
|
||||||
|
android:name=".TerminalListWidgetService"
|
||||||
|
android:permission="android.permission.BIND_REMOTEVIEWS"
|
||||||
|
android:exported="false" />
|
||||||
|
|
||||||
|
<!-- Samsung Lock Screen Face Widget -->
|
||||||
|
<receiver
|
||||||
|
android:name=".LockScreenWidgetReceiver"
|
||||||
|
android:permission="com.samsung.systemui.permission.FACE_WIDGET"
|
||||||
|
android:exported="true">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.samsung.android.intent.action.REQUEST_SERVICEBOX_REMOTEVIEWS" />
|
||||||
|
</intent-filter>
|
||||||
|
</receiver>
|
||||||
|
|
||||||
|
<meta-data
|
||||||
|
android:name="com.samsung.systemui.facewidget.executable"
|
||||||
|
android:resource="@raw/facewidgets" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.speech.RecognitionService
|
||||||
|
import android.speech.SpeechRecognizer
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stub RecognitionService required by Android to register the app
|
||||||
|
* as a complete voice interaction service / digital assistant.
|
||||||
|
*
|
||||||
|
* Without this, the system won't fully recognize the app as an assistant
|
||||||
|
* and settings will show "None" even when selected.
|
||||||
|
*
|
||||||
|
* We delegate actual speech recognition to the system's default
|
||||||
|
* RecognizerIntent in VoiceCommandActivity.
|
||||||
|
*/
|
||||||
|
class AgentRecognitionService : RecognitionService() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AgentUI.Recognition"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStartListening(intent: Intent?, callback: Callback?) {
|
||||||
|
Log.d(TAG, "onStartListening called")
|
||||||
|
// We don't do real recognition here - VoiceCommandActivity uses
|
||||||
|
// the system RecognizerIntent directly. This stub satisfies the
|
||||||
|
// Android requirement that a VoiceInteractionService must have
|
||||||
|
// an associated RecognitionService.
|
||||||
|
callback?.error(SpeechRecognizer.ERROR_CLIENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCancel(callback: Callback?) {
|
||||||
|
Log.d(TAG, "onCancel called")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStopListening(callback: Callback?) {
|
||||||
|
Log.d(TAG, "onStopListening called")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.service.voice.VoiceInteractionService
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class AgentVoiceInteractionService : VoiceInteractionService() {
|
||||||
|
|
||||||
|
override fun onReady() {
|
||||||
|
super.onReady()
|
||||||
|
Log.d(TAG, "VoiceInteractionService ready - registered as digital assistant")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShutdown() {
|
||||||
|
Log.d(TAG, "VoiceInteractionService shutting down")
|
||||||
|
super.onShutdown()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AgentUI.VIS"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.service.voice.VoiceInteractionSession
|
||||||
|
import android.service.voice.VoiceInteractionSessionService
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
class AgentVoiceInteractionSessionService : VoiceInteractionSessionService() {
|
||||||
|
|
||||||
|
override fun onNewSession(args: Bundle?): VoiceInteractionSession {
|
||||||
|
Log.d(TAG, "onNewSession called")
|
||||||
|
return AgentSession(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
private class AgentSession(
|
||||||
|
private val ctx: android.content.Context
|
||||||
|
) : VoiceInteractionSession(ctx) {
|
||||||
|
|
||||||
|
override fun onPrepareShow(args: Bundle?, showFlags: Int) {
|
||||||
|
super.onPrepareShow(args, showFlags)
|
||||||
|
// Disable the default VoiceInteractionSession window/UI -
|
||||||
|
// we launch our own activity instead
|
||||||
|
setUiEnabled(false)
|
||||||
|
Log.d(TAG, "onPrepareShow: UI disabled, will launch activity")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onShow(args: Bundle?, showFlags: Int) {
|
||||||
|
super.onShow(args, showFlags)
|
||||||
|
Log.d(TAG, "onShow called, flags=$showFlags")
|
||||||
|
|
||||||
|
val intent = Intent(ctx, VoiceCommandActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VOICE_COMMAND
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
|
// API 30+: startAssistantActivity is the proper method
|
||||||
|
// for launching activities from a VoiceInteractionSession
|
||||||
|
startAssistantActivity(intent)
|
||||||
|
Log.d(TAG, "startAssistantActivity dispatched (API 30+)")
|
||||||
|
} else {
|
||||||
|
// API 23-29: use startVoiceActivity
|
||||||
|
startVoiceActivity(intent)
|
||||||
|
Log.d(TAG, "startVoiceActivity dispatched (API <30)")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Assistant/Voice activity launch failed, trying startActivity", e)
|
||||||
|
try {
|
||||||
|
ctx.startActivity(intent)
|
||||||
|
Log.d(TAG, "Fallback startActivity dispatched")
|
||||||
|
} catch (e2: Exception) {
|
||||||
|
Log.e(TAG, "All launch methods failed", e2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finish the session - the activity handles everything from here
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onHandleAssist(
|
||||||
|
data: Bundle?,
|
||||||
|
structure: android.app.assist.AssistStructure?,
|
||||||
|
content: android.app.assist.AssistContent?
|
||||||
|
) {
|
||||||
|
Log.d(TAG, "onHandleAssist called")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AgentUI.VISS"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import android.view.View
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Samsung Face Widget receiver for the lock screen / AOD.
|
||||||
|
*
|
||||||
|
* Samsung's proprietary system sends REQUEST_SERVICEBOX_REMOTEVIEWS
|
||||||
|
* and expects RESPONSE_SERVICEBOX_REMOTEVIEWS back with RemoteViews
|
||||||
|
* for both the lock screen ("origin") and AOD ("aod").
|
||||||
|
*/
|
||||||
|
class LockScreenWidgetReceiver : BroadcastReceiver() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "AgentUI.FaceWidget"
|
||||||
|
private const val ACTION_REQUEST =
|
||||||
|
"com.samsung.android.intent.action.REQUEST_SERVICEBOX_REMOTEVIEWS"
|
||||||
|
private const val ACTION_RESPONSE =
|
||||||
|
"com.samsung.android.intent.action.RESPONSE_SERVICEBOX_REMOTEVIEWS"
|
||||||
|
private const val PAGE_ID = "agent_ui_transcript"
|
||||||
|
|
||||||
|
private val STATUS_COLORS = mapOf(
|
||||||
|
"idle" to 0xFF6b7280.toInt(),
|
||||||
|
"thinking" to 0xFF60a5fa.toInt(),
|
||||||
|
"reading" to 0xFF22d3ee.toInt(),
|
||||||
|
"writing" to 0xFF4ade80.toInt(),
|
||||||
|
"toolUse" to 0xFFfbbf24.toInt(),
|
||||||
|
"permissionRequest" to 0xFFfb923c.toInt(),
|
||||||
|
"interrupted" to 0xFFf87171.toInt(),
|
||||||
|
"error" to 0xFFf87171.toInt(),
|
||||||
|
"sessionStart" to 0xFF60a5fa.toInt(),
|
||||||
|
"sessionEnd" to 0xFF6b7280.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dimmed versions for AOD
|
||||||
|
private val AOD_STATUS_COLORS = mapOf(
|
||||||
|
"idle" to 0xFF3b3f47.toInt(),
|
||||||
|
"thinking" to 0xFF304f7a.toInt(),
|
||||||
|
"reading" to 0xFF116670.toInt(),
|
||||||
|
"writing" to 0xFF256b40.toInt(),
|
||||||
|
"toolUse" to 0xFF7a5f12.toInt(),
|
||||||
|
"permissionRequest" to 0xFF7a491e.toInt(),
|
||||||
|
"interrupted" to 0xFF7a3838.toInt(),
|
||||||
|
"error" to 0xFF7a3838.toInt(),
|
||||||
|
"sessionStart" to 0xFF304f7a.toInt(),
|
||||||
|
"sessionEnd" to 0xFF3b3f47.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action != ACTION_REQUEST) return
|
||||||
|
|
||||||
|
val pageId = intent.getStringExtra("pageId")
|
||||||
|
if (pageId != PAGE_ID) return
|
||||||
|
|
||||||
|
Log.d(TAG, "Face widget update requested for pageId=$pageId")
|
||||||
|
|
||||||
|
// Fetch data on a background thread to avoid ANR
|
||||||
|
val pendingResult = goAsync()
|
||||||
|
|
||||||
|
Thread {
|
||||||
|
try {
|
||||||
|
val terminals = fetchTerminals(context)
|
||||||
|
val lockViews = buildLockScreenViews(context, terminals)
|
||||||
|
val aodViews = buildAodViews(context, terminals)
|
||||||
|
|
||||||
|
val response = Intent(ACTION_RESPONSE).apply {
|
||||||
|
setPackage("com.android.systemui")
|
||||||
|
putExtra("package", context.packageName)
|
||||||
|
putExtra("pageId", PAGE_ID)
|
||||||
|
putExtra("show", true)
|
||||||
|
putExtra("origin", lockViews)
|
||||||
|
putExtra("aod", aodViews)
|
||||||
|
}
|
||||||
|
|
||||||
|
context.sendBroadcast(response)
|
||||||
|
Log.d(TAG, "Face widget response sent with ${terminals.size} terminals")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to build face widget", e)
|
||||||
|
// Send empty response so widget doesn't get stuck
|
||||||
|
val response = Intent(ACTION_RESPONSE).apply {
|
||||||
|
setPackage("com.android.systemui")
|
||||||
|
putExtra("package", context.packageName)
|
||||||
|
putExtra("pageId", PAGE_ID)
|
||||||
|
putExtra("show", true)
|
||||||
|
putExtra("origin", buildEmptyLockScreenViews(context))
|
||||||
|
putExtra("aod", buildEmptyAodViews(context))
|
||||||
|
}
|
||||||
|
context.sendBroadcast(response)
|
||||||
|
} finally {
|
||||||
|
pendingResult.finish()
|
||||||
|
}
|
||||||
|
}.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildLockScreenViews(
|
||||||
|
context: Context,
|
||||||
|
terminals: List<FaceWidgetTerminal>
|
||||||
|
): RemoteViews {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.face_widget_lockscreen)
|
||||||
|
|
||||||
|
val statusText = if (terminals.isEmpty()) "offline"
|
||||||
|
else "${terminals.size} agent${if (terminals.size > 1) "s" else ""}"
|
||||||
|
views.setTextViewText(R.id.fw_status, statusText)
|
||||||
|
|
||||||
|
if (terminals.isEmpty()) {
|
||||||
|
views.setViewVisibility(R.id.fw_empty, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
views.setViewVisibility(R.id.fw_empty, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Lockscreen terminal slot IDs
|
||||||
|
val slotIds = listOf(
|
||||||
|
Triple(R.id.fw_terminal_1, R.id.fw_dot_1, R.id.fw_name_1),
|
||||||
|
Triple(R.id.fw_terminal_2, R.id.fw_dot_2, R.id.fw_name_2),
|
||||||
|
Triple(R.id.fw_terminal_3, R.id.fw_dot_3, R.id.fw_name_3)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (i in slotIds.indices) {
|
||||||
|
val (container, dot, name) = slotIds[i]
|
||||||
|
if (i < terminals.size) {
|
||||||
|
val t = terminals[i]
|
||||||
|
views.setViewVisibility(container, View.VISIBLE)
|
||||||
|
views.setTextColor(dot, t.statusColor)
|
||||||
|
views.setTextViewText(name, "T${t.index} ${t.agent} ${t.status}")
|
||||||
|
} else {
|
||||||
|
views.setViewVisibility(container, View.GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildAodViews(
|
||||||
|
context: Context,
|
||||||
|
terminals: List<FaceWidgetTerminal>
|
||||||
|
): RemoteViews {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.face_widget_aod)
|
||||||
|
|
||||||
|
if (terminals.isEmpty()) {
|
||||||
|
views.setViewVisibility(R.id.fw_aod_empty, View.VISIBLE)
|
||||||
|
} else {
|
||||||
|
views.setViewVisibility(R.id.fw_aod_empty, View.GONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
val slotIds = listOf(
|
||||||
|
Triple(R.id.fw_aod_terminal_1, R.id.fw_aod_dot_1, R.id.fw_aod_name_1),
|
||||||
|
Triple(R.id.fw_aod_terminal_2, R.id.fw_aod_dot_2, R.id.fw_aod_name_2),
|
||||||
|
Triple(R.id.fw_aod_terminal_3, R.id.fw_aod_dot_3, R.id.fw_aod_name_3)
|
||||||
|
)
|
||||||
|
|
||||||
|
for (i in slotIds.indices) {
|
||||||
|
val (container, dot, name) = slotIds[i]
|
||||||
|
if (i < terminals.size) {
|
||||||
|
val t = terminals[i]
|
||||||
|
views.setViewVisibility(container, View.VISIBLE)
|
||||||
|
val aodColor = AOD_STATUS_COLORS[t.status] ?: AOD_STATUS_COLORS["idle"]!!
|
||||||
|
views.setTextColor(dot, aodColor)
|
||||||
|
views.setTextViewText(name, "T${t.index} ${t.agent}")
|
||||||
|
} else {
|
||||||
|
views.setViewVisibility(container, View.GONE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEmptyLockScreenViews(context: Context): RemoteViews {
|
||||||
|
return RemoteViews(context.packageName, R.layout.face_widget_lockscreen)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildEmptyAodViews(context: Context): RemoteViews {
|
||||||
|
return RemoteViews(context.packageName, R.layout.face_widget_aod)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun fetchTerminals(context: Context): List<FaceWidgetTerminal> {
|
||||||
|
val apiBase = ServerConfig.apiBaseUrl(context) ?: return emptyList()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val url = "$apiBase/session-state"
|
||||||
|
val req = Request.Builder().url(url).build()
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
if (!resp.isSuccessful) return emptyList()
|
||||||
|
|
||||||
|
val json = JSONObject(resp.body?.string() ?: "{}")
|
||||||
|
val registry = json.optJSONArray("registry") ?: return emptyList()
|
||||||
|
val agents = json.optJSONObject("agents")
|
||||||
|
|
||||||
|
val result = mutableListOf<FaceWidgetTerminal>()
|
||||||
|
|
||||||
|
for (i in 0 until minOf(registry.length(), 3)) { // Max 3 for face widget
|
||||||
|
val entry = registry.getJSONObject(i)
|
||||||
|
val agentName = entry.optString("agent", "")
|
||||||
|
val alive = entry.optBoolean("alive", false)
|
||||||
|
|
||||||
|
val agentState = agents?.optJSONObject(agentName)
|
||||||
|
val status = agentState?.optString("status", if (alive) "idle" else "closed")
|
||||||
|
?: if (alive) "idle" else "closed"
|
||||||
|
val statusColor = STATUS_COLORS[status] ?: STATUS_COLORS["idle"]!!
|
||||||
|
|
||||||
|
result.add(
|
||||||
|
FaceWidgetTerminal(
|
||||||
|
index = i + 1,
|
||||||
|
agent = agentName,
|
||||||
|
status = status,
|
||||||
|
statusColor = statusColor
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to fetch terminals for face widget", e)
|
||||||
|
return emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private data class FaceWidgetTerminal(
|
||||||
|
val index: Int,
|
||||||
|
val agent: String,
|
||||||
|
val status: String,
|
||||||
|
val statusColor: Int
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,40 +1,279 @@
|
|||||||
package com.agentui.desktop
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.PictureInPictureParams
|
||||||
|
import android.app.RemoteAction
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.drawable.Icon
|
||||||
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import android.util.Rational
|
||||||
|
import android.webkit.WebView
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
import org.json.JSONObject
|
import androidx.core.view.ViewCompat
|
||||||
import java.io.File
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_PIP_MIC = "com.agentui.desktop.PIP_MIC"
|
||||||
|
private const val ACTION_PIP_EXPAND = "com.agentui.desktop.PIP_EXPAND"
|
||||||
|
private const val PIP_REQUEST_CODE_MIC = 2001
|
||||||
|
private const val PIP_REQUEST_CODE_EXPAND = 2002
|
||||||
|
}
|
||||||
|
|
||||||
|
private var pendingRoute: String? = null
|
||||||
|
private var pendingVoiceTerminal: String? = null
|
||||||
|
|
||||||
|
private val pipReceiver = object : BroadcastReceiver() {
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
when (intent.action) {
|
||||||
|
ACTION_PIP_MIC -> {
|
||||||
|
Log.d("AgentUI", "PiP mic button pressed")
|
||||||
|
// Launch voice command from PiP
|
||||||
|
val voiceIntent = Intent(context, VoiceCommandActivity::class.java).apply {
|
||||||
|
action = Intent.ACTION_VOICE_COMMAND
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||||
|
}
|
||||||
|
startActivity(voiceIntent)
|
||||||
|
}
|
||||||
|
ACTION_PIP_EXPAND -> {
|
||||||
|
Log.d("AgentUI", "PiP expand button pressed")
|
||||||
|
// Bring app to foreground full-screen
|
||||||
|
val expandIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||||
|
}
|
||||||
|
startActivity(expandIntent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
syncServerUrlToPrefs()
|
syncServerUrlToPrefs()
|
||||||
|
injectSafeAreaInsets()
|
||||||
|
handleWidgetIntent(intent)
|
||||||
|
handleVoiceIntent(intent)
|
||||||
|
registerPipReceiver()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
try { unregisterReceiver(pipReceiver) } catch (_: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerPipReceiver() {
|
||||||
|
val filter = IntentFilter().apply {
|
||||||
|
addAction(ACTION_PIP_MIC)
|
||||||
|
addAction(ACTION_PIP_EXPAND)
|
||||||
|
}
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
registerReceiver(pipReceiver, filter, RECEIVER_NOT_EXPORTED)
|
||||||
|
} else {
|
||||||
|
registerReceiver(pipReceiver, filter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enter PiP when user leaves the app (home button / swipe).
|
||||||
|
*/
|
||||||
|
override fun onUserLeaveHint() {
|
||||||
|
super.onUserLeaveHint()
|
||||||
|
// Only enter PiP via voice command flow, not on regular minimize
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPictureInPictureModeChanged(
|
||||||
|
isInPictureInPictureMode: Boolean,
|
||||||
|
newConfig: Configuration
|
||||||
|
) {
|
||||||
|
super.onPictureInPictureModeChanged(isInPictureInPictureMode, newConfig)
|
||||||
|
Log.d("AgentUI", "PiP mode changed: $isInPictureInPictureMode")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enterPipIfSupported() {
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
try {
|
||||||
|
val actions = buildPipActions()
|
||||||
|
val builder = PictureInPictureParams.Builder()
|
||||||
|
.setAspectRatio(Rational(9, 16))
|
||||||
|
.setActions(actions)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||||
|
builder.setAutoEnterEnabled(false)
|
||||||
|
builder.setSeamlessResizeEnabled(true)
|
||||||
|
}
|
||||||
|
enterPictureInPictureMode(builder.build())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("AgentUI", "Failed to enter PiP: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildPipActions(): List<RemoteAction> {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
|
||||||
|
|
||||||
|
val actions = mutableListOf<RemoteAction>()
|
||||||
|
|
||||||
|
// Mic button — launch voice command
|
||||||
|
val micIntent = PendingIntent.getBroadcast(
|
||||||
|
this, PIP_REQUEST_CODE_MIC,
|
||||||
|
Intent(ACTION_PIP_MIC).setPackage(packageName),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
actions.add(RemoteAction(
|
||||||
|
Icon.createWithResource(this, android.R.drawable.ic_btn_speak_now),
|
||||||
|
"Voice", "Send voice command",
|
||||||
|
micIntent
|
||||||
|
))
|
||||||
|
|
||||||
|
// Expand button — bring app to foreground
|
||||||
|
val expandIntent = PendingIntent.getBroadcast(
|
||||||
|
this, PIP_REQUEST_CODE_EXPAND,
|
||||||
|
Intent(ACTION_PIP_EXPAND).setPackage(packageName),
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
actions.add(RemoteAction(
|
||||||
|
Icon.createWithResource(this, android.R.drawable.ic_menu_view),
|
||||||
|
"Open", "Open full app",
|
||||||
|
expandIntent
|
||||||
|
))
|
||||||
|
|
||||||
|
return actions
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
handleWidgetIntent(intent)
|
||||||
|
handleVoiceIntent(intent)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
syncServerUrlToPrefs()
|
syncServerUrlToPrefs()
|
||||||
|
pendingRoute?.let { route ->
|
||||||
|
pendingRoute = null
|
||||||
|
navigateWebView(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleWidgetIntent(intent: android.content.Intent?) {
|
||||||
|
val terminalIndex = intent?.getIntExtra("terminalIndex", -1) ?: -1
|
||||||
|
if (terminalIndex > 0) {
|
||||||
|
val route = "/transcript-debug/$terminalIndex"
|
||||||
|
Log.d("AgentUI", "Widget click → navigate to $route")
|
||||||
|
pendingRoute = route
|
||||||
|
navigateWebView(route)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleVoiceIntent(intent: android.content.Intent?) {
|
||||||
|
if (intent?.action == "com.agentui.desktop.VOICE_TERMINAL") {
|
||||||
|
val sessionId = intent.getStringExtra("ephemeralSessionId") ?: return
|
||||||
|
if (sessionId.isNotEmpty()) {
|
||||||
|
Log.d("AgentUI", "Voice intent → open terminal $sessionId")
|
||||||
|
pendingVoiceTerminal = sessionId
|
||||||
|
// Don't call openVoiceTerminal here — WebView may not exist yet.
|
||||||
|
// Instead, poll until the WebView is ready.
|
||||||
|
pollForWebViewAndOpenTerminal(sessionId, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reads the Tauri plugin-store settings.json and syncs serverUrl
|
* Retry finding the WebView up to ~3 seconds (15 attempts x 200ms).
|
||||||
* to SharedPreferences so native components (widget, voice) can use it.
|
* Once found, inject JS to open floating transcript and enter PiP.
|
||||||
*/
|
*/
|
||||||
private fun syncServerUrlToPrefs() {
|
private fun pollForWebViewAndOpenTerminal(ephemeralSessionId: String, attempt: Int) {
|
||||||
try {
|
if (attempt > 15) {
|
||||||
val storeFile = File(filesDir, "app_tauri-plugin-store/settings.json")
|
Log.w("AgentUI", "Gave up waiting for WebView after ${attempt} attempts")
|
||||||
if (!storeFile.exists()) return
|
pendingVoiceTerminal = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val json = JSONObject(storeFile.readText())
|
val webView = try { findWebView(window.decorView) } catch (_: Exception) { null }
|
||||||
val serverUrl = json.optString("serverUrl", "")
|
|
||||||
if (serverUrl.isNotEmpty()) {
|
if (webView != null) {
|
||||||
ServerConfig.setServerUrl(this, serverUrl)
|
val js = "window.__VOICE_OPEN_TERMINAL__ && window.__VOICE_OPEN_TERMINAL__('$ephemeralSessionId')"
|
||||||
Log.d("AgentUI", "Synced serverUrl to prefs: $serverUrl")
|
webView.evaluateJavascript(js, null)
|
||||||
}
|
pendingVoiceTerminal = null
|
||||||
} catch (e: Exception) {
|
Log.d("AgentUI", "Voice terminal JS dispatched (attempt $attempt): $ephemeralSessionId")
|
||||||
Log.w("AgentUI", "Failed to sync server URL", e)
|
|
||||||
|
// Enter PiP after the WebView has time to render
|
||||||
|
webView.postDelayed({ enterPipIfSupported() }, 500)
|
||||||
|
} else {
|
||||||
|
Log.d("AgentUI", "WebView not ready (attempt $attempt), retrying in 200ms")
|
||||||
|
window.decorView.postDelayed({
|
||||||
|
pollForWebViewAndOpenTerminal(ephemeralSessionId, attempt + 1)
|
||||||
|
}, 200)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun navigateWebView(route: String) {
|
||||||
|
try {
|
||||||
|
val decorView = window.decorView
|
||||||
|
val webView = findWebView(decorView)
|
||||||
|
if (webView != null) {
|
||||||
|
val js = "window.__WIDGET_NAVIGATE__ && window.__WIDGET_NAVIGATE__('$route') || (window.location.href = '$route')"
|
||||||
|
webView.evaluateJavascript(js, null)
|
||||||
|
pendingRoute = null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("AgentUI", "Failed to navigate WebView: $e")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun injectSafeAreaInsets() {
|
||||||
|
val decorView = window.decorView
|
||||||
|
ViewCompat.setOnApplyWindowInsetsListener(decorView) { view, insets ->
|
||||||
|
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||||
|
val density = resources.displayMetrics.density
|
||||||
|
val topPx = systemBars.top
|
||||||
|
val bottomPx = systemBars.bottom
|
||||||
|
val leftPx = systemBars.left
|
||||||
|
val rightPx = systemBars.right
|
||||||
|
val topDp = topPx / density
|
||||||
|
val bottomDp = bottomPx / density
|
||||||
|
val leftDp = leftPx / density
|
||||||
|
val rightDp = rightPx / density
|
||||||
|
val js = """
|
||||||
|
document.documentElement.style.setProperty('--sat', '${topDp}px');
|
||||||
|
document.documentElement.style.setProperty('--sab', '${bottomDp}px');
|
||||||
|
document.documentElement.style.setProperty('--sal', '${leftDp}px');
|
||||||
|
document.documentElement.style.setProperty('--sar', '${rightDp}px');
|
||||||
|
""".trimIndent()
|
||||||
|
try {
|
||||||
|
val webView = view.findViewWithTag<WebView>("tauri_webview")
|
||||||
|
?: view.findViewById<WebView>(android.R.id.content)?.let {
|
||||||
|
findWebView(it)
|
||||||
|
}
|
||||||
|
webView?.evaluateJavascript(js, null)
|
||||||
|
Log.d("AgentUI", "Injected safe-area: top=${topDp}dp bottom=${bottomDp}dp")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("AgentUI", "Failed to inject safe-area insets: $e")
|
||||||
|
}
|
||||||
|
ViewCompat.onApplyWindowInsets(view, insets)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findWebView(view: android.view.View): WebView? {
|
||||||
|
if (view is WebView) return view
|
||||||
|
if (view is android.view.ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
val found = findWebView(view.getChildAt(i))
|
||||||
|
if (found != null) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun syncServerUrlToPrefs() {
|
||||||
|
val url = ServerConfig.getServerUrl(this)
|
||||||
|
Log.d("AgentUI", "syncServerUrlToPrefs: resolved url=$url")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,149 @@
|
|||||||
package com.agentui.desktop
|
package com.agentui.desktop
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
object ServerConfig {
|
object ServerConfig {
|
||||||
|
private const val TAG = "ServerConfig"
|
||||||
private const val PREFS_NAME = "agent_ui_config"
|
private const val PREFS_NAME = "agent_ui_config"
|
||||||
private const val KEY_SERVER_URL = "server_url"
|
private const val KEY_SERVER_URL = "server_url"
|
||||||
|
|
||||||
fun getServerUrl(context: Context): String? {
|
fun getServerUrl(context: Context): String? {
|
||||||
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
val stored = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
.getString(KEY_SERVER_URL, null)
|
.getString(KEY_SERVER_URL, null)
|
||||||
|
if (!stored.isNullOrEmpty()) {
|
||||||
|
Log.d(TAG, "Found serverUrl in SharedPreferences: $stored")
|
||||||
|
return stored
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: search for Tauri store file in multiple possible locations
|
||||||
|
return readFromTauriStore(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setServerUrl(context: Context, url: String) {
|
fun setServerUrl(context: Context, url: String) {
|
||||||
|
val normalized = url.trimEnd('/')
|
||||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
.edit()
|
.edit()
|
||||||
.putString(KEY_SERVER_URL, url.trimEnd('/'))
|
.putString(KEY_SERVER_URL, normalized)
|
||||||
.apply()
|
.apply()
|
||||||
|
Log.d(TAG, "Saved serverUrl to SharedPreferences: $normalized")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches for serverUrl in the Tauri plugin-store settings.json.
|
||||||
|
* Tries multiple possible file locations since the Tauri store path
|
||||||
|
* can vary by plugin version and platform.
|
||||||
|
*/
|
||||||
|
private fun readFromTauriStore(context: Context): String? {
|
||||||
|
Log.d(TAG, "SharedPreferences empty, searching Tauri store files...")
|
||||||
|
|
||||||
|
// Try multiple possible paths where Tauri plugin-store may save settings.json
|
||||||
|
val possiblePaths = listOf(
|
||||||
|
File(context.filesDir, "app_tauri-plugin-store/settings.json"),
|
||||||
|
File(context.filesDir, "settings.json"),
|
||||||
|
File(context.dataDir, "app_tauri-plugin-store/settings.json"),
|
||||||
|
File(context.dataDir, "settings.json"),
|
||||||
|
File(context.filesDir, ".tauri/settings.json"),
|
||||||
|
File(context.filesDir, "tauri-plugin-store/settings.json")
|
||||||
|
)
|
||||||
|
|
||||||
|
for (file in possiblePaths) {
|
||||||
|
Log.d(TAG, " Checking: ${file.absolutePath} exists=${file.exists()}")
|
||||||
|
if (file.exists()) {
|
||||||
|
try {
|
||||||
|
val content = file.readText()
|
||||||
|
Log.d(TAG, " Found store file! Content: ${content.take(200)}")
|
||||||
|
val url = extractServerUrl(content)
|
||||||
|
if (url != null) {
|
||||||
|
setServerUrl(context, url)
|
||||||
|
Log.d(TAG, " Extracted and cached serverUrl: $url")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, " Failed to parse: ${file.absolutePath}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also search recursively for any settings.json in the data directory
|
||||||
|
val found = findFile(context.filesDir, "settings.json")
|
||||||
|
if (found != null) {
|
||||||
|
Log.d(TAG, " Found via recursive search: ${found.absolutePath}")
|
||||||
|
try {
|
||||||
|
val content = found.readText()
|
||||||
|
Log.d(TAG, " Content: ${content.take(200)}")
|
||||||
|
val url = extractServerUrl(content)
|
||||||
|
if (url != null) {
|
||||||
|
setServerUrl(context, url)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, " Failed to parse found file", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Debug: dump directory tree so we can see where files actually are
|
||||||
|
Log.w(TAG, " Could not find settings.json. Directory tree of filesDir:")
|
||||||
|
dumpTree(context.filesDir, " ")
|
||||||
|
Log.w(TAG, " Directory tree of dataDir:")
|
||||||
|
dumpTree(context.dataDir, " ")
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts serverUrl from JSON content.
|
||||||
|
* Handles both flat format {"serverUrl":"..."} and
|
||||||
|
* possible wrapped formats.
|
||||||
|
*/
|
||||||
|
private fun extractServerUrl(content: String): String? {
|
||||||
|
try {
|
||||||
|
val json = JSONObject(content)
|
||||||
|
|
||||||
|
// Direct key
|
||||||
|
val direct = json.optString("serverUrl", "")
|
||||||
|
if (direct.isNotEmpty()) return direct.trimEnd('/')
|
||||||
|
|
||||||
|
// Maybe nested under a "store" or "data" key
|
||||||
|
for (key in json.keys()) {
|
||||||
|
val inner = json.optJSONObject(key)
|
||||||
|
if (inner != null) {
|
||||||
|
val nested = inner.optString("serverUrl", "")
|
||||||
|
if (nested.isNotEmpty()) return nested.trimEnd('/')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "extractServerUrl parse error", e)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recursively find a file by name */
|
||||||
|
private fun findFile(dir: File, name: String): File? {
|
||||||
|
val files = dir.listFiles() ?: return null
|
||||||
|
for (file in files) {
|
||||||
|
if (file.isFile && file.name == name) return file
|
||||||
|
if (file.isDirectory) {
|
||||||
|
val found = findFile(file, name)
|
||||||
|
if (found != null) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dump directory tree for debugging */
|
||||||
|
private fun dumpTree(dir: File, indent: String) {
|
||||||
|
val files = dir.listFiles() ?: return
|
||||||
|
for (file in files) {
|
||||||
|
if (file.isDirectory) {
|
||||||
|
Log.d(TAG, "$indent${file.name}/")
|
||||||
|
dumpTree(file, "$indent ")
|
||||||
|
} else {
|
||||||
|
Log.d(TAG, "$indent${file.name} (${file.length()} bytes)")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** e.g. "http://192.168.1.10:4103" */
|
/** e.g. "http://192.168.1.10:4103" */
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package com.agentui.desktop
|
||||||
|
|
||||||
|
import android.appwidget.AppWidgetManager
|
||||||
|
import android.content.ComponentName
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.RemoteViews
|
||||||
|
import android.widget.RemoteViewsService
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import org.json.JSONObject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
class TerminalListWidgetService : RemoteViewsService() {
|
||||||
|
override fun onGetViewFactory(intent: Intent): RemoteViewsFactory {
|
||||||
|
return TerminalListFactory(applicationContext)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class TerminalItem(
|
||||||
|
val ephemeralSessionId: String,
|
||||||
|
val agent: String,
|
||||||
|
val label: String,
|
||||||
|
val status: String,
|
||||||
|
val alive: Boolean,
|
||||||
|
val hookBadges: String,
|
||||||
|
val lastUserPrompt: String,
|
||||||
|
val statusColor: Int,
|
||||||
|
val terminalIndex: Int // 1-based, maps to /transcript-debug/:terminalIndex
|
||||||
|
)
|
||||||
|
|
||||||
|
class TerminalListFactory(private val context: Context) : RemoteViewsService.RemoteViewsFactory {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "WidgetListFactory"
|
||||||
|
|
||||||
|
// Refresh button states
|
||||||
|
private const val ICON_NORMAL = "\u21BB" // ↻
|
||||||
|
private const val ICON_LOADING = "\u23F3" // ⏳
|
||||||
|
private const val ICON_OK = "\u2713" // ✓
|
||||||
|
private const val ICON_ERROR = "\u26A0" // ⚠
|
||||||
|
|
||||||
|
private const val COLOR_NORMAL = 0xFF8888FF.toInt()
|
||||||
|
private const val COLOR_LOADING = 0xFF60a5fa.toInt()
|
||||||
|
private const val COLOR_OK = 0xFF4ade80.toInt()
|
||||||
|
private const val COLOR_ERROR = 0xFFf87171.toInt()
|
||||||
|
|
||||||
|
private val client = OkHttpClient.Builder()
|
||||||
|
.connectTimeout(8, TimeUnit.SECONDS)
|
||||||
|
.readTimeout(8, TimeUnit.SECONDS)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private val STATUS_COLORS = mapOf(
|
||||||
|
"idle" to 0xFF6b7280.toInt(),
|
||||||
|
"thinking" to 0xFF60a5fa.toInt(),
|
||||||
|
"reading" to 0xFF22d3ee.toInt(),
|
||||||
|
"writing" to 0xFF4ade80.toInt(),
|
||||||
|
"toolUse" to 0xFFfbbf24.toInt(),
|
||||||
|
"permissionRequest" to 0xFFfb923c.toInt(),
|
||||||
|
"interrupted" to 0xFFf87171.toInt(),
|
||||||
|
"error" to 0xFFf87171.toInt(),
|
||||||
|
"sessionStart" to 0xFF60a5fa.toInt(),
|
||||||
|
"sessionEnd" to 0xFF6b7280.toInt()
|
||||||
|
)
|
||||||
|
|
||||||
|
private val TOOL_EVENTS = setOf("PreToolUse", "PostToolUse", "PostToolUseFailure")
|
||||||
|
private val PERM_EVENTS = setOf("PermissionRequest")
|
||||||
|
private val SESSION_EVENTS = setOf("SessionStart", "UserPromptSubmit", "Stop", "SessionEnd")
|
||||||
|
}
|
||||||
|
|
||||||
|
private var items = listOf<TerminalItem>()
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
override fun onCreate() {}
|
||||||
|
|
||||||
|
override fun onDataSetChanged() {
|
||||||
|
setRefreshButton(ICON_LOADING, COLOR_LOADING)
|
||||||
|
|
||||||
|
val result = fetchTerminals()
|
||||||
|
|
||||||
|
if (result == null) {
|
||||||
|
// Network error — keep previous items, show error on button
|
||||||
|
setRefreshButton(ICON_ERROR, COLOR_ERROR)
|
||||||
|
scheduleResetButton(3000)
|
||||||
|
} else {
|
||||||
|
items = result
|
||||||
|
// Brief success flash, then back to normal
|
||||||
|
setRefreshButton(ICON_OK, COLOR_OK)
|
||||||
|
scheduleResetButton(1500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
items = emptyList()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getCount(): Int = items.size
|
||||||
|
|
||||||
|
override fun getViewAt(position: Int): RemoteViews {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.widget_terminal_item)
|
||||||
|
|
||||||
|
if (position >= items.size) return views
|
||||||
|
val item = items[position]
|
||||||
|
|
||||||
|
views.setTextColor(R.id.item_dot, item.statusColor)
|
||||||
|
|
||||||
|
val statusLabel = if (item.alive) item.status else "closed"
|
||||||
|
views.setTextViewText(R.id.item_name, "T${item.terminalIndex} ${item.agent} $statusLabel")
|
||||||
|
|
||||||
|
views.setTextViewText(R.id.item_badges, item.hookBadges)
|
||||||
|
|
||||||
|
// Always show the registry label (unique per terminal)
|
||||||
|
views.setTextViewText(R.id.item_label, item.label)
|
||||||
|
|
||||||
|
val fillIntent = Intent().apply {
|
||||||
|
putExtra("terminalIndex", item.terminalIndex)
|
||||||
|
putExtra("agent", item.agent)
|
||||||
|
}
|
||||||
|
views.setOnClickFillInIntent(R.id.item_root, fillIntent)
|
||||||
|
|
||||||
|
return views
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getLoadingView(): RemoteViews? = null
|
||||||
|
override fun getViewTypeCount(): Int = 1
|
||||||
|
override fun getItemId(position: Int): Long = position.toLong()
|
||||||
|
override fun hasStableIds(): Boolean = false
|
||||||
|
|
||||||
|
// ── Refresh button state management ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update just the refresh button via partiallyUpdateAppWidget.
|
||||||
|
* This doesn't reset the ListView adapter.
|
||||||
|
*/
|
||||||
|
private fun setRefreshButton(icon: String, color: Int) {
|
||||||
|
try {
|
||||||
|
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
|
||||||
|
views.setTextViewText(R.id.btn_refresh, icon)
|
||||||
|
views.setTextColor(R.id.btn_refresh, color)
|
||||||
|
|
||||||
|
val mgr = AppWidgetManager.getInstance(context)
|
||||||
|
val ids = mgr.getAppWidgetIds(
|
||||||
|
ComponentName(context, TranscriptWidgetProvider::class.java)
|
||||||
|
)
|
||||||
|
mgr.partiallyUpdateAppWidget(ids, views)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to update refresh button", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schedule resetting the button back to normal after a delay.
|
||||||
|
*/
|
||||||
|
private fun scheduleResetButton(delayMs: Long) {
|
||||||
|
mainHandler.postDelayed({
|
||||||
|
setRefreshButton(ICON_NORMAL, COLOR_NORMAL)
|
||||||
|
}, delayMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data fetching ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns list on success, null on error.
|
||||||
|
* Items keep the same order as the terminal registry (T1, T2, T3...)
|
||||||
|
* so the index maps directly to /transcript-debug/:terminalIndex
|
||||||
|
*/
|
||||||
|
private fun fetchTerminals(): List<TerminalItem>? {
|
||||||
|
val apiBase = ServerConfig.apiBaseUrl(context) ?: return emptyList()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val url = "$apiBase/session-state"
|
||||||
|
val req = Request.Builder().url(url).build()
|
||||||
|
val resp = client.newCall(req).execute()
|
||||||
|
if (!resp.isSuccessful) return null
|
||||||
|
|
||||||
|
val json = JSONObject(resp.body?.string() ?: "{}")
|
||||||
|
val registry = json.optJSONArray("registry")
|
||||||
|
val agents = json.optJSONObject("agents")
|
||||||
|
|
||||||
|
if (registry == null || registry.length() == 0) return emptyList()
|
||||||
|
|
||||||
|
val result = mutableListOf<TerminalItem>()
|
||||||
|
|
||||||
|
for (i in 0 until registry.length()) {
|
||||||
|
val entry = registry.getJSONObject(i)
|
||||||
|
val agentName = entry.optString("agent", "")
|
||||||
|
val ephId = entry.optString("ephemeralSessionId", "")
|
||||||
|
val label = entry.optString("label", "")
|
||||||
|
val alive = entry.optBoolean("alive", false)
|
||||||
|
val terminalIndex = i + 1 // 1-based, maps to /transcript-debug/:terminalIndex
|
||||||
|
|
||||||
|
val agentState = agents?.optJSONObject(agentName)
|
||||||
|
|
||||||
|
val status = agentState?.optString("status", if (alive) "idle" else "closed")
|
||||||
|
?: if (alive) "idle" else "closed"
|
||||||
|
val statusColor = STATUS_COLORS[status] ?: STATUS_COLORS["idle"]!!
|
||||||
|
|
||||||
|
val lastUserPrompt = if (agentState != null) extractLastUserPrompt(agentState) else ""
|
||||||
|
val hookBadges = if (agentState != null) buildBadgeString(agentState) else ""
|
||||||
|
|
||||||
|
result.add(
|
||||||
|
TerminalItem(
|
||||||
|
ephemeralSessionId = ephId,
|
||||||
|
agent = agentName,
|
||||||
|
label = label,
|
||||||
|
status = status,
|
||||||
|
alive = alive,
|
||||||
|
hookBadges = hookBadges,
|
||||||
|
lastUserPrompt = lastUserPrompt,
|
||||||
|
statusColor = statusColor,
|
||||||
|
terminalIndex = terminalIndex
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keep registry order (don't sort) — index must match T1, T2, T3...
|
||||||
|
return result
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to fetch terminals", e)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun extractLastUserPrompt(state: JSONObject): String {
|
||||||
|
val history = state.optJSONArray("hookHistory") ?: return ""
|
||||||
|
for (i in history.length() - 1 downTo 0) {
|
||||||
|
val entry = history.optJSONObject(i) ?: continue
|
||||||
|
if (entry.optString("event") == "UserPromptSubmit") {
|
||||||
|
val detail = entry.optString("detail", "")
|
||||||
|
if (detail.isNotEmpty()) return detail.take(120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val stopResp = state.optString("lastStopResponse", "")
|
||||||
|
if (stopResp.isNotEmpty()) return "< ${stopResp.take(100)}"
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun buildBadgeString(state: JSONObject): String {
|
||||||
|
val history = state.optJSONArray("hookHistory") ?: return ""
|
||||||
|
var tools = 0; var perms = 0; var sessions = 0
|
||||||
|
for (i in 0 until history.length()) {
|
||||||
|
val entry = history.optJSONObject(i) ?: continue
|
||||||
|
val event = entry.optString("event", "")
|
||||||
|
when {
|
||||||
|
event in TOOL_EVENTS -> tools++
|
||||||
|
event in PERM_EVENTS -> perms++
|
||||||
|
event in SESSION_EVENTS -> sessions++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val parts = mutableListOf<String>()
|
||||||
|
if (tools > 0) parts.add("T:$tools")
|
||||||
|
if (perms > 0) parts.add("P:$perms")
|
||||||
|
if (sessions > 0) parts.add("S:$sessions")
|
||||||
|
return parts.joinToString(" ")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,12 +3,19 @@ package com.agentui.desktop
|
|||||||
import android.app.PendingIntent
|
import android.app.PendingIntent
|
||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.appwidget.AppWidgetProvider
|
import android.appwidget.AppWidgetProvider
|
||||||
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.net.Uri
|
||||||
import android.widget.RemoteViews
|
import android.widget.RemoteViews
|
||||||
|
|
||||||
class TranscriptWidgetProvider : AppWidgetProvider() {
|
class TranscriptWidgetProvider : AppWidgetProvider() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ACTION_REFRESH = "com.agentui.desktop.WIDGET_REFRESH"
|
||||||
|
const val ACTION_ITEM_CLICK = "com.agentui.desktop.WIDGET_ITEM_CLICK"
|
||||||
|
}
|
||||||
|
|
||||||
override fun onUpdate(
|
override fun onUpdate(
|
||||||
context: Context,
|
context: Context,
|
||||||
appWidgetManager: AppWidgetManager,
|
appWidgetManager: AppWidgetManager,
|
||||||
@@ -17,22 +24,61 @@ class TranscriptWidgetProvider : AppWidgetProvider() {
|
|||||||
for (appWidgetId in appWidgetIds) {
|
for (appWidgetId in appWidgetIds) {
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
|
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
|
||||||
|
|
||||||
// Tap widget → open main app
|
// ListView adapter
|
||||||
val intent = Intent(context, MainActivity::class.java)
|
val serviceIntent = Intent(context, TerminalListWidgetService::class.java).apply {
|
||||||
val pendingIntent = PendingIntent.getActivity(
|
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||||
context, 0, intent,
|
data = Uri.parse(toUri(Intent.URI_INTENT_SCHEME))
|
||||||
|
}
|
||||||
|
views.setRemoteAdapter(R.id.terminal_list, serviceIntent)
|
||||||
|
views.setEmptyView(R.id.terminal_list, R.id.empty_view)
|
||||||
|
|
||||||
|
// Item click template → opens app
|
||||||
|
val itemClickIntent = Intent(context, MainActivity::class.java).apply {
|
||||||
|
action = ACTION_ITEM_CLICK
|
||||||
|
}
|
||||||
|
val itemClickPending = PendingIntent.getActivity(
|
||||||
|
context, 0, itemClickIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
|
||||||
|
)
|
||||||
|
views.setPendingIntentTemplate(R.id.terminal_list, itemClickPending)
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
val refreshIntent = Intent(context, TranscriptWidgetProvider::class.java).apply {
|
||||||
|
action = ACTION_REFRESH
|
||||||
|
}
|
||||||
|
val refreshPending = PendingIntent.getBroadcast(
|
||||||
|
context, 1, refreshIntent,
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
views.setOnClickPendingIntent(R.id.widget_root, pendingIntent)
|
views.setOnClickPendingIntent(R.id.btn_refresh, refreshPending)
|
||||||
|
|
||||||
|
// Title tap → open app
|
||||||
|
val appIntent = Intent(context, MainActivity::class.java)
|
||||||
|
val appPending = PendingIntent.getActivity(
|
||||||
|
context, 2, appIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
|
)
|
||||||
|
views.setOnClickPendingIntent(R.id.widget_title, appPending)
|
||||||
|
|
||||||
appWidgetManager.updateAppWidget(appWidgetId, views)
|
appWidgetManager.updateAppWidget(appWidgetId, views)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Start periodic refresh worker
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
TranscriptWidgetWorker.enqueue(context)
|
super.onReceive(context, intent)
|
||||||
|
|
||||||
|
if (intent.action == ACTION_REFRESH) {
|
||||||
|
// Notify the ListView adapter to refresh its data
|
||||||
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
|
val widgetIds = appWidgetManager.getAppWidgetIds(
|
||||||
|
ComponentName(context, TranscriptWidgetProvider::class.java)
|
||||||
|
)
|
||||||
|
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.terminal_list)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEnabled(context: Context) {
|
override fun onEnabled(context: Context) {
|
||||||
|
// Periodic refresh via WorkManager for background updates
|
||||||
TranscriptWidgetWorker.enqueue(context)
|
TranscriptWidgetWorker.enqueue(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,14 +3,13 @@ package com.agentui.desktop
|
|||||||
import android.appwidget.AppWidgetManager
|
import android.appwidget.AppWidgetManager
|
||||||
import android.content.ComponentName
|
import android.content.ComponentName
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.widget.RemoteViews
|
|
||||||
import androidx.work.*
|
import androidx.work.*
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import okhttp3.Request
|
|
||||||
import org.json.JSONArray
|
|
||||||
import org.json.JSONObject
|
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic background worker that triggers the widget ListView to refresh.
|
||||||
|
* The actual data fetching happens in TerminalListFactory.onDataSetChanged().
|
||||||
|
*/
|
||||||
class TranscriptWidgetWorker(
|
class TranscriptWidgetWorker(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
params: WorkerParameters
|
params: WorkerParameters
|
||||||
@@ -18,13 +17,6 @@ class TranscriptWidgetWorker(
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val WORK_NAME = "transcript_widget_refresh"
|
const val WORK_NAME = "transcript_widget_refresh"
|
||||||
private val MSG_IDS = intArrayOf(
|
|
||||||
R.id.msg1, R.id.msg2, R.id.msg3, R.id.msg4, R.id.msg5
|
|
||||||
)
|
|
||||||
private val client = OkHttpClient.Builder()
|
|
||||||
.connectTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.readTimeout(10, TimeUnit.SECONDS)
|
|
||||||
.build()
|
|
||||||
|
|
||||||
fun enqueue(context: Context) {
|
fun enqueue(context: Context) {
|
||||||
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
||||||
@@ -39,108 +31,20 @@ class TranscriptWidgetWorker(
|
|||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun doWork(): Result {
|
override suspend fun doWork(): Result {
|
||||||
val messages = fetchMessages()
|
// Tell the widget's ListView adapter to re-fetch data
|
||||||
updateWidget(messages)
|
|
||||||
scheduleNext()
|
|
||||||
return Result.success()
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun fetchMessages(): List<String> {
|
|
||||||
val apiBase = ServerConfig.apiBaseUrl(context) ?: return listOf("No server configured")
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Get most recent session for 'ejecutor' agent
|
|
||||||
val sessionsUrl = "$apiBase/transcript-debug/sessions?agent=ejecutor"
|
|
||||||
val sessionsReq = Request.Builder().url(sessionsUrl).build()
|
|
||||||
val sessionsResp = client.newCall(sessionsReq).execute()
|
|
||||||
if (!sessionsResp.isSuccessful) return listOf("Failed to fetch sessions")
|
|
||||||
|
|
||||||
val sessionsJson = JSONArray(sessionsResp.body?.string() ?: "[]")
|
|
||||||
if (sessionsJson.length() == 0) return listOf("No sessions found")
|
|
||||||
|
|
||||||
// Get the most recent session (sorted by mtime desc from server)
|
|
||||||
val latestSession = sessionsJson.getJSONObject(0)
|
|
||||||
val sessionId = latestSession.getString("id")
|
|
||||||
|
|
||||||
// Fetch raw JSONL
|
|
||||||
val rawUrl = "$apiBase/transcript-debug/$sessionId/raw?agent=ejecutor"
|
|
||||||
val rawReq = Request.Builder().url(rawUrl).build()
|
|
||||||
val rawResp = client.newCall(rawReq).execute()
|
|
||||||
if (!rawResp.isSuccessful) return listOf("Failed to fetch transcript")
|
|
||||||
|
|
||||||
val rawText = rawResp.body?.string() ?: return listOf("Empty transcript")
|
|
||||||
return parseJsonlMessages(rawText)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
return listOf("Error: ${e.message?.take(40)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun parseJsonlMessages(jsonl: String): List<String> {
|
|
||||||
val messages = mutableListOf<String>()
|
|
||||||
val lines = jsonl.trim().split("\n").filter { it.isNotBlank() }
|
|
||||||
|
|
||||||
for (line in lines) {
|
|
||||||
try {
|
|
||||||
val obj = JSONObject(line)
|
|
||||||
val type = obj.optString("type", "")
|
|
||||||
|
|
||||||
when (type) {
|
|
||||||
"human" -> {
|
|
||||||
val content = obj.optString("message", "")
|
|
||||||
.ifEmpty { extractMessageContent(obj) }
|
|
||||||
if (content.isNotEmpty()) {
|
|
||||||
messages.add("> ${content.take(80)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
"assistant" -> {
|
|
||||||
val content = extractMessageContent(obj)
|
|
||||||
if (content.isNotEmpty()) {
|
|
||||||
messages.add("< ${content.take(80)}")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (_: Exception) { }
|
|
||||||
}
|
|
||||||
|
|
||||||
return messages.takeLast(5).ifEmpty { listOf("No messages yet") }
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun extractMessageContent(obj: JSONObject): String {
|
|
||||||
// Try "message" field first (simple string)
|
|
||||||
val msg = obj.optString("message", "")
|
|
||||||
if (msg.isNotEmpty()) return msg
|
|
||||||
|
|
||||||
// Try "message" as array of content blocks
|
|
||||||
val msgArray = obj.optJSONArray("message")
|
|
||||||
if (msgArray != null) {
|
|
||||||
for (i in 0 until msgArray.length()) {
|
|
||||||
val block = msgArray.optJSONObject(i) ?: continue
|
|
||||||
if (block.optString("type") == "text") {
|
|
||||||
return block.optString("text", "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun updateWidget(messages: List<String>) {
|
|
||||||
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
|
|
||||||
|
|
||||||
for (i in MSG_IDS.indices) {
|
|
||||||
views.setTextViewText(MSG_IDS[i], messages.getOrElse(i) { "" })
|
|
||||||
}
|
|
||||||
|
|
||||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||||
val widgetComponent = ComponentName(context, TranscriptWidgetProvider::class.java)
|
val widgetIds = appWidgetManager.getAppWidgetIds(
|
||||||
appWidgetManager.updateAppWidget(widgetComponent, views)
|
ComponentName(context, TranscriptWidgetProvider::class.java)
|
||||||
}
|
)
|
||||||
|
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.terminal_list)
|
||||||
|
|
||||||
private fun scheduleNext() {
|
// Schedule next refresh
|
||||||
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
val next = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
||||||
.setInitialDelay(30, TimeUnit.SECONDS)
|
.setInitialDelay(30, TimeUnit.SECONDS)
|
||||||
.build()
|
.build()
|
||||||
WorkManager.getInstance(context)
|
WorkManager.getInstance(context)
|
||||||
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
|
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, next)
|
||||||
|
|
||||||
|
return Result.success()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import okhttp3.*
|
|||||||
import okhttp3.MediaType.Companion.toMediaType
|
import okhttp3.MediaType.Companion.toMediaType
|
||||||
import okhttp3.RequestBody.Companion.toRequestBody
|
import okhttp3.RequestBody.Companion.toRequestBody
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
import java.util.concurrent.CountDownLatch
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.Date
|
||||||
|
import java.util.Locale
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
class VoiceCommandActivity : Activity() {
|
class VoiceCommandActivity : Activity() {
|
||||||
@@ -27,28 +29,44 @@ class VoiceCommandActivity : Activity() {
|
|||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
Log.d(TAG, "VoiceCommandActivity created, action=${intent?.action}")
|
||||||
|
|
||||||
// Check if launched via share intent
|
when (intent?.action) {
|
||||||
if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") {
|
// Launched via share intent
|
||||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
Intent.ACTION_SEND -> {
|
||||||
if (!sharedText.isNullOrBlank()) {
|
if (intent.type == "text/plain") {
|
||||||
sendToServer(sharedText)
|
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||||
|
if (!sharedText.isNullOrBlank()) {
|
||||||
|
sendTranscript(sharedText, "share-intent")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Launched as assist app or voice command
|
||||||
|
Intent.ACTION_ASSIST,
|
||||||
|
Intent.ACTION_VOICE_COMMAND,
|
||||||
|
"android.intent.action.VOICE_COMMAND" -> {
|
||||||
|
Log.d(TAG, "Launched as assistant, starting speech recognition")
|
||||||
|
startSpeechRecognition()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Otherwise, start speech recognition
|
// Default fallback: start speech recognition
|
||||||
startSpeechRecognition()
|
startSpeechRecognition()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun startSpeechRecognition() {
|
private fun startSpeechRecognition() {
|
||||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||||
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
|
||||||
putExtra(RecognizerIntent.EXTRA_PROMPT, "Agent UI - Speak your command")
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE, "es-HN")
|
||||||
|
putExtra(RecognizerIntent.EXTRA_LANGUAGE_PREFERENCE, "es-HN")
|
||||||
|
putExtra(RecognizerIntent.EXTRA_PROMPT, "Agent UI - Decí tu comando")
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
startActivityForResult(intent, SPEECH_REQUEST_CODE)
|
startActivityForResult(intent, SPEECH_REQUEST_CODE)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Speech recognition not available", e)
|
||||||
Toast.makeText(this, "Speech recognition not available", Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, "Speech recognition not available", Toast.LENGTH_SHORT).show()
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
@@ -62,95 +80,90 @@ class VoiceCommandActivity : Activity() {
|
|||||||
val results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
val results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||||
val text = results?.firstOrNull()
|
val text = results?.firstOrNull()
|
||||||
if (!text.isNullOrBlank()) {
|
if (!text.isNullOrBlank()) {
|
||||||
sendToServer(text)
|
Log.d(TAG, "Speech recognized: $text")
|
||||||
|
sendTranscript(text, "voice-assistant")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Log.d(TAG, "Speech recognition cancelled or no result")
|
||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun sendToServer(text: String) {
|
/**
|
||||||
|
* Sends the transcribed text to the server's /api/voice-transcript endpoint,
|
||||||
|
* then launches MainActivity with the target terminal so the floating
|
||||||
|
* transcript debug opens on the correct session.
|
||||||
|
*/
|
||||||
|
private fun sendTranscript(text: String, source: String) {
|
||||||
Thread {
|
Thread {
|
||||||
try {
|
try {
|
||||||
val terminalBase = ServerConfig.terminalBaseUrl(this)
|
val apiBase = ServerConfig.apiBaseUrl(this)
|
||||||
val terminalWs = ServerConfig.terminalWsUrl(this)
|
|
||||||
|
|
||||||
if (terminalBase == null || terminalWs == null) {
|
if (apiBase == null) {
|
||||||
|
Log.w(TAG, "No server configured, cannot send transcript")
|
||||||
showToastAndFinish("No server configured")
|
showToastAndFinish("No server configured")
|
||||||
return@Thread
|
return@Thread
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Create a new terminal session
|
val timestamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||||
val createBody = JSONObject().apply {
|
.format(Date())
|
||||||
put("agent", "ejecutor")
|
|
||||||
put("transcriptSessionId", "__new__")
|
val body = JSONObject().apply {
|
||||||
put("label", text.take(60))
|
put("text", text)
|
||||||
put("command", "ejecutor")
|
put("timestamp", timestamp)
|
||||||
|
put("source", source)
|
||||||
}.toString().toRequestBody("application/json".toMediaType())
|
}.toString().toRequestBody("application/json".toMediaType())
|
||||||
|
|
||||||
val createReq = Request.Builder()
|
val request = Request.Builder()
|
||||||
.url("$terminalBase/create-terminal")
|
.url("$apiBase/voice-transcript")
|
||||||
.post(createBody)
|
.post(body)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
val createResp = client.newCall(createReq).execute()
|
Log.d(TAG, "Sending transcript to $apiBase/voice-transcript")
|
||||||
if (!createResp.isSuccessful) {
|
|
||||||
showToastAndFinish("Failed to create terminal")
|
val response = client.newCall(request).execute()
|
||||||
return@Thread
|
val responseBody = response.body?.string() ?: ""
|
||||||
|
|
||||||
|
if (response.isSuccessful) {
|
||||||
|
Log.d(TAG, "Server response: $responseBody")
|
||||||
|
|
||||||
|
// Parse the ephemeralSessionId from the response
|
||||||
|
val json = JSONObject(responseBody)
|
||||||
|
val terminal = json.optString("ephemeralSessionId", "")
|
||||||
|
|
||||||
|
// Launch MainActivity with the terminal info to open floating transcript
|
||||||
|
openMainWithTerminal(terminal, text)
|
||||||
|
} else {
|
||||||
|
Log.e(TAG, "Server error ${response.code}: $responseBody")
|
||||||
|
showToastAndFinish("Server error: ${response.code}")
|
||||||
}
|
}
|
||||||
|
|
||||||
val respJson = JSONObject(createResp.body?.string() ?: "{}")
|
|
||||||
val sessionId = respJson.optString("ephemeralSessionId", "")
|
|
||||||
if (sessionId.isEmpty()) {
|
|
||||||
showToastAndFinish("No session ID returned")
|
|
||||||
return@Thread
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Connect via WebSocket and send the input
|
|
||||||
val wsUrl = "$terminalWs/ws/terminal?session=$sessionId"
|
|
||||||
val wsReq = Request.Builder().url(wsUrl).build()
|
|
||||||
val latch = CountDownLatch(1)
|
|
||||||
|
|
||||||
client.newWebSocket(wsReq, object : WebSocketListener() {
|
|
||||||
override fun onOpen(webSocket: WebSocket, response: Response) {
|
|
||||||
// Send the text as input
|
|
||||||
val inputMsg = JSONObject().apply {
|
|
||||||
put("type", "input")
|
|
||||||
put("data", text)
|
|
||||||
}.toString()
|
|
||||||
webSocket.send(inputMsg)
|
|
||||||
|
|
||||||
// Send Enter key after a short delay
|
|
||||||
Thread.sleep(80)
|
|
||||||
val enterMsg = JSONObject().apply {
|
|
||||||
put("type", "input")
|
|
||||||
put("data", "\r")
|
|
||||||
}.toString()
|
|
||||||
webSocket.send(enterMsg)
|
|
||||||
|
|
||||||
// Close after sending
|
|
||||||
Thread.sleep(200)
|
|
||||||
webSocket.close(1000, "done")
|
|
||||||
latch.countDown()
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
|
|
||||||
Log.e(TAG, "WebSocket failed", t)
|
|
||||||
latch.countDown()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
latch.await(5, TimeUnit.SECONDS)
|
|
||||||
showToastAndFinish("Sent: ${text.take(40)}")
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e(TAG, "Failed to send command", e)
|
Log.e(TAG, "Failed to send transcript", e)
|
||||||
showToastAndFinish("Error: ${e.message?.take(40)}")
|
showToastAndFinish("Error: ${e.message?.take(50)}")
|
||||||
}
|
}
|
||||||
}.start()
|
}.start()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens MainActivity and tells the WebView to open FloatingTranscriptDebug
|
||||||
|
* on the terminal that received the voice command.
|
||||||
|
*/
|
||||||
|
private fun openMainWithTerminal(terminal: String, text: String) {
|
||||||
|
runOnUiThread {
|
||||||
|
Toast.makeText(this, "Sent: ${text.take(50)}", Toast.LENGTH_SHORT).show()
|
||||||
|
|
||||||
|
val intent = Intent(this, MainActivity::class.java).apply {
|
||||||
|
action = "com.agentui.desktop.VOICE_TERMINAL"
|
||||||
|
putExtra("ephemeralSessionId", terminal)
|
||||||
|
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||||
|
}
|
||||||
|
startActivity(intent)
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showToastAndFinish(message: String) {
|
private fun showToastAndFinish(message: String) {
|
||||||
runOnUiThread {
|
runOnUiThread {
|
||||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#ff171717" />
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:shape="rectangle">
|
||||||
|
<solid android:color="#99010101" />
|
||||||
|
<corners android:radius="22dp" />
|
||||||
|
</shape>
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/face_widget_bg_aod">
|
||||||
|
|
||||||
|
<!-- Minimal AOD layout: title + terminal count -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="Agent UI"
|
||||||
|
android:textColor="#555588"
|
||||||
|
android:textSize="11sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:paddingBottom="4dp" />
|
||||||
|
|
||||||
|
<!-- Terminal 1 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fw_aod_terminal_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingTop="1dp"
|
||||||
|
android:paddingBottom="1dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_dot_1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#444466"
|
||||||
|
android:textSize="7sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_name_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#777777"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Terminal 2 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fw_aod_terminal_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingTop="1dp"
|
||||||
|
android:paddingBottom="1dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_dot_2"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#444466"
|
||||||
|
android:textSize="7sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_name_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#777777"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Terminal 3 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fw_aod_terminal_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingTop="1dp"
|
||||||
|
android:paddingBottom="1dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_dot_3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#444466"
|
||||||
|
android:textSize="7sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_name_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#777777"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_aod_empty"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:text="No agents"
|
||||||
|
android:textColor="#444444"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:gravity="center" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,140 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:background="@drawable/face_widget_bg_dark">
|
||||||
|
|
||||||
|
<!-- Title bar -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingBottom="6dp">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_title"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:text="Agent UI"
|
||||||
|
android:textColor="#8888FF"
|
||||||
|
android:textSize="12sp"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_status"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Terminal 1 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fw_terminal_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_dot_1"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#6b7280"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_name_1"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#DDDDDD"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Terminal 2 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fw_terminal_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_dot_2"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#6b7280"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_name_2"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#DDDDDD"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Terminal 3 -->
|
||||||
|
<LinearLayout
|
||||||
|
android:id="@+id/fw_terminal_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical"
|
||||||
|
android:paddingTop="2dp"
|
||||||
|
android:paddingBottom="2dp"
|
||||||
|
android:visibility="gone">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_dot_3"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#6b7280"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_name_3"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#DDDDDD"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Empty state -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/fw_empty"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="match_parent"
|
||||||
|
android:text="No agents active"
|
||||||
|
android:textColor="#666666"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:gravity="center" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:id="@+id/item_root"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:paddingTop="3dp"
|
||||||
|
android:paddingBottom="3dp"
|
||||||
|
android:background="?android:attr/selectableItemBackground">
|
||||||
|
|
||||||
|
<!-- Header: dot + agent name + status + badges -->
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<!-- Status dot -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_dot"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="\u25CF"
|
||||||
|
android:textColor="#6b7280"
|
||||||
|
android:textSize="8sp"
|
||||||
|
android:paddingEnd="4dp" />
|
||||||
|
|
||||||
|
<!-- Agent name + status -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_name"
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_weight="1"
|
||||||
|
android:textColor="#DDDDDD"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:ellipsize="end" />
|
||||||
|
|
||||||
|
<!-- Hook badges -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_badges"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#888888"
|
||||||
|
android:textSize="9sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:paddingStart="4dp" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<!-- Label / last user prompt -->
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/item_label"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:textColor="#9999CC"
|
||||||
|
android:textSize="10sp"
|
||||||
|
android:fontFamily="monospace"
|
||||||
|
android:maxLines="2"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:paddingStart="12dp"
|
||||||
|
android:paddingTop="1dp" />
|
||||||
|
|
||||||
|
</LinearLayout>
|
||||||
@@ -4,67 +4,57 @@
|
|||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="match_parent"
|
android:layout_height="match_parent"
|
||||||
android:orientation="vertical"
|
android:orientation="vertical"
|
||||||
android:padding="12dp"
|
android:padding="10dp"
|
||||||
android:background="#DD1A1A2E">
|
android:background="#DD1A1A2E">
|
||||||
|
|
||||||
<TextView
|
<!-- Title bar -->
|
||||||
android:id="@+id/widget_title"
|
<LinearLayout
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="Agent UI Transcript"
|
android:orientation="horizontal"
|
||||||
android:textColor="#8888FF"
|
android:gravity="center_vertical"
|
||||||
android:textSize="12sp"
|
android:paddingBottom="4dp">
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:paddingBottom="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/msg1"
|
android:id="@+id/widget_title"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#CCCCCC"
|
android:layout_weight="1"
|
||||||
android:textSize="11sp"
|
android:text="Agent UI"
|
||||||
android:fontFamily="monospace"
|
android:textColor="#8888FF"
|
||||||
android:maxLines="1"
|
android:textSize="11sp"
|
||||||
android:ellipsize="end" />
|
android:fontFamily="monospace" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/msg2"
|
android:id="@+id/btn_refresh"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="wrap_content"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:textColor="#CCCCCC"
|
android:text="\u21BB"
|
||||||
android:textSize="11sp"
|
android:textColor="#8888FF"
|
||||||
android:fontFamily="monospace"
|
android:textSize="14sp"
|
||||||
android:maxLines="1"
|
android:paddingStart="8dp"
|
||||||
android:ellipsize="end" />
|
android:paddingEnd="4dp"
|
||||||
|
android:background="?android:attr/selectableItemBackground" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
<TextView
|
<!-- Terminal list (dynamic, scrollable) -->
|
||||||
android:id="@+id/msg3"
|
<ListView
|
||||||
|
android:id="@+id/terminal_list"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:textColor="#CCCCCC"
|
android:divider="@null"
|
||||||
android:textSize="11sp"
|
android:dividerHeight="0dp"
|
||||||
android:fontFamily="monospace"
|
android:scrollbars="none" />
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end" />
|
|
||||||
|
|
||||||
|
<!-- Fallback when empty -->
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/msg4"
|
android:id="@+id/empty_view"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="match_parent"
|
||||||
android:textColor="#CCCCCC"
|
android:text="No terminals open"
|
||||||
android:textSize="11sp"
|
android:textColor="#666666"
|
||||||
|
android:textSize="10sp"
|
||||||
android:fontFamily="monospace"
|
android:fontFamily="monospace"
|
||||||
android:maxLines="1"
|
android:gravity="center" />
|
||||||
android:ellipsize="end" />
|
|
||||||
|
|
||||||
<TextView
|
|
||||||
android:id="@+id/msg5"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:textColor="#CCCCCC"
|
|
||||||
android:textSize="11sp"
|
|
||||||
android:fontFamily="monospace"
|
|
||||||
android:maxLines="1"
|
|
||||||
android:ellipsize="end" />
|
|
||||||
|
|
||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"agent_ui_transcript": {
|
||||||
|
"menuInSetting": 1,
|
||||||
|
"labelResNameInSetting": "face_widget_label",
|
||||||
|
"actionDetailSetting": "com.agentui.desktop.FACE_WIDGET_SETTINGS"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,4 +2,5 @@
|
|||||||
<string name="app_name">Agent UI</string>
|
<string name="app_name">Agent UI</string>
|
||||||
<string name="main_activity_title">Agent UI</string>
|
<string name="main_activity_title">Agent UI</string>
|
||||||
<string name="widget_description">Shows recent transcript messages from Agent UI</string>
|
<string name="widget_description">Shows recent transcript messages from Agent UI</string>
|
||||||
|
<string name="face_widget_label">Agent UI Terminals</string>
|
||||||
</resources>
|
</resources>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<recognition-service
|
||||||
|
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:settingsActivity="com.agentui.desktop.MainActivity" />
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:minWidth="250dp"
|
android:minWidth="250dp"
|
||||||
android:minHeight="110dp"
|
android:minHeight="140dp"
|
||||||
android:updatePeriodMillis="1800000"
|
android:updatePeriodMillis="1800000"
|
||||||
android:initialLayout="@layout/widget_transcript"
|
android:initialLayout="@layout/widget_transcript"
|
||||||
android:resizeMode="horizontal|vertical"
|
android:resizeMode="horizontal|vertical"
|
||||||
android:widgetCategory="home_screen"
|
android:widgetCategory="home_screen|keyguard"
|
||||||
android:description="@string/widget_description" />
|
android:description="@string/widget_description" />
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<voice-interaction-service xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:sessionService="com.agentui.desktop.AgentVoiceInteractionSessionService"
|
||||||
|
android:recognitionService="com.agentui.desktop.AgentRecognitionService"
|
||||||
|
android:supportsAssist="true"
|
||||||
|
android:supportsLaunchVoiceAssistFromKeyguard="true"
|
||||||
|
android:supportsLocalInteraction="true"
|
||||||
|
android:settingsActivity="com.agentui.desktop.MainActivity" />
|
||||||
@@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend/dist",
|
"frontendDist": "../frontend/dist",
|
||||||
"devUrl": "http://localhost:4100",
|
"devUrl": "http://localhost:4100",
|
||||||
"beforeDevCommand": "cd frontend && npm run dev",
|
"beforeDevCommand": "",
|
||||||
"beforeBuildCommand": "cd frontend && npx vite build"
|
"beforeBuildCommand": "cd frontend && npx vite build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
"height": 800,
|
"height": 800,
|
||||||
"minWidth": 800,
|
"minWidth": 800,
|
||||||
"minHeight": 600,
|
"minHeight": 600,
|
||||||
"decorations": true,
|
"decorations": false,
|
||||||
"resizable": true
|
"resizable": true
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Reference in New Issue
Block a user