@swiss-ai-hub/web 0.319.0 → 0.321.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/{pages/auth/login.vue → components/Auth/LoginPanel.vue} +3 -33
  2. package/components/FormKit/AgentSelector.vue +20 -7
  3. package/components/FormKit/CronInput.vue +208 -0
  4. package/components/FormKit/Repeater.vue +1 -4
  5. package/components/FormKit/VectorStoreInput.vue +43 -6
  6. package/components/Knowledge/Database/CreateModal.vue +315 -0
  7. package/components/Knowledge/Database/EmptyCard.vue +34 -0
  8. package/components/Knowledge/DeleteConfirmModal.vue +87 -0
  9. package/components/Knowledge/Document/List.vue +20 -9
  10. package/components/Knowledge/Namespace/Card.vue +18 -0
  11. package/components/Knowledge/Namespace/CreateModal.vue +11 -3
  12. package/components/Role/AccessCapabilities.vue +4 -4
  13. package/components/Role/AccessCapabilityGroup.vue +22 -6
  14. package/components/Role/AccessRulesEditor.vue +15 -12
  15. package/composables/app/useAppVersion.ts +37 -20
  16. package/composables/auth/useAuth.ts +3 -11
  17. package/composables/document/useCreateDatabase.ts +21 -0
  18. package/composables/document/useDeleteDatabase.ts +29 -0
  19. package/composables/document/useDeleteDocument.ts +1 -1
  20. package/composables/document/useDeleteDocuments.ts +1 -1
  21. package/composables/document/useDeleteNamespace.ts +30 -0
  22. package/composables/document/useIngestors.ts +23 -0
  23. package/composables/document/useScheduledDeletions.ts +16 -8
  24. package/composables/form/useCreateInstanceForm.ts +2 -2
  25. package/composables/form/useFormKitTransform.ts +44 -15
  26. package/formkit.config.ts +57 -3
  27. package/i18n/locales/de.yaml +84 -5
  28. package/i18n/locales/en.yaml +80 -3
  29. package/i18n/locales/fr.yaml +82 -3
  30. package/i18n/locales/it.yaml +84 -3
  31. package/middleware/agent-admin.ts +31 -0
  32. package/middleware/auth.global.ts +34 -16
  33. package/package.json +1 -1
  34. package/pages/[tenant]/service/agents.vue +3 -0
  35. package/pages/[tenant]/service/knowledge/[db]/[namespace]/[document_id].vue +12 -5
  36. package/pages/[tenant]/service/knowledge.vue +138 -0
  37. package/pages/auth/login/[idp].vue +57 -0
  38. package/pages/auth/login/index.vue +46 -0
  39. package/plugins/0.runtime-config.client.ts +5 -0
  40. package/plugins/oidc-client.ts +1 -1
  41. package/sdk/client/client/client.gen.ts +1 -3
  42. package/sdk/client/client/types.gen.ts +7 -13
  43. package/sdk/client/client/utils.gen.ts +1 -2
  44. package/sdk/client/core/queryKeySerializer.gen.ts +1 -6
  45. package/sdk/client/core/types.gen.ts +5 -3
  46. package/sdk/client/index.ts +47 -4
  47. package/sdk/client/schemas.gen.ts +1787 -780
  48. package/sdk/client/sdk.gen.ts +231 -1
  49. package/sdk/client/types.gen.ts +1103 -226
  50. package/plugins/keycloak-client.ts +0 -41
