| 1 | <template> |
| 2 | <n-collapse-transition :show="!!servers.length"> |
| 3 | <div class="p-4"> |
| 4 | <div class="relative flex flex-col overflow-hidden"> |
| 5 | <div |
| 6 | v-if="!!editMessageBody" |
| 7 | class="bg-secondary hover:text-primary mb-2 flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 text-sm transition-colors duration-200" |
| 8 | @click="handleCancelEdit()" |
| 9 | > |
| 10 | <Icon name="carbon:close" :size="20" /> |
| 11 | <div>Edit message</div> |
| 12 | </div> |
| 13 | <n-input |
| 14 | v-model:value.trim="input" |
| 15 | class="max-h-full min-h-20 pt-1 pb-9" |
| 16 | type="textarea" |
| 17 | placeholder="How can I help you?" |
| 18 | :autosize="{ |
| 19 | minRows: 3, |
| 20 | maxRows: 18 |
| 21 | }" |
| 22 | /> |
| 23 | <div class="absolute right-0 bottom-0 left-0 flex items-center justify-between p-2 pr-3"> |
| 24 | <div class="flex items-center gap-2.5"> |
| 25 | <n-select |
| 26 | v-model:value="selectedServer" |
| 27 | :options="serverOptions" |
| 28 | :render-option |
| 29 | size="tiny" |
| 30 | :consistent-menu-width="false" |
| 31 | class="w-auto!" |
| 32 | /> |
| 33 | <n-popover v-if="selectedServer && selectedServerDetails" trigger="hover" class="p-0!"> |
| 34 | <template #trigger> |
| 35 | <Icon name="carbon:help" :size="14" class="cursor-help" /> |
| 36 | </template> |
| 37 | <div class="divide-border flex flex-col divide-y"> |
| 38 | <div class="flex flex-col gap-1 px-3 py-2 text-sm"> |
| 39 | <div>{{ selectedServerDetails.name }}</div> |
| 40 | <div class="text-secondary text-xs"> |
| 41 | {{ selectedServerDetails.description }} |
| 42 | </div> |
| 43 | <div |
| 44 | v-if="selectedServerDetails.capabilities.length" |
| 45 | class="my-1 flex flex-wrap gap-1" |
| 46 | > |
| 47 | <n-tag |
| 48 | v-for="item of selectedServerDetails.capabilities" |
| 49 | :key="item" |
| 50 | size="small" |
| 51 | class="text-[10px] [&_.n-tag\_\_content]:pb-0.5 [&_.n-tag\_\_content]:leading-0" |
| 52 | > |
| 53 | {{ item }} |
| 54 | </n-tag> |
| 55 | </div> |
| 56 | </div> |
| 57 | </div> |
| 58 | </n-popover> |
| 59 | <n-popover trigger="hover" class="p-0!"> |
| 60 | <template #trigger> |
| 61 | <Icon name="carbon:settings-adjust" :size="14" /> |
| 62 | </template> |
| 63 | <div class="divide-border flex flex-col divide-y"> |
| 64 | <div class="px-3 py-2"> |
| 65 | <div class="flex items-center justify-between gap-4 text-sm"> |
| 66 | <div>verbose response</div> |
| 67 | <n-switch v-model:value="verbose" size="small" /> |
| 68 | </div> |
| 69 | </div> |
| 70 | <div class="px-3 py-2"> |
| 71 | <div class="flex items-center justify-between gap-4 text-sm"> |
| 72 | <div>show example questions</div> |
| 73 | <n-switch v-model:value="showQuestions" size="small" /> |
| 74 | </div> |
| 75 | </div> |
| 76 | </div> |
| 77 | </n-popover> |
| 78 | </div> |
| 79 | <n-button |
| 80 | circle |
| 81 | size="small" |
| 82 | secondary |
| 83 | :type="isFormValid ? 'primary' : undefined" |
| 84 | :disabled="!isFormValid && !loading" |
| 85 | @click="loading ? stop() : send()" |
| 86 | > |
| 87 | <Icon :name="loading ? 'carbon:stop-filled-alt' : 'carbon:arrow-up'" /> |
| 88 | </n-button> |
| 89 | </div> |
| 90 | </div> |
| 91 | </div> |
| 92 | </n-collapse-transition> |
| 93 | </template> |
| 94 | |
| 95 | <script setup lang="ts"> |
| 96 | import type { RemovableRef } from "@vueuse/core" |
| 97 | import type { SelectOption } from "naive-ui" |
| 98 | import type { VNode } from "vue" |
| 99 | import type { MCPServer } from "@/types/copilotMCP.d" |
| 100 | import { useStorage } from "@vueuse/core" |
| 101 | import _trim from "lodash/trim" |
| 102 | import { NButton, NCollapseTransition, NInput, NPopover, NSelect, NSwitch, NTag, NTooltip, useMessage } from "naive-ui" |
| 103 | import { computed, h, onBeforeMount, ref, toRefs, watch } from "vue" |
| 104 | import Api from "@/api" |
| 105 | import Icon from "@/components/common/Icon.vue" |
| 106 | |
| 107 | export interface Message { |
| 108 | input: string |
| 109 | verbose: boolean |
| 110 | server: string |
| 111 | } |
| 112 | |
| 113 | const props = defineProps<{ loading?: boolean; editMessageBody?: string | null }>() |
| 114 | |
| 115 | const emit = defineEmits<{ |
| 116 | (e: "message", value: Message): void |
| 117 | (e: "select-server", value: string): void |
| 118 | (e: "update-options", value: { verbose: boolean; showQuestions: boolean }): void |
| 119 | (e: "stop"): void |
| 120 | (e: "server-loaded"): void |
| 121 | (e: "cancel-edit"): void |
| 122 | }>() |
| 123 | |
| 124 | const { loading, editMessageBody } = toRefs(props) |
| 125 | const input = defineModel<string | null>("input", { default: null }) |
| 126 | const verbose: RemovableRef<boolean> = useStorage<boolean>("ai-chatbot-option-verbose", false, localStorage) |
| 127 | const showQuestions: RemovableRef<boolean> = useStorage<boolean>("ai-chatbot-option-questions", true, localStorage) |
| 128 | const loadingServers = ref(false) |
| 129 | const message = useMessage() |
| 130 | const servers = ref<MCPServer[]>([]) |
| 131 | const selectedServer: RemovableRef<string | null> = useStorage<string | null>( |
| 132 | "ai-chatbot-selected-server", |
| 133 | null, |
| 134 | localStorage |
| 135 | ) |
| 136 | const serverOptions = computed(() => servers.value.map(o => ({ ...o, label: o.name }))) |
| 137 | const selectedServerDetails = computed(() => servers.value.find(o => o.value === selectedServer.value)) |
| 138 | const isFormValid = computed(() => !!_trim(input.value || "")) |
| 139 | |
| 140 | function renderOption({ node, option }: { node: VNode; option: SelectOption }) { |
| 141 | return h( |
| 142 | NTooltip, |
| 143 | { placement: "right", class: "max-w-40 text-xs!" }, |
| 144 | { |
| 145 | trigger: () => node, |
| 146 | default: () => option.description |
| 147 | } |
| 148 | ) |
| 149 | } |
| 150 | |
| 151 | function getList() { |
| 152 | loadingServers.value = true |
| 153 | |
| 154 | Api.copilotMCP |
| 155 | .getAvailableServers() |
| 156 | .then(res => { |
| 157 | if (res.data.success) { |
| 158 | servers.value = res.data?.servers || [] |
| 159 | if (servers.value.length && !selectedServer.value) { |
| 160 | selectedServer.value = servers.value[0]?.value ?? null |
| 161 | } |
| 162 | emit("server-loaded") |
| 163 | } else { |
| 164 | message.warning(res.data?.message || "An error occurred. Please try again later.") |
| 165 | } |
| 166 | }) |
| 167 | .catch(err => { |
| 168 | message.error(err.response?.data?.message || "An error occurred. Please try again later.") |
| 169 | }) |
| 170 | .finally(() => { |
| 171 | loadingServers.value = false |
| 172 | }) |
| 173 | } |
| 174 | |
| 175 | function reset() { |
| 176 | input.value = null |
| 177 | } |
| 178 | |
| 179 | function send() { |
| 180 | if (input.value && selectedServer.value) { |
| 181 | emit("message", { |
| 182 | input: input.value, |
| 183 | server: selectedServer.value, |
| 184 | verbose: verbose.value |
| 185 | }) |
| 186 | |
| 187 | reset() |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | function stop() { |
| 192 | emit("stop") |
| 193 | } |
| 194 | |
| 195 | function handleCancelEdit() { |
| 196 | emit("cancel-edit") |
| 197 | reset() |
| 198 | } |
| 199 | |
| 200 | watch( |
| 201 | selectedServer, |
| 202 | val => { |
| 203 | if (val) { |
| 204 | emit("select-server", val) |
| 205 | } |
| 206 | }, |
| 207 | { immediate: true } |
| 208 | ) |
| 209 | |
| 210 | watch( |
| 211 | [verbose, showQuestions], |
| 212 | () => { |
| 213 | emit("update-options", { verbose: verbose.value, showQuestions: showQuestions.value }) |
| 214 | }, |
| 215 | { immediate: true } |
| 216 | ) |
| 217 | |
| 218 | onBeforeMount(() => { |
| 219 | getList() |
| 220 | }) |
| 221 | </script> |