feat: integrate Tauri v2 with Android widget and voice assistant

- Add Tauri v2 shell (Cargo, tauri.conf.json, capabilities, plugins)
- Migrate all fetch() calls to apiFetch() for Tauri-aware HTTP
- Migrate WebSocket endpoints to resolveEndpoints() for dynamic URLs
- Add ServerConfigDialog for remote server URL configuration
- Add tauri.ts lib with isTauri detection, apiFetch wrapper, plugin helpers
- Add server-config Pinia store with persistence via plugin-store
- Conditional PWA (disabled in Tauri builds)
- Android: home screen transcript widget (last 5 messages, 30s refresh)
- Android: voice command / share activity (SpeechRecognizer + WebSocket)
- Android: signed release APK with auto-copy to installers/
- Remove stale frontend/src-tauri directory
This commit is contained in:
2026-02-23 15:33:43 -06:00
parent 6dc0c5ff6f
commit e1aa8b1bdb
108 changed files with 8155 additions and 151 deletions

33
.gitignore vendored
View File

@@ -9,6 +9,39 @@ nul
# Voice recordings (training data) # Voice recordings (training data)
server/recordings/*.webm server/recordings/*.webm
# Tauri build artifacts
src-tauri/target/
src-tauri/installers/
# Tauri gen: ignore everything except our custom Android sources
src-tauri/gen/*
!src-tauri/gen/android/
src-tauri/gen/android/*
!src-tauri/gen/android/app/
src-tauri/gen/android/app/*
!src-tauri/gen/android/app/build.gradle.kts
!src-tauri/gen/android/app/src/
src-tauri/gen/android/app/src/*
!src-tauri/gen/android/app/src/main/
src-tauri/gen/android/app/src/main/*
!src-tauri/gen/android/app/src/main/AndroidManifest.xml
!src-tauri/gen/android/app/src/main/java/
!src-tauri/gen/android/app/src/main/res/
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/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/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/keystore.jks
# Old frontend Tauri location
frontend/src-tauri/
# Agent runtime data # Agent runtime data
.claude-*/plugins/ .claude-*/plugins/
.claude-*/plans/ .claude-*/plans/

View File

