main
vue 176 lines 5.49 KB
Raw
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>