arkaos 2.92.0 → 2.94.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.
- package/VERSION +1 -1
- package/dashboard/app/components/AgentEditDrawer.vue +388 -0
- package/dashboard/app/components/ConfirmDialog.vue +61 -0
- package/dashboard/app/components/PersonaDetailDrawer.vue +18 -11
- package/dashboard/app/composables/useConfirmDialog.ts +38 -0
- package/dashboard/app/pages/agents/[id].vue +159 -27
- package/dashboard/app/pages/knowledge.vue +10 -6
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/scripts/__pycache__/dashboard-api.cpython-313.pyc +0 -0
- package/scripts/dashboard-api.py +92 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.94.0
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR76 v2.94.0 — Agent edit drawer.
|
|
3
|
+
//
|
|
4
|
+
// Opens from the agent detail hero. Lets non-technical operators
|
|
5
|
+
// edit the safe-to-mutate fields without touching YAML directly:
|
|
6
|
+
//
|
|
7
|
+
// - Identity (name, role, tier)
|
|
8
|
+
// - Mental models (primary + secondary lists)
|
|
9
|
+
// - Frameworks list
|
|
10
|
+
// - Expertise domains + depth + years
|
|
11
|
+
// - Communication (tone, vocab level, preferred format, avoid)
|
|
12
|
+
// - Linked personas (multi-select from /api/personas)
|
|
13
|
+
//
|
|
14
|
+
// Save → PUT /api/agents/{id} (atomic YAML write on the backend).
|
|
15
|
+
// NEVER edits: id, department, behavioural DNA (DISC/Enneagram/MBTI/
|
|
16
|
+
// Big-Five). Those are intentionally locked because changing them
|
|
17
|
+
// silently invalidates the agent's identity model.
|
|
18
|
+
|
|
19
|
+
import type { Persona } from '~/types'
|
|
20
|
+
|
|
21
|
+
const props = defineProps<{
|
|
22
|
+
modelValue: boolean
|
|
23
|
+
agent: any
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{
|
|
27
|
+
(e: 'update:modelValue', value: boolean): void
|
|
28
|
+
(e: 'saved'): void
|
|
29
|
+
}>()
|
|
30
|
+
|
|
31
|
+
const { apiBase, fetchApi } = useApi()
|
|
32
|
+
const toast = useToast()
|
|
33
|
+
const confirmDialog = useConfirmDialog()
|
|
34
|
+
|
|
35
|
+
// Persona list — for the linked_personas multi-select.
|
|
36
|
+
const { data: personasData } = fetchApi<{ personas: Persona[] }>('/api/personas')
|
|
37
|
+
const personaOptions = computed(() =>
|
|
38
|
+
(personasData.value?.personas ?? []).map((p) => ({
|
|
39
|
+
label: p.name + (p.title ? ` — ${p.title}` : ''),
|
|
40
|
+
value: p.id,
|
|
41
|
+
})),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
interface AgentDraft {
|
|
45
|
+
name: string
|
|
46
|
+
role: string
|
|
47
|
+
tier: number
|
|
48
|
+
mental_models: { primary: string[]; secondary: string[] }
|
|
49
|
+
frameworks: string[]
|
|
50
|
+
expertise_domains: string[]
|
|
51
|
+
expertise_depth: string
|
|
52
|
+
expertise_years: number
|
|
53
|
+
communication: {
|
|
54
|
+
tone: string
|
|
55
|
+
vocabulary_level: string
|
|
56
|
+
preferred_format: string
|
|
57
|
+
language: string
|
|
58
|
+
avoid: string[]
|
|
59
|
+
}
|
|
60
|
+
linked_personas: string[]
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const draft = ref<AgentDraft | null>(null)
|
|
64
|
+
const saving = ref(false)
|
|
65
|
+
const dirty = ref(false)
|
|
66
|
+
|
|
67
|
+
watch(
|
|
68
|
+
() => [props.modelValue, props.agent] as const,
|
|
69
|
+
([open, agent]) => {
|
|
70
|
+
if (open && agent) {
|
|
71
|
+
draft.value = {
|
|
72
|
+
name: agent.name ?? '',
|
|
73
|
+
role: agent.role ?? '',
|
|
74
|
+
tier: agent.tier ?? 2,
|
|
75
|
+
mental_models: {
|
|
76
|
+
primary: agent.mental_models?.primary ?? [],
|
|
77
|
+
secondary: agent.mental_models?.secondary ?? [],
|
|
78
|
+
},
|
|
79
|
+
frameworks: agent.frameworks ?? [],
|
|
80
|
+
expertise_domains: agent.expertise_domains ?? [],
|
|
81
|
+
expertise_depth: agent.expertise_depth ?? '',
|
|
82
|
+
expertise_years: agent.expertise_years ?? 0,
|
|
83
|
+
communication: {
|
|
84
|
+
tone: agent.communication?.tone ?? '',
|
|
85
|
+
vocabulary_level: agent.communication?.vocabulary_level ?? '',
|
|
86
|
+
preferred_format: agent.communication?.preferred_format ?? '',
|
|
87
|
+
language: agent.communication?.language ?? '',
|
|
88
|
+
avoid: agent.communication?.avoid ?? [],
|
|
89
|
+
},
|
|
90
|
+
linked_personas: agent.linked_personas ?? [],
|
|
91
|
+
}
|
|
92
|
+
dirty.value = false
|
|
93
|
+
} else if (!open) {
|
|
94
|
+
draft.value = null
|
|
95
|
+
dirty.value = false
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
{ immediate: true },
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
function markDirty() {
|
|
102
|
+
dirty.value = true
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function listToCsv(list: string[]): string {
|
|
106
|
+
return (list ?? []).join(', ')
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function csvToList(value: string): string[] {
|
|
110
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function save() {
|
|
114
|
+
if (!draft.value || !props.agent?.id) return
|
|
115
|
+
saving.value = true
|
|
116
|
+
try {
|
|
117
|
+
const payload = {
|
|
118
|
+
name: draft.value.name,
|
|
119
|
+
role: draft.value.role,
|
|
120
|
+
tier: draft.value.tier,
|
|
121
|
+
mental_models: draft.value.mental_models,
|
|
122
|
+
frameworks: draft.value.frameworks,
|
|
123
|
+
expertise_domains: draft.value.expertise_domains,
|
|
124
|
+
expertise: {
|
|
125
|
+
depth: draft.value.expertise_depth,
|
|
126
|
+
years_equivalent: draft.value.expertise_years,
|
|
127
|
+
},
|
|
128
|
+
communication: draft.value.communication,
|
|
129
|
+
linked_personas: draft.value.linked_personas,
|
|
130
|
+
}
|
|
131
|
+
const res = await $fetch<{
|
|
132
|
+
id: string
|
|
133
|
+
updated: boolean
|
|
134
|
+
yaml_path?: string
|
|
135
|
+
error?: string
|
|
136
|
+
}>(`${apiBase}/api/agents/${props.agent.id}`, {
|
|
137
|
+
method: 'PUT',
|
|
138
|
+
body: payload,
|
|
139
|
+
})
|
|
140
|
+
if (res.error) throw new Error(res.error)
|
|
141
|
+
toast.add({
|
|
142
|
+
title: 'Agent saved',
|
|
143
|
+
description: res.yaml_path
|
|
144
|
+
? `Wrote ${res.yaml_path.split('/').slice(-3).join('/')}`
|
|
145
|
+
: 'YAML updated',
|
|
146
|
+
color: 'success',
|
|
147
|
+
})
|
|
148
|
+
dirty.value = false
|
|
149
|
+
emit('saved')
|
|
150
|
+
emit('update:modelValue', false)
|
|
151
|
+
} catch (err) {
|
|
152
|
+
toast.add({
|
|
153
|
+
title: 'Save failed',
|
|
154
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
155
|
+
color: 'error',
|
|
156
|
+
})
|
|
157
|
+
} finally {
|
|
158
|
+
saving.value = false
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function tryClose() {
|
|
163
|
+
if (dirty.value && !saving.value) {
|
|
164
|
+
const ok = await confirmDialog({
|
|
165
|
+
title: 'Discard unsaved edits?',
|
|
166
|
+
description: 'Any changes you made to this agent will be lost.',
|
|
167
|
+
confirmLabel: 'Discard',
|
|
168
|
+
cancelLabel: 'Keep editing',
|
|
169
|
+
variant: 'danger',
|
|
170
|
+
})
|
|
171
|
+
if (!ok) return
|
|
172
|
+
}
|
|
173
|
+
emit('update:modelValue', false)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const tierOptions = [
|
|
177
|
+
{ label: 'Tier 0 — C-Suite', value: 0 },
|
|
178
|
+
{ label: 'Tier 1 — Squad Lead', value: 1 },
|
|
179
|
+
{ label: 'Tier 2 — Specialist', value: 2 },
|
|
180
|
+
{ label: 'Tier 3 — Support', value: 3 },
|
|
181
|
+
]
|
|
182
|
+
const depthOptions = [
|
|
183
|
+
{ label: 'Intermediate', value: 'intermediate' },
|
|
184
|
+
{ label: 'Advanced', value: 'advanced' },
|
|
185
|
+
{ label: 'Expert', value: 'expert' },
|
|
186
|
+
{ label: 'Master', value: 'master' },
|
|
187
|
+
]
|
|
188
|
+
const vocabOptions = [
|
|
189
|
+
{ label: 'Lay (no jargon)', value: 'lay' },
|
|
190
|
+
{ label: 'Specialist (industry terms)', value: 'specialist' },
|
|
191
|
+
{ label: 'Expert (research-level)', value: 'expert' },
|
|
192
|
+
]
|
|
193
|
+
</script>
|
|
194
|
+
|
|
195
|
+
<template>
|
|
196
|
+
<USlideover
|
|
197
|
+
:open="modelValue"
|
|
198
|
+
:ui="{ content: 'max-w-2xl w-full' }"
|
|
199
|
+
@update:open="(v) => v ? null : tryClose()"
|
|
200
|
+
>
|
|
201
|
+
<template #content>
|
|
202
|
+
<UCard
|
|
203
|
+
:ui="{
|
|
204
|
+
root: 'h-full flex flex-col rounded-none',
|
|
205
|
+
body: 'flex-1 overflow-y-auto',
|
|
206
|
+
}"
|
|
207
|
+
>
|
|
208
|
+
<template #header>
|
|
209
|
+
<div class="flex items-center justify-between gap-3">
|
|
210
|
+
<div>
|
|
211
|
+
<h2 class="text-xl font-bold">Edit agent</h2>
|
|
212
|
+
<p class="text-sm text-muted mt-0.5">
|
|
213
|
+
{{ props.agent?.name }}
|
|
214
|
+
<span class="text-xs text-muted/60 ml-1">— {{ props.agent?.id }}</span>
|
|
215
|
+
</p>
|
|
216
|
+
</div>
|
|
217
|
+
<UButton
|
|
218
|
+
icon="i-lucide-x"
|
|
219
|
+
variant="ghost"
|
|
220
|
+
size="sm"
|
|
221
|
+
aria-label="Close"
|
|
222
|
+
@click="tryClose"
|
|
223
|
+
/>
|
|
224
|
+
</div>
|
|
225
|
+
</template>
|
|
226
|
+
|
|
227
|
+
<div v-if="draft" class="space-y-6">
|
|
228
|
+
<p class="rounded-lg border border-yellow-500/30 bg-yellow-500/5 p-3 text-xs text-muted">
|
|
229
|
+
<UIcon name="i-lucide-info" class="size-3.5 inline" />
|
|
230
|
+
Behavioural DNA (DISC, Enneagram, MBTI, Big Five) is locked here
|
|
231
|
+
on purpose — changing it silently invalidates the agent's
|
|
232
|
+
identity model. Edit it directly in the YAML file when truly
|
|
233
|
+
needed.
|
|
234
|
+
</p>
|
|
235
|
+
|
|
236
|
+
<section class="space-y-3">
|
|
237
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
|
|
238
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
239
|
+
<UFormField label="Name">
|
|
240
|
+
<UInput v-model="draft.name" class="w-full" @update:model-value="markDirty" />
|
|
241
|
+
</UFormField>
|
|
242
|
+
<UFormField label="Role">
|
|
243
|
+
<UInput v-model="draft.role" class="w-full" @update:model-value="markDirty" />
|
|
244
|
+
</UFormField>
|
|
245
|
+
<UFormField label="Tier">
|
|
246
|
+
<USelect
|
|
247
|
+
v-model="draft.tier"
|
|
248
|
+
:items="tierOptions"
|
|
249
|
+
class="w-full"
|
|
250
|
+
@update:model-value="markDirty"
|
|
251
|
+
/>
|
|
252
|
+
</UFormField>
|
|
253
|
+
</div>
|
|
254
|
+
</section>
|
|
255
|
+
|
|
256
|
+
<section class="space-y-3">
|
|
257
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Mental models</h3>
|
|
258
|
+
<UFormField label="Primary" help="comma-separated">
|
|
259
|
+
<UInput
|
|
260
|
+
:model-value="listToCsv(draft.mental_models.primary)"
|
|
261
|
+
@update:model-value="(v: string) => { if (draft) { draft.mental_models.primary = csvToList(v); markDirty() } }"
|
|
262
|
+
class="w-full"
|
|
263
|
+
/>
|
|
264
|
+
</UFormField>
|
|
265
|
+
<UFormField label="Secondary" help="comma-separated">
|
|
266
|
+
<UInput
|
|
267
|
+
:model-value="listToCsv(draft.mental_models.secondary)"
|
|
268
|
+
@update:model-value="(v: string) => { if (draft) { draft.mental_models.secondary = csvToList(v); markDirty() } }"
|
|
269
|
+
class="w-full"
|
|
270
|
+
/>
|
|
271
|
+
</UFormField>
|
|
272
|
+
</section>
|
|
273
|
+
|
|
274
|
+
<section class="space-y-3">
|
|
275
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Expertise</h3>
|
|
276
|
+
<UFormField label="Domains" help="comma-separated">
|
|
277
|
+
<UInput
|
|
278
|
+
:model-value="listToCsv(draft.expertise_domains)"
|
|
279
|
+
@update:model-value="(v: string) => { if (draft) { draft.expertise_domains = csvToList(v); markDirty() } }"
|
|
280
|
+
class="w-full"
|
|
281
|
+
/>
|
|
282
|
+
</UFormField>
|
|
283
|
+
<UFormField label="Frameworks" help="comma-separated">
|
|
284
|
+
<UInput
|
|
285
|
+
:model-value="listToCsv(draft.frameworks)"
|
|
286
|
+
@update:model-value="(v: string) => { if (draft) { draft.frameworks = csvToList(v); markDirty() } }"
|
|
287
|
+
class="w-full"
|
|
288
|
+
/>
|
|
289
|
+
</UFormField>
|
|
290
|
+
<div class="grid grid-cols-2 gap-3">
|
|
291
|
+
<UFormField label="Depth">
|
|
292
|
+
<USelect
|
|
293
|
+
v-model="draft.expertise_depth"
|
|
294
|
+
:items="depthOptions"
|
|
295
|
+
class="w-full"
|
|
296
|
+
@update:model-value="markDirty"
|
|
297
|
+
/>
|
|
298
|
+
</UFormField>
|
|
299
|
+
<UFormField label="Years (equivalent)">
|
|
300
|
+
<UInput
|
|
301
|
+
v-model.number="draft.expertise_years"
|
|
302
|
+
type="number"
|
|
303
|
+
:min="0"
|
|
304
|
+
:max="60"
|
|
305
|
+
class="w-full"
|
|
306
|
+
@update:model-value="markDirty"
|
|
307
|
+
/>
|
|
308
|
+
</UFormField>
|
|
309
|
+
</div>
|
|
310
|
+
</section>
|
|
311
|
+
|
|
312
|
+
<section class="space-y-3">
|
|
313
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
|
|
314
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
315
|
+
<UFormField label="Tone">
|
|
316
|
+
<UInput v-model="draft.communication.tone" class="w-full" @update:model-value="markDirty" />
|
|
317
|
+
</UFormField>
|
|
318
|
+
<UFormField label="Vocabulary level">
|
|
319
|
+
<USelect
|
|
320
|
+
v-model="draft.communication.vocabulary_level"
|
|
321
|
+
:items="vocabOptions"
|
|
322
|
+
class="w-full"
|
|
323
|
+
@update:model-value="markDirty"
|
|
324
|
+
/>
|
|
325
|
+
</UFormField>
|
|
326
|
+
<UFormField label="Preferred format">
|
|
327
|
+
<UInput v-model="draft.communication.preferred_format" class="w-full" @update:model-value="markDirty" />
|
|
328
|
+
</UFormField>
|
|
329
|
+
<UFormField label="Language">
|
|
330
|
+
<UInput v-model="draft.communication.language" placeholder="en, pt" class="w-full" @update:model-value="markDirty" />
|
|
331
|
+
</UFormField>
|
|
332
|
+
</div>
|
|
333
|
+
<UFormField label="Avoid (phrases)" help="comma-separated">
|
|
334
|
+
<UInput
|
|
335
|
+
:model-value="listToCsv(draft.communication.avoid)"
|
|
336
|
+
@update:model-value="(v: string) => { if (draft) { draft.communication.avoid = csvToList(v); markDirty() } }"
|
|
337
|
+
class="w-full"
|
|
338
|
+
/>
|
|
339
|
+
</UFormField>
|
|
340
|
+
</section>
|
|
341
|
+
|
|
342
|
+
<section class="space-y-3">
|
|
343
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">
|
|
344
|
+
Linked personas
|
|
345
|
+
<span class="ml-2 text-[10px] font-normal text-muted normal-case tracking-normal">
|
|
346
|
+
— agent draws from these persona profiles
|
|
347
|
+
</span>
|
|
348
|
+
</h3>
|
|
349
|
+
<USelectMenu
|
|
350
|
+
v-model="draft.linked_personas"
|
|
351
|
+
:items="personaOptions"
|
|
352
|
+
value-key="value"
|
|
353
|
+
multiple
|
|
354
|
+
placeholder="Select personas to link"
|
|
355
|
+
class="w-full"
|
|
356
|
+
@update:model-value="markDirty"
|
|
357
|
+
/>
|
|
358
|
+
<p class="text-xs text-muted">
|
|
359
|
+
{{ draft.linked_personas.length }} linked.
|
|
360
|
+
Personas come from the Persona library (auto-synced with your
|
|
361
|
+
Obsidian vault).
|
|
362
|
+
</p>
|
|
363
|
+
</section>
|
|
364
|
+
</div>
|
|
365
|
+
|
|
366
|
+
<template #footer>
|
|
367
|
+
<div class="flex items-center justify-between gap-2">
|
|
368
|
+
<span v-if="dirty" class="text-xs text-yellow-400">
|
|
369
|
+
<UIcon name="i-lucide-circle-dot" class="size-3 inline" />
|
|
370
|
+
Unsaved changes
|
|
371
|
+
</span>
|
|
372
|
+
<span v-else class="text-xs text-muted">No changes</span>
|
|
373
|
+
<div class="flex gap-2">
|
|
374
|
+
<UButton label="Cancel" variant="ghost" :disabled="saving" @click="tryClose" />
|
|
375
|
+
<UButton
|
|
376
|
+
label="Save"
|
|
377
|
+
icon="i-lucide-check"
|
|
378
|
+
:loading="saving"
|
|
379
|
+
:disabled="!dirty"
|
|
380
|
+
@click="save"
|
|
381
|
+
/>
|
|
382
|
+
</div>
|
|
383
|
+
</div>
|
|
384
|
+
</template>
|
|
385
|
+
</UCard>
|
|
386
|
+
</template>
|
|
387
|
+
</USlideover>
|
|
388
|
+
</template>
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR75 v2.93.0 — canonical confirm dialog (Nuxt UI v4 pattern).
|
|
3
|
+
//
|
|
4
|
+
// Replaces native window.confirm() calls. Driven by useConfirmDialog()
|
|
5
|
+
// composable, which itself uses useOverlay() to mount this component
|
|
6
|
+
// imperatively. Emits a boolean on close: true = confirm,
|
|
7
|
+
// false = cancel.
|
|
8
|
+
//
|
|
9
|
+
// Per the Nuxt UI v4 docs (https://ui.nuxt.com/docs/composables/use-overlay).
|
|
10
|
+
|
|
11
|
+
interface ConfirmDialogProps {
|
|
12
|
+
title?: string
|
|
13
|
+
description?: string
|
|
14
|
+
confirmLabel?: string
|
|
15
|
+
cancelLabel?: string
|
|
16
|
+
/**
|
|
17
|
+
* Display variant for the confirm button. Use 'error' for destructive
|
|
18
|
+
* actions (delete, etc.) so the dialog visually warns the operator.
|
|
19
|
+
*/
|
|
20
|
+
variant?: 'default' | 'danger'
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const props = withDefaults(defineProps<ConfirmDialogProps>(), {
|
|
24
|
+
title: 'Confirm action',
|
|
25
|
+
description: '',
|
|
26
|
+
confirmLabel: 'Confirm',
|
|
27
|
+
cancelLabel: 'Cancel',
|
|
28
|
+
variant: 'default',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
const emits = defineEmits<{
|
|
32
|
+
close: [value: boolean]
|
|
33
|
+
}>()
|
|
34
|
+
|
|
35
|
+
const confirmColor = computed(() =>
|
|
36
|
+
props.variant === 'danger' ? 'error' : 'primary',
|
|
37
|
+
)
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<UModal
|
|
42
|
+
:title="title"
|
|
43
|
+
:description="description"
|
|
44
|
+
:dismissible="false"
|
|
45
|
+
:ui="{ footer: 'justify-end' }"
|
|
46
|
+
>
|
|
47
|
+
<template #footer>
|
|
48
|
+
<UButton
|
|
49
|
+
:label="cancelLabel"
|
|
50
|
+
color="neutral"
|
|
51
|
+
variant="outline"
|
|
52
|
+
@click="emits('close', false)"
|
|
53
|
+
/>
|
|
54
|
+
<UButton
|
|
55
|
+
:label="confirmLabel"
|
|
56
|
+
:color="confirmColor"
|
|
57
|
+
@click="emits('close', true)"
|
|
58
|
+
/>
|
|
59
|
+
</template>
|
|
60
|
+
</UModal>
|
|
61
|
+
</template>
|
|
@@ -25,6 +25,7 @@ const emit = defineEmits<{
|
|
|
25
25
|
|
|
26
26
|
const { apiBase } = useApi()
|
|
27
27
|
const toast = useToast()
|
|
28
|
+
const confirmDialog = useConfirmDialog()
|
|
28
29
|
|
|
29
30
|
const detail = ref<DetailResponse | null>(null)
|
|
30
31
|
const editing = ref(false)
|
|
@@ -117,12 +118,14 @@ async function saveEdit() {
|
|
|
117
118
|
|
|
118
119
|
async function deletePersona() {
|
|
119
120
|
if (!props.personaId) return
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
121
|
+
const ok = await confirmDialog({
|
|
122
|
+
title: `Delete persona "${detail.value?.name ?? 'Unknown'}"?`,
|
|
123
|
+
description:
|
|
124
|
+
'Removes it from the JSON store. The Obsidian file (if any) is '
|
|
125
|
+
+ 'left in place — delete manually from Obsidian if you want it gone.',
|
|
126
|
+
confirmLabel: 'Delete persona',
|
|
127
|
+
variant: 'danger',
|
|
128
|
+
})
|
|
126
129
|
if (!ok) return
|
|
127
130
|
deleting.value = true
|
|
128
131
|
try {
|
|
@@ -147,12 +150,16 @@ async function deletePersona() {
|
|
|
147
150
|
}
|
|
148
151
|
}
|
|
149
152
|
|
|
150
|
-
function closeDrawer() {
|
|
153
|
+
async function closeDrawer() {
|
|
151
154
|
if (editing.value && !saving.value) {
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
155
|
+
const ok = await confirmDialog({
|
|
156
|
+
title: 'Discard unsaved edits?',
|
|
157
|
+
description: 'Any changes you made will be lost.',
|
|
158
|
+
confirmLabel: 'Discard',
|
|
159
|
+
cancelLabel: 'Keep editing',
|
|
160
|
+
variant: 'danger',
|
|
161
|
+
})
|
|
162
|
+
if (!ok) return
|
|
156
163
|
}
|
|
157
164
|
cancelEdit()
|
|
158
165
|
emit('update:modelValue', false)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// PR75 v2.93.0 — confirm-dialog composable.
|
|
2
|
+
//
|
|
3
|
+
// Async wrapper around the canonical Nuxt UI v4 useOverlay pattern.
|
|
4
|
+
// Replaces every window.confirm() call across the dashboard.
|
|
5
|
+
//
|
|
6
|
+
// Usage:
|
|
7
|
+
// const confirm = useConfirmDialog()
|
|
8
|
+
// const ok = await confirm({
|
|
9
|
+
// title: 'Delete persona',
|
|
10
|
+
// description: 'This removes the persona from the JSON store.',
|
|
11
|
+
// confirmLabel: 'Delete',
|
|
12
|
+
// variant: 'danger',
|
|
13
|
+
// })
|
|
14
|
+
// if (ok) { ... }
|
|
15
|
+
|
|
16
|
+
import { ConfirmDialog } from '#components'
|
|
17
|
+
|
|
18
|
+
export interface ConfirmDialogOptions {
|
|
19
|
+
title: string
|
|
20
|
+
description?: string
|
|
21
|
+
confirmLabel?: string
|
|
22
|
+
cancelLabel?: string
|
|
23
|
+
variant?: 'default' | 'danger'
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const useConfirmDialog = () => {
|
|
27
|
+
const overlay = useOverlay()
|
|
28
|
+
|
|
29
|
+
return async (options: ConfirmDialogOptions): Promise<boolean> => {
|
|
30
|
+
const modal = overlay.create(ConfirmDialog, {
|
|
31
|
+
destroyOnClose: true,
|
|
32
|
+
props: options,
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
const result = await modal.open()
|
|
36
|
+
return result === true
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -1,9 +1,42 @@
|
|
|
1
1
|
<script setup lang="ts">
|
|
2
|
+
// PR76 v2.94.0 — Agent detail modernization.
|
|
3
|
+
// Fixes:
|
|
4
|
+
// - UTabs now has default-value so DNA opens on entry
|
|
5
|
+
// - Modern hero: department-tinted gradient + initials avatar + stats
|
|
6
|
+
// - Activity stats row pulled from PR69 /api/agents/activity
|
|
7
|
+
// - Edit toggle wired to AgentEditDrawer (PUT /api/agents/{id})
|
|
8
|
+
|
|
2
9
|
const route = useRoute()
|
|
3
10
|
const agentId = route.params.id as string
|
|
4
11
|
|
|
5
12
|
const { fetchApi } = useApi()
|
|
6
|
-
const { data: agent, status, error } = fetchApi<any>(`/api/agents/${agentId}`)
|
|
13
|
+
const { data: agent, status, error, refresh } = fetchApi<any>(`/api/agents/${agentId}`)
|
|
14
|
+
|
|
15
|
+
// Per-department activity (PR69 endpoint) for the stats row.
|
|
16
|
+
interface ActivityRow {
|
|
17
|
+
call_count: number
|
|
18
|
+
total_cost_usd: number | null
|
|
19
|
+
total_tokens_in: number
|
|
20
|
+
total_tokens_out: number
|
|
21
|
+
}
|
|
22
|
+
const { data: activityData } = fetchApi<{
|
|
23
|
+
by_department: Record<string, ActivityRow>
|
|
24
|
+
period: string
|
|
25
|
+
}>('/api/agents/activity?period=week')
|
|
26
|
+
const deptActivity = computed<ActivityRow | null>(() =>
|
|
27
|
+
(activityData.value?.by_department?.[agent.value?.department ?? ''] ?? null),
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
// PR76 — edit drawer state
|
|
31
|
+
const editOpen = ref(false)
|
|
32
|
+
|
|
33
|
+
function openEditor() {
|
|
34
|
+
editOpen.value = true
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
async function onAgentSaved() {
|
|
38
|
+
await refresh()
|
|
39
|
+
}
|
|
7
40
|
|
|
8
41
|
// --- Labels & mappings ---
|
|
9
42
|
|
|
@@ -94,6 +127,55 @@ const tabs = [
|
|
|
94
127
|
{ label: 'Authority', value: 'authority', icon: 'i-lucide-shield' },
|
|
95
128
|
{ label: 'Expertise', value: 'expertise', icon: 'i-lucide-award' },
|
|
96
129
|
]
|
|
130
|
+
|
|
131
|
+
// PR76 — hero helpers
|
|
132
|
+
|
|
133
|
+
const initials = computed<string>(() => {
|
|
134
|
+
const name = agent.value?.name ?? ''
|
|
135
|
+
if (!name) return '·'
|
|
136
|
+
const parts = name.trim().split(/\s+/)
|
|
137
|
+
if (parts.length === 1) return (parts[0] ?? '').slice(0, 2).toUpperCase()
|
|
138
|
+
return ((parts[0]?.[0] ?? '') + (parts[parts.length - 1]?.[0] ?? '')).toUpperCase()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
// Per-department gradient hex pair (from + to). Picked once per dept
|
|
142
|
+
// so the same dept always renders the same hero tint.
|
|
143
|
+
const DEPT_GRADIENTS: Record<string, [string, string]> = {
|
|
144
|
+
brand: ['from-fuchsia-500/30', 'to-purple-600/10'],
|
|
145
|
+
marketing: ['from-pink-500/30', 'to-rose-600/10'],
|
|
146
|
+
dev: ['from-blue-500/30', 'to-cyan-600/10'],
|
|
147
|
+
ecom: ['from-amber-500/30', 'to-orange-600/10'],
|
|
148
|
+
finance: ['from-emerald-500/30', 'to-green-600/10'],
|
|
149
|
+
strategy: ['from-indigo-500/30', 'to-violet-600/10'],
|
|
150
|
+
kb: ['from-teal-500/30', 'to-cyan-600/10'],
|
|
151
|
+
ops: ['from-slate-500/30', 'to-gray-600/10'],
|
|
152
|
+
pm: ['from-sky-500/30', 'to-blue-600/10'],
|
|
153
|
+
saas: ['from-violet-500/30', 'to-indigo-600/10'],
|
|
154
|
+
landing: ['from-orange-500/30', 'to-red-600/10'],
|
|
155
|
+
content: ['from-rose-500/30', 'to-pink-600/10'],
|
|
156
|
+
community: ['from-yellow-500/30', 'to-amber-600/10'],
|
|
157
|
+
sales: ['from-red-500/30', 'to-orange-600/10'],
|
|
158
|
+
leadership: ['from-purple-500/30', 'to-pink-600/10'],
|
|
159
|
+
org: ['from-gray-500/30', 'to-slate-600/10'],
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const heroGradientClasses = computed(() => {
|
|
163
|
+
const dept = agent.value?.department ?? ''
|
|
164
|
+
const [from, to] = DEPT_GRADIENTS[dept] ?? ['from-primary/20', 'to-primary/5']
|
|
165
|
+
return `bg-gradient-to-br ${from} ${to}`
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
function formatCost(value: number | null | undefined): string {
|
|
169
|
+
if (value === null || value === undefined) return 'n/a'
|
|
170
|
+
if (value === 0) return '$0'
|
|
171
|
+
if (value < 0.01) return `$${value.toFixed(4)}`
|
|
172
|
+
return `$${value.toFixed(2)}`
|
|
173
|
+
}
|
|
174
|
+
function formatTokens(n: number): string {
|
|
175
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
|
176
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
|
177
|
+
return n.toString()
|
|
178
|
+
}
|
|
97
179
|
</script>
|
|
98
180
|
|
|
99
181
|
<template>
|
|
@@ -136,37 +218,87 @@ const tabs = [
|
|
|
136
218
|
</div>
|
|
137
219
|
|
|
138
220
|
<!-- Content -->
|
|
139
|
-
<div v-else class="space-y-
|
|
140
|
-
<!-- =====
|
|
141
|
-
<section
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<div class="flex
|
|
146
|
-
<
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
221
|
+
<div v-else class="space-y-6 pb-12">
|
|
222
|
+
<!-- ===== HERO ===== -->
|
|
223
|
+
<section
|
|
224
|
+
class="relative overflow-hidden rounded-2xl border border-default p-6 md:p-8"
|
|
225
|
+
:class="heroGradientClasses"
|
|
226
|
+
>
|
|
227
|
+
<div class="flex items-start gap-5">
|
|
228
|
+
<div class="shrink-0 size-20 rounded-2xl bg-default/80 border border-default flex items-center justify-center shadow-lg backdrop-blur-sm">
|
|
229
|
+
<span class="text-2xl font-bold tracking-tight text-highlighted">{{ initials }}</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div class="flex-1 min-w-0 space-y-2">
|
|
232
|
+
<div class="flex items-start justify-between gap-3 flex-wrap">
|
|
233
|
+
<div class="min-w-0">
|
|
234
|
+
<h1 class="text-3xl md:text-4xl font-bold tracking-tight text-highlighted">
|
|
235
|
+
{{ agent.name }}
|
|
236
|
+
</h1>
|
|
237
|
+
<p class="text-base md:text-lg text-muted mt-0.5">{{ agent.role }}</p>
|
|
238
|
+
</div>
|
|
239
|
+
<UButton
|
|
240
|
+
label="Edit"
|
|
241
|
+
icon="i-lucide-pencil"
|
|
242
|
+
size="sm"
|
|
243
|
+
@click="openEditor"
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
<div class="flex flex-wrap items-center gap-2 pt-1">
|
|
247
|
+
<UBadge :label="agent.department" variant="subtle" />
|
|
248
|
+
<UBadge
|
|
249
|
+
:label="`Tier ${agent.tier} — ${tierLabel[agent.tier] ?? ''}`"
|
|
250
|
+
variant="subtle"
|
|
251
|
+
:color="(tierColor[agent.tier] ?? 'neutral') as any"
|
|
252
|
+
/>
|
|
253
|
+
<UBadge
|
|
254
|
+
v-if="agent.expertise_depth"
|
|
255
|
+
:label="agent.expertise_depth"
|
|
256
|
+
variant="subtle"
|
|
257
|
+
:color="(depthColor[agent.expertise_depth] ?? 'neutral') as any"
|
|
258
|
+
/>
|
|
259
|
+
<UBadge
|
|
260
|
+
v-if="agent.expertise_years"
|
|
261
|
+
:label="`${agent.expertise_years}y experience`"
|
|
262
|
+
variant="outline"
|
|
263
|
+
/>
|
|
264
|
+
<UBadge v-if="agent.mbti" :label="agent.mbti" variant="soft" size="xs" />
|
|
265
|
+
</div>
|
|
266
|
+
<p class="text-xs text-muted/60 font-mono select-all pt-2">{{ agent.id }}</p>
|
|
267
|
+
</div>
|
|
163
268
|
</div>
|
|
269
|
+
</section>
|
|
164
270
|
|
|
165
|
-
|
|
271
|
+
<!-- ===== STATS ROW ===== -->
|
|
272
|
+
<section class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
273
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
274
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">7d calls (dept)</p>
|
|
275
|
+
<p class="text-2xl font-bold">{{ deptActivity?.call_count ?? 0 }}</p>
|
|
276
|
+
</div>
|
|
277
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
278
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">7d cost</p>
|
|
279
|
+
<p class="text-2xl font-bold">{{ formatCost(deptActivity?.total_cost_usd) }}</p>
|
|
280
|
+
</div>
|
|
281
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
282
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Tokens (in/out)</p>
|
|
283
|
+
<p class="text-lg font-semibold">
|
|
284
|
+
{{ formatTokens(deptActivity?.total_tokens_in ?? 0) }} /
|
|
285
|
+
{{ formatTokens(deptActivity?.total_tokens_out ?? 0) }}
|
|
286
|
+
</p>
|
|
287
|
+
</div>
|
|
288
|
+
<div class="rounded-xl border border-default p-4 bg-elevated/20">
|
|
289
|
+
<p class="text-xs font-semibold text-muted uppercase tracking-wider mb-1">Linked personas</p>
|
|
290
|
+
<p class="text-2xl font-bold">{{ agent.linked_personas?.length ?? 0 }}</p>
|
|
291
|
+
</div>
|
|
166
292
|
</section>
|
|
167
293
|
|
|
294
|
+
<AgentEditDrawer
|
|
295
|
+
v-model="editOpen"
|
|
296
|
+
:agent="agent"
|
|
297
|
+
@saved="onAgentSaved"
|
|
298
|
+
/>
|
|
299
|
+
|
|
168
300
|
<!-- ===== TABS ===== -->
|
|
169
|
-
<UTabs :items="tabs" class="w-full">
|
|
301
|
+
<UTabs :items="tabs" default-value="dna" class="w-full">
|
|
170
302
|
<template #content="{ item }">
|
|
171
303
|
<!-- ===== TAB: DNA ===== -->
|
|
172
304
|
<div v-if="item.value === 'dna'" class="space-y-6 mt-6">
|
|
@@ -336,14 +336,18 @@ const vectorSearchActive = computed(() =>
|
|
|
336
336
|
|
|
337
337
|
const deletingSource = ref<string | null>(null)
|
|
338
338
|
|
|
339
|
+
const confirmDialog = useConfirmDialog()
|
|
340
|
+
|
|
339
341
|
async function askDeleteSource(source: string) {
|
|
340
342
|
if (!source) return
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
343
|
+
const ok = await confirmDialog({
|
|
344
|
+
title: 'Delete every indexed chunk from this source?',
|
|
345
|
+
description:
|
|
346
|
+
`${source}\n\nRemoves the source from search results but does NOT `
|
|
347
|
+
+ 'delete the original file. You can re-ingest later if needed.',
|
|
348
|
+
confirmLabel: 'Delete chunks',
|
|
349
|
+
variant: 'danger',
|
|
350
|
+
})
|
|
347
351
|
if (!ok) return
|
|
348
352
|
await deleteSource(source)
|
|
349
353
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -258,12 +258,104 @@ def agent_detail(agent_id: str):
|
|
|
258
258
|
expertise = raw.get("expertise", {})
|
|
259
259
|
base["expertise_depth"] = expertise.get("depth", "")
|
|
260
260
|
base["expertise_years"] = expertise.get("years_equivalent", 0)
|
|
261
|
+
base["frameworks"] = raw.get("frameworks", [])
|
|
262
|
+
base["expertise_domains"] = raw.get("expertise_domains", [])
|
|
263
|
+
# PR76 v2.94.0 — linked_personas: persona IDs the agent
|
|
264
|
+
# draws from. Empty when not yet edited.
|
|
265
|
+
base["linked_personas"] = raw.get("linked_personas", [])
|
|
266
|
+
base["_yaml_path"] = str(yaml_file)
|
|
261
267
|
except Exception:
|
|
262
268
|
pass
|
|
263
269
|
|
|
264
270
|
return base
|
|
265
271
|
|
|
266
272
|
|
|
273
|
+
@app.put("/api/agents/{agent_id}")
|
|
274
|
+
def agent_update(agent_id: str, body: dict):
|
|
275
|
+
"""PR76 v2.94.0 — edit an agent. Updates the YAML file with
|
|
276
|
+
editable fields from body. Preserves untouched fields.
|
|
277
|
+
"""
|
|
278
|
+
if not isinstance(body, dict):
|
|
279
|
+
return {"error": "body must be an object"}
|
|
280
|
+
agents = _load_agents()
|
|
281
|
+
base = None
|
|
282
|
+
for a in agents:
|
|
283
|
+
if a.get("id") == agent_id:
|
|
284
|
+
base = dict(a)
|
|
285
|
+
break
|
|
286
|
+
if not base:
|
|
287
|
+
return {"error": "Agent not found"}
|
|
288
|
+
yaml_file = ARKAOS_ROOT / base.get("file", "")
|
|
289
|
+
if not yaml_file.exists():
|
|
290
|
+
return {"error": "Agent YAML file missing on disk"}
|
|
291
|
+
try:
|
|
292
|
+
import yaml as _yaml
|
|
293
|
+
except ImportError:
|
|
294
|
+
return {"error": "PyYAML unavailable"}
|
|
295
|
+
try:
|
|
296
|
+
raw = _yaml.safe_load(yaml_file.read_text(encoding="utf-8")) or {}
|
|
297
|
+
except Exception as exc:
|
|
298
|
+
return {"error": f"YAML parse failed: {exc}"}
|
|
299
|
+
if not isinstance(raw, dict):
|
|
300
|
+
return {"error": "agent YAML is not a mapping"}
|
|
301
|
+
|
|
302
|
+
for top_key in ("name", "role"):
|
|
303
|
+
if top_key in body and isinstance(body[top_key], str):
|
|
304
|
+
raw[top_key] = body[top_key]
|
|
305
|
+
if "tier" in body:
|
|
306
|
+
try:
|
|
307
|
+
raw["tier"] = int(body["tier"])
|
|
308
|
+
except (TypeError, ValueError):
|
|
309
|
+
pass
|
|
310
|
+
if "mental_models" in body and isinstance(body["mental_models"], dict):
|
|
311
|
+
mm = raw.setdefault("mental_models", {}) or {}
|
|
312
|
+
for sub in ("primary", "secondary"):
|
|
313
|
+
if sub in body["mental_models"]:
|
|
314
|
+
mm[sub] = _agent_str_list(body["mental_models"][sub])
|
|
315
|
+
raw["mental_models"] = mm
|
|
316
|
+
if "frameworks" in body:
|
|
317
|
+
raw["frameworks"] = _agent_str_list(body["frameworks"])
|
|
318
|
+
if "expertise_domains" in body:
|
|
319
|
+
raw["expertise_domains"] = _agent_str_list(body["expertise_domains"])
|
|
320
|
+
if "expertise" in body and isinstance(body["expertise"], dict):
|
|
321
|
+
expertise = raw.setdefault("expertise", {}) or {}
|
|
322
|
+
if "depth" in body["expertise"]:
|
|
323
|
+
expertise["depth"] = str(body["expertise"]["depth"])
|
|
324
|
+
if "years_equivalent" in body["expertise"]:
|
|
325
|
+
try:
|
|
326
|
+
expertise["years_equivalent"] = int(body["expertise"]["years_equivalent"])
|
|
327
|
+
except (TypeError, ValueError):
|
|
328
|
+
pass
|
|
329
|
+
raw["expertise"] = expertise
|
|
330
|
+
if "communication" in body and isinstance(body["communication"], dict):
|
|
331
|
+
comm = raw.setdefault("communication", {}) or {}
|
|
332
|
+
for key in ("tone", "vocabulary_level", "preferred_format", "language"):
|
|
333
|
+
if key in body["communication"]:
|
|
334
|
+
comm[key] = str(body["communication"][key])
|
|
335
|
+
if "avoid" in body["communication"]:
|
|
336
|
+
comm["avoid"] = _agent_str_list(body["communication"]["avoid"])
|
|
337
|
+
raw["communication"] = comm
|
|
338
|
+
if "linked_personas" in body:
|
|
339
|
+
raw["linked_personas"] = _agent_str_list(body["linked_personas"])
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
tmp = yaml_file.with_suffix(yaml_file.suffix + ".tmp")
|
|
343
|
+
tmp.write_text(
|
|
344
|
+
_yaml.safe_dump(raw, sort_keys=False, allow_unicode=True, default_flow_style=False),
|
|
345
|
+
encoding="utf-8",
|
|
346
|
+
)
|
|
347
|
+
tmp.replace(yaml_file)
|
|
348
|
+
except OSError as exc:
|
|
349
|
+
return {"error": f"write failed: {exc}"}
|
|
350
|
+
return {"id": agent_id, "updated": True, "yaml_path": str(yaml_file)}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _agent_str_list(value) -> list[str]:
|
|
354
|
+
if not isinstance(value, list):
|
|
355
|
+
return []
|
|
356
|
+
return [str(item) for item in value if isinstance(item, (str, int, float))]
|
|
357
|
+
|
|
358
|
+
|
|
267
359
|
@app.get("/api/commands")
|
|
268
360
|
def commands(dept: Optional[str] = Query(None), q: Optional[str] = Query(None)):
|
|
269
361
|
data = _load_commands()
|