@@ -0,0 +1,315 @@
1
+ <template>
2
+ <Dialog
3
+ v-model:visible="visible"
4
+ modal
5
+ :header="t('knowledge.form.create_database.title')"
6
+ :style="{ width: '50rem' }"
7
+ :closable="!isSubmitting"
8
+ >
9
+ <div class="flex flex-col gap-6">
10
+ <div
11
+ v-if="ingestorsAreLoading"
12
+ class="flex items-center justify-center py-8"
13
+ >
14
+ <ProgressSpinner />
15
+ </div>
16
+
17
+ <div
18
+ v-else-if="!ingestors || ingestors.length === 0"
19
+ class="py-8 text-center text-surface-500"
20
+ >
21
+ {{ t('knowledge.form.create_database.no_ingestors') }}
22
+ </div>
23
+
24
+ <template v-else>
25
+ <div class="flex flex-col gap-2">
26
+ <label
27
+ for="database-name-input"
28
+ class="text-sm font-medium"
29
+ >
30
+ {{ t('knowledge.form.database_name.label') }}
31
+ <span class="ml-1 text-xs text-red-500">*</span>
32
+ </label>
33
+ <InputText
34
+ id="database-name-input"
35
+ v-model="databaseName"
36
+ :placeholder="t('knowledge.form.database_name.placeholder')"
37
+ :class="{ 'p-invalid': nameValidationError }"
38
+ :disabled="isSubmitting"
39
+ />
40
+ <small
41
+ v-if="nameValidationError"
42
+ class="text-red-500"
43
+ >{{ nameValidationError }}</small>
44
+ <small
45
+ v-else
46
+ class="text-gray-500"
47
+ >{{ t('knowledge.form.database_name.help') }}</small>
48
+ </div>
49
+
50
+ <div class="flex flex-col gap-2">
51
+ <label
52
+ for="database-ingestor-select"
53
+ class="text-sm font-medium"
54
+ >
55
+ {{ t('knowledge.form.ingestor.label') }}
56
+ <span class="ml-1 text-xs text-red-500">*</span>
57
+ </label>
58
+ <Select
59
+ id="database-ingestor-select"
60
+ v-model="selectedClass"
61
+ :options="ingestors"
62
+ option-label="display_name"
63
+ option-value="name"
64
+ :placeholder="t('knowledge.form.ingestor.placeholder')"
65
+ :disabled="isSubmitting"
66
+ class="w-full"
67
+ />
68
+ <small class="text-gray-500">
69
+ {{ selectedClassData?.description || t('knowledge.form.ingestor.help') }}
70
+ </small>
71
+ </div>
72
+
73
+ <div
74
+ v-if="selectedClassData && configForm.length > 0 && formReady"
75
+ class="content flex flex-col gap-2"
76
+ >
77
+ <FormKit
78
+ id="create-database-form"
79
+ v-model="formData"
80
+ type="form"
81
+ :actions="false"
82
+ :config="{
83
+ validationVisibility: 'dirty',
84
+ }"
85
+ @submit="handleFormSubmit"
86
+ @submit-invalid="isSubmitting = false"
87
+ >
88
+ <Stepper
89
+ v-model:value="activeStep"
90
+ orientation="vertical"
91
+ >
92
+ <StepItem
93
+ v-if="simpleElementsSchema.length > 0"
94
+ :value="0"
95
+ >
96
+ <Step>{{ t('knowledge.form.create_database.steps.basic_info') }}</Step>
97
+ <StepPanel>
98
+ <div class="flex flex-col gap-6 py-4">
99
+ <FormKitSchema
100
+ :schema="simpleElementsSchema"
101
+ :data="formData"
102
+ />
103
+ </div>
104
+ </StepPanel>
105
+ </StepItem>
106
+ <StepItem
107
+ v-for="(group, index) in groupConfigs"
108
+ :key="`group-${group.name}`"
109
+ :value="getGroupStepIndex(index)"
110
+ >
111
+ <Step>{{ group.label || group.name }}</Step>
112
+ <StepPanel>
113
+ <div class="content py-4">
114
+ <FormKitSchema
115
+ :schema="group.schema"
116
+ :data="formData"
117
+ />
118
+ </div>
119
+ </StepPanel>
120
+ </StepItem>
121
+ <StepItem
122
+ v-for="(rep, index) in repeaterConfigs"
123
+ :key="`repeater-${rep.path}`"
124
+ :value="getRepeaterStepIndex(index)"
125
+ >
126
+ <Step>{{ rep.label || rep.name }}</Step>
127
+ <StepPanel>
128
+ <div class="py-4">
129
+ <FormKitRepeater
130
+ :model-value="getRepeaterData(rep.path)"
131
+ :name="rep.name"
132
+ :label="rep.label"
133
+ :add-label="rep.addLabel"
134
+ :children-schema="rep.childrenSchema"
135
+ :default-item="rep.defaultItem"
136
+ :min="rep.min"
137
+ :max="rep.max"
138
+ @update:model-value="setRepeaterData(rep.path, $event)"
139
+ />
140
+ </div>
141
+ </StepPanel>
142
+ </StepItem>
143
+ </Stepper>
144
+ </FormKit>
145
+ </div>
146
+ </template>
147
+ </div>
148
+
149
+ <template #footer>
150
+ <Button
151
+ :label="t('knowledge.actions.cancel')"
152
+ severity="secondary"
153
+ outlined
154
+ @click="closeModal"
155
+ />
156
+ <Button
157
+ :label="t('knowledge.actions.create')"
158
+ :disabled="!canSubmit"
159
+ :loading="isSubmitting"
160
+ @click="triggerFormSubmit"
161
+ />
162
+ </template>
163
+ </Dialog>
164
+ </template>
165
+
166
+ <script setup lang="ts">
167
+ import { type FormElement, serializeFormData } from '@core/composables/form/useFormKitTransform'
168
+ import { getNode } from '@formkit/core'
169
+
170
+ const props = defineProps<{
171
+ modelValue: boolean
172
+ }>()
173
+
174
+ const emit = defineEmits<{
175
+ 'update:modelValue': [value: boolean]
176
+ 'success': [data: { database: string }]
177
+ }>()
178
+
179
+ const { t, locale } = useI18n()
180
+ const toast = useToast()
181
+ const { mutateAsync: createDatabase } = useCreateDatabase()
182
+ const { ingestors, ingestorsAreLoading } = useIngestors()
183
+ const { tenantId } = useTenant()
184
+
185
+ // The ingestor list is the "class" list: each ingestor announces the form its databases are configured
186
+ // through, exactly as an agent class does, so the agent create flow is reused as is.
187
+ const {
188
+ selectedClass,
189
+ formData,
190
+ activeStep,
191
+ selectedClassData,
192
+ configForm,
193
+ simpleElementsSchema,
194
+ groupConfigs,
195
+ repeaterConfigs,
196
+ getGroupStepIndex,
197
+ getRepeaterStepIndex,
198
+ getRepeaterData,
199
+ setRepeaterData,
200
+ resetForm,
201
+ } = useCreateInstanceForm({
202
+ classes: ingestors,
203
+ classField: 'name',
204
+ idField: 'name',
205
+ initialClass: () => ingestors.value?.[0]?.name ?? '',
206
+ locale,
207
+ })
208
+
209
+ const databaseName = ref('')
210
+ const isSubmitting = ref(false)
211
+ const formReady = ref(false)
212
+
213
+ const visible = computed({
214
+ get: () => props.modelValue,
215
+ set: (value: boolean) => emit('update:modelValue', value),
216
+ })
217
+
218
+ watch(visible, async (isVisible) => {
219
+ if (!isVisible) {
220
+ formReady.value = false
221
+ databaseName.value = ''
222
+ resetForm()
223
+ return
224
+ }
225
+ await nextTick()
226
+ formReady.value = true
227
+ })
228
+
229
+ const nameValidationError = computed(() => {
230
+ if (!databaseName.value.trim()) return ''
231
+
232
+ const namePattern = /^[a-z][a-z0-9]{2,62}$/
233
+ if (!namePattern.test(databaseName.value)) {
234
+ return t('knowledge.form.database_name.validation_error')
235
+ }
236
+ return ''
237
+ })
238
+
239
+ const canSubmit = computed(
240
+ () => databaseName.value.trim() && selectedClass.value && !nameValidationError.value && !isSubmitting.value,
241
+ )
242
+
243
+ function closeModal() {
244
+ visible.value = false
245
+ }
246
+
247
+ function triggerFormSubmit() {
248
+ if (!canSubmit.value) return
249
+ isSubmitting.value = true
250
+ const formNode = getNode('create-database-form')
251
+ if (formNode) {
252
+ formNode.submit()
253
+ }
254
+ else {
255
+ handleFormSubmit()
256
+ }
257
+ }
258
+
259
+ async function handleFormSubmit() {
260
+ const database = databaseName.value
261
+ try {
262
+ await createDatabase({
263
+ database,
264
+ tenantId: tenantId.value!,
265
+ request: {
266
+ ingestor: selectedClass.value,
267
+ configuration: serializeFormData(formData.value, configForm.value as FormElement[]),
268
+ },
269
+ })
270
+
271
+ toast.add({
272
+ severity: 'success',
273
+ summary: t('knowledge.form.create_database.success'),
274
+ life: 3000,
275
+ })
276
+
277
+ emit('success', { database })
278
+ closeModal()
279
+ }
280
+ catch (error) {
281
+ console.error('Failed to create knowledge database:', error)
282
+ toast.add({
283
+ severity: 'error',
284
+ summary: t('knowledge.form.create_database.error'),
285
+ detail: error instanceof Error ? error.message : String(error),
286
+ life: 5000,
287
+ })
288
+ }
289
+ finally {
290
+ isSubmitting.value = false
291
+ }
292
+ }
293
+ </script>
294
+
295
+ <style scoped>
296
+ .content {
297
+ @apply font-light text-xs
298
+ }
299
+
300
+ .content :deep(.formkit-group-fieldset) {
301
+ @apply flex flex-col gap-6;
302
+ }
303
+
304
+ .content :deep(.formkit-outer) {
305
+ @apply pt-3 pb-1;
306
+ }
307
+
308
+ .content :deep(.formkit-group-fieldset legend) {
309
+ @apply hidden;
310
+ }
311
+
312
+ .content :deep(#create-database-form-incomplete) {
313
+ @apply font-bold text-sm text-right pr-2;
314
+ }
315
+ </style>
@@ -0,0 +1,34 @@
1
+ <template>
2
+ <button
3
+ type="button"
4
+ class="flex w-full cursor-pointer items-center gap-3 rounded-xl border-2 border-dashed border-surface-300 p-4 text-left hover:border-primary-500 hover:bg-surface-50 dark:border-surface-600 dark:hover:bg-surface-800"
5
+ @click="handleAdd"
6
+ >
7
+ <div class="flex items-center justify-center p-2">
8
+ <i
9
+ class="pi pi-database text-surface-400"
10
+ style="font-size: 1.5rem"
11
+ />
12
+ </div>
13
+ <div>
14
+ <h3 class="font-medium text-surface-600 dark:text-surface-400">
15
+ {{ t('knowledge.add_database.title') }}
16
+ </h3>
17
+ <p class="text-sm text-surface-500 dark:text-surface-400">
18
+ {{ t('knowledge.add_database.description') }}
19
+ </p>
20
+ </div>
21
+ </button>
22
+ </template>
23
+
24
+ <script setup lang="ts">
25
+ const { t } = useI18n()
26
+
27
+ const emit = defineEmits<{
28
+ add: []
29
+ }>()
30
+
31
+ const handleAdd = () => {
32
+ emit('add')
33
+ }
34
+ </script>
@@ -0,0 +1,87 @@
1
+ <template>
2
+ <Dialog
3
+ :visible="visible"
4
+ modal
5
+ :header="title"
6
+ :style="{ width: '32rem' }"
7
+ :closable="!isDeleting"
8
+ @update:visible="emit('update:visible', $event)"
9
+ >
10
+ <div class="flex flex-col gap-4">
11
+ <Message
12
+ severity="warn"
13
+ :closable="false"
14
+ >
15
+ {{ warning }}
16
+ </Message>
17
+
18
+ <div class="flex flex-col gap-2">
19
+ <label
20
+ for="delete-confirm-input"
21
+ class="text-sm font-medium"
22
+ >
23
+ {{ t('knowledge.delete.confirm_label', { name: expectedName }) }}
24
+ </label>
25
+ <!-- Deliberately no placeholder: the label already names what to type, and putting it in the
26
+ field too turns a deliberate confirmation into copying the answer out of the box. -->
27
+ <InputText
28
+ id="delete-confirm-input"
29
+ v-model="typedName"
30
+ autocomplete="off"
31
+ :disabled="isDeleting"
32
+ @keyup.enter="confirmIfMatch"
33
+ />
34
+ </div>
35
+ </div>
36
+
37
+ <template #footer>
38
+ <div class="flex justify-end gap-2">
39
+ <Button
40
+ :label="t('knowledge.actions.cancel')"
41
+ severity="secondary"
42
+ outlined
43
+ :disabled="isDeleting"
44
+ @click="emit('update:visible', false)"
45
+ />
46
+ <Button
47
+ :label="t('knowledge.delete.actions.delete')"
48
+ severity="danger"
49
+ :disabled="!nameMatches"
50
+ :loading="isDeleting"
51
+ @click="emit('confirm')"
52
+ />
53
+ </div>
54
+ </template>
55
+ </Dialog>
56
+ </template>
57
+
58
+ <script setup lang="ts">
59
+ const props = defineProps<{
60
+ visible: boolean
61
+ title: string
62
+ warning: string
63
+ expectedName: string
64
+ isDeleting?: boolean
65
+ }>()
66
+
67
+ const emit = defineEmits<{
68
+ 'update:visible': [value: boolean]
69
+ 'confirm': []
70
+ }>()
71
+
72
+ const { t } = useI18n()
73
+
74
+ const typedName = ref('')
75
+
76
+ const nameMatches = computed(() => typedName.value.trim() === props.expectedName)
77
+
78
+ const confirmIfMatch = () => {
79
+ if (nameMatches.value && !props.isDeleting) emit('confirm')
80
+ }
81
+
82
+ // Reset the typed name whenever the dialog opens for a different target, so a previous confirmation
83
+ // can never carry over and pre-satisfy the guard.
84
+ watch(() => props.visible, (isVisible) => {
85
+ if (isVisible) typedName.value = ''
86
+ })
87
+ </script>
@@ -140,7 +140,7 @@ const { tenantId } = useTenant()
140
140
  const { getDocumentSourceUrl } = useDocumentUrl()
