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)
|
||||
server/recordings/*.webm
|
||||
|
||||
# Installers / APKs
|
||||
installers/
|
||||
|
||||
# Tauri build artifacts
|
||||
src-tauri/target/
|
||||
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/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/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/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
|
||||
|
||||
# Old frontend Tauri location
|
||||
|
||||
@@ -11,6 +11,7 @@ import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue
|
||||
import PwaInstallBanner from './components/PwaInstallBanner.vue'
|
||||
import HooksApprovalModal from './components/HooksApprovalModal.vue'
|
||||
import ServerConfigDialog from './components/ServerConfigDialog.vue'
|
||||
import WindowControls from './components/WindowControls.vue'
|
||||
import { useGlobalApproval } from './composables/useGlobalApproval'
|
||||
import { initWebMCP, getWebMCP } from './services/webmcp'
|
||||
import { initTorch, destroyTorch } from './services/torch'
|
||||
@@ -20,7 +21,7 @@ import { setResponseControls } from './services/tools/handlers/responseHandlers'
|
||||
import { useCanvasStore } from './stores/canvas'
|
||||
import { useProjectCanvasStore } from './stores/projectCanvas'
|
||||
import { useSessionState } from './stores/session-state'
|
||||
import { isTauri } from './lib/tauri'
|
||||
import { isTauri, isMobileTauri, getTauriNotification } from './lib/tauri'
|
||||
import { useServerConfig } from './stores/server-config'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -35,7 +36,7 @@ const showVoice = ref(false)
|
||||
const showTranscriptDebug = ref(false)
|
||||
const showDebugConsole = ref(false)
|
||||
const toolbarVisible = ref(true)
|
||||
const forceWco = ref(false)
|
||||
const forceWco = ref(isTauri && !isMobileTauri())
|
||||
const debugLogs = ref<Array<{ type: string; message: string; time: string }>>([])
|
||||
|
||||
// Intercept console.log for debug panel
|
||||
@@ -222,6 +223,31 @@ function syncThemeColor() {
|
||||
}
|
||||
|
||||
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
|
||||
syncThemeColor()
|
||||
|
||||
@@ -287,6 +313,34 @@ onMounted(async () => {
|
||||
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(() => {
|
||||
document.removeEventListener('mousemove', trackMouse)
|
||||
document.removeEventListener('keydown', handleGlobalKeydown)
|
||||
@@ -367,6 +421,12 @@ if (serverConfig) {
|
||||
</svg>
|
||||
<span v-if="totalPending > 0" class="approval-count">{{ totalPending }}</span>
|
||||
</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)">
|
||||
<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"/>
|
||||
@@ -375,6 +435,7 @@ if (serverConfig) {
|
||||
</button>
|
||||
<span class="wco-dot" :class="{ on: forceWco }" @click="forceWco = !forceWco"></span>
|
||||
<TorchButton />
|
||||
<WindowControls />
|
||||
</div>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
@@ -503,9 +564,9 @@ if (serverConfig) {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
padding-top: calc(0.5rem + env(safe-area-inset-top, 0px));
|
||||
padding-left: calc(1rem + env(safe-area-inset-left, 0px));
|
||||
padding-right: calc(1rem + env(safe-area-inset-right, 0px));
|
||||
padding-top: calc(0.5rem + var(--sat, env(safe-area-inset-top, 0px)));
|
||||
padding-left: calc(1rem + var(--sal, env(safe-area-inset-left, 0px)));
|
||||
padding-right: calc(1rem + var(--sar, env(safe-area-inset-right, 0px)));
|
||||
background: var(--bg-primary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
flex-shrink: 0;
|
||||
@@ -537,6 +598,7 @@ if (serverConfig) {
|
||||
min-height: 32px;
|
||||
max-height: 32px;
|
||||
padding: 0 0.5rem;
|
||||
padding-right: 0;
|
||||
border-bottom: none;
|
||||
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() {
|
||||
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">
|
||||
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 { 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'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const sessionState = useSessionState()
|
||||
|
||||
const {
|
||||
selectedAgent,
|
||||
sessions,
|
||||
selectedSessionId,
|
||||
rawContent,
|
||||
conversation,
|
||||
loading,
|
||||
transitioning,
|
||||
transitionError,
|
||||
error,
|
||||
lineCount,
|
||||
isRealtime,
|
||||
sending,
|
||||
processing,
|
||||
ephemeral,
|
||||
terminalReady,
|
||||
hookMeta,
|
||||
openTerminals,
|
||||
activeTerminalSessionId,
|
||||
init,
|
||||
switchAgent,
|
||||
selectSession,
|
||||
createNewSession,
|
||||
switchToTerminal,
|
||||
closeTerminal,
|
||||
disconnectRealtime,
|
||||
sendPrompt
|
||||
} = useTranscriptDebug()
|
||||
@@ -48,21 +56,117 @@ const agents: { id: AgentName; label: string }[] = [
|
||||
{ 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) {
|
||||
voice.clearTranscript()
|
||||
sendPrompt(message)
|
||||
}
|
||||
|
||||
function handleCreateSession() {
|
||||
createNewSession()
|
||||
function handleAgentSwitch(agent: AgentName) {
|
||||
switchAgent(agent)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
init()
|
||||
await voice.init()
|
||||
function handleSessionSelect(sessionId: string) {
|
||||
selectSession(sessionId)
|
||||
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()
|
||||
voice.cleanup()
|
||||
})
|
||||
@@ -70,93 +174,166 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="transcript-debug-page">
|
||||
<!-- Header -->
|
||||
<header class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>Transcript Debug</h2>
|
||||
<span :class="['realtime-dot', { connected: isRealtime }]" :title="isRealtime ? 'Realtime: connected' : 'Realtime: disconnected'">
|
||||
<svg width="8" height="8" viewBox="0 0 8 8"><circle cx="4" cy="4" r="4" fill="currentColor"/></svg>
|
||||
</span>
|
||||
<!-- Terminal selector strip -->
|
||||
<div class="terminal-strip">
|
||||
<div class="strip-left">
|
||||
<button
|
||||
v-for="(entry, idx) in sessionState.terminalRegistry"
|
||||
:key="entry.transcriptSessionId"
|
||||
: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 class="header-selectors">
|
||||
<!-- Agent selector -->
|
||||
<div class="agent-selector">
|
||||
<button
|
||||
v-for="a in agents"
|
||||
:key="a.id"
|
||||
:class="['agent-btn', { active: selectedAgent === a.id }]"
|
||||
@click="switchAgent(a.id)"
|
||||
>
|
||||
{{ a.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Session selector -->
|
||||
<SessionSelector
|
||||
:sessions="sessions"
|
||||
:selected-id="selectedSessionId"
|
||||
:loading="loading"
|
||||
@select="selectSession"
|
||||
<div class="strip-right">
|
||||
<AgentBadge
|
||||
v-if="selectedAgent"
|
||||
:agent="selectedAgent"
|
||||
:connected="!!activeTerminalSessionId"
|
||||
:terminals="openTerminals"
|
||||
:active-session-id="activeTerminalSessionId"
|
||||
:model="conversation?.model"
|
||||
:version="conversation?.version"
|
||||
@switch-terminal="handleTerminalSwitch"
|
||||
@close-terminal="closeTerminal"
|
||||
/>
|
||||
<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>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="error-bar">{{ error }}</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="content-area">
|
||||
<div v-if="!selectedSessionId" :class="['empty-state', { fading: transitioning }]">
|
||||
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<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" />
|
||||
<line x1="16" y1="13" x2="8" y2="13" />
|
||||
<line x1="16" y1="17" x2="8" y2="17" />
|
||||
<polyline points="10 9 9 9 8 9" />
|
||||
<div :class="['content-area', { 'selector-open': showSelector }]">
|
||||
<AquaticBackground />
|
||||
<div class="readability-overlay" :style="{ background: `rgba(0, 0, 0, ${overlayOpacity})` }" />
|
||||
|
||||
<Transition name="terminal-loading">
|
||||
<div v-if="transitioning" class="loading-overlay">
|
||||
<div class="loading-spinner" />
|
||||
</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>
|
||||
<p>Select a transcript session to begin</p>
|
||||
<span>{{ sessions.length }} sessions available</span>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<span>No active terminal</span>
|
||||
<small v-if="!sessionState.terminalRegistry.length">No terminals registered</small>
|
||||
<small v-else>Select a terminal above to begin</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- New session modal -->
|
||||
<NewSessionModal
|
||||
v-if="showNewSessionModal"
|
||||
:agents="agents"
|
||||
:default-agent="selectedAgent"
|
||||
@create="handleModalCreateNew"
|
||||
@close="showNewSessionModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -170,29 +347,70 @@ onUnmounted(() => {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
/* ── Terminal strip ── */
|
||||
|
||||
.terminal-strip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 1rem;
|
||||
padding: 3px 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
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;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.realtime-dot {
|
||||
@@ -200,7 +418,6 @@ onUnmounted(() => {
|
||||
opacity: 0.4;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.realtime-dot.connected {
|
||||
color: #22c55e;
|
||||
opacity: 1;
|
||||
@@ -212,50 +429,30 @@ onUnmounted(() => {
|
||||
50% { filter: drop-shadow(0 0 6px currentColor); }
|
||||
}
|
||||
|
||||
.header-selectors {
|
||||
.strip-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.agent-btn:not(:last-child) {
|
||||
border-right: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.agent-btn:hover {
|
||||
.strip-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.agent-btn.active {
|
||||
background: var(--accent);
|
||||
.strip-btn.active {
|
||||
background: var(--accent, #0ea5e9);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* ── Error bar ── */
|
||||
|
||||
.error-bar {
|
||||
padding: 0.5rem 1rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
@@ -265,18 +462,83 @@ onUnmounted(() => {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Content area (mirrors FloatingTranscriptDebug .content) ── */
|
||||
|
||||
.content-area {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.fading {
|
||||
opacity: 0 !important;
|
||||
.readability-overlay {
|
||||
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 {
|
||||
transition: opacity 0.15s ease;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
@@ -285,103 +547,218 @@ onUnmounted(() => {
|
||||
gap: 0.75rem;
|
||||
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 {
|
||||
font-size: 15px;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
/* Chat header: absolute overlay, floats over messages */
|
||||
.content-area :deep(.chat-header) {
|
||||
position: absolute !important;
|
||||
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 {
|
||||
font-size: 13px;
|
||||
/* Hidden by default, shown only when selector-open */
|
||||
.content-area:not(.selector-open) :deep(.chat-header) {
|
||||
opacity: 0 !important;
|
||||
transform: translateY(-150%) !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.split-panels {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
/* Messages: fill entire container, pad for overlaid header / input */
|
||||
.content-area :deep(.messages-scroll) {
|
||||
background: transparent !important;
|
||||
padding-top: 3.5rem !important;
|
||||
padding-bottom: 5rem !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.panel-left {
|
||||
width: 35%;
|
||||
min-width: 250px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
padding-right: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
/* Bottom overlay: absolute container for lifecycle + input + status */
|
||||
.content-area :deep(.bottom-overlay) {
|
||||
position: absolute !important;
|
||||
bottom: 0 !important;
|
||||
left: 0 !important;
|
||||
right: 0 !important;
|
||||
z-index: 3 !important;
|
||||
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 {
|
||||
width: 4px;
|
||||
cursor: col-resize;
|
||||
background: transparent;
|
||||
flex-shrink: 0;
|
||||
margin: 0.5rem 2px;
|
||||
border-radius: 2px;
|
||||
transition: background 0.15s;
|
||||
.content-area :deep(.status-bar) {
|
||||
background: transparent !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||
border-bottom: none !important;
|
||||
padding: 0.15rem 0.5rem !important;
|
||||
}
|
||||
|
||||
.resize-handle:hover {
|
||||
background: var(--accent);
|
||||
.content-area :deep(.status-id) {
|
||||
color: rgba(255,255,255,0.35) !important;
|
||||
}
|
||||
|
||||
.panel-right {
|
||||
flex: 1;
|
||||
min-width: 300px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0.5rem;
|
||||
padding-left: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
.content-area :deep(.status-bar .copy-id-btn) {
|
||||
color: rgba(255,255,255,0.25) !important;
|
||||
}
|
||||
.content-area :deep(.status-bar .copy-id-btn:hover) {
|
||||
color: rgba(255,255,255,0.6) !important;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
}
|
||||
.content-area :deep(.status-bar .meta-badge) {
|
||||
border-radius: 0 !important;
|
||||
font-family: 'Courier New', monospace !important;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
font-size: 14px;
|
||||
}
|
||||
.content-area :deep(.status-bar .meta-badge.model) {
|
||||
background: rgba(99, 102, 241, 0.1) !important;
|
||||
color: #a5b4fc !important;
|
||||
}
|
||||
|
||||
.header-selectors {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
width: 100%;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.content-area :deep(.status-bar .meta-badge.version) {
|
||||
background: rgba(255,255,255,0.04) !important;
|
||||
color: rgba(255,255,255,0.3) !important;
|
||||
}
|
||||
|
||||
.split-panels {
|
||||
flex-direction: column;
|
||||
}
|
||||
.content-area :deep(.status-bar .meta-count),
|
||||
.content-area :deep(.status-bar .meta-duration) {
|
||||
color: rgba(255,255,255,0.2) !important;
|
||||
}
|
||||
|
||||
.panel-left {
|
||||
width: 100%;
|
||||
height: 40%;
|
||||
min-width: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
/* UserInput */
|
||||
.content-area :deep(.user-input) {
|
||||
background: transparent !important;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.04) !important;
|
||||
padding: 0.3rem 0.5rem !important;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
margin: 2px 0.5rem;
|
||||
cursor: row-resize;
|
||||
}
|
||||
/* Lifecycle ribbon */
|
||||
.content-area :deep(.lifecycle-ribbon) {
|
||||
background: transparent !important;
|
||||
pointer-events: none !important;
|
||||
}
|
||||
|
||||
.panel-right {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
/* Input container */
|
||||
.content-area :deep(.input-container) {
|
||||
background: rgba(0, 6, 18, 0.8) !important;
|
||||
border-color: rgba(14, 165, 233, 0.1) !important;
|
||||
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>
|
||||
|
||||
@@ -58,6 +58,11 @@ const router = createRouter({
|
||||
path: '/transcript-debug',
|
||||
name: 'transcript-debug',
|
||||
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:build": "npx --prefix frontend tauri build",
|
||||
"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": {
|
||||
"concurrently": "^9.2.1"
|
||||
|
||||
@@ -28,6 +28,8 @@ import {
|
||||
handleHooksApprovalRespond, handleHooksApprovalRespondPlan,
|
||||
handleHooksApprovalIgnore, handleHooksApprovalList
|
||||
} from './hooks-approval'
|
||||
import { handleSessionStateProxy } from './session-state-proxy'
|
||||
import { handleVoiceTranscript } from './voice-transcript'
|
||||
|
||||
export async function handleRequest(req: Request): Promise<Response> {
|
||||
const url = new URL(req.url)
|
||||
@@ -343,6 +345,17 @@ export async function handleRequest(req: Request): Promise<Response> {
|
||||
return handleTranscriptDebugRaw(transcriptDebugRawMatch[1], url)
|
||||
}
|
||||
|
||||
// Voice Transcript (Android voice assistant sends transcribed text here)
|
||||
if (path === '/api/voice-transcript') {
|
||||
const res = await handleVoiceTranscript(req)
|
||||
if (res) return res
|
||||
}
|
||||
|
||||
// Session State (proxy to terminal server for external clients like Android widget)
|
||||
if (path === '/api/session-state' && req.method === 'GET') {
|
||||
return handleSessionStateProxy(url)
|
||||
}
|
||||
|
||||
// Hooks Approval (long-poll for permission/plan decisions)
|
||||
if (path === '/api/hooks-approval') {
|
||||
if (req.method === 'GET') {
|
||||
|
||||
26
server/routes/session-state-proxy.ts
Normal file
26
server/routes/session-state-proxy.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { jsonResponse, errorResponse } from '../utils/cors'
|
||||
import { PORT_TERMINAL } from '../config'
|
||||
|
||||
/**
|
||||
* Proxy GET /api/session-state → terminal server.
|
||||
* Returns session-state + terminal-registry combined,
|
||||
* so external clients (Android widget) get everything in one call.
|
||||
*/
|
||||
export async function handleSessionStateProxy(url: URL): Promise<Response> {
|
||||
try {
|
||||
const [stateResp, registryResp] = await Promise.all([
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/session-state`),
|
||||
fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
|
||||
])
|
||||
|
||||
const stateData = stateResp.ok ? await stateResp.json() : { agents: {} }
|
||||
const registryData = registryResp.ok ? await registryResp.json() : { registry: [] }
|
||||
|
||||
return jsonResponse({
|
||||
agents: stateData.agents ?? {},
|
||||
registry: registryData.registry ?? []
|
||||
})
|
||||
} catch (e: any) {
|
||||
return errorResponse(`Failed to reach terminal server: ${e.message}`, 502)
|
||||
}
|
||||
}
|
||||
105
server/routes/voice-transcript.ts
Normal file
105
server/routes/voice-transcript.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { jsonResponse } from '../utils/cors'
|
||||
import { PORT_TERMINAL } from '../config'
|
||||
|
||||
export async function handleVoiceTranscript(req: Request): Promise<Response | null> {
|
||||
if (req.method !== 'POST') return null
|
||||
|
||||
try {
|
||||
const body = await req.json() as { text?: string; timestamp?: string; source?: string }
|
||||
const { text, timestamp, source } = body
|
||||
|
||||
if (!text) {
|
||||
return jsonResponse({ error: 'Missing "text" field' }, 400)
|
||||
}
|
||||
|
||||
const ts = timestamp || new Date().toISOString()
|
||||
const src = source || 'android-voice'
|
||||
|
||||
console.log(`\n🎙️ [VOICE TRANSCRIPT] ────────────────────────`)
|
||||
console.log(` Source: ${src}`)
|
||||
console.log(` Time: ${ts}`)
|
||||
console.log(` Text: "${text}"`)
|
||||
|
||||
// Find first alive terminal and send the text as input
|
||||
const result = await sendToFirstTerminal(text)
|
||||
|
||||
console.log(` Terminal: ${result.terminal || 'none found'}`)
|
||||
console.log(` Status: ${result.sent ? 'sent ✓' : result.error || 'no terminal'}`)
|
||||
console.log(` ──────────────────────────────────────────────\n`)
|
||||
|
||||
return jsonResponse({
|
||||
ok: true,
|
||||
received: text,
|
||||
timestamp: ts,
|
||||
source: src,
|
||||
sentToTerminal: result.sent,
|
||||
terminal: result.terminal || null,
|
||||
ephemeralSessionId: result.ephemeralSessionId || null
|
||||
})
|
||||
} catch (e: any) {
|
||||
console.error('[voice-transcript] Parse error:', e.message)
|
||||
return jsonResponse({ error: 'Invalid JSON body' }, 400)
|
||||
}
|
||||
}
|
||||
|
||||
async function sendToFirstTerminal(text: string): Promise<{ sent: boolean; terminal?: string; ephemeralSessionId?: string; error?: string }> {
|
||||
try {
|
||||
// Fetch terminal registry to find alive terminals
|
||||
const res = await fetch(`http://localhost:${PORT_TERMINAL}/terminal-registry`)
|
||||
if (!res.ok) {
|
||||
return { sent: false, error: `registry fetch failed: ${res.status}` }
|
||||
}
|
||||
|
||||
const registry = await res.json() as Array<{
|
||||
ephemeralSessionId: string
|
||||
agent: string
|
||||
label: string
|
||||
alive: boolean
|
||||
}>
|
||||
|
||||
// Find first alive terminal
|
||||
const target = registry.find(t => t.alive)
|
||||
if (!target) {
|
||||
return { sent: false, error: 'no alive terminals' }
|
||||
}
|
||||
|
||||
// Connect via WebSocket and send the text as input
|
||||
const wsUrl = `ws://localhost:${PORT_TERMINAL}/ws/terminal?session=${target.ephemeralSessionId}`
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const ws = new WebSocket(wsUrl)
|
||||
const timeout = setTimeout(() => {
|
||||
try { ws.close() } catch {}
|
||||
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws timeout' })
|
||||
}, 5000)
|
||||
|
||||
ws.onopen = () => {
|
||||
// Send the transcribed text
|
||||
ws.send(JSON.stringify({ type: 'input', data: text }))
|
||||
|
||||
// Send Enter after a short delay
|
||||
setTimeout(() => {
|
||||
ws.send(JSON.stringify({ type: 'input', data: '\r' }))
|
||||
|
||||
// Close after sending
|
||||
setTimeout(() => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve({
|
||||
sent: true,
|
||||
terminal: `${target.ephemeralSessionId} (${target.agent})`,
|
||||
ephemeralSessionId: target.ephemeralSessionId
|
||||
})
|
||||
}, 150)
|
||||
}, 80)
|
||||
}
|
||||
|
||||
ws.onerror = (err) => {
|
||||
clearTimeout(timeout)
|
||||
resolve({ sent: false, terminal: target.ephemeralSessionId, error: 'ws connection error' })
|
||||
}
|
||||
})
|
||||
} catch (e: any) {
|
||||
return { sent: false, error: e.message }
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,12 @@
|
||||
"store:default",
|
||||
"notification: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") {
|
||||
manifestPlaceholders["usesCleartextTraffic"] = "true"
|
||||
signingConfig = signingConfigs.getByName("release")
|
||||
isMinifyEnabled = true
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
*fileTree(".") { include("**/*.pro") }
|
||||
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||
@@ -80,7 +81,8 @@ dependencies {
|
||||
|
||||
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 {
|
||||
val variant = this
|
||||
variant.outputs.all {
|
||||
@@ -88,9 +90,36 @@ android.applicationVariants.all {
|
||||
variant.assembleProvider.get().doLast {
|
||||
val src = output.outputFile
|
||||
if (src.exists()) {
|
||||
val dest = file("../../../../installers/AgentUI-${variant.versionName}-${variant.name}.apk")
|
||||
src.copyTo(dest, overwrite = true)
|
||||
println(">> Copied APK to ${dest.absolutePath}")
|
||||
val localDir = file("../../../../installers")
|
||||
localDir.mkdirs()
|
||||
|
||||
// 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">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="com.samsung.systemui.permission.FACE_WIDGET" />
|
||||
|
||||
<!-- AndroidTV support -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
@@ -16,7 +17,8 @@
|
||||
android:launchMode="singleTask"
|
||||
android:label="@string/main_activity_title"
|
||||
android:name=".MainActivity"
|
||||
android:exported="true">
|
||||
android:exported="true"
|
||||
android:supportsPictureInPicture="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
@@ -37,7 +39,7 @@
|
||||
android:resource="@xml/transcript_widget_info" />
|
||||
</receiver>
|
||||
|
||||
<!-- Voice Command / Share Activity -->
|
||||
<!-- Voice Command / Share / Assist Activity -->
|
||||
<activity
|
||||
android:name=".VoiceCommandActivity"
|
||||
android:label="Agent UI Voice"
|
||||
@@ -48,8 +50,70 @@
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<data android:mimeType="text/plain" />
|
||||
</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>
|
||||
|
||||
<!-- 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
|
||||
android:name="androidx.core.content.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
|
||||
|
||||
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.util.Log
|
||||
import android.util.Rational
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
|
||||
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?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
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() {
|
||||
super.onResume()
|
||||
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
|
||||
* to SharedPreferences so native components (widget, voice) can use it.
|
||||
* Retry finding the WebView up to ~3 seconds (15 attempts x 200ms).
|
||||
* Once found, inject JS to open floating transcript and enter PiP.
|
||||
*/
|
||||
private fun syncServerUrlToPrefs() {
|
||||
try {
|
||||
val storeFile = File(filesDir, "app_tauri-plugin-store/settings.json")
|
||||
if (!storeFile.exists()) return
|
||||
private fun pollForWebViewAndOpenTerminal(ephemeralSessionId: String, attempt: Int) {
|
||||
if (attempt > 15) {
|
||||
Log.w("AgentUI", "Gave up waiting for WebView after ${attempt} attempts")
|
||||
pendingVoiceTerminal = null
|
||||
return
|
||||
}
|
||||
|
||||
val json = JSONObject(storeFile.readText())
|
||||
val serverUrl = json.optString("serverUrl", "")
|
||||
if (serverUrl.isNotEmpty()) {
|
||||
ServerConfig.setServerUrl(this, serverUrl)
|
||||
Log.d("AgentUI", "Synced serverUrl to prefs: $serverUrl")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w("AgentUI", "Failed to sync server URL", e)
|
||||
val webView = try { findWebView(window.decorView) } catch (_: Exception) { null }
|
||||
|
||||
if (webView != null) {
|
||||
val js = "window.__VOICE_OPEN_TERMINAL__ && window.__VOICE_OPEN_TERMINAL__('$ephemeralSessionId')"
|
||||
webView.evaluateJavascript(js, null)
|
||||
pendingVoiceTerminal = null
|
||||
Log.d("AgentUI", "Voice terminal JS dispatched (attempt $attempt): $ephemeralSessionId")
|
||||
|
||||
// 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
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import java.io.File
|
||||
|
||||
object ServerConfig {
|
||||
private const val TAG = "ServerConfig"
|
||||
private const val PREFS_NAME = "agent_ui_config"
|
||||
private const val KEY_SERVER_URL = "server_url"
|
||||
|
||||
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)
|
||||
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) {
|
||||
val normalized = url.trimEnd('/')
|
||||
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
.edit()
|
||||
.putString(KEY_SERVER_URL, url.trimEnd('/'))
|
||||
.putString(KEY_SERVER_URL, normalized)
|
||||
.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" */
|
||||
|
||||
@@ -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.appwidget.AppWidgetManager
|
||||
import android.appwidget.AppWidgetProvider
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.widget.RemoteViews
|
||||
|
||||
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(
|
||||
context: Context,
|
||||
appWidgetManager: AppWidgetManager,
|
||||
@@ -17,22 +24,61 @@ class TranscriptWidgetProvider : AppWidgetProvider() {
|
||||
for (appWidgetId in appWidgetIds) {
|
||||
val views = RemoteViews(context.packageName, R.layout.widget_transcript)
|
||||
|
||||
// Tap widget → open main app
|
||||
val intent = Intent(context, MainActivity::class.java)
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
// ListView adapter
|
||||
val serviceIntent = Intent(context, TerminalListWidgetService::class.java).apply {
|
||||
putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, appWidgetId)
|
||||
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
|
||||
)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// Start periodic refresh worker
|
||||
TranscriptWidgetWorker.enqueue(context)
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
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) {
|
||||
// Periodic refresh via WorkManager for background updates
|
||||
TranscriptWidgetWorker.enqueue(context)
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,13 @@ package com.agentui.desktop
|
||||
import android.appwidget.AppWidgetManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.widget.RemoteViews
|
||||
import androidx.work.*
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
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(
|
||||
private val context: Context,
|
||||
params: WorkerParameters
|
||||
@@ -18,13 +17,6 @@ class TranscriptWidgetWorker(
|
||||
|
||||
companion object {
|
||||
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) {
|
||||
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
||||
@@ -39,108 +31,20 @@ class TranscriptWidgetWorker(
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val messages = fetchMessages()
|
||||
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) { "" })
|
||||
}
|
||||
|
||||
// Tell the widget's ListView adapter to re-fetch data
|
||||
val appWidgetManager = AppWidgetManager.getInstance(context)
|
||||
val widgetComponent = ComponentName(context, TranscriptWidgetProvider::class.java)
|
||||
appWidgetManager.updateAppWidget(widgetComponent, views)
|
||||
}
|
||||
val widgetIds = appWidgetManager.getAppWidgetIds(
|
||||
ComponentName(context, TranscriptWidgetProvider::class.java)
|
||||
)
|
||||
appWidgetManager.notifyAppWidgetViewDataChanged(widgetIds, R.id.terminal_list)
|
||||
|
||||
private fun scheduleNext() {
|
||||
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
||||
// Schedule next refresh
|
||||
val next = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
|
||||
.setInitialDelay(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
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.RequestBody.Companion.toRequestBody
|
||||
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
|
||||
|
||||
class VoiceCommandActivity : Activity() {
|
||||
@@ -27,28 +29,44 @@ class VoiceCommandActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
Log.d(TAG, "VoiceCommandActivity created, action=${intent?.action}")
|
||||
|
||||
// Check if launched via share intent
|
||||
if (intent?.action == Intent.ACTION_SEND && intent.type == "text/plain") {
|
||||
val sharedText = intent.getStringExtra(Intent.EXTRA_TEXT)
|
||||
if (!sharedText.isNullOrBlank()) {
|
||||
sendToServer(sharedText)
|
||||
when (intent?.action) {
|
||||
// Launched via share intent
|
||||
Intent.ACTION_SEND -> {
|
||||
if (intent.type == "text/plain") {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise, start speech recognition
|
||||
// Default fallback: start speech recognition
|
||||
startSpeechRecognition()
|
||||
}
|
||||
|
||||
private fun startSpeechRecognition() {
|
||||
val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
|
||||
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 {
|
||||
startActivityForResult(intent, SPEECH_REQUEST_CODE)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Speech recognition not available", e)
|
||||
Toast.makeText(this, "Speech recognition not available", Toast.LENGTH_SHORT).show()
|
||||
finish()
|
||||
}
|
||||
@@ -62,95 +80,90 @@ class VoiceCommandActivity : Activity() {
|
||||
val results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
|
||||
val text = results?.firstOrNull()
|
||||
if (!text.isNullOrBlank()) {
|
||||
sendToServer(text)
|
||||
Log.d(TAG, "Speech recognized: $text")
|
||||
sendTranscript(text, "voice-assistant")
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Speech recognition cancelled or no result")
|
||||
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 {
|
||||
try {
|
||||
val terminalBase = ServerConfig.terminalBaseUrl(this)
|
||||
val terminalWs = ServerConfig.terminalWsUrl(this)
|
||||
val apiBase = ServerConfig.apiBaseUrl(this)
|
||||
|
||||
if (terminalBase == null || terminalWs == null) {
|
||||
if (apiBase == null) {
|
||||
Log.w(TAG, "No server configured, cannot send transcript")
|
||||
showToastAndFinish("No server configured")
|
||||
return@Thread
|
||||
}
|
||||
|
||||
// 1. Create a new terminal session
|
||||
val createBody = JSONObject().apply {
|
||||
put("agent", "ejecutor")
|
||||
put("transcriptSessionId", "__new__")
|
||||
put("label", text.take(60))
|
||||
put("command", "ejecutor")
|
||||
val timestamp = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
|
||||
.format(Date())
|
||||
|
||||
val body = JSONObject().apply {
|
||||
put("text", text)
|
||||
put("timestamp", timestamp)
|
||||
put("source", source)
|
||||
}.toString().toRequestBody("application/json".toMediaType())
|
||||
|
||||
val createReq = Request.Builder()
|
||||
.url("$terminalBase/create-terminal")
|
||||
.post(createBody)
|
||||
val request = Request.Builder()
|
||||
.url("$apiBase/voice-transcript")
|
||||
.post(body)
|
||||
.build()
|
||||
|
||||
val createResp = client.newCall(createReq).execute()
|
||||
if (!createResp.isSuccessful) {
|
||||
showToastAndFinish("Failed to create terminal")
|
||||
return@Thread
|
||||
Log.d(TAG, "Sending transcript to $apiBase/voice-transcript")
|
||||
|
||||
val response = client.newCall(request).execute()
|
||||
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) {
|
||||
Log.e(TAG, "Failed to send command", e)
|
||||
showToastAndFinish("Error: ${e.message?.take(40)}")
|
||||
Log.e(TAG, "Failed to send transcript", e)
|
||||
showToastAndFinish("Error: ${e.message?.take(50)}")
|
||||
}
|
||||
}.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) {
|
||||
runOnUiThread {
|
||||
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_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="12dp"
|
||||
android:padding="10dp"
|
||||
android:background="#DD1A1A2E">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/widget_title"
|
||||
<!-- Title bar -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Agent UI Transcript"
|
||||
android:textColor="#8888FF"
|
||||
android:textSize="12sp"
|
||||
android:fontFamily="monospace"
|
||||
android:paddingBottom="4dp" />
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/msg1"
|
||||
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" />
|
||||
<TextView
|
||||
android:id="@+id/widget_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Agent UI"
|
||||
android:textColor="#8888FF"
|
||||
android:textSize="11sp"
|
||||
android:fontFamily="monospace" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/msg2"
|
||||
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" />
|
||||
<TextView
|
||||
android:id="@+id/btn_refresh"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="\u21BB"
|
||||
android:textColor="#8888FF"
|
||||
android:textSize="14sp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:background="?android:attr/selectableItemBackground" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/msg3"
|
||||
<!-- Terminal list (dynamic, scrollable) -->
|
||||
<ListView
|
||||
android:id="@+id/terminal_list"
|
||||
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" />
|
||||
android:layout_height="match_parent"
|
||||
android:divider="@null"
|
||||
android:dividerHeight="0dp"
|
||||
android:scrollbars="none" />
|
||||
|
||||
<!-- Fallback when empty -->
|
||||
<TextView
|
||||
android:id="@+id/msg4"
|
||||
android:id="@+id/empty_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textColor="#CCCCCC"
|
||||
android:textSize="11sp"
|
||||
android:layout_height="match_parent"
|
||||
android:text="No terminals open"
|
||||
android:textColor="#666666"
|
||||
android:textSize="10sp"
|
||||
android:fontFamily="monospace"
|
||||
android:maxLines="1"
|
||||
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" />
|
||||
android:gravity="center" />
|
||||
|
||||
</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="main_activity_title">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>
|
||||
@@ -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"?>
|
||||
<appwidget-provider xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:minWidth="250dp"
|
||||
android:minHeight="110dp"
|
||||
android:minHeight="140dp"
|
||||
android:updatePeriodMillis="1800000"
|
||||
android:initialLayout="@layout/widget_transcript"
|
||||
android:resizeMode="horizontal|vertical"
|
||||
android:widgetCategory="home_screen"
|
||||
android:widgetCategory="home_screen|keyguard"
|
||||
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": {
|
||||
"frontendDist": "../frontend/dist",
|
||||
"devUrl": "http://localhost:4100",
|
||||
"beforeDevCommand": "cd frontend && npm run dev",
|
||||
"beforeDevCommand": "",
|
||||
"beforeBuildCommand": "cd frontend && npx vite build"
|
||||
},
|
||||
"app": {
|
||||
@@ -17,7 +17,7 @@
|
||||
"height": 800,
|
||||
"minWidth": 800,
|
||||
"minHeight": 600,
|
||||
"decorations": true,
|
||||
"decorations": false,
|
||||
"resizable": true
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user