/ src / renderer / components / common / ElicitationCard.vue
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>