feat(frontend): full remote ui with mock data
This commit is contained in:
1 parent
143bca8a8b
commit
0705f4abc5
1 file changed
+295
-42
+295
-42
@@ -1,11 +1,174 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import TitleBar from '../components/TitleBar.vue';
|
import TitleBar from '../components/TitleBar.vue';
|
||||||
import { ref } from 'vue';
|
import { ref, reactive, computed, onMounted } from 'vue';
|
||||||
|
import * as backend from '../backend';
|
||||||
|
import * as ui from '../ui';
|
||||||
|
|
||||||
|
/** Connected external tool remotes (initialized with mock data) */
|
||||||
|
const remotes = ref([
|
||||||
|
{
|
||||||
|
name: 'weather-service',
|
||||||
|
connected: true,
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: 'get_forecast',
|
||||||
|
description: 'Retrieve weather forecast for a location.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
city: { type: 'string', description: 'City name or code.' },
|
||||||
|
days: { type: 'number', description: 'Number of days ahead.' }
|
||||||
|
},
|
||||||
|
required: ['city']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'search_storms',
|
||||||
|
description: 'Find active storm systems near coordinates.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
lat: { type: 'number', description: 'Latitude.' },
|
||||||
|
lon: { type: 'number', description: 'Longitude.' },
|
||||||
|
radius_km: { type: 'number', description: 'Search radius.' }
|
||||||
|
},
|
||||||
|
required: ['lat', 'lon']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
agentIds: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'code-repo-indexer',
|
||||||
|
connected: true,
|
||||||
|
functions: [
|
||||||
|
{
|
||||||
|
name: 'search_codebase',
|
||||||
|
description: 'Search repository code by query.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Search text.' },
|
||||||
|
lang: { type: 'string', description: 'Language filter.' },
|
||||||
|
path_regex: { type: 'string', description: 'Path pattern.' }
|
||||||
|
},
|
||||||
|
required: ['query']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'find_symbol',
|
||||||
|
description: 'Locate a symbol definition in the codebase.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
symbol_name: { type: 'string', description: 'Name of the symbol.' },
|
||||||
|
file_path: { type: 'string', description: 'Restrict to a file.' }
|
||||||
|
},
|
||||||
|
required: ['symbol_name']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
agentIds: []
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Tracks expanded remote panels using a Set to support multiple open sections */
|
||||||
|
const expandedRemoteIds = ref(new Set());
|
||||||
|
|
||||||
|
/** Currently selected remote name for invoking a function */
|
||||||
|
const activeRemoteName = ref('');
|
||||||
|
|
||||||
|
/** Currently selected function spec inside the active remote */
|
||||||
|
const activeFunction = ref(null);
|
||||||
|
|
||||||
|
/** JSON string containing arguments for the active function */
|
||||||
|
const activeArgsJson = ref('{}');
|
||||||
|
|
||||||
|
/** Parsed result object of the last successful call (null until called) */
|
||||||
|
const callResult = ref(null);
|
||||||
|
|
||||||
|
/** Error message from the last call attempt (null when no error) */
|
||||||
|
const callError = ref('');
|
||||||
|
|
||||||
|
/** List of locally persisted remotes managed via localStorage */
|
||||||
const localRemoteList = ref([]);
|
const localRemoteList = ref([]);
|
||||||
|
|
||||||
|
/** Key used to serialize/deserialize local remotes from browser storage */
|
||||||
const STORAGE_KEY = 'local_remotes';
|
const STORAGE_KEY = 'local_remotes';
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
// fetchRemotes(); // Disabled — using hardcoded mock data for now
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Toggle expansion of a remote detail panel */
|
||||||
|
function toggleRemote(name) {
|
||||||
|
// Create a new Set instance to ensure Vue detects the change
|
||||||
|
const newSet = new Set(expandedRemoteIds.value);
|
||||||
|
if (newSet.has(name)) {
|
||||||
|
newSet.delete(name);
|
||||||
|
} else {
|
||||||
|
newSet.add(name);
|
||||||
|
}
|
||||||
|
expandedRemoteIds.value = newSet;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set the active remote and expose its function list to the caller */
|
||||||
|
function selectRemote(name) {
|
||||||
|
const found = remotes.value.find(r => r.name === name);
|
||||||
|
if (!found) return;
|
||||||
|
if (!activeFunction.value && found.functions.length) {
|
||||||
|
selectFunction(found, found.functions[0]);
|
||||||
|
} else {
|
||||||
|
resetCallState();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Activate a specific function for invocation */
|
||||||
|
function selectFunction(remote, fn) {
|
||||||
|
if (remote && fn) {
|
||||||
|
activeRemoteName.value = remote.name;
|
||||||
|
activeFunction.value = fn;
|
||||||
|
} else {
|
||||||
|
activeRemoteName.value = null;
|
||||||
|
activeFunction.value = null;
|
||||||
|
}
|
||||||
|
resetCallState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear call result/error and reinitialize args JSON */
|
||||||
|
function resetCallState() {
|
||||||
|
callResult.value = null;
|
||||||
|
callError.value = '';
|
||||||
|
activeArgsJson.value = '{}';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Execute a call against the active remote & function */
|
||||||
|
async function doCall() {
|
||||||
|
callResult.value = null;
|
||||||
|
callError.value = '';
|
||||||
|
if (!activeRemoteName.value || !activeFunction.value) {
|
||||||
|
callError.value = 'Select a remote and function first.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let argsParsed;
|
||||||
|
try {
|
||||||
|
argsParsed = activeArgsJson.value.trim() ? JSON.parse(activeArgsJson.value) : {};
|
||||||
|
} catch (e) {
|
||||||
|
callError.value = 'Invalid JSON args: ' + e.message;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
callResult.value = await backend.sendAction('remote-call', {
|
||||||
|
remotename: activeRemoteName.value,
|
||||||
|
function: activeFunction.value.name,
|
||||||
|
args: argsParsed,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
callError.value = e.message || String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Local Remotes (unchanged from previous iteration) ----
|
||||||
function loadLocalRemoteList() {
|
function loadLocalRemoteList() {
|
||||||
let data = [];
|
let data = [];
|
||||||
try {
|
try {
|
||||||
@@ -14,10 +177,7 @@ function loadLocalRemoteList() {
|
|||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
if (Array.isArray(parsed)) data = parsed;
|
if (Array.isArray(parsed)) data = parsed;
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
// ignore corrupt storage
|
|
||||||
}
|
|
||||||
// Force disconnect all on startup
|
|
||||||
data.forEach(r => r.connected = false);
|
data.forEach(r => r.connected = false);
|
||||||
localRemoteList.value = data;
|
localRemoteList.value = data;
|
||||||
}
|
}
|
||||||
@@ -45,13 +205,9 @@ function disconnectLocalRemote(remote) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeLocalRemote(remote) {
|
function removeLocalRemote(remote) {
|
||||||
if (remote.connected) {
|
if (remote.connected) disconnectLocalRemote(remote);
|
||||||
disconnectLocalRemote(remote);
|
|
||||||
}
|
|
||||||
const index = localRemoteList.value.indexOf(remote);
|
const index = localRemoteList.value.indexOf(remote);
|
||||||
if (index > -1) {
|
if (index > -1) localRemoteList.value.splice(index, 1);
|
||||||
localRemoteList.value.splice(index, 1);
|
|
||||||
}
|
|
||||||
saveLocalRemote();
|
saveLocalRemote();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,9 +216,89 @@ loadLocalRemoteList();
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main>
|
<main>
|
||||||
<TitleBar>
|
<TitleBar><h1>Remotes</h1></TitleBar>
|
||||||
<h1>Remotes</h1>
|
|
||||||
</TitleBar>
|
<!-- ============================== -->
|
||||||
|
<!-- Active Function Call Panel -->
|
||||||
|
<!-- ============================== -->
|
||||||
|
<section v-if="activeRemoteName && activeFunction" class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Invoke: {{ activeFunction.name }}</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="call-layout">
|
||||||
|
<div class="call-inputs">
|
||||||
|
<label>Arguments (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
v-model="activeArgsJson"
|
||||||
|
rows="6"
|
||||||
|
placeholder='{"param": "value"}'
|
||||||
|
spellcheck="false"
|
||||||
|
></textarea>
|
||||||
|
<button class="btn btn-primary" @click="doCall">Execute Call</button>
|
||||||
|
<button class="btn" @click="selectFunction(null, null)">Cancel</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="call-output">
|
||||||
|
<div v-if="callResult !== null" class="output-block success">
|
||||||
|
<strong>Result</strong>
|
||||||
|
<pre>{{ JSON.stringify(callResult, null, 2) }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="callError" class="output-block error">
|
||||||
|
<strong>Error</strong>
|
||||||
|
<pre>{{ callError }}</pre>
|
||||||
|
</div>
|
||||||
|
<div v-if="callResult === null && callError === ''" class="output-placeholder">
|
||||||
|
Call will appear here
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ======================== -->
|
||||||
|
<!-- External Remotes Section -->
|
||||||
|
<!-- ======================== -->
|
||||||
|
<section class="section">
|
||||||
|
<div class="section-header">
|
||||||
|
<h2>Remotes</h2>
|
||||||
|
<span class="count-badge">{{ remotes.length }} registered</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="remotes.length === 0" class="empty-state">No remotes connected.</div>
|
||||||
|
|
||||||
|
<div v-for="remote in remotes" :key="remote.name" class="card">
|
||||||
|
<div class="card-header" @click="toggleRemote(remote.name)">
|
||||||
|
<div class="header-left">
|
||||||
|
<strong>{{ remote.name }}</strong>
|
||||||
|
<span class="status-dot" :class="{ active: remote.connected }"></span>
|
||||||
|
<span class="status-text">{{ remote.connected ? 'Connected' : 'Disconnected' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="expand-icon">{{ expandedRemoteIds.has(remote.name) ? '▼' : '▶' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="expandedRemoteIds.has(remote.name)" class="card-body">
|
||||||
|
<div class="functions-label">Functions (click to activate):</div>
|
||||||
|
<div class="func-grid">
|
||||||
|
<button
|
||||||
|
v-for="(fn, idx) in remote.functions"
|
||||||
|
:key="idx"
|
||||||
|
class="func-card"
|
||||||
|
:class="{ active: activeRemoteName === remote.name && activeFunction?.name === fn.name }"
|
||||||
|
@click.stop="selectFunction(remote, fn)"
|
||||||
|
>
|
||||||
|
<div class="func-name">{{ fn.name }}</div>
|
||||||
|
<div class="func-desc">{{ fn.description }}</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ========================== -->
|
||||||
|
<!-- Local Remotes Section -->
|
||||||
|
<!-- ========================== -->
|
||||||
|
<section class="section">
|
||||||
|
<h2>Local Remotes</h2>
|
||||||
<div class="remote-list">
|
<div class="remote-list">
|
||||||
<div v-for="(remote, index) in localRemoteList" :key="index" class="remote-entry">
|
<div v-for="(remote, index) in localRemoteList" :key="index" class="remote-entry">
|
||||||
<div v-if="remote.connected" class="view-mode">
|
<div v-if="remote.connected" class="view-mode">
|
||||||
@@ -82,40 +318,57 @@ loadLocalRemoteList();
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
|
<button class="btn btn-primary" @click="createLocalRemote">+ Create Local Remote</button>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.remote-list {
|
/* ---------- Shared utilities ---------- */
|
||||||
margin-top: 1rem;
|
.section { margin-top: 1.5rem; padding-top: 1rem; border-top: 1px solid var(--color-border); }
|
||||||
}
|
.section-header { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 0.75rem; }
|
||||||
|
.count-badge { font-size: 0.8em; color: var(--color-text-mute); background: var(--color-background-muted); padding: 2px 8px; border-radius: 10px; }
|
||||||
|
.empty-state { padding: 1em; color: var(--color-text-mute); }
|
||||||
|
|
||||||
.remote-entry {
|
/* ---------- Card layout ---------- */
|
||||||
display: flex;
|
.card { border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 0.75rem; background: var(--color-background-soft); }
|
||||||
align-items: center;
|
.card-header { display: flex; justify-content: space-between; align-items: center; padding: 0.75rem 1rem; cursor: pointer; }
|
||||||
gap: 1rem;
|
.header-left { display: flex; align-items: center; gap: 0.5rem; }
|
||||||
padding: 1rem;
|
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #9ca3af; }
|
||||||
border: 1px solid var(--color-border);
|
.status-dot.active { background: #22c55e; box-shadow: 0 0 4px rgba(34,197,94,0.5); }
|
||||||
border-radius: var(--border-radius);
|
.status-text { font-size: 0.85em; color: var(--color-text-mute); }
|
||||||
margin-bottom: 1rem;
|
.expand-icon { color: var(--color-text-mute); }
|
||||||
background-color: var(--color-background-soft);
|
.card-body { padding: 0 1rem 1rem; }
|
||||||
}
|
.functions-label { font-weight: bold; font-size: 0.85em; color: var(--color-text-heading); margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
.view-mode {
|
/* ---------- Function grid ---------- */
|
||||||
display: flex;
|
.func-grid { display: flex; flex-wrap: wrap; gap: 0.5rem; }
|
||||||
align-items: center;
|
.func-card { text-align: left; border: 1px solid var(--color-border); border-radius: var(--border-radius); padding: 0.5rem 0.75rem; cursor: pointer; background: var(--color-background); transition: all 0.15s ease; }
|
||||||
gap: 1rem;
|
.func-card:hover { background: var(--color-background-hover); }
|
||||||
flex-grow: 1;
|
.func-card.active { border-color: var(--color-primary); background: var(--color-primary-light, rgba(79,70,229,0.1)); }
|
||||||
}
|
.func-name { font-weight: bold; font-family: monospace; font-size: 0.9em; }
|
||||||
|
.func-desc { font-size: 0.8em; color: var(--color-text-mute); margin-top: 2px; }
|
||||||
|
|
||||||
.status-text {
|
/* ---------- Call panel ---------- */
|
||||||
font-weight: bold;
|
.call-layout { display: flex; gap: 1rem; }
|
||||||
}
|
.call-inputs, .call-output { flex: 1; }
|
||||||
|
.call-inputs label { display: block; font-weight: bold; margin-bottom: 0.25rem; }
|
||||||
|
.call-inputs textarea { width: 100%; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); resize: vertical; font-family: monospace; font-size: 0.85em; margin-bottom: 0.5rem; }
|
||||||
|
.output-block { margin-bottom: 0.75rem; }
|
||||||
|
.output-block.success pre { background: var(--color-success-bg, #dcfce7); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
|
||||||
|
.output-block.error { color: #ef4444; }
|
||||||
|
.output-block.error pre { background: var(--color-error-bg, #fee2e2); padding: 0.5rem; border-radius: var(--border-radius); overflow-x: auto; }
|
||||||
|
.output-placeholder { padding: 1em; color: var(--color-text-mute); font-style: italic; }
|
||||||
|
|
||||||
.input-field {
|
/* ---------- Buttons (shared across sections) ---------- */
|
||||||
flex-grow: 1;
|
.btn { padding: 0.5em 1em; border-radius: var(--border-radius); border: none; cursor: pointer; font-weight: 500; }
|
||||||
padding: 0.5rem;
|
.btn-primary { background: var(--color-primary, #4f46e5); color: #fff; }
|
||||||
border: 1px solid var(--color-border);
|
.btn-secondary { background: var(--color-muted, #9ca3af); color: #fff; }
|
||||||
border-radius: var(--border-radius);
|
.btn-danger { background: var(--color-error, #ef4444); color: #fff; }
|
||||||
}
|
|
||||||
|
/* ---------- Local remotes (preserved from original) ---------- */
|
||||||
|
.remote-list { margin-top: 1rem; }
|
||||||
|
.remote-entry { display: flex; align-items: center; gap: 1rem; padding: 1rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); margin-bottom: 1rem; background-color: var(--color-background-soft); }
|
||||||
|
.view-mode { display: flex; align-items: center; gap: 1rem; flex-grow: 1; }
|
||||||
|
.status-text { font-weight: bold; }
|
||||||
|
.input-field { flex-grow: 1; padding: 0.5rem; border: 1px solid var(--color-border); border-radius: var(--border-radius); }
|
||||||
</style>
|
</style>
|
||||||
Reference in new issue
Block a user