ElicitationCard.vue
1 <script setup lang="ts"> 2 import { ref, toRaw, computed } from 'vue' 3 import { ElicitationTransfer } from '@/renderer/utils' 4 import { IpcElicitRequestCallback, ElicitRequest, ElicitResponse } from '@/types/ipc' 5 import { validateNumberRange } from '@/renderer/store/dxt' 6 import { useI18n } from 'vue-i18n' 7 const { t } = useI18n() 8 9 type RemoveIndexSignature<T> = { 10 [K in keyof T as string extends K ? never : K]: T[K] 11 } 12 13 type ElicitRequestParams = RemoveIndexSignature<ElicitRequest['params']> 14 15 // type ElicitationProperty = ElicitRequestParams['requestedSchema']['properties'][string]['type'] 16 17 type ElicitationProperty = string | number | boolean | string[] 18 19 type ElicitationEnumKey = 'const' | 'title' 20 21 type ElicitationEnum = Record<ElicitationEnumKey, string> 22 23 const elicitationResults = ref<Record<string, ElicitationProperty>>({}) 24 25 const elicitationDialog = ref(false) 26 27 const elicitationParams = ref<ElicitRequestParams | {}>({}) 28 29 const elicitationChannel = ref('') 30 31 const normalizedProperties = computed(() => { 32 const params = elicitationParams.value 33 if (!params || !('requestedSchema' in params)) return [] 34 35 const props = params.requestedSchema.properties 36 return Object.keys(props).map((key) => { 37 const para = props[key] 38 return { 39 key, 40 para, 41 42 hasEnum: 'enum' in para && para.enum !== undefined, 43 enums: ('enum' in para ? para.enum : []) as string[], 44 45 hasEnumItems: 'oneOf' in para && para.oneOf !== undefined, 46 enumItems: ('oneOf' in para ? para.oneOf : []) as ElicitationEnum[], 47 48 hasMultiEnum: 'items' in para && 'enum' in para.items, 49 multiEnums: ('items' in para && 'enum' in para.items ? para.items.enum : []) as string[], 50 51 hasMultiEnumItems: 'items' in para && 'anyOf' in para.items, 52 multiEnumItems: ('items' in para && 'anyOf' in para.items 53 ? para.items.anyOf 54 : []) as ElicitationEnum[], 55 56 isString: para.type === 'string', 57 isNumber: para.type === 'number', 58 isInteger: para.type === 'integer', 59 isBoolean: para.type === 'boolean', 60 61 minLength: 'minLength' in para ? para.minLength : undefined, 62 maxLength: 'maxLength' in para ? para.maxLength : undefined, 63 64 minItems: 'minItems' in para ? para.minItems : undefined, 65 maxItems: 'maxItems' in para ? para.maxItems : undefined, 66 67 minimum: 'minimum' in para ? para.minimum : undefined, 68 maximum: 'maximum' in para ? para.maximum : undefined, 69 70 title: para.title, 71 description: para.description, 72 label: para.description || para.title, 73 default: 'default' in para ? para.default : undefined 74 } 75 }) 76 }) 77 78 const configExist = (name: string) => { 79 const exists = name in elicitationResults.value 80 return { 81 exists, 82 configValue: exists ? elicitationResults.value[name] : undefined 83 } 84 } 85 86 const updateConfigAttribute = (name: string, value: ElicitationProperty | null) => { 87 if (value === null) { 88 elicitationResults.value[name] = '' 89 } else { 90 elicitationResults.value[name] = value 91 } 92 } 93 94 const dynamicModel = (name: string) => ({ 95 get: (defaultVal: ElicitationProperty | undefined) => { 96 const { exists, configValue } = configExist(name) 97 if (defaultVal && !exists) { 98 updateConfigAttribute(name, defaultVal) 99 return defaultVal 100 } 101 return configValue 102 }, 103 set: (val: ElicitationProperty | null) => updateConfigAttribute(name, val) 104 }) 105 106 const declineElicitation = () => { 107 const response: ElicitResponse = { 108 action: 'decline' 109 } 110 ElicitationTransfer.response(elicitationChannel.value, response) 111 clearSampling() 112 return 113 } 114 115 // const cancelElicitation = () => { 116 // const response: ElicitResponse = { 117 // "action": "cancel" 118 // } 119 // ElicitationTransfer.response(elicitationChannel.value, response) 120 // clearSampling() 121 // return 122 // } 123 124 const acceptElicitation = () => { 125 const response: ElicitResponse = { 126 action: 'accept', 127 content: toRaw(elicitationResults.value) 128 } 129 ElicitationTransfer.response(elicitationChannel.value, response) 130 clearSampling() 131 return 132 } 133 134 const clearSampling = () => { 135 elicitationDialog.value = false 136 elicitationParams.value = {} 137 elicitationChannel.value = '' 138 elicitationResults.value = {} 139 return 140 } 141 142 const getErrorState = (key: string): boolean => { 143 const { exists } = configExist(key) 144 if (exists) { 145 return false 146 } 147 148 const value = elicitationParams.value 149 150 if (value && typeof value === 'object' && 'requestedSchema' in value) { 151 const isRequired = value.requestedSchema.required 152 153 if (isRequired && isRequired.length > 0) { 154 return isRequired.includes(key) 155 } else { 156 return false 157 } 158 } else { 159 return false 160 } 161 } 162 163 const getErrorMessages = (key: string) => { 164 return getErrorState(key) ? [t('dxt.required')] : [] 165 } 166 167 const validateStringLength = ( 168 min: number | undefined | unknown, 169 max: number | undefined | unknown 170 ) => { 171 return (value: string | null | unknown): boolean | string => { 172 if (!value || typeof value !== 'string') return true 173 174 const num = value.length 175 176 if (min !== undefined && num < Number(min)) { 177 return t('elicitation.string.too-short', { min }) 178 } 179 180 if (max !== undefined && num > Number(max)) { 181 return t('elicitation.string.too-long', { max }) 182 } 183 184 return true 185 } 186 } 187 188 const validateEnumLength = ( 189 min: number | undefined | unknown, 190 max: number | undefined | unknown 191 ) => { 192 return (value: string[] | null): boolean | string => { 193 if (!value || !value.length) return true 194 195 const num = value.length 196 197 if (min !== undefined && num < Number(min)) { 198 return t('elicitation.string.too-short', { min }) 199 } 200 201 if (max !== undefined && num > Number(max)) { 202 return t('elicitation.string.too-long', { max }) 203 } 204 205 return true 206 } 207 } 208 209 const handleProgress: IpcElicitRequestCallback = (_event, progress) => { 210 console.log('Elicitation', progress) 211 elicitationDialog.value = true 212 elicitationParams.value = progress.request.params as ElicitRequestParams 213 elicitationChannel.value = progress.responseChannel 214 } 215 216 ElicitationTransfer.request(handleProgress) 217 </script> 218 219 <template> 220 <!-- For UI visualization without chat --> 221 <!-- <v-btn @click="elicitationDialog = true" color="surface-variant" text="Open Dialog" variant="flat"></v-btn> --> 222 <v-dialog v-model="elicitationDialog" persistent max-width="80vw" max-height="80vh" scrollable> 223 <v-card :title="$t('elicitation.title')"> 224 <v-divider></v-divider> 225 <v-card-text> 226 {{ 'message' in elicitationParams ? elicitationParams?.message : null }} 227 </v-card-text> 228 <v-card-text 229 v-if=" 230 elicitationParams && 231 'requestedSchema' in elicitationParams && 232 typeof elicitationParams.requestedSchema === 'object' && 233 'properties' in elicitationParams.requestedSchema 234 " 235 > 236 <v-row v-for="item in normalizedProperties" :key="item.key" class="mx-3 mb-2 mt-1"> 237 <!-- Single-select enum (without titles) --> 238 <v-select 239 v-if="item.hasEnum" 240 prepend-icon="mdi-list-box-outline" 241 :label="item.label" 242 variant="outlined" 243 density="compact" 244 :items="item.enums" 245 :model-value="dynamicModel(item.key).get(item.default) as string" 246 clearable 247 @update:model-value="dynamicModel(item.key).set($event)" 248 ></v-select> 249 <!-- Single-select enum (with titles) --> 250 <v-select 251 v-else-if="item.hasEnumItems" 252 prepend-icon="mdi-list-box-outline" 253 :label="item.label" 254 variant="outlined" 255 density="compact" 256 :items="item.enumItems" 257 item-value="const" 258 :model-value="dynamicModel(item.key).get(item.default)" 259 clearable 260 @update:model-value="dynamicModel(item.key).set($event)" 261 ></v-select> 262 <!-- Multi-select enum (without titles) --> 263 <v-select 264 v-else-if="item.hasMultiEnum" 265 prepend-icon="mdi-list-box-outline" 266 :label="item.label" 267 variant="outlined" 268 density="compact" 269 :items="item.multiEnums" 270 :multiple="true" 271 :rules="[validateEnumLength(item.minItems, item.maxItems)]" 272 clearable 273 :model-value="dynamicModel(item.key).get(item.default) as string[]" 274 @update:model-value="dynamicModel(item.key).set($event)" 275 ></v-select> 276 <!-- Multi-select enum (with titles) --> 277 <v-select 278 v-else-if="item.hasMultiEnumItems" 279 prepend-icon="mdi-list-box-outline" 280 :label="item.label" 281 variant="outlined" 282 density="compact" 283 :items="item.multiEnumItems" 284 :multiple="true" 285 item-value="const" 286 :rules="[validateEnumLength(item.minItems, item.maxItems)]" 287 clearable 288 :model-value="dynamicModel(item.key).get(item.default) as string[]" 289 @update:model-value="dynamicModel(item.key).set($event)" 290 ></v-select> 291 <v-text-field 292 v-else-if="item.isString" 293 prepend-icon="mdi-alphabetical" 294 :label="item.label" 295 density="compact" 296 variant="outlined" 297 :placeholder="item.title" 298 :rules="[validateStringLength(item.minLength, item.maxLength)]" 299 :model-value="dynamicModel(item.key).get(item.default)" 300 clearable 301 :error="getErrorState(item.key)" 302 :error-messages="getErrorMessages(item.key)" 303 @update:model-value="dynamicModel(item.key).set($event)" 304 > 305 </v-text-field> 306 <v-number-input 307 v-else-if="item.isInteger" 308 prepend-icon="mdi-numeric" 309 :model-value="dynamicModel(item.key).get(item.default) as number" 310 :label="item.label" 311 density="compact" 312 variant="outlined" 313 :placeholder="item.title" 314 :max="item.maximum" 315 :min="item.minimum" 316 :hint="validateNumberRange(item.minimum, item.maximum)" 317 clearable 318 :error="getErrorState(item.key)" 319 :error-messages="getErrorMessages(item.key)" 320 @update:model-value="dynamicModel(item.key).set($event)" 321 ></v-number-input> 322 <v-checkbox 323 v-else-if="item.isBoolean" 324 v-tooltip:bottom="item.title" 325 color="secondary" 326 :model-value="dynamicModel(item.key).get(item.default)" 327 :label="item.label" 328 @update:model-value="dynamicModel(item.key).set($event)" 329 hide-details 330 ></v-checkbox> 331 </v-row> 332 </v-card-text> 333 <v-card-actions> 334 <v-spacer></v-spacer> 335 <v-icon-btn 336 v-tooltip:top="$t('elicitation.decline')" 337 icon="mdi-cancel" 338 color="error" 339 variant="plain" 340 rounded="lg" 341 @click="declineElicitation" 342 ></v-icon-btn> 343 <v-icon-btn 344 v-tooltip:top="$t('elicitation.accept')" 345 icon="mdi-check-bold" 346 color="success" 347 variant="plain" 348 rounded="lg" 349 @click="acceptElicitation()" 350 ></v-icon-btn> 351 </v-card-actions> 352 </v-card> 353 </v-dialog> 354 </template>