feat(frontend): schema input in remote function calls

This commit is contained in:
root authored and ShiveringOcelot committed 2026-09-21 11:17:14 -05:00
1 parent 4dc776dda4
commit 5aa086cb15
2 files changed
+516 -19

No files matched your search

+490
View File
@@ -0,0 +1,490 @@
<script setup>
import { ref, computed, watch } from 'vue'
const props = defineProps({
/** JSON schema object describing the input structure */
schema: { type: Object, required: true },
/** Current value of the input (can be any type) */
modelValue: { type: [Object, Array, String, Number, Boolean, null], default: () => null }
})
const emit = defineEmits(['update:modelValue'])
/**
* Convert value to matching schema type based on schema type
*/
function coerceValue(val, schemaType) {
if (schemaType === 'dynamic') return val ?? null
switch (schemaType) {
case 'object': return val ?? {}
case 'array': return Array.isArray(val) ? val : []
case 'string': return String(val ?? '')
case 'number': return typeof val === 'number' ? val : 0
case 'boolean': return typeof val === 'boolean' ? val : false
default: return val
}
}
/**
* Generate schema-specific default value
*/
function defaultValue(type) {
if (type === 'dynamic') return null
if (type === 'object') return {}
if (type === 'array') return []
if (type === 'string') return ''
if (type === 'number') return 0
if (type === 'boolean') return false
return null
}
const currentValue = computed({
get: () => props.modelValue,
set: (val) => {
if (props.schema.type === 'object') {
emit('update:modelValue', coerceValue(val, 'object'))
} else if (props.schema.type === 'array') {
emit('update:modelValue', coerceValue(val, 'array'))
} else {
emit('update:modelValue', val)
}
}
})
function setObjectProperty(key, value) {
const current = currentValue.value || {}
emit('update:modelValue', coerceValue({ ...current, [key]: value }, 'object'))
}
function setArrayElement(index, value) {
const current = currentValue.value || []
const newArr = [...current]
newArr[index] = value
emit('update:modelValue', coerceValue(newArr, 'array'))
}
function addObjectProperty() {
const current = currentValue.value || {}
let i = Object.keys(current).length
let newKey = `prop_${i}`
while (newKey in current) newKey = `prop_${++i}`
emit('update:modelValue', coerceValue({ ...current, [newKey]: defaultValue('string') }, 'object'))
}
function removeObjectProperty(key) {
// Copy first: never mutate the prop object in place.
const current = { ...(currentValue.value || {}) }
delete current[key]
emit('update:modelValue', coerceValue(current, 'object'))
}
/** Rename an object property key (fires on @change of the key input). */
function renameObjectProperty(oldKey, newKey) {
newKey = newKey.trim()
if (!newKey || newKey === oldKey) return
const current = currentValue.value || {}
if (newKey in current) return // would silently overwrite another property
const out = {}
for (const [k, v] of Object.entries(current)) {
out[k === oldKey ? newKey : k] = v
}
emit('update:modelValue', coerceValue(out, 'object'))
}
function addArrayElement() {
const current = currentValue.value || []
const newItem = props.schema.items?.type === 'dynamic'
? defaultValue('object')
: defaultValue(props.schema.items?.type || 'object')
const newArr = [...current, newItem]
emit('update:modelValue', coerceValue(newArr, 'array'))
}
function removeArrayElement(index) {
const current = currentValue.value || []
const newArr = [...current]
newArr.splice(index, 1)
emit('update:modelValue', coerceValue(newArr, 'array'))
}
function handleStringChange(val) {
// e.target.value is already a string; emit it verbatim so the displayed
// text and the stored value stay identical (no JSON quoting round-trip).
emit('update:modelValue', val)
}
function handleNumberChange(val) {
const num = parseFloat(val)
emit('update:modelValue', isNaN(num) ? 0 : num)
}
function handleBooleanChange(val) {
emit('update:modelValue', coerceValue(val, 'boolean'))
}
function handleDynamicChange(val) {
// val is the selected type name ('object' | 'array' | 'string' | 'number' |
// 'boolean' | 'null'); emit that type's default VALUE, not the type name.
// defaultValue('null') falls through to null, which is the intended result.
emit('update:modelValue', defaultValue(val))
}
function handleArrayTypeChange(index, type) {
const current = currentValue.value || []
const newArr = [...current]
newArr[index] = defaultValue(type)
emit('update:modelValue', coerceValue(newArr, 'array'))
}
function valueToDisplay() {
const val = currentValue.value
if (val === null || val === undefined) return ''
return JSON.stringify(val, null, 2)
}
function textareaHeight(value) {
const lines = String(value ?? '').split('\n').length
const height = Math.max(1.5, lines * 1.5)
return `${Math.min(height, 8)}rem`
}
/**
* Best-effort schema type for a runtime value.
* Plain `typeof` alone is not enough: typeof null === 'object' and
* typeof [] === 'object'.
*/
function inferSchemaType(value) {
if (value === null || value === undefined) return 'null'
if (Array.isArray(value)) return 'array'
const t = typeof value
if (t === 'string' || t === 'number' || t === 'boolean' || t === 'object') return t
return 'string'
}
/**
* Schema for a single object property. The declared schema
* (schema.properties[key]) wins; only keys without a declaration fall back
* to inferring the type from the current value.
*/
function propertySchema(key, value) {
const declared = props.schema.properties?.[key]
if (declared && declared.type) {
const t = Array.isArray(declared.type) ? declared.type.find(x => x !== 'null') : declared.type
if (t === 'integer') return { type: 'number' }
if (t) return { ...declared, type: t }
}
return { type: inferSchemaType(value) }
}
// Fallback editor state: raw JSON text in a local ref so half-typed JSON is
// not clobbered by re-renders. Emitted back through JSON.parse once valid.
const fallbackText = ref('')
watch(
() => props.modelValue,
() => { fallbackText.value = valueToDisplay() },
{ immediate: true }
)
function handleFallbackChange(val) {
fallbackText.value = val
if (val.trim() === '') {
emit('update:modelValue', null)
return
}
try {
emit('update:modelValue', JSON.parse(val))
} catch (e) {
// Not valid JSON yet: keep the typed text; emit as soon as it parses.
}
}
</script>
<template>
<!-- Object type: nested properties -->
<div v-if="props.schema.type === 'object'" class="schema-input">
<div class="object-container">
<div v-for="(prop, key) in (currentValue || {})" :key="key" class="object-row">
<input
type="text"
:value="key"
@change="(e) => renameObjectProperty(key, e.target.value)"
class="property-key"
/>
<span class="colon">:</span>
<SchemaInput
v-if="prop !== undefined"
:schema="propertySchema(key, prop)"
:model-value="prop"
@update:model-value="(v) => setObjectProperty(key, v)"
class="property-value"
/>
<button class="btn-remove" @click="removeObjectProperty(key)" title="Remove property">×</button>
</div>
<button class="btn-add" @click="addObjectProperty">+ Add Property</button>
</div>
</div>
<!-- Array type: list of items -->
<div v-else-if="props.schema.type === 'array'" class="schema-input">
<div class="array-container">
<div v-if="(currentValue || []).length === 0" class="empty-array">Array is empty</div>
<div v-for="(item, idx) in (currentValue || [])" :key="idx" class="array-row">
<span class="array-index">{{ idx }}:</span>
<SchemaInput
v-if="!props.schema.items || props.schema.items.type !== 'dynamic'"
:schema="props.schema.items || { type: inferSchemaType(item) }"
:model-value="item"
@update:model-value="(v) => setArrayElement(idx, v)"
class="array-value"
/>
<div v-else class="dynamic-item">
<select
:value="inferSchemaType(item)"
@change="(e) => handleArrayTypeChange(idx, e.target.value)"
class="item-type-select"
>
<option value="object">Object</option>
<option value="array">Array</option>
<option value="string">String</option>
<option value="number">Number</option>
<option value="boolean">Boolean</option>
<option value="null">Empty</option>
</select>
<SchemaInput
v-if="item !== null && item !== undefined"
:schema="{ type: inferSchemaType(item) }"
:model-value="item"
@update:model-value="(v) => setArrayElement(idx, v)"
/>
</div>
<button class="btn-remove" @click="removeArrayElement(idx)" title="Remove item">×</button>
</div>
<button class="btn-add" @click="addArrayElement">+ Add Item</button>
</div>
</div>
<!-- String type: textarea with auto-height -->
<div v-else-if="props.schema.type === 'string'" class="schema-input">
<textarea
:value="currentValue ?? ''"
@input="(e) => handleStringChange(e.target.value)"
class="string-input"
:style="{ height: textareaHeight(currentValue) }"
spellcheck="false"
/>
</div>
<!-- Number type: number input -->
<div v-else-if="props.schema.type === 'number'" class="schema-input">
<input
type="number"
:value="currentValue ?? ''"
@input="(e) => handleNumberChange(e.target.value)"
class="number-input"
/>
</div>
<!-- Boolean type: checkbox -->
<div v-else-if="props.schema.type === 'boolean'" class="schema-input">
<label class="checkbox-label">
<input
type="checkbox"
:checked="currentValue ?? false"
@change="(e) => handleBooleanChange(e.target.checked)"
/>
<span>{{ currentValue !== false ? 'Checked' : 'Unchecked' }}</span>
</label>
</div>
<!-- Dynamic type: allows any value including empty -->
<div v-else-if="props.schema.type === 'dynamic'" class="schema-input">
<div class="dynamic-container">
<div class="dynamic-selector">
<label>Dynamic Value:</label>
<select
:value="currentValue === null || currentValue === undefined ? 'null' : inferSchemaType(currentValue)"
@change="(e) => handleDynamicChange(e.target.value)"
class="dynamic-select"
>
<option value="object">Object {}</option>
<option value="array">Array []</option>
<option value="string">String ""</option>
<option value="number">Number 0</option>
<option value="boolean">Boolean false</option>
<option value="null">Empty (null)</option>
</select>
</div>
<SchemaInput
v-if="currentValue !== null && currentValue !== undefined"
:schema="{ type: inferSchemaType(currentValue) }"
:model-value="currentValue"
@update:model-value="emit('update:modelValue', $event)"
/>
</div>
</div>
<!-- Fallback: plain textarea for unknown types -->
<div v-else class="schema-input">
<textarea
:value="fallbackText"
@input="(e) => handleFallbackChange(e.target.value)"
class="fallback-input"
/>
</div>
</template>
<style scoped>
.schema-input { width: 100%; }
/* Object styles */
.object-container { display: flex; flex-direction: column; gap: 0.5rem; }
.object-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.property-key {
width: 120px;
padding: 0.3rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
font-family: inherit;
}
.property-key:focus { outline: none; border-color: var(--color-primary); }
.colon { color: var(--color-text-mute); }
.property-value { flex: 1; min-width: 0; }
/* Array styles */
.array-container { display: flex; flex-direction: column; gap: 0.5rem; }
.array-row {
display: flex;
gap: 0.5rem;
align-items: center;
}
.array-index {
color: var(--color-text-mute);
font-family: monospace;
font-size: 0.9em;
width: 3rem;
flex-shrink: 0;
}
.array-value { flex: 1; min-width: 0; }
.empty-array {
color: var(--color-text-mute);
font-style: italic;
padding: 0.5rem;
}
.dynamic-item {
display: flex;
gap: 0.5rem;
align-items: center;
}
.item-type-select {
padding: 0.2rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
flex-shrink: 0;
}
/* String styles */
.string-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
resize: none;
font-family: monospace;
box-sizing: border-box;
}
/* Number styles */
.number-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
}
/* Boolean styles */
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input { width: 1rem; height: 1rem; }
/* Dynamic styles */
.dynamic-container { display: flex; flex-direction: column; gap: 0.5rem; }
.dynamic-selector {
display: flex;
gap: 0.5rem;
align-items: center;
}
.dynamic-selector label { color: var(--color-text-mute); font-size: 0.85em; }
.dynamic-select {
padding: 0.2rem 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
}
/* Fallback styles */
.fallback-input {
width: 100%;
padding: 0.5rem;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-background);
resize: vertical;
}
/* Buttons */
.btn-remove {
background: var(--color-error);
color: white;
border: none;
border-radius: 4px;
width: 1.8rem;
height: 1.8rem;
font-size: 1.2rem;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.btn-remove:hover { background: #dc2626; }
.btn-add {
background: var(--color-muted);
color: white;
border: none;
border-radius: 4px;
padding: 0.5rem 1rem;
cursor: pointer;
font-size: 0.85em;
}
</style>
+26 -19
View File
@@ -99,6 +99,7 @@ backend.onAction('remote-call', async ({ requestId, remotename, function: fnName
<script setup>
import TitleBar from '../components/TitleBar.vue';
import SchemaInput from '../components/SchemaInput.vue';
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
import * as backend from '../backend';
import * as ui from '../ui';
@@ -246,26 +247,38 @@ function toggleFn(row, fn) {
// ---- Calling ----
/** Type-appropriate default argument JSON for a function spec */
function defaultArgsJson(fn) {
function buildArgSchema(fn) {
const props = fn.parameters?.properties || {};
const required = fn.parameters?.required || [];
return {
type: 'object',
properties: props,
required: required,
};
}
/** Type-appropriate default argument values for a function spec */
function defaultArgs(fn) {
const props = fn.parameters?.properties || {};
const out = {};
for (const [name, p] of Object.entries(props)) {
const type = p.type;
// JSON Schema allows type arrays (e.g. ["string", "null"]): use the non-null member
const type = Array.isArray(p?.type) ? p.type.find(t => t !== 'null') : p?.type;
out[name] =
type === 'string' ? '' :
type === 'number' ? 0 :
type === 'number' || type === 'integer' ? 0 :
type === 'boolean' ? false :
type === 'array' ? [] :
type === 'object' ? {} : null;
}
return JSON.stringify(out, null, 2);
return out;
}
function callStateFor(row, fn) {
const key = fnStateKey(row, fn);
if (!callStates[key]) {
callStates[key] = { args: defaultArgsJson(fn), result: null, error: '', busy: false };
callStates[key] = { args: defaultArgs(fn), result: null, error: '', busy: false };
}
return callStates[key];
}
@@ -274,19 +287,14 @@ async function doCall(row, fn) {
const state = callStateFor(row, fn);
state.result = null;
state.error = '';
let argsParsed;
try {
argsParsed = state.args.trim() ? JSON.parse(state.args) : {};
} catch (e) {
state.error = 'Invalid JSON args: ' + e.message;
return;
}
state.busy = true;
try {
// state.args is the object edited by SchemaInput (seeded by defaultArgs);
// send it as-is — no JSON string handling anymore.
const response = await backend.sendAction('remote-call', {
remotename: row.remote.name,
function: fn.name,
args: argsParsed,
args: state.args ?? {},
});
state.result = response.result;
} catch (e) {
@@ -497,12 +505,11 @@ function removeArgument(fn, argIdx) {
<!-- Callable mode (connected remotes) -->
<template v-if="!isEditable(row)">
<div class="detail-left">
<label>Arguments (JSON)</label>
<textarea
<label>Arguments</label>
<SchemaInput
v-model="callStateFor(row, fn).args"
placeholder='{"param": "value"}'
spellcheck="false"
></textarea>
:schema="buildArgSchema(fn)"
/>
<button class="btn btn-primary btn-sm" :disabled="callStateFor(row, fn).busy" @click="doCall(row, fn)">
{{ callStateFor(row, fn).busy ? 'Calling...' : 'Execute Call' }}
</button>