frontend: json editor
This commit is contained in:
1 parent
bdd7f89663
commit
cef6553863
4 files changed
+205
-542
No files matched your search
@@ -0,0 +1,141 @@
|
|||||||
|
<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'])
|
||||||
|
|
||||||
|
const errorFlag = ref(false)
|
||||||
|
const currentValue = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: (val) => {
|
||||||
|
emit('update:modelValue', val)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function inferValueSchema(value) {
|
||||||
|
if (value == null) return 'null'
|
||||||
|
if (Array.isArray(value)) return 'array'
|
||||||
|
return typeof value
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateJsonValue(json) {
|
||||||
|
try {
|
||||||
|
let parsed = JSON.parse(json);
|
||||||
|
currentValue.value = parsed;
|
||||||
|
errorFlag.value = false;
|
||||||
|
} catch (e) {
|
||||||
|
errorFlag.value = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleField(field) {
|
||||||
|
// Copy first: never mutate the field object in place.
|
||||||
|
const current = { ...(currentValue.value || {}) }
|
||||||
|
|
||||||
|
if (field.enabled) {
|
||||||
|
current[field.key] = field.current;
|
||||||
|
} else {
|
||||||
|
delete current[field.key];
|
||||||
|
}
|
||||||
|
|
||||||
|
currentValue.value = current;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateField(field) {
|
||||||
|
// Never mutate the field object in place.
|
||||||
|
currentValue.value = {
|
||||||
|
...(currentValue.value || {}),
|
||||||
|
[field.key]: field.current
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const computedFields = computed(() => {
|
||||||
|
let fields = [];
|
||||||
|
let skip = {};
|
||||||
|
let current = currentValue.value;
|
||||||
|
|
||||||
|
for (let key in (props.schema.properties || {})) {
|
||||||
|
let field = {
|
||||||
|
key,
|
||||||
|
current: current[key], // or default
|
||||||
|
enabled: current.hasOwnProperty(key),
|
||||||
|
schema: props.schema.properties[key],
|
||||||
|
};
|
||||||
|
if (field.enabled) skip[key] = true;
|
||||||
|
fields.push(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof current == 'object') {
|
||||||
|
for (let key in current) {
|
||||||
|
if (skip[key]) continue;
|
||||||
|
|
||||||
|
fields.push({
|
||||||
|
key,
|
||||||
|
current: current[key],
|
||||||
|
enabled: true,
|
||||||
|
schema: {type: null},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="schema.enum">
|
||||||
|
<select v-model="currentValue">
|
||||||
|
<option v-for="(value, index) in schema.enum"
|
||||||
|
:value="value">
|
||||||
|
{{ schema.ui?.enumNames[index] || String(value) }}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="schema.type == 'object'">
|
||||||
|
<div v-for="entry in computedFields" :key="key">
|
||||||
|
<input type="checkbox" v-model="entry.enabled"
|
||||||
|
@change="toggleField(entry)" />
|
||||||
|
<label>{{ entry.key }}</label>
|
||||||
|
<!--button @click="removeObjectFielderty(key)" title="Remove property">×</button-->
|
||||||
|
<JsonEditor
|
||||||
|
v-if="entry.enabled"
|
||||||
|
v-model="entry.current"
|
||||||
|
:schema="entry.schema"
|
||||||
|
@update:model-value="updateField(entry)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<button class="hollow" @click="addField()">+ Add Property</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="schema.type == 'string'">
|
||||||
|
<textarea v-model="currentValue"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="schema.type == 'number'">
|
||||||
|
<input v-model="currentValue" type="number"></input>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else>
|
||||||
|
<textarea
|
||||||
|
:class="{error: errorFlag}"
|
||||||
|
:value="JSON.stringify(currentValue)"
|
||||||
|
@change="updateJsonValue($event.target.value)"
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.error { color: var(--color-error); }
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
resize: vertical;
|
||||||
|
height: 5em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,490 +0,0 @@
|
|||||||
<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>
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
} from "vue";
|
} from "vue";
|
||||||
|
|
||||||
import TitleBar from '../components/TitleBar.vue';
|
import TitleBar from '../components/TitleBar.vue';
|
||||||
|
import JsonEditor from '../components/JsonEditor.vue';
|
||||||
|
|
||||||
import * as backend from '../backend';
|
import * as backend from '../backend';
|
||||||
import * as ui from '../ui';
|
import * as ui from '../ui';
|
||||||
@@ -19,10 +20,16 @@
|
|||||||
// Data
|
// Data
|
||||||
const agent = ref({});
|
const agent = ref({});
|
||||||
const kindInfo = ref({});
|
const kindInfo = ref({});
|
||||||
const configFields = ref([]);
|
const config = ref({
|
||||||
|
schema: {type: 'object'},
|
||||||
|
object: {}
|
||||||
|
});
|
||||||
const resources = ref([]);
|
const resources = ref([]);
|
||||||
const tools = ref([]);
|
const tools = ref([]);
|
||||||
const modelList = ref([]);
|
const modelSchema = ref({
|
||||||
|
type: 'number', enum: [],
|
||||||
|
ui: {enumNames: {}},
|
||||||
|
});
|
||||||
|
|
||||||
// UI State
|
// UI State
|
||||||
const addingTool = ref(null);
|
const addingTool = ref(null);
|
||||||
@@ -30,6 +37,10 @@
|
|||||||
|
|
||||||
// === State Management === //
|
// === State Management === //
|
||||||
|
|
||||||
|
watch(() => config.value, newConfig => {
|
||||||
|
console.log(newConfig);
|
||||||
|
});
|
||||||
|
|
||||||
async function loadAgentData (agentname) {
|
async function loadAgentData (agentname) {
|
||||||
const _agent = await backend.getAgent(agentname);
|
const _agent = await backend.getAgent(agentname);
|
||||||
if (!_agent) {
|
if (!_agent) {
|
||||||
@@ -51,18 +62,32 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
backend.sendAction('get-agent-config-fields', {agentname}).then(res => {
|
backend.sendAction('get-agent-config-fields', {agentname}).then(res => {
|
||||||
const dataObject = Object.fromEntries(res.fields.map(
|
let value = {};
|
||||||
field => [field.name, field.value]
|
let properties = {};
|
||||||
));
|
|
||||||
|
|
||||||
configFields.value = res.fields.map(field => ({
|
for (let field of res.fields) {
|
||||||
name: field.name,
|
let type = field.type;
|
||||||
type: field.type,
|
if (typeof type == 'string')
|
||||||
value: field.value,
|
type = {type: field.type};
|
||||||
kind: field.type,
|
|
||||||
default: null,
|
// Special model type
|
||||||
enabled: field.value != null,
|
if (type.type == 'model_id') {
|
||||||
}));
|
type = modelSchema.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
properties[field.name] = type;
|
||||||
|
|
||||||
|
// horde sends empty agent fields as null
|
||||||
|
if (field.value != null) {
|
||||||
|
value[field.name] = field.value;
|
||||||
|
// If a field is removed and had an initial value,
|
||||||
|
// null must be sent to remove upstream
|
||||||
|
type._initial = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.value.schema.properties = properties;
|
||||||
|
config.value.object = value;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Replace with a backend call
|
// Replace with a backend call
|
||||||
@@ -74,7 +99,15 @@
|
|||||||
|
|
||||||
async function loadModels () {
|
async function loadModels () {
|
||||||
let res = await backend.sendAction("get-models");
|
let res = await backend.sendAction("get-models");
|
||||||
modelList.value = res.models;
|
|
||||||
|
let values = [];
|
||||||
|
let names = [];
|
||||||
|
for (let model of res.models) {
|
||||||
|
values.push(model.id);
|
||||||
|
names.push(model.name);
|
||||||
|
}
|
||||||
|
modelSchema.value.enum = values;
|
||||||
|
modelSchema.value.ui.enumNames = names;
|
||||||
}
|
}
|
||||||
|
|
||||||
// This won't fire when immediately, the mounted callback will take care
|
// This won't fire when immediately, the mounted callback will take care
|
||||||
@@ -87,7 +120,9 @@
|
|||||||
loadModels();
|
loadModels();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// === Prompt === //
|
// === Prompt === //
|
||||||
|
|
||||||
const promptInstructionEnabled = ref(false);
|
const promptInstructionEnabled = ref(false);
|
||||||
const promptInstruction = ref("");
|
const promptInstruction = ref("");
|
||||||
const promptInput = ref("");
|
const promptInput = ref("");
|
||||||
@@ -148,6 +183,8 @@
|
|||||||
|
|
||||||
// === Configuration === //
|
// === Configuration === //
|
||||||
|
|
||||||
|
const configObject = ref({});
|
||||||
|
|
||||||
async function updateIdentifier() {
|
async function updateIdentifier() {
|
||||||
const value = prompt(`Enter a new identifier for ${agent.value.name}`);
|
const value = prompt(`Enter a new identifier for ${agent.value.name}`);
|
||||||
if (value) {
|
if (value) {
|
||||||
@@ -181,33 +218,19 @@
|
|||||||
ui.alert("Cancelled name update");
|
ui.alert("Cancelled name update");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
async function saveChanges() {
|
async function saveConfig() {
|
||||||
const dataObject = {};
|
let object = {...config.value.object};
|
||||||
for (let field of configFields.value) {
|
|
||||||
if (field.enabled) {
|
// Collect removed properties
|
||||||
let value = field.value;
|
for (let [key, schema] of Object.entries(config.value.schema.properties)) {
|
||||||
if (field.kind == 'number') {
|
if (object[key] === undefined && schema._initial) {
|
||||||
try {
|
object[key] = null;
|
||||||
value = parseFloat(value);
|
|
||||||
} catch (e) {
|
|
||||||
ui.alert("bad number at field " + field.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else if (field.kind != 'string') {
|
|
||||||
try {
|
|
||||||
value = JSON.parse(value);
|
|
||||||
} catch (e) {
|
|
||||||
ui.alert("bad JSON value at field " + field.name);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
dataObject[field.name] = value;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await backend.act("update-agent", {
|
await backend.act("update-agent", {
|
||||||
agentname: agent.value.agentname,
|
agentname: agent.value.agentname,
|
||||||
data: dataObject,
|
data: object,
|
||||||
}, {
|
}, {
|
||||||
alert: 'Agent updated',
|
alert: 'Agent updated',
|
||||||
cache: {agents: true},
|
cache: {agents: true},
|
||||||
@@ -301,19 +324,8 @@
|
|||||||
|
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<div v-for="(configField, index) in configFields" :key="index">
|
<JsonEditor :schema="config.schema" v-model="config.object" />
|
||||||
<span class="label">{{ configField.name }}</span>
|
<button type="submit" @click="saveConfig()">Save</button>
|
||||||
<input type="checkbox" v-model="configField.enabled" />
|
|
||||||
<br>
|
|
||||||
<select v-if="configField.kind == 'model_id'" v-model="configField.value" :disabled="!configField.enabled">
|
|
||||||
<option v-if="!modelList.length" disabled>empty</option>
|
|
||||||
<option :value="model.id" v-for="model in modelList">{{ model.name }}</option>
|
|
||||||
</select>
|
|
||||||
<input v-else-if="configField.kind=='number'" type="number" v-model="configField.value" class="config-input" :disabled="!configField.enabled"/>
|
|
||||||
<textarea v-else v-model="configField.value" class="config-input" :disabled="!configField.enabled"/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="submit" @click="saveChanges()">Save</button>
|
|
||||||
</section>
|
</section>
|
||||||
<section>
|
<section>
|
||||||
<h3>Tools</h3>
|
<h3>Tools</h3>
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ backend.onAction('remote-call', async ({ requestId, remotename, function: fnName
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import TitleBar from '../components/TitleBar.vue';
|
import TitleBar from '../components/TitleBar.vue';
|
||||||
import SchemaInput from '../components/SchemaInput.vue';
|
import JsonEditor from '../components/JsonEditor.vue';
|
||||||
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
import { ref, reactive, computed, onMounted, onUnmounted } from 'vue';
|
||||||
import * as backend from '../backend';
|
import * as backend from '../backend';
|
||||||
import * as ui from '../ui';
|
import * as ui from '../ui';
|
||||||
@@ -272,7 +272,7 @@ async function doCall(row, fn) {
|
|||||||
state.error = '';
|
state.error = '';
|
||||||
state.busy = true;
|
state.busy = true;
|
||||||
try {
|
try {
|
||||||
// state.args is the object edited by SchemaInput (seeded by defaultArgs);
|
// state.args is the object edited by JsonEditor (seeded by defaultArgs);
|
||||||
// send it as-is — no JSON string handling anymore.
|
// send it as-is — no JSON string handling anymore.
|
||||||
const response = await backend.sendAction('remote-call', {
|
const response = await backend.sendAction('remote-call', {
|
||||||
remotename: row.remote.name,
|
remotename: row.remote.name,
|
||||||
@@ -489,7 +489,7 @@ function removeArgument(fn, argIdx) {
|
|||||||
<template v-if="!isEditable(row)">
|
<template v-if="!isEditable(row)">
|
||||||
<div class="detail-left">
|
<div class="detail-left">
|
||||||
<label>Arguments</label>
|
<label>Arguments</label>
|
||||||
<SchemaInput
|
<JsonEditor
|
||||||
v-model="callStateFor(row, fn).args"
|
v-model="callStateFor(row, fn).args"
|
||||||
:schema="buildArgSchema(fn)"
|
:schema="buildArgSchema(fn)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
Reference in new issue
Block a user