arkaos 3.2.0 → 3.4.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/core/agents/__pycache__/field_suggester.cpython-313.pyc +0 -0
- package/core/personas/__pycache__/description_drafter.cpython-313.pyc +0 -0
- package/core/personas/description_drafter.py +111 -0
- package/dashboard/app/components/PersonaWizard.vue +102 -10
- package/dashboard/app/pages/agents/index.vue +121 -0
- package/dashboard/app/pages/personas/index.vue +122 -1
- 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 +86 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
3.
|
|
1
|
+
3.4.0
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""AI-powered persona draft from a free-text description (PR83a v3.3.0).
|
|
2
|
+
|
|
3
|
+
Sibling to `core/personas/builder.PersonaBuilder` but does NOT require
|
|
4
|
+
indexed content. Useful when:
|
|
5
|
+
|
|
6
|
+
- The operator wants to model a persona quickly without ingesting sources
|
|
7
|
+
- A YouTuber / author isn't yet in the knowledge base
|
|
8
|
+
- The persona is a synthetic archetype rather than a real person
|
|
9
|
+
|
|
10
|
+
Reuses the same JSON schema and parsing as the vector-driven builder so
|
|
11
|
+
the resulting Persona is interchangeable.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import uuid
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
from core.personas.builder import (
|
|
21
|
+
_PERSONA_SYSTEM_PROMPT,
|
|
22
|
+
_extract_json_object,
|
|
23
|
+
)
|
|
24
|
+
from core.personas.schema import (
|
|
25
|
+
Persona,
|
|
26
|
+
PersonaBigFive,
|
|
27
|
+
PersonaCommunication,
|
|
28
|
+
PersonaDISC,
|
|
29
|
+
PersonaEnneagram,
|
|
30
|
+
)
|
|
31
|
+
from core.runtime.llm_provider import LLMProvider, LLMUnavailable, get_llm_provider
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
_DESCRIPTION_MIN_CHARS = 20
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PersonaDraftError(RuntimeError):
|
|
38
|
+
"""LLM produced unusable output or could not be reached."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class PersonaDraftResult:
|
|
43
|
+
persona: Persona
|
|
44
|
+
provider_name: str
|
|
45
|
+
raw_response: str
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def draft_persona_from_description(
|
|
49
|
+
description: str,
|
|
50
|
+
*,
|
|
51
|
+
name: str,
|
|
52
|
+
source_label: str = "",
|
|
53
|
+
provider: LLMProvider | None = None,
|
|
54
|
+
) -> PersonaDraftResult:
|
|
55
|
+
"""Generate a Persona draft from a free-text description."""
|
|
56
|
+
description = (description or "").strip()
|
|
57
|
+
if not name or not name.strip():
|
|
58
|
+
raise PersonaDraftError("name must not be empty")
|
|
59
|
+
if len(description) < _DESCRIPTION_MIN_CHARS:
|
|
60
|
+
raise PersonaDraftError(
|
|
61
|
+
f"description must be at least {_DESCRIPTION_MIN_CHARS} characters"
|
|
62
|
+
)
|
|
63
|
+
llm = provider or get_llm_provider()
|
|
64
|
+
prompt = _build_prompt(name.strip(), description)
|
|
65
|
+
try:
|
|
66
|
+
resp = llm.complete(prompt, max_tokens=3000, system=_PERSONA_SYSTEM_PROMPT)
|
|
67
|
+
except LLMUnavailable as exc:
|
|
68
|
+
raise PersonaDraftError(str(exc)) from exc
|
|
69
|
+
persona = _parse(name.strip(), source_label.strip() or name.strip(), resp.text)
|
|
70
|
+
return PersonaDraftResult(
|
|
71
|
+
persona=persona, provider_name=llm.name(), raw_response=resp.text,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _build_prompt(name: str, description: str) -> str:
|
|
76
|
+
return (
|
|
77
|
+
f"Person: {name}\n\n"
|
|
78
|
+
f"Description provided by the operator:\n{description}\n\n"
|
|
79
|
+
"Build the persona purely from the description above. If a field is "
|
|
80
|
+
"not implied, choose the closest neutral default rather than "
|
|
81
|
+
"fabricating. NEVER invent quotes — leave key_quotes empty if no "
|
|
82
|
+
"quotes are present in the description."
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _parse(name: str, source_label: str, raw: str) -> Persona:
|
|
87
|
+
data = _extract_json_object(raw)
|
|
88
|
+
if data is None:
|
|
89
|
+
raise PersonaDraftError(
|
|
90
|
+
f"LLM did not return a JSON object; raw response: {raw[:200]!r}"
|
|
91
|
+
)
|
|
92
|
+
try:
|
|
93
|
+
return Persona(
|
|
94
|
+
id=str(uuid.uuid4()),
|
|
95
|
+
name=name,
|
|
96
|
+
title=str(data.get("title") or ""),
|
|
97
|
+
tagline=str(data.get("tagline") or ""),
|
|
98
|
+
source=source_label,
|
|
99
|
+
disc=PersonaDISC(**(data.get("disc") or {})),
|
|
100
|
+
enneagram=PersonaEnneagram(**(data.get("enneagram") or {})),
|
|
101
|
+
big_five=PersonaBigFive(**(data.get("big_five") or {})),
|
|
102
|
+
mbti=str(data.get("mbti") or "").upper() or "INTJ",
|
|
103
|
+
mental_models=[str(x) for x in (data.get("mental_models") or [])],
|
|
104
|
+
expertise_domains=[str(x) for x in (data.get("expertise_domains") or [])],
|
|
105
|
+
frameworks=[str(x) for x in (data.get("frameworks") or [])],
|
|
106
|
+
key_quotes=[str(x) for x in (data.get("key_quotes") or [])],
|
|
107
|
+
communication=PersonaCommunication(**(data.get("communication") or {})),
|
|
108
|
+
created_at=datetime.now(timezone.utc).isoformat(),
|
|
109
|
+
)
|
|
110
|
+
except (TypeError, ValueError) as exc:
|
|
111
|
+
raise PersonaDraftError(f"persona schema mismatch: {exc}") from exc
|
|
@@ -37,6 +37,12 @@ const sourceLineCount = computed(() =>
|
|
|
37
37
|
.length,
|
|
38
38
|
)
|
|
39
39
|
|
|
40
|
+
// PR83a v3.3.0 — Mode 3: build from a free-text description (no chunks).
|
|
41
|
+
type Mode = 'sources' | 'existing' | 'description'
|
|
42
|
+
const mode = ref<Mode>('sources')
|
|
43
|
+
const description = ref('')
|
|
44
|
+
const descriptionLength = computed(() => description.value.trim().length)
|
|
45
|
+
|
|
40
46
|
// ─── Step 2 state ────────────────────────────────────────────────────────
|
|
41
47
|
const ingestJobs = ref<Array<{
|
|
42
48
|
source: string
|
|
@@ -81,7 +87,14 @@ const tierOptions = [
|
|
|
81
87
|
|
|
82
88
|
|
|
83
89
|
async function startIngest() {
|
|
84
|
-
if (
|
|
90
|
+
if (mode.value === 'description') {
|
|
91
|
+
// PR83a — no ingest, no chunks. Build directly from description.
|
|
92
|
+
if (descriptionLength.value < 20 || !name.value.trim()) return
|
|
93
|
+
step.value = 3
|
|
94
|
+
await runDescriptionBuild()
|
|
95
|
+
return
|
|
96
|
+
}
|
|
97
|
+
if (mode.value === 'existing' || skipIngest.value) {
|
|
85
98
|
// Jump straight to step 3 — operator says content is already indexed.
|
|
86
99
|
step.value = 3
|
|
87
100
|
await runBuild()
|
|
@@ -166,6 +179,36 @@ onBeforeUnmount(() => {
|
|
|
166
179
|
// ─── Step 3: build the persona draft ────────────────────────────────────
|
|
167
180
|
|
|
168
181
|
|
|
182
|
+
async function runDescriptionBuild() {
|
|
183
|
+
building.value = true
|
|
184
|
+
buildError.value = null
|
|
185
|
+
draft.value = null
|
|
186
|
+
try {
|
|
187
|
+
const res = await $fetch<{ persona: Persona, provider_name: string, error?: string }>(
|
|
188
|
+
`${apiBase}/api/personas/draft`,
|
|
189
|
+
{
|
|
190
|
+
method: 'POST',
|
|
191
|
+
body: {
|
|
192
|
+
name: name.value.trim(),
|
|
193
|
+
description: description.value.trim(),
|
|
194
|
+
source_label: sourceLabel.value.trim() || name.value.trim(),
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
)
|
|
198
|
+
if ('error' in res && typeof (res as any).error === 'string') {
|
|
199
|
+
throw new Error((res as any).error)
|
|
200
|
+
}
|
|
201
|
+
draft.value = res.persona
|
|
202
|
+
chunksUsed.value = 0
|
|
203
|
+
step.value = 4
|
|
204
|
+
} catch (err) {
|
|
205
|
+
buildError.value = err instanceof Error ? err.message : 'unknown error'
|
|
206
|
+
} finally {
|
|
207
|
+
building.value = false
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
|
|
169
212
|
async function runBuild() {
|
|
170
213
|
building.value = true
|
|
171
214
|
buildError.value = null
|
|
@@ -308,7 +351,28 @@ function backToStep1() {
|
|
|
308
351
|
/>
|
|
309
352
|
</UFormField>
|
|
310
353
|
|
|
354
|
+
<UFormField label="How should we generate this persona?">
|
|
355
|
+
<div class="grid grid-cols-1 md:grid-cols-3 gap-2">
|
|
356
|
+
<button
|
|
357
|
+
v-for="m in ([
|
|
358
|
+
{ key: 'sources', title: 'Ingest sources', desc: 'YouTube, articles, PDFs — best fidelity' },
|
|
359
|
+
{ key: 'existing', title: 'Existing chunks', desc: 'Use what is already indexed' },
|
|
360
|
+
{ key: 'description', title: 'From description', desc: 'No sources — pure description' },
|
|
361
|
+
] as const)"
|
|
362
|
+
:key="m.key"
|
|
363
|
+
type="button"
|
|
364
|
+
class="text-left rounded-lg border p-3 transition-colors"
|
|
365
|
+
:class="mode === m.key ? 'border-primary bg-primary/5' : 'border-default hover:border-primary/40'"
|
|
366
|
+
@click="mode = m.key"
|
|
367
|
+
>
|
|
368
|
+
<p class="text-sm font-semibold">{{ m.title }}</p>
|
|
369
|
+
<p class="text-xs text-muted mt-1">{{ m.desc }}</p>
|
|
370
|
+
</button>
|
|
371
|
+
</div>
|
|
372
|
+
</UFormField>
|
|
373
|
+
|
|
311
374
|
<UFormField
|
|
375
|
+
v-if="mode === 'sources'"
|
|
312
376
|
label="Sources (one URL per line)"
|
|
313
377
|
help="YouTube videos, articles, PDFs, blog posts about this person. The builder will search the indexed chunks and synthesise their behavioural DNA. Up to 50 sources per batch."
|
|
314
378
|
>
|
|
@@ -317,28 +381,56 @@ function backToStep1() {
|
|
|
317
381
|
:rows="6"
|
|
318
382
|
placeholder="https://www.youtube.com/watch?v=... https://example.com/article https://example.com/paper.pdf"
|
|
319
383
|
class="w-full font-mono text-sm"
|
|
320
|
-
:disabled="skipIngest"
|
|
321
384
|
/>
|
|
322
385
|
</UFormField>
|
|
323
386
|
|
|
324
|
-
<
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
387
|
+
<UFormField
|
|
388
|
+
v-else-if="mode === 'description'"
|
|
389
|
+
label="Description"
|
|
390
|
+
help="Plain-text description of the person — their style, beliefs, what they do, how they talk. The LLM uses this verbatim. Minimum 20 characters."
|
|
391
|
+
>
|
|
392
|
+
<UTextarea
|
|
393
|
+
v-model="description"
|
|
394
|
+
:rows="6"
|
|
395
|
+
placeholder="A direct-response copywriter who treats offers as the only true growth lever. Punchy, allergic to fluff. Loves Hormozi-style hooks."
|
|
396
|
+
class="w-full"
|
|
329
397
|
/>
|
|
398
|
+
</UFormField>
|
|
399
|
+
|
|
400
|
+
<div
|
|
401
|
+
v-if="mode === 'sources'"
|
|
402
|
+
class="flex items-center justify-between text-xs text-muted"
|
|
403
|
+
>
|
|
404
|
+
<span>{{ sourceLineCount }} source{{ sourceLineCount === 1 ? '' : 's' }} detected</span>
|
|
405
|
+
</div>
|
|
406
|
+
|
|
407
|
+
<div v-else-if="mode === 'existing'" class="text-xs text-muted">
|
|
408
|
+
We will search the vector DB for chunks tagged with this name and synthesise from what we find. Make sure you've ingested content for this person first.
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div v-else-if="mode === 'description'" class="text-xs text-muted">
|
|
412
|
+
{{ descriptionLength }} character{{ descriptionLength === 1 ? '' : 's' }} ·
|
|
413
|
+
{{ descriptionLength >= 20 ? 'ready' : `${20 - descriptionLength} more needed` }}
|
|
330
414
|
</div>
|
|
331
415
|
|
|
332
416
|
<div class="flex justify-end gap-2 pt-4">
|
|
333
417
|
<UButton
|
|
334
|
-
:label="
|
|
418
|
+
:label="(
|
|
419
|
+
mode === 'sources' ? `Index ${sourceLineCount} source${sourceLineCount === 1 ? '' : 's'} & build`
|
|
420
|
+
: mode === 'existing' ? 'Generate from existing knowledge'
|
|
421
|
+
: 'Generate from description'
|
|
422
|
+
)"
|
|
335
423
|
icon="i-lucide-arrow-right"
|
|
336
|
-
:disabled="
|
|
424
|
+
:disabled="(
|
|
425
|
+
!name.trim()
|
|
426
|
+
|| (mode === 'sources' && (sourceLineCount === 0 || sourceLineCount > 50))
|
|
427
|
+
|| (mode === 'description' && descriptionLength < 20)
|
|
428
|
+
)"
|
|
337
429
|
size="md"
|
|
338
430
|
@click="startIngest"
|
|
339
431
|
/>
|
|
340
432
|
</div>
|
|
341
|
-
<p v-if="sourceLineCount > 50" class="text-xs text-red-400">
|
|
433
|
+
<p v-if="mode === 'sources' && sourceLineCount > 50" class="text-xs text-red-400">
|
|
342
434
|
Over the 50-source cap. Trim the list before continuing.
|
|
343
435
|
</p>
|
|
344
436
|
</div>
|
|
@@ -138,6 +138,7 @@ const tierColor = (tier: number) => {
|
|
|
138
138
|
}
|
|
139
139
|
|
|
140
140
|
const columns: TableColumn<Agent>[] = [
|
|
141
|
+
{ id: 'select', header: '' },
|
|
141
142
|
{ accessorKey: 'name', header: 'Name' },
|
|
142
143
|
{ accessorKey: 'role', header: 'Role' },
|
|
143
144
|
{ accessorKey: 'department', header: 'Department' },
|
|
@@ -155,6 +156,78 @@ const columns: TableColumn<Agent>[] = [
|
|
|
155
156
|
function goToAgent(id: string) {
|
|
156
157
|
navigateTo(`/agents/${id}`)
|
|
157
158
|
}
|
|
159
|
+
|
|
160
|
+
// PR83b v3.4.0 — bulk selection + delete.
|
|
161
|
+
const confirmDialog = useConfirmDialog()
|
|
162
|
+
const selected = ref<Set<string>>(new Set())
|
|
163
|
+
const bulkDeleting = ref(false)
|
|
164
|
+
|
|
165
|
+
function toggleSelected(id: string) {
|
|
166
|
+
if (selected.value.has(id)) selected.value.delete(id)
|
|
167
|
+
else selected.value.add(id)
|
|
168
|
+
selected.value = new Set(selected.value)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function toggleAllVisible() {
|
|
172
|
+
const visibleIds = paginatedAgents.value.map((a) => a.id)
|
|
173
|
+
const allSelected = visibleIds.every((id) => selected.value.has(id))
|
|
174
|
+
const next = new Set(selected.value)
|
|
175
|
+
for (const id of visibleIds) {
|
|
176
|
+
if (allSelected) next.delete(id)
|
|
177
|
+
else next.add(id)
|
|
178
|
+
}
|
|
179
|
+
selected.value = next
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const allVisibleSelected = computed(() => {
|
|
183
|
+
const visibleIds = paginatedAgents.value.map((a) => a.id)
|
|
184
|
+
return visibleIds.length > 0 && visibleIds.every((id) => selected.value.has(id))
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
function clearSelection() {
|
|
188
|
+
selected.value = new Set()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async function bulkDelete() {
|
|
192
|
+
if (selected.value.size === 0) return
|
|
193
|
+
const ids = Array.from(selected.value)
|
|
194
|
+
const ok = await confirmDialog({
|
|
195
|
+
title: `Delete ${ids.length} agent${ids.length === 1 ? '' : 's'}?`,
|
|
196
|
+
description: 'YAML files will be removed from disk. This cannot be undone. Tier 0 agents are protected and will be skipped.',
|
|
197
|
+
confirmLabel: `Delete ${ids.length}`,
|
|
198
|
+
cancelLabel: 'Cancel',
|
|
199
|
+
variant: 'danger',
|
|
200
|
+
})
|
|
201
|
+
if (!ok) return
|
|
202
|
+
bulkDeleting.value = true
|
|
203
|
+
const results = await Promise.allSettled(
|
|
204
|
+
ids.map((id) =>
|
|
205
|
+
$fetch<{ deleted?: boolean, error?: string }>(`${apiBase}/api/agents/${id}`, {
|
|
206
|
+
method: 'DELETE',
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
)
|
|
210
|
+
const successes = results.filter(
|
|
211
|
+
(r) => r.status === 'fulfilled' && r.value.deleted,
|
|
212
|
+
).length
|
|
213
|
+
const failures = ids.length - successes
|
|
214
|
+
if (successes > 0) {
|
|
215
|
+
toast.add({
|
|
216
|
+
title: `Deleted ${successes} agent${successes === 1 ? '' : 's'}`,
|
|
217
|
+
description: failures > 0 ? `${failures} skipped (Tier 0 or missing)` : undefined,
|
|
218
|
+
color: failures > 0 ? 'warning' : 'success',
|
|
219
|
+
})
|
|
220
|
+
} else {
|
|
221
|
+
toast.add({
|
|
222
|
+
title: 'Nothing deleted',
|
|
223
|
+
description: 'All targets were protected or missing.',
|
|
224
|
+
color: 'error',
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
clearSelection()
|
|
228
|
+
bulkDeleting.value = false
|
|
229
|
+
await refreshAll()
|
|
230
|
+
}
|
|
158
231
|
</script>
|
|
159
232
|
|
|
160
233
|
<template>
|
|
@@ -233,6 +306,21 @@ function goToAgent(id: string) {
|
|
|
233
306
|
td: 'border-b border-default'
|
|
234
307
|
}"
|
|
235
308
|
>
|
|
309
|
+
<template #select-header>
|
|
310
|
+
<UCheckbox
|
|
311
|
+
:model-value="allVisibleSelected"
|
|
312
|
+
aria-label="Select all visible"
|
|
313
|
+
@update:model-value="toggleAllVisible"
|
|
314
|
+
/>
|
|
315
|
+
</template>
|
|
316
|
+
<template #select-cell="{ row }">
|
|
317
|
+
<UCheckbox
|
|
318
|
+
:model-value="selected.has(row.original.id)"
|
|
319
|
+
:aria-label="`Select ${row.original.name}`"
|
|
320
|
+
@update:model-value="() => toggleSelected(row.original.id)"
|
|
321
|
+
@click.stop
|
|
322
|
+
/>
|
|
323
|
+
</template>
|
|
236
324
|
<template #name-cell="{ row }">
|
|
237
325
|
<button class="text-left font-medium text-primary hover:underline" @click="goToAgent(row.original.id)">
|
|
238
326
|
{{ row.original.name }}
|
|
@@ -277,6 +365,39 @@ function goToAgent(id: string) {
|
|
|
277
365
|
</template>
|
|
278
366
|
</UTable>
|
|
279
367
|
|
|
368
|
+
<Transition
|
|
369
|
+
enter-active-class="transition ease-out duration-150"
|
|
370
|
+
enter-from-class="translate-y-4 opacity-0"
|
|
371
|
+
enter-to-class="translate-y-0 opacity-100"
|
|
372
|
+
leave-active-class="transition ease-in duration-100"
|
|
373
|
+
leave-from-class="translate-y-0 opacity-100"
|
|
374
|
+
leave-to-class="translate-y-4 opacity-0"
|
|
375
|
+
>
|
|
376
|
+
<div
|
|
377
|
+
v-if="selected.size > 0"
|
|
378
|
+
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-xl border border-default bg-elevated shadow-lg px-4 py-2"
|
|
379
|
+
>
|
|
380
|
+
<span class="text-sm font-semibold">
|
|
381
|
+
{{ selected.size }} selected
|
|
382
|
+
</span>
|
|
383
|
+
<UButton
|
|
384
|
+
label="Clear"
|
|
385
|
+
variant="ghost"
|
|
386
|
+
size="xs"
|
|
387
|
+
@click="clearSelection"
|
|
388
|
+
/>
|
|
389
|
+
<div class="h-5 w-px bg-default" />
|
|
390
|
+
<UButton
|
|
391
|
+
label="Delete"
|
|
392
|
+
icon="i-lucide-trash-2"
|
|
393
|
+
color="error"
|
|
394
|
+
size="sm"
|
|
395
|
+
:loading="bulkDeleting"
|
|
396
|
+
@click="bulkDelete"
|
|
397
|
+
/>
|
|
398
|
+
</div>
|
|
399
|
+
</Transition>
|
|
400
|
+
|
|
280
401
|
<div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
|
|
281
402
|
<UPagination
|
|
282
403
|
:page="page"
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
import type { TableColumn } from '@nuxt/ui'
|
|
9
9
|
import type { Persona } from '~/types'
|
|
10
10
|
|
|
11
|
-
const { fetchApi } = useApi()
|
|
11
|
+
const { fetchApi, apiBase } = useApi()
|
|
12
12
|
|
|
13
13
|
const { data, status, error, refresh } = await fetchApi<{
|
|
14
14
|
personas: Persona[]
|
|
@@ -118,6 +118,7 @@ function agentCount(personaId: string): number {
|
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
const columns: TableColumn<Persona>[] = [
|
|
121
|
+
{ id: 'select', header: '' },
|
|
121
122
|
{ accessorKey: 'name', header: 'Name' },
|
|
122
123
|
{ accessorKey: 'title', header: 'Title' },
|
|
123
124
|
{ accessorKey: 'source', header: 'Source' },
|
|
@@ -131,6 +132,78 @@ const columns: TableColumn<Persona>[] = [
|
|
|
131
132
|
function goToPersona(id: string) {
|
|
132
133
|
navigateTo(`/personas/${id}`)
|
|
133
134
|
}
|
|
135
|
+
|
|
136
|
+
// PR83b v3.4.0 — bulk selection + delete.
|
|
137
|
+
const toast = useToast()
|
|
138
|
+
const confirmDialog = useConfirmDialog()
|
|
139
|
+
const selected = ref<Set<string>>(new Set())
|
|
140
|
+
const bulkDeleting = ref(false)
|
|
141
|
+
|
|
142
|
+
function toggleSelected(id: string) {
|
|
143
|
+
if (selected.value.has(id)) selected.value.delete(id)
|
|
144
|
+
else selected.value.add(id)
|
|
145
|
+
selected.value = new Set(selected.value)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function toggleAllVisible() {
|
|
149
|
+
const visibleIds = paginatedPersonas.value.map((p) => p.id)
|
|
150
|
+
const allSelected = visibleIds.every((id) => selected.value.has(id))
|
|
151
|
+
const next = new Set(selected.value)
|
|
152
|
+
for (const id of visibleIds) {
|
|
153
|
+
if (allSelected) next.delete(id)
|
|
154
|
+
else next.add(id)
|
|
155
|
+
}
|
|
156
|
+
selected.value = next
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const allVisibleSelected = computed(() => {
|
|
160
|
+
const visibleIds = paginatedPersonas.value.map((p) => p.id)
|
|
161
|
+
return visibleIds.length > 0 && visibleIds.every((id) => selected.value.has(id))
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
function clearSelection() {
|
|
165
|
+
selected.value = new Set()
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function bulkDelete() {
|
|
169
|
+
if (selected.value.size === 0) return
|
|
170
|
+
const ids = Array.from(selected.value)
|
|
171
|
+
const ok = await confirmDialog({
|
|
172
|
+
title: `Delete ${ids.length} persona${ids.length === 1 ? '' : 's'}?`,
|
|
173
|
+
description: 'Personas will be removed from the JSON store and the Obsidian vault. This cannot be undone.',
|
|
174
|
+
confirmLabel: `Delete ${ids.length}`,
|
|
175
|
+
cancelLabel: 'Cancel',
|
|
176
|
+
variant: 'danger',
|
|
177
|
+
})
|
|
178
|
+
if (!ok) return
|
|
179
|
+
bulkDeleting.value = true
|
|
180
|
+
const results = await Promise.allSettled(
|
|
181
|
+
ids.map((id) =>
|
|
182
|
+
$fetch<{ deleted?: boolean, error?: string }>(`${apiBase}/api/personas/${id}`, {
|
|
183
|
+
method: 'DELETE',
|
|
184
|
+
}),
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
const successes = results.filter(
|
|
188
|
+
(r) => r.status === 'fulfilled' && r.value.deleted,
|
|
189
|
+
).length
|
|
190
|
+
const failures = ids.length - successes
|
|
191
|
+
if (successes > 0) {
|
|
192
|
+
toast.add({
|
|
193
|
+
title: `Deleted ${successes} persona${successes === 1 ? '' : 's'}`,
|
|
194
|
+
description: failures > 0 ? `${failures} failed` : undefined,
|
|
195
|
+
color: failures > 0 ? 'warning' : 'success',
|
|
196
|
+
})
|
|
197
|
+
} else {
|
|
198
|
+
toast.add({
|
|
199
|
+
title: 'Nothing deleted',
|
|
200
|
+
color: 'error',
|
|
201
|
+
})
|
|
202
|
+
}
|
|
203
|
+
clearSelection()
|
|
204
|
+
bulkDeleting.value = false
|
|
205
|
+
await refreshAll()
|
|
206
|
+
}
|
|
134
207
|
</script>
|
|
135
208
|
|
|
136
209
|
<template>
|
|
@@ -218,6 +291,21 @@ function goToPersona(id: string) {
|
|
|
218
291
|
}"
|
|
219
292
|
@select="(row: Persona) => goToPersona(row.id)"
|
|
220
293
|
>
|
|
294
|
+
<template #select-header>
|
|
295
|
+
<UCheckbox
|
|
296
|
+
:model-value="allVisibleSelected"
|
|
297
|
+
aria-label="Select all visible"
|
|
298
|
+
@update:model-value="toggleAllVisible"
|
|
299
|
+
/>
|
|
300
|
+
</template>
|
|
301
|
+
<template #select-cell="{ row }">
|
|
302
|
+
<UCheckbox
|
|
303
|
+
:model-value="selected.has(row.original.id)"
|
|
304
|
+
:aria-label="`Select ${row.original.name}`"
|
|
305
|
+
@update:model-value="() => toggleSelected(row.original.id)"
|
|
306
|
+
@click.stop
|
|
307
|
+
/>
|
|
308
|
+
</template>
|
|
221
309
|
<template #name-cell="{ row }">
|
|
222
310
|
<span class="font-medium">{{ row.original.name }}</span>
|
|
223
311
|
</template>
|
|
@@ -282,6 +370,39 @@ function goToPersona(id: string) {
|
|
|
282
370
|
</template>
|
|
283
371
|
</UTable>
|
|
284
372
|
|
|
373
|
+
<Transition
|
|
374
|
+
enter-active-class="transition ease-out duration-150"
|
|
375
|
+
enter-from-class="translate-y-4 opacity-0"
|
|
376
|
+
enter-to-class="translate-y-0 opacity-100"
|
|
377
|
+
leave-active-class="transition ease-in duration-100"
|
|
378
|
+
leave-from-class="translate-y-0 opacity-100"
|
|
379
|
+
leave-to-class="translate-y-4 opacity-0"
|
|
380
|
+
>
|
|
381
|
+
<div
|
|
382
|
+
v-if="selected.size > 0"
|
|
383
|
+
class="fixed bottom-6 left-1/2 -translate-x-1/2 z-50 flex items-center gap-3 rounded-xl border border-default bg-elevated shadow-lg px-4 py-2"
|
|
384
|
+
>
|
|
385
|
+
<span class="text-sm font-semibold">
|
|
386
|
+
{{ selected.size }} selected
|
|
387
|
+
</span>
|
|
388
|
+
<UButton
|
|
389
|
+
label="Clear"
|
|
390
|
+
variant="ghost"
|
|
391
|
+
size="xs"
|
|
392
|
+
@click="clearSelection"
|
|
393
|
+
/>
|
|
394
|
+
<div class="h-5 w-px bg-default" />
|
|
395
|
+
<UButton
|
|
396
|
+
label="Delete"
|
|
397
|
+
icon="i-lucide-trash-2"
|
|
398
|
+
color="error"
|
|
399
|
+
size="sm"
|
|
400
|
+
:loading="bulkDeleting"
|
|
401
|
+
@click="bulkDelete"
|
|
402
|
+
/>
|
|
403
|
+
</div>
|
|
404
|
+
</Transition>
|
|
405
|
+
|
|
285
406
|
<div v-if="totalPages > 1" class="flex items-center justify-center mt-6">
|
|
286
407
|
<UPagination
|
|
287
408
|
:page="page"
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -1094,6 +1094,55 @@ def persona_clone(persona_id: str, body: dict = {}):
|
|
|
1094
1094
|
return {"agent_id": agent_id, "department": department, "file": f"departments/{department}/agents/{agent_id}.yaml"}
|
|
1095
1095
|
|
|
1096
1096
|
|
|
1097
|
+
@app.delete("/api/agents/{agent_id}")
|
|
1098
|
+
def agent_delete(agent_id: str):
|
|
1099
|
+
"""PR83b v3.4.0 — delete an agent's YAML file.
|
|
1100
|
+
|
|
1101
|
+
Refuses to delete Tier 0 (C-Suite) agents — those are governance
|
|
1102
|
+
fixtures and need direct YAML removal to make the intent explicit.
|
|
1103
|
+
|
|
1104
|
+
Resolves the YAML location two ways:
|
|
1105
|
+
1. From the cached registry (covers seeded agents)
|
|
1106
|
+
2. By scanning departments/*/agents/<id>.yaml (covers
|
|
1107
|
+
freshly-created agents that aren't in the registry yet)
|
|
1108
|
+
"""
|
|
1109
|
+
yaml_file = _resolve_agent_yaml(agent_id)
|
|
1110
|
+
if yaml_file is None:
|
|
1111
|
+
return {"error": "Agent not found"}
|
|
1112
|
+
tier = _agent_tier_from_yaml(yaml_file)
|
|
1113
|
+
if tier == 0:
|
|
1114
|
+
return {"error": "Cannot delete Tier 0 (C-Suite) agents from the dashboard"}
|
|
1115
|
+
try:
|
|
1116
|
+
yaml_file.unlink()
|
|
1117
|
+
except OSError as exc:
|
|
1118
|
+
return {"error": f"delete failed: {exc}"}
|
|
1119
|
+
return {"deleted": True, "id": agent_id, "yaml_path": str(yaml_file)}
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _resolve_agent_yaml(agent_id: str) -> Optional[Path]:
|
|
1123
|
+
# 1. Check the cached registry first.
|
|
1124
|
+
for a in _load_agents():
|
|
1125
|
+
if a.get("id") == agent_id:
|
|
1126
|
+
candidate = ARKAOS_ROOT / a.get("file", "")
|
|
1127
|
+
if candidate.exists():
|
|
1128
|
+
return candidate
|
|
1129
|
+
# 2. Filesystem scan — covers freshly-created files.
|
|
1130
|
+
dept_root = ARKAOS_ROOT / "departments"
|
|
1131
|
+
if dept_root.exists():
|
|
1132
|
+
for path in dept_root.glob(f"*/agents/{agent_id}.yaml"):
|
|
1133
|
+
return path
|
|
1134
|
+
return None
|
|
1135
|
+
|
|
1136
|
+
|
|
1137
|
+
def _agent_tier_from_yaml(yaml_file: Path) -> int:
|
|
1138
|
+
try:
|
|
1139
|
+
import yaml as _yaml
|
|
1140
|
+
raw = _yaml.safe_load(yaml_file.read_text(encoding="utf-8")) or {}
|
|
1141
|
+
except Exception:
|
|
1142
|
+
return 99
|
|
1143
|
+
return int(raw.get("tier") or 99)
|
|
1144
|
+
|
|
1145
|
+
|
|
1097
1146
|
@app.delete("/api/personas/{persona_id}")
|
|
1098
1147
|
def persona_delete(persona_id: str):
|
|
1099
1148
|
mgr = _get_persona_manager()
|
|
@@ -1827,6 +1876,43 @@ def _build_agent_yaml(
|
|
|
1827
1876
|
return payload
|
|
1828
1877
|
|
|
1829
1878
|
|
|
1879
|
+
# --- AI persona draft from description (PR83a v3.3.0) ---
|
|
1880
|
+
|
|
1881
|
+
@app.post("/api/personas/draft")
|
|
1882
|
+
def personas_draft(body: dict):
|
|
1883
|
+
"""Generate a Persona draft from a free-text description (no vector DB).
|
|
1884
|
+
|
|
1885
|
+
Body: {
|
|
1886
|
+
"description": "...", # min 20 chars
|
|
1887
|
+
"name": "Alex Carter", # required
|
|
1888
|
+
"source_label": "..." # optional
|
|
1889
|
+
}
|
|
1890
|
+
Returns: {"persona": {...}, "provider_name": "..."}
|
|
1891
|
+
|
|
1892
|
+
Sibling to /api/personas/build (which requires indexed chunks). Useful
|
|
1893
|
+
when the operator wants a quick draft without ingesting sources first.
|
|
1894
|
+
The result is NOT saved — operator reviews + POSTs to /api/personas.
|
|
1895
|
+
"""
|
|
1896
|
+
from core.personas.description_drafter import (
|
|
1897
|
+
PersonaDraftError,
|
|
1898
|
+
draft_persona_from_description,
|
|
1899
|
+
)
|
|
1900
|
+
|
|
1901
|
+
description = (body.get("description") or "").strip()
|
|
1902
|
+
name = (body.get("name") or "").strip()
|
|
1903
|
+
source_label = (body.get("source_label") or "").strip()
|
|
1904
|
+
try:
|
|
1905
|
+
res = draft_persona_from_description(
|
|
1906
|
+
description, name=name, source_label=source_label,
|
|
1907
|
+
)
|
|
1908
|
+
except PersonaDraftError as exc:
|
|
1909
|
+
return {"error": str(exc)}
|
|
1910
|
+
return {
|
|
1911
|
+
"persona": res.persona.model_dump(),
|
|
1912
|
+
"provider_name": res.provider_name,
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
|
|
1830
1916
|
# --- AI agent draft from description (PR82b v3.1.0) ---
|
|
1831
1917
|
|
|
1832
1918
|
@app.post("/api/agents/draft")
|