arkaos 2.91.0 → 2.92.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/knowledge/__pycache__/vector_store.cpython-313.pyc +0 -0
- package/core/personas/__pycache__/obsidian_store.cpython-313.pyc +0 -0
- package/dashboard/app/components/PersonaDetailDrawer.vue +508 -0
- package/dashboard/app/pages/personas.vue +33 -4
- 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 +106 -1
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.92.0
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,508 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// PR74 v2.92.0 — Persona detail + edit drawer.
|
|
3
|
+
//
|
|
4
|
+
// Click a persona card on the list → this drawer opens with every
|
|
5
|
+
// field visible. Toggle to Edit mode to mutate any field, then save
|
|
6
|
+
// via PUT /api/personas/{id} (writes to both JSON store + Obsidian).
|
|
7
|
+
|
|
8
|
+
import type { Persona } from '~/types'
|
|
9
|
+
|
|
10
|
+
interface DetailResponse extends Persona {
|
|
11
|
+
_source_store?: 'obsidian' | 'json'
|
|
12
|
+
_obsidian_path?: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const props = defineProps<{
|
|
16
|
+
modelValue: boolean
|
|
17
|
+
personaId: string | null
|
|
18
|
+
}>()
|
|
19
|
+
|
|
20
|
+
const emit = defineEmits<{
|
|
21
|
+
(e: 'update:modelValue', value: boolean): void
|
|
22
|
+
(e: 'saved', persona: Persona): void
|
|
23
|
+
(e: 'deleted', personaId: string): void
|
|
24
|
+
}>()
|
|
25
|
+
|
|
26
|
+
const { apiBase } = useApi()
|
|
27
|
+
const toast = useToast()
|
|
28
|
+
|
|
29
|
+
const detail = ref<DetailResponse | null>(null)
|
|
30
|
+
const editing = ref(false)
|
|
31
|
+
const draft = ref<Persona | null>(null)
|
|
32
|
+
const saving = ref(false)
|
|
33
|
+
const deleting = ref(false)
|
|
34
|
+
const loading = ref(false)
|
|
35
|
+
const loadError = ref<string | null>(null)
|
|
36
|
+
|
|
37
|
+
watch(
|
|
38
|
+
() => [props.modelValue, props.personaId] as const,
|
|
39
|
+
async ([open, id]) => {
|
|
40
|
+
if (!open || !id) {
|
|
41
|
+
detail.value = null
|
|
42
|
+
editing.value = false
|
|
43
|
+
draft.value = null
|
|
44
|
+
loadError.value = null
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
await loadDetail(id)
|
|
48
|
+
},
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
async function loadDetail(id: string) {
|
|
52
|
+
loading.value = true
|
|
53
|
+
loadError.value = null
|
|
54
|
+
try {
|
|
55
|
+
const data = await $fetch<DetailResponse | { error: string }>(
|
|
56
|
+
`${apiBase}/api/personas/${id}`,
|
|
57
|
+
)
|
|
58
|
+
if ('error' in data && data.error) {
|
|
59
|
+
loadError.value = data.error
|
|
60
|
+
detail.value = null
|
|
61
|
+
} else {
|
|
62
|
+
detail.value = data as DetailResponse
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
loadError.value = err instanceof Error ? err.message : 'unknown error'
|
|
66
|
+
} finally {
|
|
67
|
+
loading.value = false
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function startEdit() {
|
|
72
|
+
if (!detail.value) return
|
|
73
|
+
draft.value = JSON.parse(JSON.stringify(detail.value)) as Persona
|
|
74
|
+
editing.value = true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function cancelEdit() {
|
|
78
|
+
draft.value = null
|
|
79
|
+
editing.value = false
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function saveEdit() {
|
|
83
|
+
if (!draft.value || !props.personaId) return
|
|
84
|
+
saving.value = true
|
|
85
|
+
try {
|
|
86
|
+
const res = await $fetch<{
|
|
87
|
+
id: string
|
|
88
|
+
updated: boolean
|
|
89
|
+
json_written: boolean
|
|
90
|
+
obsidian_path: string | null
|
|
91
|
+
error?: string
|
|
92
|
+
}>(`${apiBase}/api/personas/${props.personaId}`, {
|
|
93
|
+
method: 'PUT',
|
|
94
|
+
body: draft.value,
|
|
95
|
+
})
|
|
96
|
+
if (res.error) throw new Error(res.error)
|
|
97
|
+
toast.add({
|
|
98
|
+
title: 'Persona saved',
|
|
99
|
+
description: res.obsidian_path
|
|
100
|
+
? `Wrote ${res.obsidian_path.split('/').slice(-2).join('/')}`
|
|
101
|
+
: 'Saved to JSON store',
|
|
102
|
+
color: 'success',
|
|
103
|
+
})
|
|
104
|
+
emit('saved', draft.value)
|
|
105
|
+
detail.value = { ...detail.value, ...draft.value } as DetailResponse
|
|
106
|
+
editing.value = false
|
|
107
|
+
} catch (err) {
|
|
108
|
+
toast.add({
|
|
109
|
+
title: 'Save failed',
|
|
110
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
111
|
+
color: 'error',
|
|
112
|
+
})
|
|
113
|
+
} finally {
|
|
114
|
+
saving.value = false
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function deletePersona() {
|
|
119
|
+
if (!props.personaId) return
|
|
120
|
+
if (typeof window === 'undefined') return
|
|
121
|
+
const ok = window.confirm(
|
|
122
|
+
`Delete persona "${detail.value?.name}"?\n\n`
|
|
123
|
+
+ 'This removes it from the JSON store. The Obsidian file (if any) '
|
|
124
|
+
+ 'is left in place — delete manually from Obsidian if you want it gone.',
|
|
125
|
+
)
|
|
126
|
+
if (!ok) return
|
|
127
|
+
deleting.value = true
|
|
128
|
+
try {
|
|
129
|
+
await $fetch(`${apiBase}/api/personas/${props.personaId}`, {
|
|
130
|
+
method: 'DELETE',
|
|
131
|
+
})
|
|
132
|
+
toast.add({
|
|
133
|
+
title: 'Persona deleted',
|
|
134
|
+
description: detail.value?.name ?? '',
|
|
135
|
+
color: 'success',
|
|
136
|
+
})
|
|
137
|
+
emit('deleted', props.personaId)
|
|
138
|
+
emit('update:modelValue', false)
|
|
139
|
+
} catch (err) {
|
|
140
|
+
toast.add({
|
|
141
|
+
title: 'Delete failed',
|
|
142
|
+
description: err instanceof Error ? err.message : 'unknown error',
|
|
143
|
+
color: 'error',
|
|
144
|
+
})
|
|
145
|
+
} finally {
|
|
146
|
+
deleting.value = false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function closeDrawer() {
|
|
151
|
+
if (editing.value && !saving.value) {
|
|
152
|
+
if (typeof window !== 'undefined'
|
|
153
|
+
&& !window.confirm('Discard unsaved edits?')) {
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
cancelEdit()
|
|
158
|
+
emit('update:modelValue', false)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function listToCsv(list: string[] | undefined): string {
|
|
162
|
+
return (list ?? []).join(', ')
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function csvToList(value: string): string[] {
|
|
166
|
+
return value.split(',').map((s) => s.trim()).filter(Boolean)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const mbtiOptions = [
|
|
170
|
+
'INTJ', 'INTP', 'ENTJ', 'ENTP',
|
|
171
|
+
'INFJ', 'INFP', 'ENFJ', 'ENFP',
|
|
172
|
+
'ISTJ', 'ISFJ', 'ESTJ', 'ESFJ',
|
|
173
|
+
'ISTP', 'ISFP', 'ESTP', 'ESFP',
|
|
174
|
+
].map((t) => ({ label: t, value: t }))
|
|
175
|
+
|
|
176
|
+
const discOptions = [
|
|
177
|
+
{ label: 'D — Dominance', value: 'D' },
|
|
178
|
+
{ label: 'I — Influence', value: 'I' },
|
|
179
|
+
{ label: 'S — Steadiness', value: 'S' },
|
|
180
|
+
{ label: 'C — Conscientiousness', value: 'C' },
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
const vocabOptions = [
|
|
184
|
+
{ label: 'Lay (no jargon)', value: 'lay' },
|
|
185
|
+
{ label: 'Specialist (industry terms)', value: 'specialist' },
|
|
186
|
+
{ label: 'Expert (research-level)', value: 'expert' },
|
|
187
|
+
]
|
|
188
|
+
</script>
|
|
189
|
+
|
|
190
|
+
<template>
|
|
191
|
+
<USlideover
|
|
192
|
+
:open="modelValue"
|
|
193
|
+
:ui="{ content: 'max-w-2xl w-full' }"
|
|
194
|
+
@update:open="(v) => v ? null : closeDrawer()"
|
|
195
|
+
>
|
|
196
|
+
<template #content>
|
|
197
|
+
<UCard
|
|
198
|
+
:ui="{
|
|
199
|
+
root: 'h-full flex flex-col rounded-none',
|
|
200
|
+
body: 'flex-1 overflow-y-auto',
|
|
201
|
+
}"
|
|
202
|
+
>
|
|
203
|
+
<template #header>
|
|
204
|
+
<div class="flex items-start justify-between gap-3">
|
|
205
|
+
<div class="min-w-0">
|
|
206
|
+
<h2 class="text-xl font-bold truncate">
|
|
207
|
+
{{ detail?.name ?? 'Persona' }}
|
|
208
|
+
</h2>
|
|
209
|
+
<div class="flex items-center gap-2 mt-1 flex-wrap">
|
|
210
|
+
<UBadge
|
|
211
|
+
v-if="detail?._source_store === 'obsidian'"
|
|
212
|
+
label="From Obsidian"
|
|
213
|
+
icon="i-lucide-file-text"
|
|
214
|
+
color="primary"
|
|
215
|
+
variant="subtle"
|
|
216
|
+
size="xs"
|
|
217
|
+
/>
|
|
218
|
+
<UBadge
|
|
219
|
+
v-else-if="detail?._source_store === 'json'"
|
|
220
|
+
label="JSON store"
|
|
221
|
+
variant="outline"
|
|
222
|
+
size="xs"
|
|
223
|
+
/>
|
|
224
|
+
<span
|
|
225
|
+
v-if="detail?._obsidian_path"
|
|
226
|
+
class="text-xs text-muted font-mono truncate"
|
|
227
|
+
:title="detail._obsidian_path"
|
|
228
|
+
>
|
|
229
|
+
{{ detail._obsidian_path.split('/').slice(-2).join('/') }}
|
|
230
|
+
</span>
|
|
231
|
+
</div>
|
|
232
|
+
</div>
|
|
233
|
+
<div class="flex items-center gap-1 shrink-0">
|
|
234
|
+
<UButton
|
|
235
|
+
v-if="!editing"
|
|
236
|
+
icon="i-lucide-pencil"
|
|
237
|
+
variant="ghost"
|
|
238
|
+
size="sm"
|
|
239
|
+
aria-label="Edit persona"
|
|
240
|
+
@click="startEdit"
|
|
241
|
+
/>
|
|
242
|
+
<UButton
|
|
243
|
+
v-if="!editing"
|
|
244
|
+
icon="i-lucide-trash-2"
|
|
245
|
+
color="error"
|
|
246
|
+
variant="ghost"
|
|
247
|
+
size="sm"
|
|
248
|
+
:loading="deleting"
|
|
249
|
+
aria-label="Delete persona"
|
|
250
|
+
@click="deletePersona"
|
|
251
|
+
/>
|
|
252
|
+
<UButton
|
|
253
|
+
icon="i-lucide-x"
|
|
254
|
+
variant="ghost"
|
|
255
|
+
size="sm"
|
|
256
|
+
aria-label="Close"
|
|
257
|
+
@click="closeDrawer"
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
</div>
|
|
261
|
+
</template>
|
|
262
|
+
|
|
263
|
+
<div v-if="loading" class="flex items-center justify-center py-12">
|
|
264
|
+
<UIcon name="i-lucide-loader-2" class="size-8 animate-spin text-muted" />
|
|
265
|
+
</div>
|
|
266
|
+
|
|
267
|
+
<div v-else-if="loadError" class="flex flex-col items-center justify-center gap-3 py-12">
|
|
268
|
+
<UIcon name="i-lucide-alert-triangle" class="size-10 text-red-500" />
|
|
269
|
+
<p class="text-sm text-muted">{{ loadError }}</p>
|
|
270
|
+
</div>
|
|
271
|
+
|
|
272
|
+
<div v-else-if="detail && !editing" class="space-y-6">
|
|
273
|
+
<p v-if="detail.tagline" class="text-base italic text-muted">
|
|
274
|
+
"{{ detail.tagline }}"
|
|
275
|
+
</p>
|
|
276
|
+
|
|
277
|
+
<section v-if="detail.title || detail.source">
|
|
278
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Identity</h3>
|
|
279
|
+
<dl class="grid grid-cols-3 gap-2 text-sm">
|
|
280
|
+
<dt class="text-muted">Title</dt>
|
|
281
|
+
<dd class="col-span-2">{{ detail.title || '—' }}</dd>
|
|
282
|
+
<dt class="text-muted">Source</dt>
|
|
283
|
+
<dd class="col-span-2 font-mono text-xs">{{ detail.source || '—' }}</dd>
|
|
284
|
+
</dl>
|
|
285
|
+
</section>
|
|
286
|
+
|
|
287
|
+
<section>
|
|
288
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Behavioural DNA</h3>
|
|
289
|
+
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
|
290
|
+
<dt class="text-muted">MBTI</dt>
|
|
291
|
+
<dd>{{ detail.mbti || '—' }}</dd>
|
|
292
|
+
<dt class="text-muted">DISC</dt>
|
|
293
|
+
<dd>
|
|
294
|
+
{{ detail.disc?.primary || '—' }}{{ detail.disc?.secondary ? `/${detail.disc.secondary}` : '' }}
|
|
295
|
+
</dd>
|
|
296
|
+
<dt class="text-muted">Enneagram</dt>
|
|
297
|
+
<dd>
|
|
298
|
+
{{ detail.enneagram?.type ?? '—' }}w{{ detail.enneagram?.wing ?? '?' }}
|
|
299
|
+
</dd>
|
|
300
|
+
</dl>
|
|
301
|
+
<div class="mt-3 space-y-1.5">
|
|
302
|
+
<div
|
|
303
|
+
v-for="trait in ([
|
|
304
|
+
['Openness', detail.big_five?.openness ?? 0],
|
|
305
|
+
['Conscientiousness', detail.big_five?.conscientiousness ?? 0],
|
|
306
|
+
['Extraversion', detail.big_five?.extraversion ?? 0],
|
|
307
|
+
['Agreeableness', detail.big_five?.agreeableness ?? 0],
|
|
308
|
+
['Neuroticism', detail.big_five?.neuroticism ?? 0],
|
|
309
|
+
] as Array<[string, number]>)"
|
|
310
|
+
:key="trait[0]"
|
|
311
|
+
class="flex items-center gap-3"
|
|
312
|
+
>
|
|
313
|
+
<span class="text-xs text-muted w-36 shrink-0">{{ trait[0] }}</span>
|
|
314
|
+
<div class="flex-1 h-2 rounded-full bg-muted/15 overflow-hidden">
|
|
315
|
+
<div class="h-2 rounded-full bg-primary" :style="{ width: `${trait[1]}%` }" />
|
|
316
|
+
</div>
|
|
317
|
+
<span class="text-xs font-mono w-10 text-right">{{ trait[1] }}</span>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
</section>
|
|
321
|
+
|
|
322
|
+
<section v-if="detail.mental_models?.length">
|
|
323
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
|
|
324
|
+
Mental models ({{ detail.mental_models.length }})
|
|
325
|
+
</h3>
|
|
326
|
+
<div class="flex flex-wrap gap-1.5">
|
|
327
|
+
<UBadge
|
|
328
|
+
v-for="m in detail.mental_models"
|
|
329
|
+
:key="m"
|
|
330
|
+
:label="m"
|
|
331
|
+
variant="outline"
|
|
332
|
+
size="xs"
|
|
333
|
+
/>
|
|
334
|
+
</div>
|
|
335
|
+
</section>
|
|
336
|
+
|
|
337
|
+
<section v-if="detail.expertise_domains?.length">
|
|
338
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
|
|
339
|
+
Expertise ({{ detail.expertise_domains.length }})
|
|
340
|
+
</h3>
|
|
341
|
+
<div class="flex flex-wrap gap-1.5">
|
|
342
|
+
<UBadge
|
|
343
|
+
v-for="e in detail.expertise_domains"
|
|
344
|
+
:key="e"
|
|
345
|
+
:label="e"
|
|
346
|
+
variant="soft"
|
|
347
|
+
size="xs"
|
|
348
|
+
/>
|
|
349
|
+
</div>
|
|
350
|
+
</section>
|
|
351
|
+
|
|
352
|
+
<section v-if="detail.frameworks?.length">
|
|
353
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
|
|
354
|
+
Frameworks ({{ detail.frameworks.length }})
|
|
355
|
+
</h3>
|
|
356
|
+
<div class="flex flex-wrap gap-1.5">
|
|
357
|
+
<UBadge
|
|
358
|
+
v-for="f in detail.frameworks"
|
|
359
|
+
:key="f"
|
|
360
|
+
:label="f"
|
|
361
|
+
variant="outline"
|
|
362
|
+
size="xs"
|
|
363
|
+
/>
|
|
364
|
+
</div>
|
|
365
|
+
</section>
|
|
366
|
+
|
|
367
|
+
<section v-if="detail.key_quotes?.length">
|
|
368
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">
|
|
369
|
+
Key quotes ({{ detail.key_quotes.length }})
|
|
370
|
+
</h3>
|
|
371
|
+
<ul class="space-y-2">
|
|
372
|
+
<li
|
|
373
|
+
v-for="q in detail.key_quotes"
|
|
374
|
+
:key="q"
|
|
375
|
+
class="text-sm italic text-muted border-l-2 border-primary/30 pl-3"
|
|
376
|
+
>
|
|
377
|
+
"{{ q }}"
|
|
378
|
+
</li>
|
|
379
|
+
</ul>
|
|
380
|
+
</section>
|
|
381
|
+
|
|
382
|
+
<section v-if="detail.communication">
|
|
383
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted mb-2">Communication</h3>
|
|
384
|
+
<dl class="grid grid-cols-3 gap-2 text-sm">
|
|
385
|
+
<dt class="text-muted">Tone</dt>
|
|
386
|
+
<dd class="col-span-2">{{ detail.communication.tone || '—' }}</dd>
|
|
387
|
+
<dt class="text-muted">Vocabulary</dt>
|
|
388
|
+
<dd class="col-span-2">{{ detail.communication.vocabulary_level || '—' }}</dd>
|
|
389
|
+
</dl>
|
|
390
|
+
</section>
|
|
391
|
+
</div>
|
|
392
|
+
|
|
393
|
+
<div v-else-if="draft" class="space-y-5">
|
|
394
|
+
<section class="space-y-3">
|
|
395
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Identity</h3>
|
|
396
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
397
|
+
<UFormField label="Name" required>
|
|
398
|
+
<UInput v-model="draft.name" class="w-full" />
|
|
399
|
+
</UFormField>
|
|
400
|
+
<UFormField label="Title">
|
|
401
|
+
<UInput v-model="draft.title" class="w-full" />
|
|
402
|
+
</UFormField>
|
|
403
|
+
<UFormField label="Source">
|
|
404
|
+
<UInput v-model="draft.source" class="w-full" />
|
|
405
|
+
</UFormField>
|
|
406
|
+
<UFormField label="Tagline">
|
|
407
|
+
<UInput v-model="draft.tagline" class="w-full" />
|
|
408
|
+
</UFormField>
|
|
409
|
+
</div>
|
|
410
|
+
</section>
|
|
411
|
+
|
|
412
|
+
<section class="space-y-3">
|
|
413
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Behavioural DNA</h3>
|
|
414
|
+
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
415
|
+
<UFormField label="MBTI">
|
|
416
|
+
<USelect v-model="draft.mbti" :items="mbtiOptions" class="w-full" />
|
|
417
|
+
</UFormField>
|
|
418
|
+
<UFormField label="DISC primary">
|
|
419
|
+
<USelect v-model="draft.disc.primary" :items="discOptions" class="w-full" />
|
|
420
|
+
</UFormField>
|
|
421
|
+
<UFormField label="Enneagram">
|
|
422
|
+
<UInput v-model.number="draft.enneagram.type" type="number" :min="1" :max="9" class="w-full" />
|
|
423
|
+
</UFormField>
|
|
424
|
+
<UFormField label="Wing">
|
|
425
|
+
<UInput v-model.number="draft.enneagram.wing" type="number" :min="1" :max="9" class="w-full" />
|
|
426
|
+
</UFormField>
|
|
427
|
+
</div>
|
|
428
|
+
<div class="space-y-2">
|
|
429
|
+
<div
|
|
430
|
+
v-for="key in (['openness', 'conscientiousness', 'extraversion', 'agreeableness', 'neuroticism'] as const)"
|
|
431
|
+
:key="key"
|
|
432
|
+
class="flex items-center gap-3"
|
|
433
|
+
>
|
|
434
|
+
<label class="text-xs text-muted w-36 shrink-0 capitalize">{{ key }}</label>
|
|
435
|
+
<UInput
|
|
436
|
+
v-model.number="draft.big_five[key]"
|
|
437
|
+
type="number"
|
|
438
|
+
:min="0"
|
|
439
|
+
:max="100"
|
|
440
|
+
class="w-20"
|
|
441
|
+
/>
|
|
442
|
+
<input
|
|
443
|
+
v-model.number="draft.big_five[key]"
|
|
444
|
+
type="range"
|
|
445
|
+
:min="0"
|
|
446
|
+
:max="100"
|
|
447
|
+
class="flex-1"
|
|
448
|
+
/>
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
</section>
|
|
452
|
+
|
|
453
|
+
<section class="space-y-3">
|
|
454
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Knowledge</h3>
|
|
455
|
+
<UFormField label="Mental models" help="comma-separated">
|
|
456
|
+
<UInput
|
|
457
|
+
:model-value="listToCsv(draft.mental_models)"
|
|
458
|
+
@update:model-value="(v: string) => draft && (draft.mental_models = csvToList(v))"
|
|
459
|
+
class="w-full"
|
|
460
|
+
/>
|
|
461
|
+
</UFormField>
|
|
462
|
+
<UFormField label="Expertise domains" help="comma-separated">
|
|
463
|
+
<UInput
|
|
464
|
+
:model-value="listToCsv(draft.expertise_domains)"
|
|
465
|
+
@update:model-value="(v: string) => draft && (draft.expertise_domains = csvToList(v))"
|
|
466
|
+
class="w-full"
|
|
467
|
+
/>
|
|
468
|
+
</UFormField>
|
|
469
|
+
<UFormField label="Frameworks" help="comma-separated">
|
|
470
|
+
<UInput
|
|
471
|
+
:model-value="listToCsv(draft.frameworks)"
|
|
472
|
+
@update:model-value="(v: string) => draft && (draft.frameworks = csvToList(v))"
|
|
473
|
+
class="w-full"
|
|
474
|
+
/>
|
|
475
|
+
</UFormField>
|
|
476
|
+
</section>
|
|
477
|
+
|
|
478
|
+
<section class="space-y-3">
|
|
479
|
+
<h3 class="text-xs font-semibold uppercase tracking-wider text-muted">Communication</h3>
|
|
480
|
+
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
481
|
+
<UFormField label="Tone">
|
|
482
|
+
<UInput v-model="draft.communication.tone" class="w-full" />
|
|
483
|
+
</UFormField>
|
|
484
|
+
<UFormField label="Vocabulary level">
|
|
485
|
+
<USelect v-model="draft.communication.vocabulary_level" :items="vocabOptions" class="w-full" />
|
|
486
|
+
</UFormField>
|
|
487
|
+
</div>
|
|
488
|
+
</section>
|
|
489
|
+
</div>
|
|
490
|
+
|
|
491
|
+
<template #footer>
|
|
492
|
+
<div v-if="editing" class="flex justify-end gap-2">
|
|
493
|
+
<UButton label="Cancel" variant="ghost" :disabled="saving" @click="cancelEdit" />
|
|
494
|
+
<UButton
|
|
495
|
+
label="Save"
|
|
496
|
+
icon="i-lucide-check"
|
|
497
|
+
:loading="saving"
|
|
498
|
+
@click="saveEdit"
|
|
499
|
+
/>
|
|
500
|
+
</div>
|
|
501
|
+
<p v-else class="text-xs text-muted text-right">
|
|
502
|
+
Click ✏️ to edit. Saves to JSON store + Obsidian vault when configured.
|
|
503
|
+
</p>
|
|
504
|
+
</template>
|
|
505
|
+
</UCard>
|
|
506
|
+
</template>
|
|
507
|
+
</USlideover>
|
|
508
|
+
</template>
|
|
@@ -9,6 +9,23 @@ const { data, status, error, refresh } = fetchApi<{ personas: Persona[]; total:
|
|
|
9
9
|
|
|
10
10
|
const personas = computed(() => data.value?.personas ?? [])
|
|
11
11
|
|
|
12
|
+
// PR74 v2.92.0 — detail/edit drawer state
|
|
13
|
+
const detailOpen = ref(false)
|
|
14
|
+
const detailPersonaId = ref<string | null>(null)
|
|
15
|
+
|
|
16
|
+
function openDetail(persona: Persona) {
|
|
17
|
+
detailPersonaId.value = persona.id
|
|
18
|
+
detailOpen.value = true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function onDetailSaved() {
|
|
22
|
+
await refresh()
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function onDetailDeleted(_id: string) {
|
|
26
|
+
await refresh()
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
// --- Creation mode ---
|
|
13
30
|
// PR62 v2.79.0 — three modes: list (default), wizard (AI builder), manual.
|
|
14
31
|
// The wizard is the new primary path; manual stays as fallback for
|
|
@@ -517,12 +534,24 @@ function discColor(disc: string): string {
|
|
|
517
534
|
/>
|
|
518
535
|
</div>
|
|
519
536
|
|
|
537
|
+
<!-- PR74 v2.92.0 — detail/edit drawer -->
|
|
538
|
+
<PersonaDetailDrawer
|
|
539
|
+
v-model="detailOpen"
|
|
540
|
+
:persona-id="detailPersonaId"
|
|
541
|
+
@saved="onDetailSaved"
|
|
542
|
+
@deleted="onDetailDeleted"
|
|
543
|
+
/>
|
|
544
|
+
|
|
520
545
|
<!-- Personas Grid -->
|
|
521
546
|
<div v-if="personas.length" class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
|
522
547
|
<UCard
|
|
523
548
|
v-for="persona in personas"
|
|
524
549
|
:key="persona.id"
|
|
525
|
-
class="group flex flex-col"
|
|
550
|
+
class="group flex flex-col cursor-pointer hover:border-primary/40 transition-colors"
|
|
551
|
+
role="button"
|
|
552
|
+
tabindex="0"
|
|
553
|
+
@click="openDetail(persona)"
|
|
554
|
+
@keydown.enter="openDetail(persona)"
|
|
526
555
|
>
|
|
527
556
|
<div class="flex flex-col gap-3 flex-1">
|
|
528
557
|
<!-- Header -->
|
|
@@ -583,7 +612,7 @@ function discColor(disc: string): string {
|
|
|
583
612
|
</div>
|
|
584
613
|
|
|
585
614
|
<!-- Actions -->
|
|
586
|
-
<div class="pt-3 mt-auto border-t border-default space-y-3">
|
|
615
|
+
<div class="pt-3 mt-auto border-t border-default space-y-3" @click.stop>
|
|
587
616
|
<div class="flex gap-2">
|
|
588
617
|
<UButton
|
|
589
618
|
label="Clone to Agent"
|
|
@@ -591,7 +620,7 @@ function discColor(disc: string): string {
|
|
|
591
620
|
size="sm"
|
|
592
621
|
variant="solid"
|
|
593
622
|
class="flex-1"
|
|
594
|
-
@click="toggleClone(persona)"
|
|
623
|
+
@click.stop="toggleClone(persona)"
|
|
595
624
|
/>
|
|
596
625
|
<UButton
|
|
597
626
|
icon="i-lucide-trash-2"
|
|
@@ -600,7 +629,7 @@ function discColor(disc: string): string {
|
|
|
600
629
|
color="error"
|
|
601
630
|
:loading="deleting === persona.id"
|
|
602
631
|
aria-label="Delete persona"
|
|
603
|
-
@click="deletePersona(persona)"
|
|
632
|
+
@click.stop="deletePersona(persona)"
|
|
604
633
|
/>
|
|
605
634
|
</div>
|
|
606
635
|
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -785,13 +785,118 @@ def _obsidian_store_available() -> bool:
|
|
|
785
785
|
|
|
786
786
|
@app.get("/api/personas/{persona_id}")
|
|
787
787
|
def persona_detail(persona_id: str):
|
|
788
|
+
"""PR74 v2.92.0 — detail endpoint now checks the Obsidian vault
|
|
789
|
+
in addition to the JSON store, so vault-only personas (Alex
|
|
790
|
+
Hormozi, Naval, etc.) resolve correctly.
|
|
791
|
+
"""
|
|
792
|
+
# Try Obsidian first — it's the source of truth on conflicts.
|
|
793
|
+
try:
|
|
794
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
795
|
+
ob_store = ObsidianPersonaStore()
|
|
796
|
+
if ob_store.available:
|
|
797
|
+
for p in ob_store.list_all():
|
|
798
|
+
if p.id == persona_id:
|
|
799
|
+
payload = p.model_dump()
|
|
800
|
+
payload["_source_store"] = "obsidian"
|
|
801
|
+
payload["_obsidian_path"] = str(
|
|
802
|
+
ob_store.personas_dir / f"{p.name}.md"
|
|
803
|
+
)
|
|
804
|
+
return payload
|
|
805
|
+
except Exception:
|
|
806
|
+
pass
|
|
807
|
+
|
|
788
808
|
mgr = _get_persona_manager()
|
|
789
809
|
if not mgr:
|
|
790
810
|
return {"error": "Persona manager unavailable"}
|
|
791
811
|
p = mgr.get(persona_id)
|
|
792
812
|
if not p:
|
|
793
813
|
return {"error": "Persona not found"}
|
|
794
|
-
|
|
814
|
+
payload = p.model_dump()
|
|
815
|
+
payload["_source_store"] = "json"
|
|
816
|
+
return payload
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
@app.put("/api/personas/{persona_id}")
|
|
820
|
+
def persona_update(persona_id: str, body: dict):
|
|
821
|
+
"""PR74 v2.92.0 — update an existing persona. Writes to both the
|
|
822
|
+
JSON store (when the persona exists there) and the Obsidian vault
|
|
823
|
+
(when configured). Best-effort: a vault write failure does not
|
|
824
|
+
abort the JSON-side success and vice versa.
|
|
825
|
+
|
|
826
|
+
The persona name can change; in that case the old Obsidian file
|
|
827
|
+
is left in place (operator can delete it manually) and a new one
|
|
828
|
+
is created with the updated name.
|
|
829
|
+
"""
|
|
830
|
+
from core.personas.schema import (
|
|
831
|
+
Persona, PersonaDISC, PersonaEnneagram, PersonaBigFive, PersonaCommunication,
|
|
832
|
+
)
|
|
833
|
+
|
|
834
|
+
# Start from existing data so partial-update bodies don't wipe fields.
|
|
835
|
+
existing = persona_detail(persona_id)
|
|
836
|
+
if "error" in existing:
|
|
837
|
+
return existing
|
|
838
|
+
merged = {**existing, **{k: v for k, v in body.items() if v is not None}}
|
|
839
|
+
|
|
840
|
+
name = merged.get("name", "Unknown")
|
|
841
|
+
new_id = (
|
|
842
|
+
merged.get("id")
|
|
843
|
+
or name.lower().replace(" ", "-").replace(".", "")
|
|
844
|
+
)
|
|
845
|
+
|
|
846
|
+
updated = Persona(
|
|
847
|
+
id=new_id,
|
|
848
|
+
name=name,
|
|
849
|
+
title=merged.get("title", ""),
|
|
850
|
+
tagline=merged.get("tagline", ""),
|
|
851
|
+
source=merged.get("source", name),
|
|
852
|
+
disc=PersonaDISC(**(merged.get("disc", {}) or {})),
|
|
853
|
+
enneagram=PersonaEnneagram(**(merged.get("enneagram", {}) or {})),
|
|
854
|
+
big_five=PersonaBigFive(**(merged.get("big_five", {}) or {})),
|
|
855
|
+
mbti=merged.get("mbti", "INTJ"),
|
|
856
|
+
mental_models=merged.get("mental_models", []) or [],
|
|
857
|
+
expertise_domains=merged.get("expertise_domains", []) or [],
|
|
858
|
+
frameworks=merged.get("frameworks", []) or [],
|
|
859
|
+
key_quotes=merged.get("key_quotes", []) or [],
|
|
860
|
+
communication=PersonaCommunication(
|
|
861
|
+
**(merged.get("communication", {}) or {}),
|
|
862
|
+
),
|
|
863
|
+
created_at=merged.get("created_at", ""),
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
# JSON store — only if the persona originally lived there.
|
|
867
|
+
json_written = False
|
|
868
|
+
if existing.get("_source_store") != "obsidian":
|
|
869
|
+
mgr = _get_persona_manager()
|
|
870
|
+
if mgr:
|
|
871
|
+
try:
|
|
872
|
+
mgr.update(persona_id, updated.model_dump())
|
|
873
|
+
json_written = True
|
|
874
|
+
except Exception:
|
|
875
|
+
# Fall through to create if update isn't supported.
|
|
876
|
+
try:
|
|
877
|
+
mgr.create(updated)
|
|
878
|
+
json_written = True
|
|
879
|
+
except Exception:
|
|
880
|
+
json_written = False
|
|
881
|
+
|
|
882
|
+
# Obsidian — always overwrite when vault is configured.
|
|
883
|
+
obsidian_path: str | None = None
|
|
884
|
+
try:
|
|
885
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
886
|
+
ob_store = ObsidianPersonaStore()
|
|
887
|
+
if ob_store.available or ob_store._vault_path is not None:
|
|
888
|
+
written = ob_store.write(updated)
|
|
889
|
+
if written is not None:
|
|
890
|
+
obsidian_path = str(written)
|
|
891
|
+
except Exception:
|
|
892
|
+
pass
|
|
893
|
+
|
|
894
|
+
return {
|
|
895
|
+
"id": updated.id,
|
|
896
|
+
"updated": True,
|
|
897
|
+
"json_written": json_written,
|
|
898
|
+
"obsidian_path": obsidian_path,
|
|
899
|
+
}
|
|
795
900
|
|
|
796
901
|
|
|
797
902
|
@app.post("/api/personas")
|