@@ -9,6 +9,12 @@
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git", "@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.2",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
@@ -19,6 +25,7 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",
@@ -2415,6 +2422,278 @@
"sourcemap-codec": "^1.4.8" "sourcemap-codec": "^1.4.8"
} }
}, },
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
"tauri": "tauri.js"
},
"engines": {
"node": ">= 10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"cpu": [
"ia32"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@tauri-apps/plugin-clipboard-manager": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-clipboard-manager/-/plugin-clipboard-manager-2.3.2.tgz",
"integrity": "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-http": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz",
"integrity": "sha512-+F2lEH/c9b0zSsOXKq+5hZNcd9F4IIKCK1T17RqMwpCmVnx2aoqY8yIBccCd25HTYUb3j6NPVbRax/m00hKG8A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-notification": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz",
"integrity": "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@tauri-apps/plugin-store": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"license": "MIT" "license": "MIT"

View File

@@ -8,10 +8,25 @@
"dev": "vite", "dev": "vite",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"generate-icons": "node scripts/generate-icons.js" "generate-icons": "node scripts/generate-icons.js",
"tauri": "tauri",
"tauri:dev": "tauri dev",
"tauri:build": "tauri build",
"tauri:android:init": "tauri android init",
"tauri:android:dev": "tauri android dev",
"tauri:android:build": "tauri android build",
"tauri:ios:init": "tauri ios init",
"tauri:ios:dev": "tauri ios dev",
"tauri:ios:build": "tauri ios build"
}, },
"dependencies": { "dependencies": {
"@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git", "@nucleoriofrio/webmcp": "git+https://gitea.nucleoriofrio.com/nucleo000/webmcp.git",
"@tauri-apps/api": "^2.10.1",
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
"@tauri-apps/plugin-dialog": "^2.6.0",
"@tauri-apps/plugin-http": "^2.5.7",
"@tauri-apps/plugin-notification": "^2.3.3",
"@tauri-apps/plugin-store": "^2.4.2",
"@xterm/addon-fit": "^0.11.0", "@xterm/addon-fit": "^0.11.0",
"@xterm/addon-web-links": "^0.12.0", "@xterm/addon-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0", "@xterm/addon-webgl": "^0.19.0",
@@ -22,6 +37,7 @@
"vue-router": "^4.6.4" "vue-router": "^4.6.4"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2", "@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1", "@vue/tsconfig": "^0.8.1",

View File

@@ -10,19 +10,27 @@ import FloatingTranscriptDebug from './components/FloatingTranscriptDebug.vue'
import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue' import TerminalFabStack from './components/transcript-debug/TerminalFabStack.vue'
import PwaInstallBanner from './components/PwaInstallBanner.vue' import PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue' import HooksApprovalModal from './components/HooksApprovalModal.vue'
import ServerConfigDialog from './components/ServerConfigDialog.vue'
import { useGlobalApproval } from './composables/useGlobalApproval' import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp' import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch' import { initTorch, destroyTorch } from './services/torch'
import { initSessionStateWS, destroySessionStateWS } from './services/session-state-ws' import { initSessionStateWS, destroySessionStateWS } from './services/session-state-ws'
import { endpoints } from './config/endpoints'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry' import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
import { setResponseControls } from './services/tools/handlers/responseHandlers' import { setResponseControls } from './services/tools/handlers/responseHandlers'
import { useCanvasStore } from './stores/canvas' import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas' import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state' import { useSessionState } from './stores/session-state'
import { isTauri } from './lib/tauri'
import { useServerConfig } from './stores/server-config'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
// Tauri server config
const serverConfig = isTauri ? useServerConfig() : null
const showServerConfig = ref(false)
const needsServerConfig = computed(() => isTauri && serverConfig && !serverConfig.isConfigured)
const showVoice = ref(false) const showVoice = ref(false)
const showTranscriptDebug = ref(false) const showTranscriptDebug = ref(false)
const showDebugConsole = ref(false) const showDebugConsole = ref(false)
@@ -293,6 +301,20 @@ watch(() => route.name, (newPage) => {
activatePageTools(newPage as PageName) activatePageTools(newPage as PageName)
} }
}) })
// Watch for Tauri server config changes — re-init services when server is configured
if (serverConfig) {
watch(() => serverConfig!.isConfigured, async (configured) => {
if (configured) {
showServerConfig.value = false
// Re-initialize all services with the new server URL
initSessionStateWS()
initWhisperSocket()
await initWebMCP()
await initTorch()
}
})
}
</script> </script>
<template> <template>
@@ -325,7 +347,12 @@ watch(() => route.name, (newPage) => {
</svg> </svg>
<span v-if="debugLogs.length" class="log-count">{{ debugLogs.length }}</span> <span v-if="debugLogs.length" class="log-count">{{ debugLogs.length }}</span>
</button> </button>
<PwaInstallBanner /> <PwaInstallBanner v-if="!isTauri" />
<button v-if="isTauri" class="server-config-btn" @click="showServerConfig = true" title="Server settings">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="2" y="2" width="20" height="8" rx="2" ry="2"/><rect x="2" y="14" width="20" height="8" rx="2" ry="2"/><line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</svg>
</button>
</div> </div>
<div class="header-right"> <div class="header-right">
<button <button
@@ -427,6 +454,9 @@ watch(() => route.name, (newPage) => {
<!-- Global Hooks Approval Modal --> <!-- Global Hooks Approval Modal -->
<HooksApprovalModal /> <HooksApprovalModal />
<!-- Tauri Server Config Dialog -->
<ServerConfigDialog v-if="needsServerConfig || showServerConfig" />
<!-- Debug Console Panel --> <!-- Debug Console Panel -->
<Teleport to="body"> <Teleport to="body">
<Transition name="debug-slide"> <Transition name="debug-slide">
@@ -1031,6 +1061,28 @@ watch(() => route.name, (newPage) => {
} }
} }
/* Server Config Button (Tauri) */
.server-config-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
border: 1px solid var(--border-color);
border-radius: 5px;
color: var(--text-secondary);
cursor: pointer;
transition: all 0.15s ease;
}
.server-config-btn:hover {
background: var(--bg-hover);
color: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
}
/* Debug Console Button */ /* Debug Console Button */
.debug-btn { .debug-btn {
display: flex; display: flex;

View File

@@ -1,7 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useCanvasStore } from '../stores/canvas' import { useCanvasStore } from '../stores/canvas'
import { endpoints } from '../config/endpoints' import { apiFetch } from '@/lib/tauri'
import { resolveEndpoints } from '../config/endpoints'
// Web Speech API types (not in default TS lib) // Web Speech API types (not in default TS lib)
interface SpeechRecognitionEvent extends Event { interface SpeechRecognitionEvent extends Event {
@@ -64,7 +65,7 @@ const containerRef = ref<HTMLElement | null>(null)
let recognition: SpeechRecognition | null = null as SpeechRecognition | null let recognition: SpeechRecognition | null = null as SpeechRecognition | null
// WebSocket connection to terminal // WebSocket connection to terminal
const WS_URL = endpoints.terminal const WS_URL = resolveEndpoints().terminal
let socket: WebSocket | null = null let socket: WebSocket | null = null
const connected = ref(false) const connected = ref(false)
@@ -78,7 +79,7 @@ let pendingWhisperSend = false // Flag to send transcript when Whisper responds
const useWhisper = ref(false) const useWhisper = ref(false)
const whisperReady = ref(false) const whisperReady = ref(false)
const whisperLoading = ref(false) const whisperLoading = ref(false)
const WHISPER_WS_URL = endpoints.whisper const WHISPER_WS_URL = resolveEndpoints().whisper
let whisperSocket: WebSocket | null = null let whisperSocket: WebSocket | null = null
let mediaRecorder: MediaRecorder | null = null let mediaRecorder: MediaRecorder | null = null
let audioChunks: Blob[] = [] let audioChunks: Blob[] = []
@@ -172,7 +173,7 @@ async function saveRecordingToBackend(blob: Blob) {
reader.onloadend = async () => { reader.onloadend = async () => {
const base64 = (reader.result as string).split(',')[1] const base64 = (reader.result as string).split(',')[1]
const response = await fetch('/api/recordings', { const response = await apiFetch('/api/recordings', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -331,7 +332,7 @@ function initRecognition() {
async function checkWhisperStatus(updateLoading = true) { async function checkWhisperStatus(updateLoading = true) {
try { try {
const res = await fetch('/api/whisper/status') const res = await apiFetch('/api/whisper/status')
const data = await res.json() const data = await res.json()
useWhisper.value = data.enabled useWhisper.value = data.enabled
whisperReady.value = data.running whisperReady.value = data.running
@@ -365,7 +366,7 @@ async function toggleWhisperMode() {
} }
try { try {
const res = await fetch('/api/whisper/toggle', { const res = await apiFetch('/api/whisper/toggle', {
method: 'POST' method: 'POST'
}) })
const data = await res.json() const data = await res.json()

View File

@@ -0,0 +1,282 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useServerConfig } from '@/stores/server-config'
const serverConfig = useServerConfig()
const urlInput = ref('')
const testing = ref(false)
const testResult = ref<'idle' | 'success' | 'error'>('idle')
async function handleTest() {
if (!urlInput.value.trim()) return
testing.value = true
testResult.value = 'idle'
const ok = await serverConfig.testConnection(urlInput.value.trim())
testResult.value = ok ? 'success' : 'error'
testing.value = false
}
async function handleConnect() {
if (!urlInput.value.trim()) return
const ok = await serverConfig.setServer(urlInput.value.trim())
if (ok) {
testResult.value = 'success'
}
}
function selectRecent(url: string) {
urlInput.value = url
testResult.value = 'idle'
}
onMounted(() => {
if (serverConfig.serverUrl) {
urlInput.value = serverConfig.serverUrl
}
})
</script>
<template>
<div class="server-config-overlay">
<div class="server-config-dialog">
<div class="dialog-header">
<h2>Connect to Server</h2>
<p class="dialog-subtitle">Enter the URL of your Agent UI backend</p>
</div>
<div class="dialog-body">
<div class="input-group">
<label for="server-url">Server URL</label>
<div class="input-row">
<input
id="server-url"
v-model="urlInput"
type="url"
placeholder="https://your-server.com or http://192.168.1.100:4101"
@keydown.enter="handleConnect"
:disabled="serverConfig.loading"
/>
<button
class="btn btn-test"
@click="handleTest"
:disabled="!urlInput.trim() || testing"
>
{{ testing ? 'Testing...' : 'Test' }}
</button>
</div>
<div v-if="testResult === 'success'" class="status-msg success">
Connected successfully
</div>
<div v-if="testResult === 'error'" class="status-msg error">
Could not connect to server
</div>
<div v-if="serverConfig.error" class="status-msg error">
{{ serverConfig.error }}
</div>
</div>
<div v-if="serverConfig.recentUrls.length > 0" class="recent-urls">
<label>Recent</label>
<div class="recent-list">
<button
v-for="url in serverConfig.recentUrls"
:key="url"
class="recent-item"
@click="selectRecent(url)"
>
{{ url }}
</button>
</div>
</div>
</div>
<div class="dialog-footer">
<button
class="btn btn-primary"
@click="handleConnect"
:disabled="!urlInput.trim() || serverConfig.loading"
>
{{ serverConfig.loading ? 'Connecting...' : 'Connect' }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.server-config-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(8px);
z-index: 99999;
}
.server-config-dialog {
background: var(--bg-primary, #0f0f14);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 12px;
width: 90%;
max-width: 480px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.dialog-header {
padding: 1.5rem 1.5rem 0;
}
.dialog-header h2 {
margin: 0;
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #e4e4e7);
}
.dialog-subtitle {
margin: 0.25rem 0 0;
font-size: 0.85rem;
color: var(--text-secondary, #a1a1aa);
}
.dialog-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.input-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.input-group label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #a1a1aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.input-row {
display: flex;
gap: 0.5rem;
}
.input-row input {
flex: 1;
padding: 0.6rem 0.8rem;
background: var(--bg-secondary, #1a1a24);
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 8px;
color: var(--text-primary, #e4e4e7);
font-size: 0.9rem;
outline: none;
transition: border-color 0.2s;
}
.input-row input:focus {
border-color: var(--accent, #6366f1);
}
.input-row input::placeholder {
color: var(--text-muted, #52525b);
}
.btn {
padding: 0.6rem 1rem;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.1));
border-radius: 8px;
background: var(--bg-secondary, #1a1a24);
color: var(--text-primary, #e4e4e7);
font-size: 0.85rem;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.btn:hover:not(:disabled) {
background: var(--bg-hover, #252530);
border-color: var(--accent, #6366f1);
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--accent, #6366f1);
border-color: var(--accent, #6366f1);
color: white;
font-weight: 500;
width: 100%;
}
.btn-primary:hover:not(:disabled) {
background: #4f46e5;
border-color: #4f46e5;
}
.status-msg {
font-size: 0.8rem;
padding: 0.4rem 0;
}
.status-msg.success {
color: #10b981;
}
.status-msg.error {
color: #ef4444;
}
.recent-urls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.recent-urls label {
font-size: 0.8rem;
font-weight: 500;
color: var(--text-secondary, #a1a1aa);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.recent-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.recent-item {
padding: 0.4rem 0.6rem;
background: transparent;
border: 1px solid var(--border-color, rgba(255, 255, 255, 0.06));
border-radius: 6px;
color: var(--text-secondary, #a1a1aa);
font-size: 0.8rem;
text-align: left;
cursor: pointer;
transition: all 0.15s;
}
.recent-item:hover {
background: var(--bg-hover, #252530);
color: var(--text-primary, #e4e4e7);
border-color: var(--accent, #6366f1);
}
.dialog-footer {
padding: 0 1.5rem 1.5rem;
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { AgentName, SessionInfo } from '@/types/transcript-debug' import type { AgentName, SessionInfo } from '@/types/transcript-debug'
const props = defineProps<{ const props = defineProps<{
@@ -51,7 +52,7 @@ async function fetchAllSessions() {
await Promise.all( await Promise.all(
props.agents.map(async (a) => { props.agents.map(async (a) => {
try { try {
const res = await fetch(`/api/transcript-debug/sessions?agent=${a.id}`) const res = await apiFetch(`/api/transcript-debug/sessions?agent=${a.id}`)
if (res.ok) map[a.id] = await res.json() if (res.ok) map[a.id] = await res.json()
else map[a.id] = [] else map[a.id] = []
} catch { } catch {

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { TableInfo, TableSchema, DbStats } from '@/types/database' import type { TableInfo, TableSchema, DbStats } from '@/types/database'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
@@ -17,7 +18,7 @@ export function useDatabaseApi() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_BASE}/tables`) const res = await apiFetch(`${API_BASE}/tables`)
if (!res.ok) throw new Error('Failed to fetch tables') if (!res.ok) throw new Error('Failed to fetch tables')
tables.value = await res.json() tables.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -29,7 +30,7 @@ export function useDatabaseApi() {
async function fetchDbStats() { async function fetchDbStats() {
try { try {
const res = await fetch(`${API_BASE}/stats`) const res = await apiFetch(`${API_BASE}/stats`)
if (!res.ok) throw new Error('Failed to fetch stats') if (!res.ok) throw new Error('Failed to fetch stats')
dbStats.value = await res.json() dbStats.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -39,7 +40,7 @@ export function useDatabaseApi() {
async function fetchTableSchema(tableName: string) { async function fetchTableSchema(tableName: string) {
try { try {
const res = await fetch(`${API_BASE}/tables/${tableName}/schema`) const res = await apiFetch(`${API_BASE}/tables/${tableName}/schema`)
if (!res.ok) throw new Error('Failed to fetch schema') if (!res.ok) throw new Error('Failed to fetch schema')
tableSchema.value = await res.json() tableSchema.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -52,7 +53,7 @@ export function useDatabaseApi() {
loading.value = true loading.value = true
try { try {
const offset = (page - 1) * pageSize const offset = (page - 1) * pageSize
const res = await fetch(`${API_BASE}/tables/${tableName}/data?limit=${pageSize}&offset=${offset}`) const res = await apiFetch(`${API_BASE}/tables/${tableName}/data?limit=${pageSize}&offset=${offset}`)
if (!res.ok) throw new Error('Failed to fetch data') if (!res.ok) throw new Error('Failed to fetch data')
const result = await res.json() const result = await res.json()
tableData.value = result.rows tableData.value = result.rows

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
const API_BASE = '/api/database' const API_BASE = '/api/database'
@@ -17,7 +18,7 @@ export function useQueryExecutor() {
queryResult.value = null queryResult.value = null
try { try {
const res = await fetch(`${API_BASE}/query`, { const res = await apiFetch(`${API_BASE}/query`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: queryText.value }) body: JSON.stringify({ query: queryText.value })

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { GitStatus, CommitInfo, FileDiff, BranchInfo, CompareResult, DiffResult, TreeNode, FileContent } from '@/types/git' import type { GitStatus, CommitInfo, FileDiff, BranchInfo, CompareResult, DiffResult, TreeNode, FileContent } from '@/types/git'
const API_BASE = '/api/git' const API_BASE = '/api/git'
@@ -20,7 +21,7 @@ export function useGitApi() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_BASE}/status`) const res = await apiFetch(`${API_BASE}/status`)
if (!res.ok) throw new Error('Failed to fetch status') if (!res.ok) throw new Error('Failed to fetch status')
status.value = await res.json() status.value = await res.json()
currentBranch.value = status.value?.branch || '' currentBranch.value = status.value?.branch || ''
@@ -39,7 +40,7 @@ export function useGitApi() {
if (options?.staged) params.set('staged', 'true') if (options?.staged) params.set('staged', 'true')
if (options?.file) params.set('file', options.file) if (options?.file) params.set('file', options.file)
const res = await fetch(`${API_BASE}/diff?${params}`) const res = await apiFetch(`${API_BASE}/diff?${params}`)
if (!res.ok) throw new Error('Failed to fetch diff') if (!res.ok) throw new Error('Failed to fetch diff')
diff.value = await res.json() diff.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -53,7 +54,7 @@ export function useGitApi() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_BASE}/log?limit=${limit}&offset=${offset}`) const res = await apiFetch(`${API_BASE}/log?limit=${limit}&offset=${offset}`)
if (!res.ok) throw new Error('Failed to fetch log') if (!res.ok) throw new Error('Failed to fetch log')
const data = await res.json() const data = await res.json()
commits.value = append ? [...commits.value, ...data] : data commits.value = append ? [...commits.value, ...data] : data
@@ -68,7 +69,7 @@ export function useGitApi() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_BASE}/log/${sha}`) const res = await apiFetch(`${API_BASE}/log/${sha}`)
if (!res.ok) throw new Error('Failed to fetch commit') if (!res.ok) throw new Error('Failed to fetch commit')
selectedCommit.value = await res.json() selectedCommit.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -81,7 +82,7 @@ export function useGitApi() {
async function fetchBranches() { async function fetchBranches() {
error.value = null error.value = null
try { try {
const res = await fetch(`${API_BASE}/branches`) const res = await apiFetch(`${API_BASE}/branches`)
if (!res.ok) throw new Error('Failed to fetch branches') if (!res.ok) throw new Error('Failed to fetch branches')
branches.value = await res.json() branches.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -93,7 +94,7 @@ export function useGitApi() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_BASE}/compare`, { const res = await apiFetch(`${API_BASE}/compare`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ base, head }) body: JSON.stringify({ base, head })
@@ -125,7 +126,7 @@ export function useGitApi() {
const params = new URLSearchParams() const params = new URLSearchParams()
if (path) params.set('path', path) if (path) params.set('path', path)
const res = await fetch(`${API_BASE}/tree?${params}`) const res = await apiFetch(`${API_BASE}/tree?${params}`)
if (!res.ok) throw new Error('Failed to fetch file tree') if (!res.ok) throw new Error('Failed to fetch file tree')
fileTree.value = await res.json() fileTree.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -140,7 +141,7 @@ export function useGitApi() {
error.value = null error.value = null
try { try {
const params = new URLSearchParams({ path }) const params = new URLSearchParams({ path })
const res = await fetch(`${API_BASE}/file?${params}`) const res = await apiFetch(`${API_BASE}/file?${params}`)
if (!res.ok) throw new Error('Failed to fetch file content') if (!res.ok) throw new Error('Failed to fetch file content')
fileContent.value = await res.json() fileContent.value = await res.json()
} catch (e: any) { } catch (e: any) {

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue' import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval' import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
export function useHooksApproval() { export function useHooksApproval() {
@@ -33,7 +34,7 @@ export function useHooksApproval() {
async function respondPermission(requestId: string, decision: 'allow' | 'deny') { async function respondPermission(requestId: string, decision: 'allow' | 'deny') {
try { try {
await fetch('/api/hooks-approval/respond', { await apiFetch('/api/hooks-approval/respond', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision }) body: JSON.stringify({ requestId, decision })
@@ -48,7 +49,7 @@ export function useHooksApproval() {
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) { async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
try { try {
await fetch('/api/hooks-approval/respond-plan', { await apiFetch('/api/hooks-approval/respond-plan', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason }) body: JSON.stringify({ requestId, decision, reason })
@@ -63,7 +64,7 @@ export function useHooksApproval() {
async function fetchPending() { async function fetchPending() {
try { try {
const res = await fetch('/api/hooks-approval') const res = await apiFetch('/api/hooks-approval')
if (!res.ok) return if (!res.ok) return
const data = await res.json() const data = await res.json()

View File

@@ -1,5 +1,6 @@
import { ref, shallowRef, computed, onUnmounted } from 'vue' import { ref, shallowRef, computed, onUnmounted } from 'vue'
import { endpoints, terminalApiUrl } from '@/config/endpoints' import { resolveEndpoints, terminalApiUrl } from '@/config/endpoints'
import { apiFetch } from '@/lib/tauri'
import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal' import { useEphemeralTerminal, type EphemeralTerminal } from '../useEphemeralTerminal'
import { useSessionState } from '@/stores/session-state' import { useSessionState } from '@/stores/session-state'
import type { import type {
@@ -102,7 +103,7 @@ export function useTranscriptDebug() {
updates: { transcriptSessionId?: string; label?: string } updates: { transcriptSessionId?: string; label?: string }
) { ) {
try { try {
await fetch(terminalApiUrl('/update-terminal'), { await apiFetch(terminalApiUrl('/update-terminal'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId, ...updates }) body: JSON.stringify({ ephemeralSessionId, ...updates })
@@ -112,7 +113,7 @@ export function useTranscriptDebug() {
async function unregisterTerminalOnServer(ephemeralSessionId: string) { async function unregisterTerminalOnServer(ephemeralSessionId: string) {
try { try {
await fetch(terminalApiUrl('/unregister-terminal'), { await apiFetch(terminalApiUrl('/unregister-terminal'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId }) body: JSON.stringify({ ephemeralSessionId })
@@ -141,7 +142,7 @@ export function useTranscriptDebug() {
try { try {
// Server creates PTY, runs command, registers in registry, broadcasts // Server creates PTY, runs command, registers in registry, broadcasts
const res = await fetch(terminalApiUrl('/create-terminal'), { const res = await apiFetch(terminalApiUrl('/create-terminal'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -215,7 +216,7 @@ export function useTranscriptDebug() {
} else if (ephSid) { } else if (ephSid) {
// No local terminal — kill the PTY directly on server // No local terminal — kill the PTY directly on server
try { try {
await fetch(terminalApiUrl('/kill-session'), { await apiFetch(terminalApiUrl('/kill-session'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: ephSid }) body: JSON.stringify({ sessionId: ephSid })
@@ -324,7 +325,7 @@ export function useTranscriptDebug() {
if (socket?.readyState === WebSocket.OPEN) return if (socket?.readyState === WebSocket.OPEN) return
// Same sync server as git (port 4105) // Same sync server as git (port 4105)
socket = new WebSocket(endpoints.git) socket = new WebSocket(resolveEndpoints().git)
socket.onopen = () => { socket.onopen = () => {
isRealtime.value = true isRealtime.value = true
@@ -436,7 +437,7 @@ export function useTranscriptDebug() {
if (!selectedSessionId.value) return if (!selectedSessionId.value) return
try { try {
const res = await fetch(`/api/transcript-debug/${selectedSessionId.value}/raw?agent=${selectedAgent.value}`) const res = await apiFetch(`/api/transcript-debug/${selectedSessionId.value}/raw?agent=${selectedAgent.value}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
rawContent.value = await res.text() rawContent.value = await res.text()
const parsed = parseJsonl(rawContent.value, selectedSessionId.value) const parsed = parseJsonl(rawContent.value, selectedSessionId.value)
@@ -544,7 +545,7 @@ export function useTranscriptDebug() {
async function fetchSessions() { async function fetchSessions() {
try { try {
const res = await fetch(`/api/transcript-debug/sessions?agent=${selectedAgent.value}`) const res = await apiFetch(`/api/transcript-debug/sessions?agent=${selectedAgent.value}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
sessions.value = await res.json() sessions.value = await res.json()
} catch (e: any) { } catch (e: any) {
@@ -557,7 +558,7 @@ export function useTranscriptDebug() {
// Determine agent from registry if different from selected // Determine agent from registry if different from selected
const entry = serverRegistry.value.find(e => e.transcriptSessionId === sessionId) const entry = serverRegistry.value.find(e => e.transcriptSessionId === sessionId)
const agent = entry?.agent || selectedAgent.value const agent = entry?.agent || selectedAgent.value
const res = await fetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`) const res = await apiFetch(`/api/transcript-debug/${sessionId}/raw?agent=${agent}`)
if (!res.ok) throw new Error(`HTTP ${res.status}`) if (!res.ok) throw new Error(`HTTP ${res.status}`)
rawContent.value = await res.text() rawContent.value = await res.text()
conversation.value = parseJsonl(rawContent.value, sessionId) conversation.value = parseJsonl(rawContent.value, sessionId)

View File

@@ -10,7 +10,8 @@
import { ref, computed, type Ref } from 'vue' import { ref, computed, type Ref } from 'vue'
import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer' import { useTerminalRenderer, type TerminalRenderer } from './useTerminalRenderer'
import { endpoints, terminalApiUrl } from '../config/endpoints' import { resolveEndpoints, terminalApiUrl } from '../config/endpoints'
import { apiFetch } from '@/lib/tauri'
export type EphemeralState = 'off' | 'connecting' | 'shell-ready' | 'running' | 'exited' export type EphemeralState = 'off' | 'connecting' | 'shell-ready' | 'running' | 'exited'
@@ -95,7 +96,7 @@ export function useEphemeralTerminal(
if (state.value !== 'off') return if (state.value !== 'off') return
state.value = 'connecting' state.value = 'connecting'
const wsBase = endpoints.terminal const wsBase = resolveEndpoints().terminal
const sep = wsBase.includes('?') ? '&' : '?' const sep = wsBase.includes('?') ? '&' : '?'
const wsUrl = `${wsBase}${sep}session=${ephemeralSessionId}` const wsUrl = `${wsBase}${sep}session=${ephemeralSessionId}`
@@ -202,7 +203,7 @@ export function useEphemeralTerminal(
// Force-kill via HTTP as safety net // Force-kill via HTTP as safety net
try { try {
await fetch(terminalApiUrl('/kill-session'), { await apiFetch(terminalApiUrl('/kill-session'), {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: ephemeralSessionId }) body: JSON.stringify({ sessionId: ephemeralSessionId })

View File

@@ -1,5 +1,6 @@
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useSessionState } from '@/stores/session-state' import { useSessionState } from '@/stores/session-state'
import { apiFetch } from '@/lib/tauri'
import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval' import type { HooksApprovalPermissionRequest, HooksApprovalPlanRequest } from '@/types/hooks-approval'
export interface ApprovalSessionGroup { export interface ApprovalSessionGroup {
@@ -79,7 +80,7 @@ export function useGlobalApproval() {
async function respondPermission(requestId: string, decision: string, reason?: string) { async function respondPermission(requestId: string, decision: string, reason?: string) {
console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`) console.log(`[GlobalApproval] Responding permission ${requestId}: ${decision}${reason ? ' reason=' + reason : ''}`)
try { try {
await fetch('/api/hooks-approval/respond', { await apiFetch('/api/hooks-approval/respond', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason }) body: JSON.stringify({ requestId, decision, reason })
@@ -93,7 +94,7 @@ export function useGlobalApproval() {
async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) { async function respondPlan(requestId: string, decision: 'approve' | 'reject' | 'edit', reason?: string) {
console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`) console.log(`[GlobalApproval] Responding plan ${requestId}: ${decision}`)
try { try {
await fetch('/api/hooks-approval/respond-plan', { await apiFetch('/api/hooks-approval/respond-plan', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason }) body: JSON.stringify({ requestId, decision, reason })
@@ -106,7 +107,7 @@ export function useGlobalApproval() {
async function ignoreApproval(requestId: string) { async function ignoreApproval(requestId: string) {
console.log(`[GlobalApproval] Ignoring ${requestId} (UI-only removal)`) console.log(`[GlobalApproval] Ignoring ${requestId} (UI-only removal)`)
try { try {
await fetch('/api/hooks-approval/ignore', { await apiFetch('/api/hooks-approval/ignore', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId }) body: JSON.stringify({ requestId })

View File

@@ -1,8 +1,11 @@
/** /**
* Centralized endpoint configuration for Agent UI * Centralized endpoint configuration for Agent UI
* Automatically detects HTTPS/Traefik and uses appropriate protocols * Automatically detects HTTPS/Traefik and uses appropriate protocols
* Supports Tauri mode where all URLs resolve against a configured server
*/ */
import { isTauri, getServerUrl } from '@/lib/tauri'
// Detect if running over HTTPS (behind Traefik) // Detect if running over HTTPS (behind Traefik)
export const isSecure = window.location.protocol === 'https:' export const isSecure = window.location.protocol === 'https:'
@@ -30,7 +33,7 @@ function buildHttpUrl(securePath: string, devPort: number): string {
return `http://${hostname}:${devPort}` return `http://${hostname}:${devPort}`
} }
// Endpoint configuration // Static endpoint configuration (used in web mode)
export const endpoints = { export const endpoints = {
// Terminal WebSocket // Terminal WebSocket
terminal: buildWsUrl('/ws/terminal', 4103), terminal: buildWsUrl('/ws/terminal', 4103),
@@ -57,17 +60,85 @@ export const endpoints = {
api: '/api' api: '/api'
} }
/**
* Resolve endpoints dynamically.
* In Tauri mode, builds WebSocket/HTTP URLs against the configured server.
* In web mode, returns the static endpoints.
*/
export function resolveEndpoints() {
if (!isTauri) return endpoints
const serverUrl = getServerUrl()
if (!serverUrl) return endpoints
// Parse server URL to get host and protocol
let url: URL
try {
url = new URL(serverUrl)
} catch {
return endpoints
}
const secure = url.protocol === 'https:'
const wsProto = secure ? 'wss:' : 'ws:'
const host = url.host // includes port if non-standard
function tauriBuildWs(securePath: string, devPort: number): string {
if (secure) {
return `${wsProto}//${host}${securePath}`
}
// In Tauri dev, use the server host with specific ports
const baseHost = url.hostname
return `${wsProto}//${baseHost}:${devPort}`
}
function tauriBuildHttp(securePath: string, devPort: number): string {
if (secure) {
return `${url.protocol}//${host}${securePath}`
}
const baseHost = url.hostname
return `http://${baseHost}:${devPort}`
}
return {
terminal: tauriBuildWs('/ws/terminal', 4103),
git: tauriBuildWs('/ws/git', 4105),
claudeStatus: tauriBuildWs('/ws/status', 4103),
whisper: tauriBuildWs('/ws/whisper', 4104),
webmcp: tauriBuildWs('/ws/mcp', 4102),
webmcpHttp: tauriBuildHttp('/mcp', 4102),
torch: tauriBuildWs('/ws/git', 4105),
api: `${serverUrl}/api`
}
}
/** Get the API base URL (for use in Tauri and web) */
export function getApiBase(): string {
if (!isTauri) return '/api'
const serverUrl = getServerUrl()
return serverUrl ? `${serverUrl}/api` : '/api'
}
// Agent terminal helpers // Agent terminal helpers
export function agentTerminalUrl(agentId: string): string { export function agentTerminalUrl(agentId: string): string {
const base = endpoints.terminal const base = resolveEndpoints().terminal
const sep = base.includes('?') ? '&' : '?' const sep = base.includes('?') ? '&' : '?'
return `${base}${sep}session=agent-${agentId}` return `${base}${sep}session=agent-${agentId}`
} }
export function terminalApiUrl(path: string): string { export function terminalApiUrl(path: string): string {
if (isTauri) {
const serverUrl = getServerUrl()
if (serverUrl) {
const url = new URL(serverUrl)
const secure = url.protocol === 'https:'
if (secure) return `https://${url.host}/ws/terminal${path}`
return `http://${url.hostname}:4103${path}`
}
}
if (isSecure) return `https://${hostname}/ws/terminal${path}` if (isSecure) return `https://${hostname}/ws/terminal${path}`
return `http://${hostname}:4103${path}` return `http://${hostname}:4103${path}`
} }
// Debug logging // Debug logging
console.log('[Endpoints]', isSecure ? 'HTTPS/Traefik mode' : 'Development mode', endpoints) console.log('[Endpoints]', isTauri ? 'Tauri mode' : (isSecure ? 'HTTPS/Traefik mode' : 'Development mode'), endpoints)

85
frontend/src/lib/tauri.ts Normal file
View File

@@ -0,0 +1,85 @@
/**
* Tauri detection and fetch wrapper for Agent UI.
* In Tauri mode, relative /api/... paths are resolved against the configured server URL.
* In web mode, this is a transparent pass-through to native fetch.
*/
// Detect if running inside a Tauri webview
export const isTauri = '__TAURI_INTERNALS__' in window
// Detect mobile Tauri (Android/iOS)
export function isMobileTauri(): boolean {
if (!isTauri) return false
const ua = navigator.userAgent.toLowerCase()
return /android|iphone|ipad|ipod/.test(ua)
}
// Server URL storage (in-memory, loaded from Tauri store on init)
let _serverUrl = ''
export function getServerUrl(): string {
return _serverUrl
}
export function setServerUrl(url: string) {
// Normalize: remove trailing slash
_serverUrl = url.replace(/\/+$/, '')
}
/**
* Resolve a path to a full URL.
* - In web mode: returns the path as-is (relative URLs work with Vite proxy / reverse proxy)
* - In Tauri mode: prepends the configured server URL
*/
export function resolveUrl(path: string): string {
if (!isTauri) return path
if (!_serverUrl) {
console.warn('[Tauri] No server URL configured, using path as-is:', path)
return path
}
// If already absolute, return as-is
if (path.startsWith('http://') || path.startsWith('https://')) return path
return `${_serverUrl}${path}`
}
/**
* Fetch wrapper that resolves relative API paths in Tauri mode.
* In Tauri, uses @tauri-apps/plugin-http for proper CORS-free requests.
* In web, delegates to native fetch.
*/
export async function apiFetch(input: string | URL | Request, init?: RequestInit): Promise<Response> {
if (!isTauri) {
return fetch(input, init)
}
// Resolve URL
const url = typeof input === 'string' ? resolveUrl(input) : input
// Use Tauri HTTP plugin for cross-origin requests
try {
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
return tauriFetch(url, init)
} catch (e) {
// Fallback to native fetch if plugin fails
console.warn('[Tauri] HTTP plugin failed, falling back to fetch:', e)
return fetch(url, init)
}
}
// Dynamic plugin imports (only used behind isTauri checks)
export async function getTauriStore() {
const { LazyStore } = await import('@tauri-apps/plugin-store')
return new LazyStore('settings.json')
}
export async function getTauriNotification() {
return import('@tauri-apps/plugin-notification')
}
export async function getTauriClipboard() {
return import('@tauri-apps/plugin-clipboard-manager')
}
export async function getTauriDialog() {
return import('@tauri-apps/plugin-dialog')
}

View File

@@ -3,6 +3,7 @@ import { createPinia } from 'pinia'
import App from './App.vue' import App from './App.vue'
import router from './router' import router from './router'
import './styles/main.css' import './styles/main.css'
import { isTauri } from './lib/tauri'
const pinia = createPinia() const pinia = createPinia()
const app = createApp(App) const app = createApp(App)
@@ -14,9 +15,23 @@ app.use(router)
;(window as any).__vueApp = app ;(window as any).__vueApp = app
;(window as any).__pinia = pinia ;(window as any).__pinia = pinia
async function bootstrap() {
// In Tauri mode, load server config before anything else
if (isTauri) {
const { useServerConfig } = await import('./stores/server-config')
const serverConfig = useServerConfig(pinia)
await serverConfig.loadConfig()
}
// Inicializar tema antes de montar la app // Inicializar tema antes de montar la app
import { useThemeStore } from './stores/theme' const { useThemeStore } = await import('./stores/theme')
const themeStore = useThemeStore(pinia) const themeStore = useThemeStore(pinia)
themeStore.fetchThemes().then(() => { await themeStore.fetchThemes().catch(() => {
app.mount('#app') // In Tauri without server configured, themes will fail — mount anyway
console.warn('[Main] Failed to fetch themes, mounting app anyway')
}) })
app.mount('#app')
}
bootstrap()

View File

@@ -2,7 +2,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import { useGitApi } from '@/composables/git' import { useGitApi } from '@/composables/git'
import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer, StatusTree } from '@/components/git' import { DiffViewer, FileTree, CommitList, BranchSelector, ProjectTree, FileViewer, StatusTree } from '@/components/git'
import { endpoints } from '@/config/endpoints' import { resolveEndpoints } from '@/config/endpoints'
type TabName = 'status' | 'history' | 'compare' | 'files' type TabName = 'status' | 'history' | 'compare' | 'files'
@@ -50,7 +50,7 @@ const isRealtime = ref(false)
function connectGitWatcher() { function connectGitWatcher() {
if (gitSocket?.readyState === WebSocket.OPEN) return if (gitSocket?.readyState === WebSocket.OPEN) return
gitSocket = new WebSocket(endpoints.git) gitSocket = new WebSocket(resolveEndpoints().git)
gitSocket.onopen = () => { gitSocket.onopen = () => {
isRealtime.value = true isRealtime.value = true

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, watch } from 'vue' import { ref, computed, onMounted, watch } from 'vue'
import { apiFetch } from '@/lib/tauri'
interface FileNode { interface FileNode {
name: string name: string
@@ -82,7 +83,7 @@ async function connect() {
try { try {
// Test connection and get repo info // Test connection and get repo info
const res = await fetch('/api/gitea/repo', { const res = await apiFetch('/api/gitea/repo', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -119,7 +120,7 @@ async function connect() {
async function loadFileTree(path: string = '') { async function loadFileTree(path: string = '') {
try { try {
const res = await fetch('/api/gitea/tree', { const res = await apiFetch('/api/gitea/tree', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -184,7 +185,7 @@ async function selectFile(node: FileNode) {
fileContent.value = '' fileContent.value = ''
try { try {
const res = await fetch('/api/gitea/file', { const res = await apiFetch('/api/gitea/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({

View File

@@ -21,6 +21,7 @@ import { useCanvasStore } from '../stores/canvas'
import { useThemeStore } from '../stores/theme' import { useThemeStore } from '../stores/theme'
import { useWindowsStore } from '../stores/windows' import { useWindowsStore } from '../stores/windows'
import WindowContainer from '../components/WindowContainer.vue' import WindowContainer from '../components/WindowContainer.vue'
import { apiFetch } from '@/lib/tauri'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
const API_URL = '' const API_URL = ''
@@ -128,7 +129,7 @@ export const componentsApi = {
if (opts?.includeArchived) params.set('include_archived', 'true') if (opts?.includeArchived) params.set('include_archived', 'true')
if (opts?.limit) params.set('limit', String(opts.limit)) if (opts?.limit) params.set('limit', String(opts.limit))
const qs = params.toString() const qs = params.toString()
const res = await fetch(`${API_URL}/api/components${qs ? '?' + qs : ''}`) const res = await apiFetch(`${API_URL}/api/components${qs ? '?' + qs : ''}`)
const data = await res.json() const data = await res.json()
return data.map((row: any) => ({ return data.map((row: any) => ({
...row, ...row,
@@ -139,7 +140,7 @@ export const componentsApi = {
}, },
async getById(id: string): Promise<VueComponentDefinition | null> { async getById(id: string): Promise<VueComponentDefinition | null> {
const res = await fetch(`${API_URL}/api/components/${id}`) const res = await apiFetch(`${API_URL}/api/components/${id}`)
if (!res.ok) return null if (!res.ok) return null
const row = await res.json() const row = await res.json()
return { return {
@@ -151,7 +152,7 @@ export const componentsApi = {
}, },
async save(component: VueComponentDefinition): Promise<{ success: boolean; id: string }> { async save(component: VueComponentDefinition): Promise<{ success: boolean; id: string }> {
const res = await fetch(`${API_URL}/api/components`, { const res = await apiFetch(`${API_URL}/api/components`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(component) body: JSON.stringify(component)
@@ -160,7 +161,7 @@ export const componentsApi = {
}, },
async update(id: string, data: Partial<VueComponentDefinition>): Promise<{ success: boolean }> { async update(id: string, data: Partial<VueComponentDefinition>): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/components/${id}`, { const res = await apiFetch(`${API_URL}/api/components/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -173,12 +174,12 @@ export const componentsApi = {
}, },
async delete(id: string): Promise<{ success: boolean; warning?: string }> { async delete(id: string): Promise<{ success: boolean; warning?: string }> {
const res = await fetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' }) const res = await apiFetch(`${API_URL}/api/components/${id}`, { method: 'DELETE' })
return res.json() return res.json()
}, },
async archiveAll(): Promise<{ success: boolean }> { async archiveAll(): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/components`, { method: 'DELETE' }) const res = await apiFetch(`${API_URL}/api/components`, { method: 'DELETE' })
return res.json() return res.json()
} }
} }

View File

@@ -1,4 +1,4 @@
import { endpoints } from '@/config/endpoints' import { resolveEndpoints } from '@/config/endpoints'
import { useSessionState } from '@/stores/session-state' import { useSessionState } from '@/stores/session-state'
let ws: WebSocket | null = null let ws: WebSocket | null = null
@@ -10,7 +10,7 @@ function connect() {
const store = useSessionState() const store = useSessionState()
// Connect to terminal server (4103) — same base as terminal WS // Connect to terminal server (4103) — same base as terminal WS
const url = endpoints.terminal const url = resolveEndpoints().terminal
console.log('[SessionStateWS] Connecting to', url) console.log('[SessionStateWS] Connecting to', url)
ws = new WebSocket(url) ws = new WebSocket(url)

View File

@@ -1,4 +1,5 @@
import type { ThemeVariables, Theme, DesignTokens } from '../stores/theme' import type { ThemeVariables, Theme, DesignTokens } from '../stores/theme'
import { apiFetch } from '@/lib/tauri'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
const API_URL = '' const API_URL = ''
@@ -9,24 +10,24 @@ const API_URL = ''
export const themesApi = { export const themesApi = {
async getAll(): Promise<Theme[]> { async getAll(): Promise<Theme[]> {
const res = await fetch(`${API_URL}/api/themes`) const res = await apiFetch(`${API_URL}/api/themes`)
return res.json() return res.json()
}, },
async getById(id: string): Promise<Theme | null> { async getById(id: string): Promise<Theme | null> {
const res = await fetch(`${API_URL}/api/themes/${id}`) const res = await apiFetch(`${API_URL}/api/themes/${id}`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
}, },
async getActive(): Promise<Theme | null> { async getActive(): Promise<Theme | null> {
const res = await fetch(`${API_URL}/api/themes/active`) const res = await apiFetch(`${API_URL}/api/themes/active`)
if (!res.ok) return null if (!res.ok) return null
return res.json() return res.json()
}, },
async save(theme: Partial<Theme>): Promise<{ success: boolean; id: string }> { async save(theme: Partial<Theme>): Promise<{ success: boolean; id: string }> {
const res = await fetch(`${API_URL}/api/themes`, { const res = await apiFetch(`${API_URL}/api/themes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(theme) body: JSON.stringify(theme)
@@ -35,17 +36,17 @@ export const themesApi = {
}, },
async delete(id: string): Promise<{ success: boolean; error?: string }> { async delete(id: string): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' }) const res = await apiFetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
return res.json() return res.json()
}, },
async setDefault(id: string): Promise<{ success: boolean }> { async setDefault(id: string): Promise<{ success: boolean }> {
const res = await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' }) const res = await apiFetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
return res.json() return res.json()
}, },
async getDesignTokens(): Promise<DesignTokens> { async getDesignTokens(): Promise<DesignTokens> {
const res = await fetch(`${API_URL}/api/design-tokens`) const res = await apiFetch(`${API_URL}/api/design-tokens`)
return res.json() return res.json()
}, },

View File

@@ -1,4 +1,5 @@
import type { ToolConfig } from './index' import type { ToolConfig } from './index'
import { apiFetch } from '@/lib/tauri'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
const API_BASE = '' const API_BASE = ''
@@ -12,7 +13,7 @@ export function createDatabaseHandlers(): ToolConfig[] {
schema: { type: 'object', properties: {} }, schema: { type: 'object', properties: {} },
handler: async () => { handler: async () => {
try { try {
const res = await fetch(`${API_BASE}/api/database/tables`) const res = await apiFetch(`${API_BASE}/api/database/tables`)
if (!res.ok) throw new Error('Failed to fetch tables') if (!res.ok) throw new Error('Failed to fetch tables')
const tables = await res.json() const tables = await res.json()
@@ -40,7 +41,7 @@ export function createDatabaseHandlers(): ToolConfig[] {
}, },
handler: async (args: { table: string }) => { handler: async (args: { table: string }) => {
try { try {
const res = await fetch(`${API_BASE}/api/database/tables/${args.table}/schema`) const res = await apiFetch(`${API_BASE}/api/database/tables/${args.table}/schema`)
if (!res.ok) { if (!res.ok) {
if (res.status === 404) return `Tabla "${args.table}" no encontrada` if (res.status === 404) return `Tabla "${args.table}" no encontrada`
throw new Error('Failed to fetch schema') throw new Error('Failed to fetch schema')
@@ -81,7 +82,7 @@ export function createDatabaseHandlers(): ToolConfig[] {
const limit = Math.min(args.limit || 20, 100) const limit = Math.min(args.limit || 20, 100)
const offset = args.offset || 0 const offset = args.offset || 0
const res = await fetch( const res = await apiFetch(
`${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}` `${API_BASE}/api/database/tables/${args.table}/data?limit=${limit}&offset=${offset}`
) )
if (!res.ok) { if (!res.ok) {
@@ -118,7 +119,7 @@ export function createDatabaseHandlers(): ToolConfig[] {
schema: { type: 'object', properties: {} }, schema: { type: 'object', properties: {} },
handler: async () => { handler: async () => {
try { try {
const res = await fetch(`${API_BASE}/api/database/stats`) const res = await apiFetch(`${API_BASE}/api/database/stats`)
if (!res.ok) throw new Error('Failed to fetch stats') if (!res.ok) throw new Error('Failed to fetch stats')
const stats = await res.json() const stats = await res.json()
@@ -146,7 +147,7 @@ export function createDatabaseHandlers(): ToolConfig[] {
}, },
handler: async (args: { query: string }) => { handler: async (args: { query: string }) => {
try { try {
const res = await fetch(`${API_BASE}/api/database/query`, { const res = await apiFetch(`${API_BASE}/api/database/query`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: args.query }) body: JSON.stringify({ query: args.query })

View File

@@ -5,18 +5,19 @@ import {
type VueComponentDefinition type VueComponentDefinition
} from '../../dynamicComponents' } from '../../dynamicComponents'
import { getWindowDefinitions } from './canvasHandlers' import { getWindowDefinitions } from './canvasHandlers'
import { apiFetch } from '@/lib/tauri'
const API_URL = '' const API_URL = ''
export const fsComponentsApi = { export const fsComponentsApi = {
async list(): Promise<VueComponentDefinition[]> { async list(): Promise<VueComponentDefinition[]> {
const res = await fetch(`${API_URL}/api/fs-components`) const res = await apiFetch(`${API_URL}/api/fs-components`)
if (!res.ok) throw new Error(`Failed to list fs-components: ${res.statusText}`) if (!res.ok) throw new Error(`Failed to list fs-components: ${res.statusText}`)
return res.json() return res.json()
}, },
async getByFolder(folder: string): Promise<VueComponentDefinition> { async getByFolder(folder: string): Promise<VueComponentDefinition> {
const res = await fetch(`${API_URL}/api/fs-components/${encodeURIComponent(folder)}`) const res = await apiFetch(`${API_URL}/api/fs-components/${encodeURIComponent(folder)}`)
if (!res.ok) throw new Error(`Component "${folder}" not found`) if (!res.ok) throw new Error(`Component "${folder}" not found`)
return res.json() return res.json()
} }

View File

@@ -1,4 +1,5 @@
import type { ToolConfig } from './index' import type { ToolConfig } from './index'
import { apiFetch } from '@/lib/tauri'
const API_BASE = '' const API_BASE = ''
@@ -14,7 +15,7 @@ export function createGitHandlers(): ToolConfig[] {
}, },
handler: async () => { handler: async () => {
try { try {
const res = await fetch(`${API_BASE}/api/git/status`) const res = await apiFetch(`${API_BASE}/api/git/status`)
if (!res.ok) { if (!res.ok) {
const err = await res.json() const err = await res.json()
return `Error: ${err.error}` return `Error: ${err.error}`
@@ -83,7 +84,7 @@ export function createGitHandlers(): ToolConfig[] {
if (args.staged) params.set('staged', 'true') if (args.staged) params.set('staged', 'true')
if (args.file) params.set('file', args.file) if (args.file) params.set('file', args.file)
const res = await fetch(`${API_BASE}/api/git/diff?${params}`) const res = await apiFetch(`${API_BASE}/api/git/diff?${params}`)
if (!res.ok) { if (!res.ok) {
const err = await res.json() const err = await res.json()
return `Error: ${err.error}` return `Error: ${err.error}`
@@ -131,7 +132,7 @@ export function createGitHandlers(): ToolConfig[] {
}, },
handler: async (args: { base: string; head: string }) => { handler: async (args: { base: string; head: string }) => {
try { try {
const res = await fetch(`${API_BASE}/api/git/compare`, { const res = await apiFetch(`${API_BASE}/api/git/compare`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(args) body: JSON.stringify(args)
@@ -197,7 +198,7 @@ export function createGitHandlers(): ToolConfig[] {
if (args.author) params.set('author', args.author) if (args.author) params.set('author', args.author)
if (args.since) params.set('since', args.since) if (args.since) params.set('since', args.since)
const res = await fetch(`${API_BASE}/api/git/log?${params}`) const res = await apiFetch(`${API_BASE}/api/git/log?${params}`)
if (!res.ok) { if (!res.ok) {
const err = await res.json() const err = await res.json()
return `Error: ${err.error}` return `Error: ${err.error}`
@@ -235,7 +236,7 @@ export function createGitHandlers(): ToolConfig[] {
}, },
handler: async () => { handler: async () => {
try { try {
const res = await fetch(`${API_BASE}/api/git/branches`) const res = await apiFetch(`${API_BASE}/api/git/branches`)
if (!res.ok) { if (!res.ok) {
const err = await res.json() const err = await res.json()
return `Error: ${err.error}` return `Error: ${err.error}`

View File

@@ -1,4 +1,5 @@
import type { ToolConfig } from './index' import type { ToolConfig } from './index'
import { apiFetch } from '@/lib/tauri'
let routerInstance: any = null let routerInstance: any = null
@@ -234,7 +235,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
}, },
handler: async () => { handler: async () => {
try { try {
const res = await fetch('/api/whisper/status') const res = await apiFetch('/api/whisper/status')
const data = await res.json() const data = await res.json()
return `Whisper GPU Status:\n` + return `Whisper GPU Status:\n` +
` Enabled: ${data.enabled ? 'Yes' : 'No'}\n` + ` Enabled: ${data.enabled ? 'Yes' : 'No'}\n` +
@@ -257,7 +258,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
}, },
handler: async () => { handler: async () => {
try { try {
const res = await fetch('/api/whisper/toggle', { const res = await apiFetch('/api/whisper/toggle', {
method: 'POST' method: 'POST'
}) })
const data = await res.json() const data = await res.json()
@@ -287,7 +288,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
}, },
handler: async () => { handler: async () => {
try { try {
const res = await fetch('/api/whisper/start', { const res = await apiFetch('/api/whisper/start', {
method: 'POST' method: 'POST'
}) })
const data = await res.json() const data = await res.json()
@@ -315,7 +316,7 @@ export function createGlobalHandlers(callbacks: ToolManagementCallbacks): ToolCo
}, },
handler: async () => { handler: async () => {
try { try {
const res = await fetch('/api/whisper/stop', { const res = await apiFetch('/api/whisper/stop', {
method: 'POST' method: 'POST'
}) })
const data = await res.json() const data = await res.json()

View File

@@ -1,4 +1,5 @@
import type { ToolConfig } from './index' import type { ToolConfig } from './index'
import { apiFetch } from '@/lib/tauri'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
const API_BASE = '' const API_BASE = ''
@@ -34,7 +35,7 @@ export function createSourceCodeHandlers(): ToolConfig[] {
} }
try { try {
const res = await fetch(`${API_BASE}/api/gitea/repo`, { const res = await apiFetch(`${API_BASE}/api/gitea/repo`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(giteaCredentials) body: JSON.stringify(giteaCredentials)
@@ -75,7 +76,7 @@ export function createSourceCodeHandlers(): ToolConfig[] {
} }
try { try {
const res = await fetch(`${API_BASE}/api/gitea/tree`, { const res = await apiFetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path || '' }) body: JSON.stringify({ ...giteaCredentials, path: args.path || '' })
@@ -131,7 +132,7 @@ export function createSourceCodeHandlers(): ToolConfig[] {
} }
try { try {
const res = await fetch(`${API_BASE}/api/gitea/file`, { const res = await apiFetch(`${API_BASE}/api/gitea/file`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path }) body: JSON.stringify({ ...giteaCredentials, path: args.path })
@@ -177,7 +178,7 @@ export function createSourceCodeHandlers(): ToolConfig[] {
} }
try { try {
const treeRes = await fetch(`${API_BASE}/api/gitea/tree`, { const treeRes = await apiFetch(`${API_BASE}/api/gitea/tree`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: args.path || '' }) body: JSON.stringify({ ...giteaCredentials, path: args.path || '' })
@@ -199,7 +200,7 @@ export function createSourceCodeHandlers(): ToolConfig[] {
for (const file of files.slice(0, maxFiles)) { for (const file of files.slice(0, maxFiles)) {
try { try {
const fileRes = await fetch(`${API_BASE}/api/gitea/file`, { const fileRes = await apiFetch(`${API_BASE}/api/gitea/file`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...giteaCredentials, path: file.path }) body: JSON.stringify({ ...giteaCredentials, path: file.path })

View File

@@ -1,7 +1,7 @@
import { useTorchStore } from '../stores/torch' import { useTorchStore } from '../stores/torch'
import { autoConnect, disconnectWebMCP } from './webmcp' import { autoConnect, disconnectWebMCP } from './webmcp'
import { onTorchConnected, onTorchDisconnected } from './toolRegistry' import { onTorchConnected, onTorchDisconnected } from './toolRegistry'
import { endpoints } from '../config/endpoints' import { resolveEndpoints } from '../config/endpoints'
let torchWs: WebSocket | null = null let torchWs: WebSocket | null = null
let clientId: string | null = null let clientId: string | null = null
@@ -18,7 +18,7 @@ function connectToTorchServer(): Promise<void> {
} }
console.log('[Torch] Connecting to server...') console.log('[Torch] Connecting to server...')
torchWs = new WebSocket(endpoints.torch) torchWs = new WebSocket(resolveEndpoints().torch)
torchWs.onopen = () => { torchWs.onopen = () => {
console.log('[Torch] Connected to server') console.log('[Torch] Connected to server')

View File

@@ -1,14 +1,16 @@
import { useCanvasStore } from '../stores/canvas' import { useCanvasStore } from '../stores/canvas'
import { endpoints, isSecure, wsProtocol, hostname } from '../config/endpoints' import { endpoints, isSecure, wsProtocol, hostname, resolveEndpoints } from '../config/endpoints'
import { isTauri, getServerUrl } from '@/lib/tauri'
import { apiFetch } from '@/lib/tauri'
// WebMCP HTTP API base for direct token requests // WebMCP HTTP API base for direct token requests (resolved dynamically for Tauri)
const WEBMCP_HTTP = endpoints.webmcpHttp function getWebmcpHttp() { return resolveEndpoints().webmcpHttp }
let webmcpInstance: any = null let webmcpInstance: any = null
const registeredTools = new Set<string>() const registeredTools = new Set<string>()
const eventUnsubscribers: Array<() => void> = [] const eventUnsubscribers: Array<() => void> = []
const API_BASE = endpoints.api function getApiBase() { return resolveEndpoints().api }
let tokenPollingInterval: number | null = null let tokenPollingInterval: number | null = null
export async function initWebMCP() { export async function initWebMCP() {
@@ -258,7 +260,7 @@ export function isToolRegistered(name: string): boolean {
// Token polling functions // Token polling functions
export async function checkForToken(): Promise<string | null> { export async function checkForToken(): Promise<string | null> {
try { try {
const res = await fetch(`${API_BASE}/webmcp-token`) const res = await apiFetch(`${getApiBase()}/webmcp-token`)
const data = await res.json() const data = await res.json()
return data.token || null return data.token || null
} catch (e) { } catch (e) {
@@ -268,7 +270,7 @@ export async function checkForToken(): Promise<string | null> {
export async function clearToken(): Promise<void> { export async function clearToken(): Promise<void> {
try { try {
await fetch(`${API_BASE}/webmcp-token`, { method: 'DELETE' }) await apiFetch(`${getApiBase()}/webmcp-token`, { method: 'DELETE' })
} catch (e) { } catch (e) {
// ignore // ignore
} }
@@ -315,11 +317,11 @@ export async function requestToken(): Promise<string | null> {
try { try {
console.log('[WebMCP] Requesting token from server...') console.log('[WebMCP] Requesting token from server...')
// In HTTPS mode, use Agent UI API as proxy (Traefik can't reach WebMCP directly) // In HTTPS or Tauri mode, use Agent UI API as proxy
// In development, call WebMCP directly // In development, call WebMCP directly
const url = isSecure ? `${API_BASE}/webmcp-request-token` : `${WEBMCP_HTTP}/token` const url = (isSecure || isTauri) ? `${getApiBase()}/webmcp-request-token` : `${getWebmcpHttp()}/token`
const res = await fetch(url, { const res = await apiFetch(url, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' } headers: { 'Content-Type': 'application/json' }
}) })
@@ -392,9 +394,18 @@ export async function connectWithToken(token: string): Promise<boolean> {
// Clear the pending token from server // Clear the pending token from server
await clearToken() await clearToken()
// If behind HTTPS/Traefik, modify token to use secure WebSocket // Modify token for correct WebSocket URL based on environment
let finalToken = token let finalToken = token
if (isSecure) { if (isTauri) {
// In Tauri, rewrite WebSocket URL to match configured server
const parsed = parseToken(token)
if (parsed) {
const wsUrl = resolveEndpoints().webmcp
const modifiedToken = { server: wsUrl, token: parsed.token }
finalToken = btoa(JSON.stringify(modifiedToken))
console.log('[WebMCP] Modified token for Tauri:', wsUrl)
}
} else if (isSecure) {
const parsed = parseToken(token) const parsed = parseToken(token)
if (parsed) { if (parsed) {
// Replace ws://localhost:4102 with wss://hostname/ws/mcp // Replace ws://localhost:4102 with wss://hostname/ws/mcp

View File

@@ -4,7 +4,8 @@
*/ */
import { ref } from 'vue' import { ref } from 'vue'
import { endpoints } from '../config/endpoints' import { resolveEndpoints } from '../config/endpoints'
import { apiFetch } from '@/lib/tauri'
export type WhisperStatus = 'offline' | 'loading' | 'ready' export type WhisperStatus = 'offline' | 'loading' | 'ready'
@@ -28,8 +29,8 @@ const listeners = new Set<TranscriptionCallback>()
function connect() { function connect() {
if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return if (socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) return
console.log('[WhisperSocket] Connecting to', endpoints.whisper) console.log('[WhisperSocket] Connecting to', resolveEndpoints().whisper)
socket = new WebSocket(endpoints.whisper) socket = new WebSocket(resolveEndpoints().whisper)
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
if (socket && socket.readyState !== WebSocket.OPEN) { if (socket && socket.readyState !== WebSocket.OPEN) {
@@ -86,7 +87,7 @@ function scheduleReconnect() {
async function checkStatusAndConnect() { async function checkStatusAndConnect() {
try { try {
const res = await fetch('/api/whisper/status') const res = await apiFetch('/api/whisper/status')
const data = await res.json() const data = await res.json()
if (data.running) { if (data.running) {
connect() connect()
@@ -148,7 +149,7 @@ export async function reconnect() {
} }
try { try {
const res = await fetch('/api/whisper/toggle', { method: 'POST' }) const res = await apiFetch('/api/whisper/toggle', { method: 'POST' })
const data = await res.json() const data = await res.json()
if (data.running) { if (data.running) {
connect() connect()
@@ -158,7 +159,7 @@ export async function reconnect() {
for (let i = 0; i < 60; i++) { for (let i = 0; i < 60; i++) {
await new Promise(r => setTimeout(r, 2000)) await new Promise(r => setTimeout(r, 2000))
try { try {
const s = await fetch('/api/whisper/status') const s = await apiFetch('/api/whisper/status')
const d = await s.json() const d = await s.json()
if (d.running) { if (d.running) {
connect() connect()

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
// ── Existing types ── // ── Existing types ──
@@ -238,7 +239,7 @@ export const useAgentsStore = defineStore('agents', () => {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch('/api/agents') const res = await apiFetch('/api/agents')
if (!res.ok) throw new Error('Failed to fetch agents') if (!res.ok) throw new Error('Failed to fetch agents')
agents.value = await res.json() agents.value = await res.json()
if (agents.value.length && !selectedAgentId.value) { if (agents.value.length && !selectedAgentId.value) {
@@ -263,7 +264,7 @@ export const useAgentsStore = defineStore('agents', () => {
async function loadFile(agentId: string, file: AgentFile) { async function loadFile(agentId: string, file: AgentFile) {
error.value = null error.value = null
try { try {
const res = await fetch(`/api/agents/file?path=${encodeURIComponent(file.path)}`) const res = await apiFetch(`/api/agents/file?path=${encodeURIComponent(file.path)}`)
if (!res.ok) { if (!res.ok) {
const data = await res.json() const data = await res.json()
throw new Error(data.error || 'Failed to load file') throw new Error(data.error || 'Failed to load file')
@@ -288,7 +289,7 @@ export const useAgentsStore = defineStore('agents', () => {
saving.value = true saving.value = true
error.value = null error.value = null
try { try {
const res = await fetch('/api/agents/file', { const res = await apiFetch('/api/agents/file', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -339,8 +340,8 @@ export const useAgentsStore = defineStore('agents', () => {
try { try {
const agentId = selectedAgentId.value || 'main' const agentId = selectedAgentId.value || 'main'
const [configRes, knownRes] = await Promise.all([ const [configRes, knownRes] = await Promise.all([
fetch(`/api/agents/config?agentId=${encodeURIComponent(agentId)}`), apiFetch(`/api/agents/config?agentId=${encodeURIComponent(agentId)}`),
fetch('/api/agents/known-tools') apiFetch('/api/agents/known-tools')
]) ])
if (!configRes.ok) throw new Error('Failed to fetch config') if (!configRes.ok) throw new Error('Failed to fetch config')
@@ -533,7 +534,7 @@ export const useAgentsStore = defineStore('agents', () => {
} }
} }
const res = await fetch('/api/agents/config/permissions', { const res = await apiFetch('/api/agents/config/permissions', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -558,8 +559,8 @@ export const useAgentsStore = defineStore('agents', () => {
error.value = null error.value = null
try { try {
const [mcpRes, configRes] = await Promise.all([ const [mcpRes, configRes] = await Promise.all([
fetch('/api/agents/mcp-json'), apiFetch('/api/agents/mcp-json'),
fetch(`/api/agents/config?agentId=${encodeURIComponent(selectedAgentId.value || 'main')}`) apiFetch(`/api/agents/config?agentId=${encodeURIComponent(selectedAgentId.value || 'main')}`)
]) ])
if (!mcpRes.ok) throw new Error('Failed to fetch MCP config') if (!mcpRes.ok) throw new Error('Failed to fetch MCP config')
@@ -604,7 +605,7 @@ export const useAgentsStore = defineStore('agents', () => {
mcpServersObj[s.name] = entry mcpServersObj[s.name] = entry
} }
const res = await fetch('/api/agents/config/mcp', { const res = await apiFetch('/api/agents/config/mcp', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mcpServers: mcpServersObj }) body: JSON.stringify({ mcpServers: mcpServersObj })
@@ -636,7 +637,7 @@ export const useAgentsStore = defineStore('agents', () => {
saving.value = true saving.value = true
error.value = null error.value = null
try { try {
const res = await fetch('/api/agents/config/hooks', { const res = await apiFetch('/api/agents/config/hooks', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -692,7 +693,7 @@ export const useAgentsStore = defineStore('agents', () => {
error.value = null error.value = null
try { try {
const agentId = selectedAgentId.value || 'main' const agentId = selectedAgentId.value || 'main'
const res = await fetch(`/api/agents/skills?agentId=${encodeURIComponent(agentId)}`) const res = await apiFetch(`/api/agents/skills?agentId=${encodeURIComponent(agentId)}`)
if (!res.ok) throw new Error('Failed to fetch skills') if (!res.ok) throw new Error('Failed to fetch skills')
skills.value = await res.json() skills.value = await res.json()
if (skills.value.length && !selectedSkill.value) { if (skills.value.length && !selectedSkill.value) {
@@ -711,7 +712,7 @@ export const useAgentsStore = defineStore('agents', () => {
pluginsLoading.value = true pluginsLoading.value = true
error.value = null error.value = null
try { try {
const res = await fetch('/api/agents/plugins') const res = await apiFetch('/api/agents/plugins')
if (!res.ok) throw new Error('Failed to fetch plugins') if (!res.ok) throw new Error('Failed to fetch plugins')
plugins.value = await res.json() plugins.value = await res.json()
} catch (e: any) { } catch (e: any) {

View File

@@ -1,5 +1,6 @@
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { apiFetch } from '@/lib/tauri'
export interface HookNotification { export interface HookNotification {
id: string id: string
@@ -172,7 +173,7 @@ export const useClaudeHooksStore = defineStore('claude-hooks', () => {
async function respondPermission(notifId: string, requestId: string, decision: 'allow' | 'deny') { async function respondPermission(notifId: string, requestId: string, decision: 'allow' | 'deny') {
try { try {
await fetch('/api/claude-permission-respond', { await apiFetch('/api/claude-permission-respond', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision }) body: JSON.stringify({ requestId, decision })

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { ProjectCanvas, CanvasComponent, ComponentUsage } from '../types/canvas' import type { ProjectCanvas, CanvasComponent, ComponentUsage } from '../types/canvas'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production // Uses relative URLs - works with Vite proxy in dev and Traefik in production
@@ -51,7 +52,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
error.value = null error.value = null
try { try {
const url = includeArchived ? `${API_URL}/api/canvas?include_archived=true` : `${API_URL}/api/canvas` const url = includeArchived ? `${API_URL}/api/canvas?include_archived=true` : `${API_URL}/api/canvas`
const res = await fetch(url) const res = await apiFetch(url)
if (!res.ok) throw new Error('Failed to fetch canvases') if (!res.ok) throw new Error('Failed to fetch canvases')
canvases.value = await res.json() canvases.value = await res.json()
} catch (e) { } catch (e) {
@@ -64,7 +65,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchToolbarCanvases() { async function fetchToolbarCanvases() {
try { try {
const res = await fetch(`${API_URL}/api/canvas/toolbar`) const res = await apiFetch(`${API_URL}/api/canvas/toolbar`)
if (!res.ok) throw new Error('Failed to fetch toolbar canvases') if (!res.ok) throw new Error('Failed to fetch toolbar canvases')
toolbarCanvases.value = await res.json() toolbarCanvases.value = await res.json()
} catch (e) { } catch (e) {
@@ -75,7 +76,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchDefaultCanvas(): Promise<ProjectCanvas | null> { async function fetchDefaultCanvas(): Promise<ProjectCanvas | null> {
try { try {
const res = await fetch(`${API_URL}/api/canvas/default`) const res = await apiFetch(`${API_URL}/api/canvas/default`)
if (!res.ok) throw new Error('Failed to fetch default canvas') if (!res.ok) throw new Error('Failed to fetch default canvas')
const data = await res.json() const data = await res.json()
if (data.hasDefault) { if (data.hasDefault) {
@@ -93,7 +94,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchCanvasById(id: string): Promise<ProjectCanvas | null> { async function fetchCanvasById(id: string): Promise<ProjectCanvas | null> {
try { try {
const res = await fetch(`${API_URL}/api/canvas/${id}`) const res = await apiFetch(`${API_URL}/api/canvas/${id}`)
if (!res.ok) { if (!res.ok) {
if (res.status === 404) return null if (res.status === 404) return null
throw new Error('Failed to fetch canvas') throw new Error('Failed to fetch canvas')
@@ -109,7 +110,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
saving.value = true saving.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_URL}/api/canvas`, { const res = await apiFetch(`${API_URL}/api/canvas`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -131,7 +132,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
saving.value = true saving.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_URL}/api/canvas/${id}`, { const res = await apiFetch(`${API_URL}/api/canvas/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -158,7 +159,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function deleteCanvas(id: string): Promise<boolean> { async function deleteCanvas(id: string): Promise<boolean> {
error.value = null error.value = null
try { try {
const res = await fetch(`${API_URL}/api/canvas/${id}`, { const res = await apiFetch(`${API_URL}/api/canvas/${id}`, {
method: 'DELETE' method: 'DELETE'
}) })
if (!res.ok) { if (!res.ok) {
@@ -185,7 +186,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
saving.value = true saving.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_URL}/api/canvas/${id}/clone`, { const res = await apiFetch(`${API_URL}/api/canvas/${id}/clone`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName }) body: JSON.stringify({ name: newName })
@@ -207,7 +208,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchCanvasComponents(canvasId: string) { async function fetchCanvasComponents(canvasId: string) {
loading.value = true loading.value = true
try { try {
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components`) const res = await apiFetch(`${API_URL}/api/canvas/${canvasId}/components`)
if (!res.ok) throw new Error('Failed to fetch canvas components') if (!res.ok) throw new Error('Failed to fetch canvas components')
activeCanvasComponents.value = await res.json() activeCanvasComponents.value = await res.json()
} catch (e) { } catch (e) {
@@ -225,7 +226,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
position?: number position?: number
): Promise<boolean> { ): Promise<boolean> {
try { try {
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components`, { const res = await apiFetch(`${API_URL}/api/canvas/${canvasId}/components`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ component_id: componentId, props, position }) body: JSON.stringify({ component_id: componentId, props, position })
@@ -241,7 +242,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function removeComponentFromCanvas(canvasId: string, componentId: string): Promise<boolean> { async function removeComponentFromCanvas(canvasId: string, componentId: string): Promise<boolean> {
try { try {
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components/${componentId}`, { const res = await apiFetch(`${API_URL}/api/canvas/${canvasId}/components/${componentId}`, {
method: 'DELETE' method: 'DELETE'
}) })
if (!res.ok) throw new Error('Failed to remove component from canvas') if (!res.ok) throw new Error('Failed to remove component from canvas')
@@ -259,7 +260,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
data: { position?: number; props?: Record<string, any>; layout?: any; is_visible?: boolean } data: { position?: number; props?: Record<string, any>; layout?: any; is_visible?: boolean }
): Promise<boolean> { ): Promise<boolean> {
try { try {
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components/${componentId}`, { const res = await apiFetch(`${API_URL}/api/canvas/${canvasId}/components/${componentId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -276,7 +277,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
// Component Usage // Component Usage
async function getComponentUsage(componentId: string): Promise<ComponentUsage | null> { async function getComponentUsage(componentId: string): Promise<ComponentUsage | null> {
try { try {
const res = await fetch(`${API_URL}/api/components/${componentId}/usage`) const res = await apiFetch(`${API_URL}/api/components/${componentId}/usage`)
if (!res.ok) throw new Error('Failed to get component usage') if (!res.ok) throw new Error('Failed to get component usage')
return await res.json() return await res.json()
} catch (e) { } catch (e) {

View File

@@ -0,0 +1,109 @@
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { isTauri, getTauriStore, setServerUrl } from '@/lib/tauri'
export const useServerConfig = defineStore('server-config', () => {
const serverUrl = ref('')
const isConfigured = computed(() => !!serverUrl.value)
const loading = ref(false)
const error = ref('')
const recentUrls = ref<string[]>([])
/** Load saved config from Tauri store */
async function loadConfig() {
if (!isTauri) return
try {
const store = await getTauriStore()
const saved = await store.get<string>('serverUrl')
if (saved) {
serverUrl.value = saved
setServerUrl(saved)
}
const savedRecent = await store.get<string[]>('recentUrls')
if (savedRecent) {
recentUrls.value = savedRecent
}
} catch (e) {
console.warn('[ServerConfig] Failed to load config:', e)
}
}
/** Test connection to a server URL */
async function testConnection(url: string): Promise<boolean> {
try {
const normalizedUrl = url.replace(/\/+$/, '')
const { fetch: tauriFetch } = await import('@tauri-apps/plugin-http')
const res = await tauriFetch(`${normalizedUrl}/api/health`, {
method: 'GET',
})
return res.ok
} catch {
return false
}
}
/** Set and persist the server URL (validates connectivity first) */
async function setServer(url: string): Promise<boolean> {
loading.value = true
error.value = ''
try {
const normalizedUrl = url.replace(/\/+$/, '')
const ok = await testConnection(normalizedUrl)
if (!ok) {
error.value = 'Could not connect to server'
return false
}
serverUrl.value = normalizedUrl
setServerUrl(normalizedUrl)
// Persist to Tauri store
const store = await getTauriStore()
await store.set('serverUrl', normalizedUrl)
// Update recent URLs
const filtered = recentUrls.value.filter(u => u !== normalizedUrl)
recentUrls.value = [normalizedUrl, ...filtered].slice(0, 5)
await store.set('recentUrls', recentUrls.value)
await store.save()
return true
} catch (e: any) {
error.value = e.message || 'Unknown error'
return false
} finally {
loading.value = false
}
}
/** Clear the saved configuration */
async function clearConfig() {
serverUrl.value = ''
setServerUrl('')
error.value = ''
if (isTauri) {
try {
const store = await getTauriStore()
await store.delete('serverUrl')
await store.save()
} catch (e) {
console.warn('[ServerConfig] Failed to clear config:', e)
}
}
}
return {
serverUrl,
isConfigured,
loading,
error,
recentUrls,
loadConfig,
testConnection,
setServer,
clearConfig,
}
})

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
import { terminalApiUrl } from '../config/endpoints' import { terminalApiUrl } from '../config/endpoints'
// ── Types (mirror server/services/session-state.ts) ── // ── Types (mirror server/services/session-state.ts) ──
@@ -196,7 +197,7 @@ export const useSessionState = defineStore('session-state', () => {
// ── Actions ── // ── Actions ──
async function respondApproval(requestId: string, decision: string, reason?: string) { async function respondApproval(requestId: string, decision: string, reason?: string) {
await fetch('/api/hooks-approval/respond', { await apiFetch('/api/hooks-approval/respond', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason }) body: JSON.stringify({ requestId, decision, reason })
@@ -204,7 +205,7 @@ export const useSessionState = defineStore('session-state', () => {
} }
async function respondPlanApproval(requestId: string, decision: string, reason?: string) { async function respondPlanApproval(requestId: string, decision: string, reason?: string) {
await fetch('/api/hooks-approval/respond-plan', { await apiFetch('/api/hooks-approval/respond-plan', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason }) body: JSON.stringify({ requestId, decision, reason })
@@ -213,7 +214,7 @@ export const useSessionState = defineStore('session-state', () => {
async function refreshAgentState(agent: string) { async function refreshAgentState(agent: string) {
try { try {
const res = await fetch(terminalApiUrl(`/session-state/${agent}`)) const res = await apiFetch(terminalApiUrl(`/session-state/${agent}`))
if (!res.ok) return if (!res.ok) return
const state = await res.json() as AgentSessionState const state = await res.json() as AgentSessionState
agents.value = { ...agents.value, [agent]: state } agents.value = { ...agents.value, [agent]: state }

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref } from 'vue' import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
import { useWindowsStore } from './windows' import { useWindowsStore } from './windows'
import { import {
getScriptLog, getScriptLog,
@@ -142,7 +143,7 @@ export const useSnapshotsStore = defineStore('snapshots', () => {
async function save(name: string): Promise<string> { async function save(name: string): Promise<string> {
const snapshot = captureState(name) const snapshot = captureState(name)
const res = await fetch(`${API_URL}/api/snapshots`, { const res = await apiFetch(`${API_URL}/api/snapshots`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
@@ -159,7 +160,7 @@ export const useSnapshotsStore = defineStore('snapshots', () => {
// ---- List snapshots ---- // ---- List snapshots ----
async function list(): Promise<SnapshotSummary[]> { async function list(): Promise<SnapshotSummary[]> {
const res = await fetch(`${API_URL}/api/snapshots`) const res = await apiFetch(`${API_URL}/api/snapshots`)
const data = await res.json() const data = await res.json()
snapshots.value = data snapshots.value = data
return data return data
@@ -167,14 +168,14 @@ export const useSnapshotsStore = defineStore('snapshots', () => {
// ---- Load full snapshot ---- // ---- Load full snapshot ----
async function load(id: string): Promise<CanvasSnapshot> { async function load(id: string): Promise<CanvasSnapshot> {
const res = await fetch(`${API_URL}/api/snapshots/${id}`) const res = await apiFetch(`${API_URL}/api/snapshots/${id}`)
const row = await res.json() const row = await res.json()
return row.data return row.data
} }
// ---- Remove snapshot ---- // ---- Remove snapshot ----
async function remove(id: string): Promise<void> { async function remove(id: string): Promise<void> {
await fetch(`${API_URL}/api/snapshots/${id}`, { method: 'DELETE' }) await apiFetch(`${API_URL}/api/snapshots/${id}`, { method: 'DELETE' })
await list() await list()
} }

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
// ===================== // =====================
// Types // Types
@@ -95,7 +96,7 @@ export const useThemeStore = defineStore('theme', () => {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const res = await fetch(`${API_URL}/api/themes`) const res = await apiFetch(`${API_URL}/api/themes`)
themes.value = await res.json() themes.value = await res.json()
const defaultTheme = themes.value.find(t => t.is_default) const defaultTheme = themes.value.find(t => t.is_default)
@@ -113,7 +114,7 @@ export const useThemeStore = defineStore('theme', () => {
async function fetchDesignTokens() { async function fetchDesignTokens() {
try { try {
const res = await fetch(`${API_URL}/api/design-tokens`) const res = await apiFetch(`${API_URL}/api/design-tokens`)
designTokens.value = await res.json() designTokens.value = await res.json()
} catch (e) { } catch (e) {
console.error('Error fetching design tokens:', e) console.error('Error fetching design tokens:', e)
@@ -123,7 +124,7 @@ export const useThemeStore = defineStore('theme', () => {
async function saveTheme(theme: Partial<Theme> & { name: string; variables: ThemeVariables }) { async function saveTheme(theme: Partial<Theme> & { name: string; variables: ThemeVariables }) {
saving.value = true saving.value = true
try { try {
const res = await fetch(`${API_URL}/api/themes`, { const res = await apiFetch(`${API_URL}/api/themes`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(theme) body: JSON.stringify(theme)
@@ -142,7 +143,7 @@ export const useThemeStore = defineStore('theme', () => {
async function updateTheme(id: string, data: { name?: string; description?: string; variables?: ThemeVariables; metadata?: ThemeMetadata }) { async function updateTheme(id: string, data: { name?: string; description?: string; variables?: ThemeVariables; metadata?: ThemeMetadata }) {
saving.value = true saving.value = true
try { try {
const res = await fetch(`${API_URL}/api/themes/${id}`, { const res = await apiFetch(`${API_URL}/api/themes/${id}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data) body: JSON.stringify(data)
@@ -164,7 +165,7 @@ export const useThemeStore = defineStore('theme', () => {
async function deleteTheme(id: string) { async function deleteTheme(id: string) {
try { try {
const res = await fetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' }) const res = await apiFetch(`${API_URL}/api/themes/${id}`, { method: 'DELETE' })
const result = await res.json() const result = await res.json()
if (result.error) { if (result.error) {
error.value = result.error error.value = result.error
@@ -180,7 +181,7 @@ export const useThemeStore = defineStore('theme', () => {
async function setDefaultTheme(id: string) { async function setDefaultTheme(id: string) {
try { try {
await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' }) await apiFetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
await fetchThemes() await fetchThemes()
} catch (e) { } catch (e) {
error.value = 'Error setting default theme' error.value = 'Error setting default theme'

View File

@@ -6,7 +6,10 @@ import path from 'path'
const __dirname = path.dirname(fileURLToPath(import.meta.url)) const __dirname = path.dirname(fileURLToPath(import.meta.url))
const isTauri = !!process.env.TAURI_ENV_PLATFORM
export default defineConfig({ export default defineConfig({
envPrefix: ['VITE_', 'TAURI_ENV_'],
resolve: { resolve: {
alias: { alias: {
'@': path.resolve(__dirname, 'src'), '@': path.resolve(__dirname, 'src'),
@@ -16,7 +19,8 @@ export default defineConfig({
}, },
plugins: [ plugins: [
vue(), vue(),
VitePWA({ // PWA only in web builds (not Tauri)
...(!isTauri ? [VitePWA({
registerType: 'autoUpdate', registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.svg', 'icons/*.png'], includeAssets: ['favicon.svg', 'icons/*.svg', 'icons/*.png'],
devOptions: { devOptions: {
@@ -67,7 +71,7 @@ export default defineConfig({
} }
] ]
} }
}) })] : [])
], ],
server: { server: {
port: 4100, port: 4100,

View File

@@ -7,7 +7,12 @@
"start": "bun run kill-ports && concurrently -n api,terminal,frontend -c blue,yellow,green \"cd server && bun --watch run index.ts\" \"cd server && bun run terminal.ts\" \"cd frontend && bun run dev --host\"", "start": "bun run kill-ports && concurrently -n api,terminal,frontend -c blue,yellow,green \"cd server && bun --watch run index.ts\" \"cd server && bun run terminal.ts\" \"cd frontend && bun run dev --host\"",
"start:api": "cd server && bun --watch run index.ts", "start:api": "cd server && bun --watch run index.ts",
"start:terminal": "cd server && bun run terminal.ts", "start:terminal": "cd server && bun run terminal.ts",
"start:frontend": "cd frontend && bun run dev --host" "start:frontend": "cd frontend && bun run dev --host",
"tauri": "npx --prefix frontend tauri",
"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"
}, },
"devDependencies": { "devDependencies": {
"concurrently": "^9.2.1" "concurrently": "^9.2.1"

6235
src-tauri/Cargo.lock generated Normal file

File diff suppressed because it is too large Load Diff

23
src-tauri/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "agent-ui"
version = "0.1.0"
description = "Agent UI - Desktop & Mobile App"
authors = ["you"]
edition = "2021"
[lib]
name = "agent_ui_lib"
crate-type = ["staticlib", "cdylib", "rlib"]
[build-dependencies]
tauri-build = { version = "2", features = [] }
[dependencies]
tauri = { version = "2", features = [] }
tauri-plugin-http = "2"
tauri-plugin-store = "2"
tauri-plugin-notification = "2"
tauri-plugin-clipboard-manager = "2"
tauri-plugin-dialog = "2"
serde = { version = "1", features = ["derive"] }
serde_json = "1"

3
src-tauri/build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,20 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-utils/schema.json",
"identifier": "default",
"description": "Default permissions for Agent UI",
"windows": ["main"],
"permissions": [
"core:default",
{
"identifier": "http:default",
"allow": [
{ "url": "http://**" },
{ "url": "https://**" }
]
},
"store:default",
"notification:default",
"clipboard-manager:default",
"dialog:default"
]
}

View File

@@ -0,0 +1,97 @@
import java.util.Properties
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("rust")
}
val tauriProperties = Properties().apply {
val propFile = file("tauri.properties")
if (propFile.exists()) {
propFile.inputStream().use { load(it) }
}
}
android {
signingConfigs {
create("release") {
storeFile = file("../keystore.jks")
storePassword = "agentui123"
keyAlias = "agentui"
keyPassword = "agentui123"
}
}
compileSdk = 36
namespace = "com.agentui.desktop"
defaultConfig {
manifestPlaceholders["usesCleartextTraffic"] = "false"
applicationId = "com.agentui.desktop"
minSdk = 24
targetSdk = 36
versionCode = tauriProperties.getProperty("tauri.android.versionCode", "1").toInt()
versionName = tauriProperties.getProperty("tauri.android.versionName", "1.0")
}
buildTypes {
getByName("debug") {
manifestPlaceholders["usesCleartextTraffic"] = "true"
isDebuggable = true
isJniDebuggable = true
isMinifyEnabled = false
packaging { jniLibs.keepDebugSymbols.add("*/arm64-v8a/*.so")
jniLibs.keepDebugSymbols.add("*/armeabi-v7a/*.so")
jniLibs.keepDebugSymbols.add("*/x86/*.so")
jniLibs.keepDebugSymbols.add("*/x86_64/*.so")
}
}
getByName("release") {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = true
proguardFiles(
*fileTree(".") { include("**/*.pro") }
.plus(getDefaultProguardFile("proguard-android-optimize.txt"))
.toList().toTypedArray()
)
}
}
kotlinOptions {
jvmTarget = "1.8"
}
buildFeatures {
buildConfig = true
}
}
rust {
rootDirRel = "../../../../"
}
dependencies {
implementation("androidx.webkit:webkit:1.14.0")
implementation("androidx.appcompat:appcompat:1.7.1")
implementation("androidx.activity:activity-ktx:1.10.1")
implementation("com.google.android.material:material:1.12.0")
implementation("androidx.work:work-runtime-ktx:2.9.0")
implementation("com.squareup.okhttp3:okhttp:4.12.0")
testImplementation("junit:junit:4.13.2")
androidTestImplementation("androidx.test.ext:junit:1.1.4")
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0")
}
apply(from = "tauri.build.gradle.kts")
// Copy APK to src-tauri/installers after build
android.applicationVariants.all {
val variant = this
variant.outputs.all {
val output = this
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}")
}
}
}
}

View File

@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="utf-8"?>
<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" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />
<application
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/Theme.agent_ui"
android:usesCleartextTraffic="${usesCleartextTraffic}">
<activity
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|smallestScreenSize|screenLayout|uiMode"
android:launchMode="singleTask"
android:label="@string/main_activity_title"
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<!-- AndroidTV support -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
<!-- Transcript Widget -->
<receiver
android:name=".TranscriptWidgetProvider"
android:exported="false">
<intent-filter>
<action android:name="android.appwidget.action.APPWIDGET_UPDATE" />
</intent-filter>
<meta-data
android:name="android.appwidget.provider"
android:resource="@xml/transcript_widget_info" />
</receiver>
<!-- Voice Command / Share Activity -->
<activity
android:name=".VoiceCommandActivity"
android:label="Agent UI Voice"
android:theme="@android:style/Theme.Translucent.NoTitleBar"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -0,0 +1,40 @@
package com.agentui.desktop
import android.os.Bundle
import android.util.Log
import androidx.activity.enableEdgeToEdge
import org.json.JSONObject
import java.io.File
class MainActivity : TauriActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge()
super.onCreate(savedInstanceState)
syncServerUrlToPrefs()
}
override fun onResume() {
super.onResume()
syncServerUrlToPrefs()
}
/**
* Reads the Tauri plugin-store settings.json and syncs serverUrl
* to SharedPreferences so native components (widget, voice) can use it.
*/
private fun syncServerUrlToPrefs() {
try {
val storeFile = File(filesDir, "app_tauri-plugin-store/settings.json")
if (!storeFile.exists()) 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)
}
}
}

View File

@@ -0,0 +1,40 @@
package com.agentui.desktop
import android.content.Context
object 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)
.getString(KEY_SERVER_URL, null)
}
fun setServerUrl(context: Context, url: String) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_SERVER_URL, url.trimEnd('/'))
.apply()
}
/** e.g. "http://192.168.1.10:4103" */
fun terminalBaseUrl(context: Context): String? {
val base = getServerUrl(context) ?: return null
val uri = android.net.Uri.parse(base)
val host = uri.host ?: return null
return "${uri.scheme}://$host:4103"
}
/** e.g. "ws://192.168.1.10:4103" */
fun terminalWsUrl(context: Context): String? {
val base = terminalBaseUrl(context) ?: return null
return base.replace("http://", "ws://").replace("https://", "wss://")
}
/** e.g. "http://192.168.1.10:4100/api" */
fun apiBaseUrl(context: Context): String? {
val base = getServerUrl(context) ?: return null
return "${base.trimEnd('/')}/api"
}
}

View File

@@ -0,0 +1,42 @@
package com.agentui.desktop
import android.app.PendingIntent
import android.appwidget.AppWidgetManager
import android.appwidget.AppWidgetProvider
import android.content.Context
import android.content.Intent
import android.widget.RemoteViews
class TranscriptWidgetProvider : AppWidgetProvider() {
override fun onUpdate(
context: Context,
appWidgetManager: AppWidgetManager,
appWidgetIds: IntArray
) {
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,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
views.setOnClickPendingIntent(R.id.widget_root, pendingIntent)
appWidgetManager.updateAppWidget(appWidgetId, views)
}
// Start periodic refresh worker
TranscriptWidgetWorker.enqueue(context)
}
override fun onEnabled(context: Context) {
TranscriptWidgetWorker.enqueue(context)
}
override fun onDisabled(context: Context) {
TranscriptWidgetWorker.cancel(context)
}
}

View File

@@ -0,0 +1,146 @@
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
class TranscriptWidgetWorker(
private val context: Context,
params: WorkerParameters
) : CoroutineWorker(context, params) {
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>()
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
fun cancel(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
}
}
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) { "" })
}
val appWidgetManager = AppWidgetManager.getInstance(context)
val widgetComponent = ComponentName(context, TranscriptWidgetProvider::class.java)
appWidgetManager.updateAppWidget(widgetComponent, views)
}
private fun scheduleNext() {
val request = OneTimeWorkRequestBuilder<TranscriptWidgetWorker>()
.setInitialDelay(30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(WORK_NAME, ExistingWorkPolicy.REPLACE, request)
}
}

View File

@@ -0,0 +1,160 @@
package com.agentui.desktop
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.speech.RecognizerIntent
import android.util.Log
import android.widget.Toast
import okhttp3.*
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
class VoiceCommandActivity : Activity() {
companion object {
private const val TAG = "AgentUI.Voice"
private const val SPEECH_REQUEST_CODE = 1001
}
private val client = OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(10, TimeUnit.SECONDS)
.build()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 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)
return
}
}
// Otherwise, 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")
}
try {
startActivityForResult(intent, SPEECH_REQUEST_CODE)
} catch (e: Exception) {
Toast.makeText(this, "Speech recognition not available", Toast.LENGTH_SHORT).show()
finish()
}
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == SPEECH_REQUEST_CODE) {
if (resultCode == RESULT_OK && data != null) {
val results = data.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)
val text = results?.firstOrNull()
if (!text.isNullOrBlank()) {
sendToServer(text)
return
}
}
finish()
}
}
private fun sendToServer(text: String) {
Thread {
try {
val terminalBase = ServerConfig.terminalBaseUrl(this)
val terminalWs = ServerConfig.terminalWsUrl(this)
if (terminalBase == null || terminalWs == null) {
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")
}.toString().toRequestBody("application/json".toMediaType())
val createReq = Request.Builder()
.url("$terminalBase/create-terminal")
.post(createBody)
.build()
val createResp = client.newCall(createReq).execute()
if (!createResp.isSuccessful) {
showToastAndFinish("Failed to create terminal")
return@Thread
}
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)}")
}
}.start()
}
private fun showToastAndFinish(message: String) {
runOnUiThread {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
finish()
}
}
}

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/widget_root"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:padding="12dp"
android:background="#DD1A1A2E">
<TextView
android:id="@+id/widget_title"
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" />
<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/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/msg3"
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/msg4"
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/msg5"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:textColor="#CCCCCC"
android:textSize="11sp"
android:fontFamily="monospace"
android:maxLines="1"
android:ellipsize="end" />
</LinearLayout>

View File

@@ -0,0 +1,5 @@
<resources>
<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>
</resources>

View File

@@ -0,0 +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:updatePeriodMillis="1800000"
android:initialLayout="@layout/widget_transcript"
android:resizeMode="horizontal|vertical"
android:widgetCategory="home_screen"
android:description="@string/widget_description" />

BIN
src-tauri/icons/128x128.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
src-tauri/icons/32x32.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
src-tauri/icons/64x64.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#fff</color>
</resources>

BIN
src-tauri/icons/icon.icns Normal file

Binary file not shown.

BIN
src-tauri/icons/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

Some files were not shown because too many files have changed in this diff Show More