141
141
  const { deleteDocument, isDeleting } = useDeleteDocument()
142
142
  const { deleteDocuments, isDeleting: isBatchDeleting } = useDeleteDocuments()
143
- const { isScheduled, schedule, unschedule } = useScheduledDeletions(
143
+ const { isScheduled, scheduledAt, schedule, unschedule } = useScheduledDeletions(
144
144
  () => route.params.db as string,
145
145
  () => route.params.namespace as string,
146
146
  )
@@ -163,18 +163,24 @@ const checkedDocuments = ref<DocumentDto[]>([])
163
163
 
164
164
  const formatted = (datestr: string) => useDateFormat(new Date(datestr), 'DD.MM.YYYY')
165
165
 
166
- const isDocumentDeleting = (document: DocumentDto) => isScheduled(document.id) && document.is_ingested
166
+ const isDocumentDeleting = (document: DocumentDto) => isScheduled(document.id)
167
167
 
168
- // Processing documents are mid-ingestion; removing their source now would race the pipeline.
169
- const isDocumentDeletable = (document: DocumentDto) => document.is_ingested && !isDocumentDeleting(document)
168
+ // Deleting is safe at any stage of ingestion: the API only removes the source file, and the pipeline
169
+ // drops the document's partition before the remove job touches any store, which aborts an in-flight
170
+ // run before it writes. So the only reason to withhold the action is a deletion already in flight.
171
+ const isDocumentDeletable = (document: DocumentDto) => !isDocumentDeleting(document)
170
172
 
171
- // Ids derive from the source URI, so re-uploading a just-deleted file reuses its id. When that id
172
- // reappears as a placeholder, the scheduled entry is stale and must be cleared.
173
+ // Ids derive from the source URI, so re-uploading a just-deleted file reuses its id and would inherit
174
+ // its "Deleting" badge. An upload rewrites the document, so an updated_at newer than the moment we
175
+ // scheduled the deletion means this row is the replacement, not the one still on its way out.
173
176
  watch(
174
177
  () => props.documents,
175
178
  (documents) => {
176
179
  const reUploadedIds = (documents ?? [])
177
- .filter(document => !document.is_ingested && isScheduled(document.id))
180
+ .filter((document) => {
181
+ const requestedAt = scheduledAt(document.id)
182
+ return requestedAt != null && new Date(document.updated_at).getTime() > requestedAt
183
+ })
178
184
  .map(document => document.id)
179
185
  if (reUploadedIds.length > 0) {
180
186
  unschedule(reUploadedIds)
@@ -200,15 +206,20 @@ const onSelectAllChange = ({ checked }: { checked: boolean }) => {
200
206
 
201
207
  const handleRowClick = (event: DataTableRowClickEvent) => {
202
208
  const document = event.data as DocumentDto
203
- if (document.is_ingested && !isDocumentDeleting(document)) {
209
+ if (!isDocumentDeleting(document)) {
204
210
  emit('selected', document)
205
211
  }
206
212
  }
207
213
 
214
+ // Only a deletion in flight makes a row inert. A document still being ingested stays dimmed but usable —
215
+ // otherwise a failed ingestion leaves a row that can be neither opened nor removed.
208
216
  const getRowClass = (data: DocumentDto) => {
209
- if (!data.is_ingested || isDocumentDeleting(data)) {
217
+ if (isDocumentDeleting(data)) {
210
218
  return 'opacity-50 cursor-not-allowed pointer-events-none'
211
219
  }
220
+ if (!data.is_ingested) {
221
+ return 'opacity-50'
222
+ }
212
223
  return data.id === route.params.document_id ? 'bg-surface-100 dark:bg-surface-800' : ''
213
224
  }
214
225
 
@@ -45,6 +45,16 @@
45
45
  severity="secondary"
46
46
  @click.stop="handleUploadClick"
47
47
  />
48
+ <Button
49
+ v-if="!autoSync"
50
+ v-tooltip.top="t('knowledge.delete_namespace')"
51
+ icon="pi pi-trash"
52
+ rounded
53
+ text
54
+ size="small"
55
+ severity="danger"
56
+ @click.stop="handleDeleteClick"
57
+ />
48
58
  <Badge
49
59
  :value="namespace.number_of_documents"
50
60
  size="large"
@@ -67,6 +77,8 @@ import { capitalCase } from 'change-case'
67
77
 
68
78
  import type { NamespaceDto } from '@core/sdk/client'
69
79
 
80
+ // Not gated on the database's `deletable`: a legacy database cannot be removed as a whole, but its
81
+ // namespaces can. Only an auto-synced source, which would just re-sync them, blocks both affordances.
70
82
  const props = defineProps<{
71
83
  namespace: NamespaceDto
72
84
  autoSync?: boolean
@@ -75,6 +87,7 @@ const props = defineProps<{
75
87
  const emit = defineEmits<{
76
88
  upload: [namespace: NamespaceDto]
77
89
  edit: [namespace: NamespaceDto]
90
+ delete: [namespace: NamespaceDto]
78
91
  }>()
79
92
 
80
93
  const route = useRoute()
@@ -105,4 +118,9 @@ const handleEditClick = (event: Event) => {
105
118
  event.stopPropagation()
106
119
  emit('edit', props.namespace)
107
120
  }
121
+
122
+ const handleDeleteClick = (event: Event) => {
123
+ event.stopPropagation()
124
+ emit('delete', props.namespace)
125
+ }
108
126
  </script>
@@ -190,9 +190,17 @@ const handleCreate = async () => {
190
190
  tenantId: tenantId.value!,
191
191
  }
192
192
 
193
- await createNamespace(requestBody)
194
- emit('success', { database: selectedDatabase.value, namespace: name.value })
195
- closeModal()
193
+ try {
194
+ await createNamespace(requestBody)
195
+ emit('success', { database: selectedDatabase.value, namespace: name.value })
196
+ closeModal()
197
+ }
198
+ catch {
199
+ error.value = t('knowledge.form.create_error')
200
+ }
201
+ finally {
202
+ isCreating.value = false
203
+ }
196
204
  }
197
205
 
198
206
  watch(() => props.modelValue, (isVisible) => {
@@ -21,8 +21,8 @@
21
21
  :group="group"
22
22
  :depth="0"
23
23
  :readonly="readonly"
24
- @add="(rule) => emit('add', rule)"
25
- @remove="(rule) => emit('remove', rule)"
24
+ @add="(rules) => emit('add', rules)"
25
+ @remove="(rules) => emit('remove', rules)"
26
26
  />
27
27
  </div>
28
28
  <p
@@ -55,8 +55,8 @@ const props = withDefaults(defineProps<{
55
55
  })
56
56
 
57
57
  const emit = defineEmits<{
58
- add: [rule: string]
59
- remove: [rule: string]
58
+ add: [rules: string[]]
59
+ remove: [rules: string[]]
60
60
  }>()
61
61
 
62
62
  const { capabilities, capabilitiesAreLoading } = useAccessCapabilities(
@@ -78,8 +78,9 @@
78
78
  </span>
79
79
  <code
80
80
  v-if="cap.rule"
81
+ v-tooltip.top="ruleTooltip(cap)"
81
82
  class="mt-0.5 shrink-0 font-mono text-[11px] text-surface-300 transition-colors group-hover/cap:text-surface-500 dark:text-surface-600 dark:group-hover/cap:text-surface-400"
82
- >{{ cap.rule }}</code>
83
+ >{{ cap.companion_rules?.length ? `${cap.rule} +${cap.companion_rules.length}` : cap.rule }}</code>
83
84
  </label>
84
85
  </div>
85
86
 
@@ -95,8 +96,8 @@
95
96
  :group="sub"
96
97
  :depth="depth + 1"
97
98
  :readonly="readonly"
98
- @add="(rule) => emit('add', rule)"
99
- @remove="(rule) => emit('remove', rule)"
99
+ @add="(rules) => emit('add', rules)"
100
+ @remove="(rules) => emit('remove', rules)"
100
101
  />
101
102
  </div>
102
103
  </div>
@@ -119,12 +120,27 @@ const props = withDefaults(defineProps<{
119
120
  })
120
121
 
121
122
  const emit = defineEmits<{
122
- add: [rule: string]
123
- remove: [rule: string]
123
+ add: [rules: string[]]
124
+ remove: [rules: string[]]
124
125
  }>()
125
126
 
127
+ // Two lists, two meanings. `companion_rules` is written and cleared with the row — a knowledge database
128
+ // owns its namespaces, and the grammar has no single form covering a node and its subtree, so that row
129
+ // needs both. `revoked_rules` is only ever cleared: an agent class does not own the profiles built from it,
130
+ // so ticking must never write `<class>.>`, while unticking still takes an old ceiling's copy of it. Each
131
+ // direction emits one payload because the parent writes them through a single `v-model`, which cannot
132
+ // absorb two writes in the same tick — the second would read a model value the first had not yet updated.
133
+ // The badge counts only what ticking grants; what unticking additionally takes is left to the tooltip.
134
+ const ruleTooltip = (cap: Capability) =>
135
+ [
136
+ cap.companion_rules?.length ? t('role.capability_also_granted', { rules: cap.companion_rules.join(', ') }) : null,
137
+ cap.revoked_rules?.length ? t('role.capability_also_cleared', { rules: cap.revoked_rules.join(', ') }) : null,
138
+ ].filter(Boolean).join('\n') || undefined
139
+
126
140
  const onToggle = (cap: Capability, value: boolean) => {
127
141
  if (props.readonly || !cap.rule) return
128
- emit(value ? 'add' : 'remove', cap.rule)
142
+ const granted = [cap.rule, ...(cap.companion_rules ?? [])]
143
+ if (value) emit('add', granted)
144
+ else emit('remove', [...granted, ...(cap.revoked_rules ?? [])])
129
145
  }
130
146
  </script>