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

View File

@@ -9,6 +9,12 @@
"version": "0.0.0",
"dependencies": {
"@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-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
@@ -19,6 +25,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
@@ -2415,6 +2422,278 @@
"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": {
"version": "1.0.8",
"license": "MIT"

View File

@@ -8,10 +8,25 @@
"dev": "vite",
"build": "vue-tsc -b && vite build",
"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": {
"@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-web-links": "^0.12.0",
"@xterm/addon-webgl": "^0.19.0",
@@ -22,6 +37,7 @@
"vue-router": "^4.6.4"
},
"devDependencies": {
"@tauri-apps/cli": "^2.10.0",
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@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 PwaInstallBanner from './components/PwaInstallBanner.vue'
import HooksApprovalModal from './components/HooksApprovalModal.vue'
import ServerConfigDialog from './components/ServerConfigDialog.vue'
import { useGlobalApproval } from './composables/useGlobalApproval'
import { initWebMCP, getWebMCP } from './services/webmcp'
import { initTorch, destroyTorch } from './services/torch'
import { initSessionStateWS, destroySessionStateWS } from './services/session-state-ws'
import { endpoints } from './config/endpoints'
import { initToolRegistry, activatePageTools, initToolsOnRefresh } from './services/toolRegistry'
import { setResponseControls } from './services/tools/handlers/responseHandlers'
import { useCanvasStore } from './stores/canvas'
import { useProjectCanvasStore } from './stores/projectCanvas'
import { useSessionState } from './stores/session-state'
import { isTauri } from './lib/tauri'
import { useServerConfig } from './stores/server-config'
const route = useRoute()
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 showTranscriptDebug = ref(false)
const showDebugConsole = ref(false)
@@ -293,6 +301,20 @@ watch(() => route.name, (newPage) => {
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>
<template>
@@ -325,7 +347,12 @@ watch(() => route.name, (newPage) => {
</svg>
<span v-if="debugLogs.length" class="log-count">{{ debugLogs.length }}</span>
</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 class="header-right">
<button
@@ -427,6 +454,9 @@ watch(() => route.name, (newPage) => {
<!-- Global Hooks Approval Modal -->
<HooksApprovalModal />
<!-- Tauri Server Config Dialog -->
<ServerConfigDialog v-if="needsServerConfig || showServerConfig" />
<!-- Debug Console Panel -->
<Teleport to="body">
<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-btn {
display: flex;

View File

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

View File

@@ -1,4 +1,5 @@
import { ref } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { TableInfo, TableSchema, DbStats } from '@/types/database'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production
@@ -17,7 +18,7 @@ export function useDatabaseApi() {
loading.value = true
error.value = null
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')
tables.value = await res.json()
} catch (e: any) {
@@ -29,7 +30,7 @@ export function useDatabaseApi() {
async function fetchDbStats() {
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')
dbStats.value = await res.json()
} catch (e: any) {
@@ -39,7 +40,7 @@ export function useDatabaseApi() {
async function fetchTableSchema(tableName: string) {
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')
tableSchema.value = await res.json()
} catch (e: any) {
@@ -52,7 +53,7 @@ export function useDatabaseApi() {
loading.value = true
try {
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')
const result = await res.json()
tableData.value = result.rows

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
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 { useSessionState } from '@/stores/session-state'
import type {
@@ -102,7 +103,7 @@ export function useTranscriptDebug() {
updates: { transcriptSessionId?: string; label?: string }
) {
try {
await fetch(terminalApiUrl('/update-terminal'), {
await apiFetch(terminalApiUrl('/update-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId, ...updates })
@@ -112,7 +113,7 @@ export function useTranscriptDebug() {
async function unregisterTerminalOnServer(ephemeralSessionId: string) {
try {
await fetch(terminalApiUrl('/unregister-terminal'), {
await apiFetch(terminalApiUrl('/unregister-terminal'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ephemeralSessionId })
@@ -141,7 +142,7 @@ export function useTranscriptDebug() {
try {
// 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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
@@ -215,7 +216,7 @@ export function useTranscriptDebug() {
} else if (ephSid) {
// No local terminal — kill the PTY directly on server
try {
await fetch(terminalApiUrl('/kill-session'), {
await apiFetch(terminalApiUrl('/kill-session'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sessionId: ephSid })
@@ -324,7 +325,7 @@ export function useTranscriptDebug() {
if (socket?.readyState === WebSocket.OPEN) return
// Same sync server as git (port 4105)
socket = new WebSocket(endpoints.git)
socket = new WebSocket(resolveEndpoints().git)
socket.onopen = () => {
isRealtime.value = true
@@ -436,7 +437,7 @@ export function useTranscriptDebug() {
if (!selectedSessionId.value) return
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}`)
rawContent.value = await res.text()
const parsed = parseJsonl(rawContent.value, selectedSessionId.value)
@@ -544,7 +545,7 @@ export function useTranscriptDebug() {
async function fetchSessions() {
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}`)
sessions.value = await res.json()
} catch (e: any) {
@@ -557,7 +558,7 @@ export function useTranscriptDebug() {
// Determine agent from registry if different from selected
const entry = serverRegistry.value.find(e => e.transcriptSessionId === sessionId)
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}`)
rawContent.value = await res.text()
conversation.value = parseJsonl(rawContent.value, sessionId)

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
/**
* Centralized endpoint configuration for Agent UI
* 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)
export const isSecure = window.location.protocol === 'https:'
@@ -30,7 +33,7 @@ function buildHttpUrl(securePath: string, devPort: number): string {
return `http://${hostname}:${devPort}`
}
// Endpoint configuration
// Static endpoint configuration (used in web mode)
export const endpoints = {
// Terminal WebSocket
terminal: buildWsUrl('/ws/terminal', 4103),
@@ -57,17 +60,85 @@ export const endpoints = {
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
export function agentTerminalUrl(agentId: string): string {
const base = endpoints.terminal
const base = resolveEndpoints().terminal
const sep = base.includes('?') ? '&' : '?'
return `${base}${sep}session=agent-${agentId}`
}
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}`
return `http://${hostname}:4103${path}`
}
// 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 router from './router'
import './styles/main.css'
import { isTauri } from './lib/tauri'
const pinia = createPinia()
const app = createApp(App)
@@ -14,9 +15,23 @@ app.use(router)
;(window as any).__vueApp = app
;(window as any).__pinia = pinia
// Inicializar tema antes de montar la app
import { useThemeStore } from './stores/theme'
const themeStore = useThemeStore(pinia)
themeStore.fetchThemes().then(() => {
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
const { useThemeStore } = await import('./stores/theme')
const themeStore = useThemeStore(pinia)
await themeStore.fetchThemes().catch(() => {
// 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 { useGitApi } from '@/composables/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'
@@ -50,7 +50,7 @@ const isRealtime = ref(false)
function connectGitWatcher() {
if (gitSocket?.readyState === WebSocket.OPEN) return
gitSocket = new WebSocket(endpoints.git)
gitSocket = new WebSocket(resolveEndpoints().git)
gitSocket.onopen = () => {
isRealtime.value = true

View File

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

View File

@@ -21,6 +21,7 @@ import { useCanvasStore } from '../stores/canvas'
import { useThemeStore } from '../stores/theme'
import { useWindowsStore } from '../stores/windows'
import WindowContainer from '../components/WindowContainer.vue'
import { apiFetch } from '@/lib/tauri'
// Uses relative URLs - works with Vite proxy in dev and Traefik in production
const API_URL = ''
@@ -128,7 +129,7 @@ export const componentsApi = {
if (opts?.includeArchived) params.set('include_archived', 'true')
if (opts?.limit) params.set('limit', String(opts.limit))
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()
return data.map((row: any) => ({
...row,
@@ -139,7 +140,7 @@ export const componentsApi = {
},
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
const row = await res.json()
return {
@@ -151,7 +152,7 @@ export const componentsApi = {
},
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(component)
@@ -160,7 +161,7 @@ export const componentsApi = {
},
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -173,12 +174,12 @@ export const componentsApi = {
},
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()
},
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()
}
}

View File

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

View File

@@ -1,4 +1,5 @@
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
const API_URL = ''
@@ -9,24 +10,24 @@ const API_URL = ''
export const themesApi = {
async getAll(): Promise<Theme[]> {
const res = await fetch(`${API_URL}/api/themes`)
const res = await apiFetch(`${API_URL}/api/themes`)
return res.json()
},
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
return res.json()
},
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
return res.json()
},
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(theme)
@@ -35,17 +36,17 @@ export const themesApi = {
},
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()
},
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()
},
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()
},

View File

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

View File

@@ -5,18 +5,19 @@ import {
type VueComponentDefinition
} from '../../dynamicComponents'
import { getWindowDefinitions } from './canvasHandlers'
import { apiFetch } from '@/lib/tauri'
const API_URL = ''
export const fsComponentsApi = {
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}`)
return res.json()
},
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`)
return res.json()
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,14 +1,16 @@
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
const WEBMCP_HTTP = endpoints.webmcpHttp
// WebMCP HTTP API base for direct token requests (resolved dynamically for Tauri)
function getWebmcpHttp() { return resolveEndpoints().webmcpHttp }
let webmcpInstance: any = null
const registeredTools = new Set<string>()
const eventUnsubscribers: Array<() => void> = []
const API_BASE = endpoints.api
function getApiBase() { return resolveEndpoints().api }
let tokenPollingInterval: number | null = null
export async function initWebMCP() {
@@ -258,7 +260,7 @@ export function isToolRegistered(name: string): boolean {
// Token polling functions
export async function checkForToken(): Promise<string | null> {
try {
const res = await fetch(`${API_BASE}/webmcp-token`)
const res = await apiFetch(`${getApiBase()}/webmcp-token`)
const data = await res.json()
return data.token || null
} catch (e) {
@@ -268,7 +270,7 @@ export async function checkForToken(): Promise<string | null> {
export async function clearToken(): Promise<void> {
try {
await fetch(`${API_BASE}/webmcp-token`, { method: 'DELETE' })
await apiFetch(`${getApiBase()}/webmcp-token`, { method: 'DELETE' })
} catch (e) {
// ignore
}
@@ -315,11 +317,11 @@ export async function requestToken(): Promise<string | null> {
try {
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
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',
headers: { 'Content-Type': 'application/json' }
})
@@ -392,9 +394,18 @@ export async function connectWithToken(token: string): Promise<boolean> {
// Clear the pending token from server
await clearToken()
// If behind HTTPS/Traefik, modify token to use secure WebSocket
// Modify token for correct WebSocket URL based on environment
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)
if (parsed) {
// Replace ws://localhost:4102 with wss://hostname/ws/mcp

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
import type { ProjectCanvas, CanvasComponent, ComponentUsage } from '../types/canvas'
// 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
try {
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')
canvases.value = await res.json()
} catch (e) {
@@ -64,7 +65,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchToolbarCanvases() {
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')
toolbarCanvases.value = await res.json()
} catch (e) {
@@ -75,7 +76,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchDefaultCanvas(): Promise<ProjectCanvas | null> {
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')
const data = await res.json()
if (data.hasDefault) {
@@ -93,7 +94,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchCanvasById(id: string): Promise<ProjectCanvas | null> {
try {
const res = await fetch(`${API_URL}/api/canvas/${id}`)
const res = await apiFetch(`${API_URL}/api/canvas/${id}`)
if (!res.ok) {
if (res.status === 404) return null
throw new Error('Failed to fetch canvas')
@@ -109,7 +110,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
saving.value = true
error.value = null
try {
const res = await fetch(`${API_URL}/api/canvas`, {
const res = await apiFetch(`${API_URL}/api/canvas`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -131,7 +132,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
saving.value = true
error.value = null
try {
const res = await fetch(`${API_URL}/api/canvas/${id}`, {
const res = await apiFetch(`${API_URL}/api/canvas/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -158,7 +159,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function deleteCanvas(id: string): Promise<boolean> {
error.value = null
try {
const res = await fetch(`${API_URL}/api/canvas/${id}`, {
const res = await apiFetch(`${API_URL}/api/canvas/${id}`, {
method: 'DELETE'
})
if (!res.ok) {
@@ -185,7 +186,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
saving.value = true
error.value = null
try {
const res = await fetch(`${API_URL}/api/canvas/${id}/clone`, {
const res = await apiFetch(`${API_URL}/api/canvas/${id}/clone`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name: newName })
@@ -207,7 +208,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
async function fetchCanvasComponents(canvasId: string) {
loading.value = true
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')
activeCanvasComponents.value = await res.json()
} catch (e) {
@@ -225,7 +226,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
position?: number
): Promise<boolean> {
try {
const res = await fetch(`${API_URL}/api/canvas/${canvasId}/components`, {
const res = await apiFetch(`${API_URL}/api/canvas/${canvasId}/components`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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> {
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'
})
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 }
): Promise<boolean> {
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',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -276,7 +277,7 @@ export const useProjectCanvasStore = defineStore('projectCanvas', () => {
// Component Usage
async function getComponentUsage(componentId: string): Promise<ComponentUsage | null> {
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')
return await res.json()
} 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 { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
import { terminalApiUrl } from '../config/endpoints'
// ── Types (mirror server/services/session-state.ts) ──
@@ -196,7 +197,7 @@ export const useSessionState = defineStore('session-state', () => {
// ── Actions ──
async function respondApproval(requestId: string, decision: string, reason?: string) {
await fetch('/api/hooks-approval/respond', {
await apiFetch('/api/hooks-approval/respond', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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) {
await fetch('/api/hooks-approval/respond-plan', {
await apiFetch('/api/hooks-approval/respond-plan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ requestId, decision, reason })
@@ -213,7 +214,7 @@ export const useSessionState = defineStore('session-state', () => {
async function refreshAgentState(agent: string) {
try {
const res = await fetch(terminalApiUrl(`/session-state/${agent}`))
const res = await apiFetch(terminalApiUrl(`/session-state/${agent}`))
if (!res.ok) return
const state = await res.json() as AgentSessionState
agents.value = { ...agents.value, [agent]: state }

View File

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

View File

@@ -1,5 +1,6 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { apiFetch } from '@/lib/tauri'
// =====================
// Types
@@ -95,7 +96,7 @@ export const useThemeStore = defineStore('theme', () => {
loading.value = true
error.value = null
try {
const res = await fetch(`${API_URL}/api/themes`)
const res = await apiFetch(`${API_URL}/api/themes`)
themes.value = await res.json()
const defaultTheme = themes.value.find(t => t.is_default)
@@ -113,7 +114,7 @@ export const useThemeStore = defineStore('theme', () => {
async function fetchDesignTokens() {
try {
const res = await fetch(`${API_URL}/api/design-tokens`)
const res = await apiFetch(`${API_URL}/api/design-tokens`)
designTokens.value = await res.json()
} catch (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 }) {
saving.value = true
try {
const res = await fetch(`${API_URL}/api/themes`, {
const res = await apiFetch(`${API_URL}/api/themes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
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 }) {
saving.value = true
try {
const res = await fetch(`${API_URL}/api/themes/${id}`, {
const res = await apiFetch(`${API_URL}/api/themes/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
@@ -164,7 +165,7 @@ export const useThemeStore = defineStore('theme', () => {
async function deleteTheme(id: string) {
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()
if (result.error) {
error.value = result.error
@@ -180,7 +181,7 @@ export const useThemeStore = defineStore('theme', () => {
async function setDefaultTheme(id: string) {
try {
await fetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
await apiFetch(`${API_URL}/api/themes/${id}/default`, { method: 'POST' })
await fetchThemes()
} catch (e) {
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 isTauri = !!process.env.TAURI_ENV_PLATFORM
export default defineConfig({
envPrefix: ['VITE_', 'TAURI_ENV_'],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
@@ -16,7 +19,8 @@ export default defineConfig({
},
plugins: [
vue(),
VitePWA({
// PWA only in web builds (not Tauri)
...(!isTauri ? [VitePWA({
registerType: 'autoUpdate',
includeAssets: ['favicon.svg', 'icons/*.svg', 'icons/*.png'],
devOptions: {
@@ -67,7 +71,7 @@ export default defineConfig({
}
]
}
})
})] : [])
],
server: {
port: 4100,