| 1 | <template> |
| 2 | <n-card size="small" title="Teach the palace" segmented> |
| 3 | <div class="flex flex-col gap-6"> |
| 4 | <p class="text-sm">Queue a lesson for the MemPalace. The NanoClaw drainer ingests these asynchronously.</p> |
| 5 | |
| 6 | <div class="grid grid-cols-1 gap-6 md:grid-cols-2"> |
| 7 | <n-form-item label="Room" :show-feedback="false"> |
| 8 | <n-select |
| 9 | v-model:value="lesson.lesson_type" |
| 10 | :options="lessonTypeOptions" |
| 11 | placeholder="Select room" |
| 12 | /> |
| 13 | </n-form-item> |
| 14 | |
| 15 | <n-form-item label="Durability" :show-feedback="false"> |
| 16 | <div class="flex items-center gap-3"> |
| 17 | <n-switch v-model:value="lesson.durable" /> |
| 18 | <span class="text-secondary text-sm"> |
| 19 | {{ lesson.durable ? "Durable (persistent)" : "One-off (single session)" }} |
| 20 | </span> |
| 21 | </div> |
| 22 | </n-form-item> |
| 23 | </div> |
| 24 | |
| 25 | <n-form-item label="Lesson text" :show-feedback="false"> |
| 26 | <n-input |
| 27 | v-model:value="lesson.lesson_text" |
| 28 | type="textarea" |
| 29 | placeholder="What should the palace remember?" |
| 30 | :autosize="{ minRows: 3, maxRows: 10 }" |
| 31 | /> |
| 32 | </n-form-item> |
| 33 | |
| 34 | <!-- Similar-lesson preview --> |
| 35 | <div v-if="similarLoading || similarLessons.length" class="flex flex-col gap-2"> |
| 36 | <div class="text-secondary text-sm"> |
| 37 | <span v-if="similarLoading">Searching similar lessons…</span> |
| 38 | <span v-else>Similar lessons already in the palace:</span> |
| 39 | </div> |
| 40 | <div v-if="!similarLoading" class="flex flex-col gap-1"> |
| 41 | <CardEntity v-for="(hit, idx) of similarLessons" :key="hit.id ?? idx" size="small" embedded> |
| 42 | <template #headerMain> |
| 43 | {{ hit.room || "—" }} |
| 44 | </template> |
| 45 | <template #headerExtra> |
| 46 | <Badge v-if="hit.score != null" type="splitted" bright> |
| 47 | <template #label>score</template> |
| 48 | <template #value>{{ hit.score.toFixed(2) }}</template> |
| 49 | </Badge> |
| 50 | </template> |
| 51 | <template #default> |
| 52 | <div>{{ hit.text || "(no text)" }}</div> |
| 53 | </template> |
| 54 | </CardEntity> |
| 55 | </div> |
| 56 | </div> |
| 57 | |
| 58 | <div class="flex items-center justify-end gap-3"> |
| 59 | <n-button |
| 60 | :disabled="!canQueueLesson" |
| 61 | :loading="queuing" |
| 62 | type="primary" |
| 63 | secondary |
| 64 | @click="handleQueueLesson" |
| 65 | > |
| 66 | {{ queuing ? "Queueing..." : "Queue lesson" }} |
| 67 | </n-button> |
| 68 | </div> |
| 69 | </div> |
| 70 | </n-card> |
| 71 | </template> |
| 72 | |
| 73 | <script setup lang="ts"> |
| 74 | import type { AiAnalystReport, AiAnalystReview, Durability, LessonType, PalaceSearchHit } from "@/types/aiAnalyst.d" |
| 75 | import type { ApiError } from "@/types/common" |
| 76 | import { NButton, NCard, NFormItem, NInput, NSelect, NSwitch, useMessage } from "naive-ui" |
| 77 | import { computed, ref, toRefs, watch } from "vue" |
| 78 | import Api from "@/api" |
| 79 | import Badge from "@/components/common/Badge.vue" |
| 80 | import CardEntity from "@/components/common/cards/CardEntity.vue" |
| 81 | import { getApiErrorMessage } from "@/utils" |
| 82 | |
| 83 | const props = defineProps<{ |
| 84 | report: AiAnalystReport |
| 85 | existingReview: AiAnalystReview | null |
| 86 | }>() |
| 87 | |
| 88 | const { report, existingReview } = toRefs(props) |
| 89 | |
| 90 | const message = useMessage() |
| 91 | const queuing = ref(false) |
| 92 | |
| 93 | const lessonTypeOptions: { label: string; value: LessonType }[] = [ |
| 94 | { label: "Environment", value: "environment" }, |
| 95 | { label: "False positives", value: "false_positives" }, |
| 96 | { label: "Assets", value: "assets" }, |
| 97 | { label: "Threat intel", value: "threat_intel" }, |
| 98 | { label: "Alerts", value: "alerts" } |
| 99 | ] |
| 100 | |
| 101 | const lesson = ref<{ lesson_type: LessonType | null; lesson_text: string; durable: boolean }>({ |
| 102 | lesson_type: null, |
| 103 | lesson_text: "", |
| 104 | durable: true |
| 105 | }) |
| 106 | const lessonDurability = computed<Durability>(() => (lesson.value.durable ? "durable" : "one_off")) |
| 107 | |
| 108 | const canQueueLesson = computed(() => !!lesson.value.lesson_type && lesson.value.lesson_text.trim().length > 0) |
| 109 | |
| 110 | // Debounced similar-lesson preview — re-run when the user pauses typing OR |
| 111 | // changes the room. Keeps the lesson draft honest against what's already stored. |
| 112 | const similarLessons = ref<PalaceSearchHit[]>([]) |
| 113 | const similarLoading = ref(false) |
| 114 | let similarTimer: ReturnType<typeof setTimeout> | null = null |
| 115 | |
| 116 | async function handleQueueLesson() { |
| 117 | if (!canQueueLesson.value || !lesson.value.lesson_type) return |
| 118 | |
| 119 | queuing.value = true |
| 120 | |
| 121 | try { |
| 122 | const res = await Api.aiAnalyst.queuePalaceLesson({ |
| 123 | customer_code: report.value.customer_code, |
| 124 | lesson_type: lesson.value.lesson_type, |
| 125 | lesson_text: lesson.value.lesson_text.trim(), |
| 126 | durability: lessonDurability.value, |
| 127 | ...(existingReview.value ? { review_id: existingReview.value.id } : {}) |
| 128 | }) |
| 129 | if (res.data.success) { |
| 130 | message.success(res.data.message || "Lesson queued") |
| 131 | lesson.value.lesson_text = "" |
| 132 | similarLessons.value = [] |
| 133 | } else { |
| 134 | message.warning(res.data.message || "Failed to queue lesson") |
| 135 | } |
| 136 | } catch (err) { |
| 137 | message.error(getApiErrorMessage(err as ApiError) || "Failed to queue lesson") |
| 138 | } finally { |
| 139 | queuing.value = false |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | function scheduleSimilarSearch() { |
| 144 | if (similarTimer) clearTimeout(similarTimer) |
| 145 | |
| 146 | const text = lesson.value.lesson_text.trim() |
| 147 | |
| 148 | if (text.length < 8 || !lesson.value.lesson_type) { |
| 149 | similarLessons.value = [] |
| 150 | similarLoading.value = false |
| 151 | return |
| 152 | } |
| 153 | |
| 154 | similarLoading.value = true |
| 155 | |
| 156 | similarTimer = setTimeout(async () => { |
| 157 | try { |
| 158 | const res = await Api.aiAnalyst.searchPalaceLessons( |
| 159 | report.value.customer_code, |
| 160 | text, |
| 161 | lesson.value.lesson_type ?? undefined, |
| 162 | 5 |
| 163 | ) |
| 164 | if (res.data.success) similarLessons.value = res.data.lessons || [] |
| 165 | else similarLessons.value = [] |
| 166 | } catch { |
| 167 | // Non-fatal — preview is best-effort |
| 168 | similarLessons.value = [] |
| 169 | } finally { |
| 170 | similarLoading.value = false |
| 171 | } |
| 172 | }, 500) |
| 173 | } |
| 174 | |
| 175 | watch(() => [lesson.value.lesson_text, lesson.value.lesson_type], scheduleSimilarSearch) |
| 176 | </script> |