arkaos 2.90.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/knowledge/vector_store.py +93 -30
- package/core/personas/__pycache__/obsidian_store.cpython-313.pyc +0 -0
- package/core/personas/obsidian_store.py +276 -0
- package/dashboard/app/components/PersonaDetailDrawer.vue +508 -0
- package/dashboard/app/pages/knowledge.vue +19 -5
- package/dashboard/app/pages/personas.vue +33 -4
- package/dashboard/app/types/index.d.ts +4 -0
- 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 +182 -7
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
2.
|
|
1
|
+
2.92.0
|
|
Binary file
|
|
@@ -13,24 +13,81 @@ from typing import Any, Optional
|
|
|
13
13
|
from core.knowledge.embedder import embed, embed_batch, EMBEDDING_DIMS
|
|
14
14
|
|
|
15
15
|
|
|
16
|
+
_VEC_UNAVAILABLE_REASON: str = "" # set by _load_vec on failure
|
|
17
|
+
|
|
18
|
+
|
|
16
19
|
def _load_vec(db: sqlite3.Connection) -> bool:
|
|
17
|
-
"""Try to load sqlite-vec extension.
|
|
20
|
+
"""Try to load sqlite-vec extension.
|
|
21
|
+
|
|
22
|
+
PR73 v2.91.0 — failure reason is now captured in a module-level
|
|
23
|
+
string so the dashboard / /api/knowledge/stats can surface why
|
|
24
|
+
vector search is unavailable instead of just "Unavailable".
|
|
25
|
+
Common reasons:
|
|
26
|
+
- sqlite-vec not installed (pip install sqlite-vec)
|
|
27
|
+
- Python sqlite3 built without enable_load_extension (rare on
|
|
28
|
+
homebrew Python; common on system Python on some distros)
|
|
29
|
+
"""
|
|
30
|
+
global _VEC_UNAVAILABLE_REASON
|
|
18
31
|
try:
|
|
19
32
|
db.enable_load_extension(True)
|
|
20
|
-
|
|
33
|
+
except AttributeError:
|
|
34
|
+
_VEC_UNAVAILABLE_REASON = (
|
|
35
|
+
"Python sqlite3 was built without extension loading support. "
|
|
36
|
+
"On macOS, reinstall via Homebrew Python: brew install python."
|
|
37
|
+
)
|
|
38
|
+
return False
|
|
39
|
+
except sqlite3.OperationalError as exc:
|
|
40
|
+
_VEC_UNAVAILABLE_REASON = f"sqlite3 refused extension loading: {exc}"
|
|
41
|
+
return False
|
|
42
|
+
try:
|
|
43
|
+
import sqlite_vec # noqa: PLC0415
|
|
44
|
+
except ImportError:
|
|
45
|
+
_VEC_UNAVAILABLE_REASON = (
|
|
46
|
+
"sqlite-vec package missing. Install with: "
|
|
47
|
+
"pip install sqlite-vec"
|
|
48
|
+
)
|
|
49
|
+
return False
|
|
50
|
+
try:
|
|
21
51
|
sqlite_vec.load(db)
|
|
52
|
+
_VEC_UNAVAILABLE_REASON = ""
|
|
22
53
|
return True
|
|
23
|
-
except
|
|
54
|
+
except Exception as exc: # noqa: BLE001
|
|
55
|
+
_VEC_UNAVAILABLE_REASON = f"sqlite_vec.load failed: {exc}"
|
|
24
56
|
return False
|
|
25
57
|
|
|
26
58
|
|
|
59
|
+
def vec_unavailable_reason() -> str:
|
|
60
|
+
"""Public accessor for the last vec-load failure reason."""
|
|
61
|
+
return _VEC_UNAVAILABLE_REASON
|
|
62
|
+
|
|
63
|
+
|
|
27
64
|
class VectorStore:
|
|
28
|
-
"""SQLite-vec backed vector store for knowledge retrieval.
|
|
65
|
+
"""SQLite-vec backed vector store for knowledge retrieval.
|
|
66
|
+
|
|
67
|
+
PR73 v2.91.0 — connection is opened with ``check_same_thread=False``
|
|
68
|
+
so background ingest workers (knowledge_ingest, knowledge_ingest_bulk
|
|
69
|
+
in scripts/dashboard-api.py) can reuse a long-lived FastAPI-scoped
|
|
70
|
+
store instance without hitting ``sqlite3.ProgrammingError: SQLite
|
|
71
|
+
objects created in a thread can only be used in that same thread``.
|
|
72
|
+
Concurrent writes are serialised via ``_write_lock``; SQLite's WAL
|
|
73
|
+
journal_mode lets readers continue while a writer holds the lock.
|
|
74
|
+
"""
|
|
29
75
|
|
|
30
76
|
def __init__(self, db_path: str | Path = ":memory:") -> None:
|
|
77
|
+
import threading
|
|
31
78
|
self._db_path = str(db_path)
|
|
32
|
-
|
|
79
|
+
# check_same_thread=False — see class docstring.
|
|
80
|
+
self._db = sqlite3.connect(self._db_path, check_same_thread=False)
|
|
33
81
|
self._db.row_factory = sqlite3.Row
|
|
82
|
+
# WAL gives concurrent readers + a single writer at the engine
|
|
83
|
+
# level. The Python lock below serialises our application-level
|
|
84
|
+
# writes per VectorStore instance.
|
|
85
|
+
try:
|
|
86
|
+
self._db.execute("PRAGMA journal_mode=WAL")
|
|
87
|
+
except sqlite3.OperationalError:
|
|
88
|
+
# in-memory DBs don't support WAL — harmless.
|
|
89
|
+
pass
|
|
90
|
+
self._write_lock = threading.Lock()
|
|
34
91
|
self._vec_available = _load_vec(self._db)
|
|
35
92
|
self._init_schema()
|
|
36
93
|
|
|
@@ -102,26 +159,31 @@ class VectorStore:
|
|
|
102
159
|
meta_json = json.dumps(metadata or {})
|
|
103
160
|
count = 0
|
|
104
161
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
(cursor.lastrowid, emb_blob),
|
|
162
|
+
# PR73 v2.91.0 — serialise writes from background ingest threads.
|
|
163
|
+
# check_same_thread=False on the connection lets the threads use
|
|
164
|
+
# `self._db`; the lock ensures only one writer at a time so
|
|
165
|
+
# cursor lastrowid stays consistent.
|
|
166
|
+
with self._write_lock:
|
|
167
|
+
for i, text in enumerate(texts):
|
|
168
|
+
heading = headings[i] if headings and i < len(headings) else ""
|
|
169
|
+
emb_blob = None
|
|
170
|
+
|
|
171
|
+
if embeddings and i < len(embeddings):
|
|
172
|
+
emb_blob = _vec_to_blob(embeddings[i])
|
|
173
|
+
|
|
174
|
+
cursor = self._db.execute(
|
|
175
|
+
"INSERT INTO chunks (text, heading, source, file_hash, metadata, embedding) VALUES (?, ?, ?, ?, ?, ?)",
|
|
176
|
+
(text, heading, source, file_hash, meta_json, emb_blob),
|
|
121
177
|
)
|
|
122
|
-
count += 1
|
|
123
178
|
|
|
124
|
-
|
|
179
|
+
if self._vec_available and emb_blob:
|
|
180
|
+
self._db.execute(
|
|
181
|
+
"INSERT INTO vec_chunks (rowid, embedding) VALUES (?, ?)",
|
|
182
|
+
(cursor.lastrowid, emb_blob),
|
|
183
|
+
)
|
|
184
|
+
count += 1
|
|
185
|
+
|
|
186
|
+
self._db.commit()
|
|
125
187
|
return count
|
|
126
188
|
|
|
127
189
|
def search(self, query: str, top_k: int = 5) -> list[dict]:
|
|
@@ -201,13 +263,14 @@ class VectorStore:
|
|
|
201
263
|
|
|
202
264
|
def remove_file(self, source: str) -> int:
|
|
203
265
|
"""Remove all chunks from a source file."""
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
266
|
+
with self._write_lock:
|
|
267
|
+
if self._vec_available:
|
|
268
|
+
rows = self._db.execute("SELECT id FROM chunks WHERE source = ?", (source,)).fetchall()
|
|
269
|
+
for r in rows:
|
|
270
|
+
self._db.execute("DELETE FROM vec_chunks WHERE rowid = ?", (r["id"],))
|
|
271
|
+
deleted = self._db.execute("DELETE FROM chunks WHERE source = ?", (source,)).rowcount
|
|
272
|
+
self._db.commit()
|
|
273
|
+
return deleted
|
|
211
274
|
|
|
212
275
|
def get_stats(self) -> dict:
|
|
213
276
|
"""Get store statistics."""
|
|
Binary file
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"""Obsidian-backed persona store (PR73 v2.91.0).
|
|
2
|
+
|
|
3
|
+
Reads personas from ``<vaultPath>/Personas/*.md`` and writes new
|
|
4
|
+
personas back to the same folder. The vault path comes from
|
|
5
|
+
``~/.arkaos/profile.json::vaultPath``; when that's empty / missing,
|
|
6
|
+
the store gracefully degrades to a no-op (the JSON store keeps
|
|
7
|
+
working).
|
|
8
|
+
|
|
9
|
+
Per the operator's instruction: "personas podiamos ir buscar a lista
|
|
10
|
+
aqui o que esta no obsidian acho que é o mais sensato e quando criamos
|
|
11
|
+
guardamos no obsidian tambem". This module implements both directions.
|
|
12
|
+
|
|
13
|
+
Schema (frontmatter):
|
|
14
|
+
type: persona
|
|
15
|
+
name: <full name>
|
|
16
|
+
source: <one-line summary>
|
|
17
|
+
title: <role label>
|
|
18
|
+
tagline: <essence>
|
|
19
|
+
mbti: <4-letter>
|
|
20
|
+
disc: { primary, secondary }
|
|
21
|
+
enneagram: { type, wing }
|
|
22
|
+
big_five: { openness, ... }
|
|
23
|
+
mental_models: [list]
|
|
24
|
+
expertise: [list] # legacy alias for expertise_domains
|
|
25
|
+
expertise_domains: [list]
|
|
26
|
+
frameworks: [list]
|
|
27
|
+
key_quotes: [list]
|
|
28
|
+
communication: { tone, vocabulary_level, preferred_format, avoid }
|
|
29
|
+
|
|
30
|
+
Unknown frontmatter keys are ignored. Body markdown is preserved
|
|
31
|
+
when reading but not re-injected into the Persona model (the model
|
|
32
|
+
doesn't carry free-form notes).
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import re
|
|
38
|
+
import uuid
|
|
39
|
+
from datetime import datetime, timezone
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, Iterable
|
|
42
|
+
|
|
43
|
+
from core.personas.schema import (
|
|
44
|
+
Persona,
|
|
45
|
+
PersonaBigFive,
|
|
46
|
+
PersonaCommunication,
|
|
47
|
+
PersonaDISC,
|
|
48
|
+
PersonaEnneagram,
|
|
49
|
+
)
|
|
50
|
+
from core.profile import ProfileManager
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n?(.*)\Z", re.DOTALL)
|
|
54
|
+
_PERSONAS_SUBDIR = "Personas"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_UNSET = object()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ObsidianPersonaStore:
|
|
61
|
+
"""Read / write personas as Markdown files in an Obsidian vault."""
|
|
62
|
+
|
|
63
|
+
def __init__(self, vault_path: Path | None | object = _UNSET) -> None:
|
|
64
|
+
# Distinguish "not passed" (auto-resolve from profile) from
|
|
65
|
+
# "passed as None" (explicitly no vault — tests use this).
|
|
66
|
+
if vault_path is _UNSET:
|
|
67
|
+
self._vault_path = self._resolve_vault_path()
|
|
68
|
+
else:
|
|
69
|
+
self._vault_path = vault_path # type: ignore[assignment]
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def available(self) -> bool:
|
|
73
|
+
return bool(self._vault_path and self.personas_dir.exists())
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def personas_dir(self) -> Path:
|
|
77
|
+
return (self._vault_path or Path()) / _PERSONAS_SUBDIR
|
|
78
|
+
|
|
79
|
+
@staticmethod
|
|
80
|
+
def _resolve_vault_path() -> Path | None:
|
|
81
|
+
try:
|
|
82
|
+
profile = ProfileManager().read()
|
|
83
|
+
except Exception: # noqa: BLE001
|
|
84
|
+
return None
|
|
85
|
+
if not profile.vaultPath:
|
|
86
|
+
return None
|
|
87
|
+
path = Path(profile.vaultPath).expanduser()
|
|
88
|
+
return path if path.exists() else None
|
|
89
|
+
|
|
90
|
+
# ─── Read ────────────────────────────────────────────────────────────
|
|
91
|
+
|
|
92
|
+
def list_all(self) -> list[Persona]:
|
|
93
|
+
"""Return every parseable persona in <vault>/Personas/."""
|
|
94
|
+
if not self.available:
|
|
95
|
+
return []
|
|
96
|
+
out: list[Persona] = []
|
|
97
|
+
for md_path in sorted(self.personas_dir.glob("*.md")):
|
|
98
|
+
persona = self._read_file(md_path)
|
|
99
|
+
if persona is not None:
|
|
100
|
+
out.append(persona)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
def _read_file(self, md_path: Path) -> Persona | None:
|
|
104
|
+
try:
|
|
105
|
+
text = md_path.read_text(encoding="utf-8", errors="replace")
|
|
106
|
+
except OSError:
|
|
107
|
+
return None
|
|
108
|
+
fm = self._parse_frontmatter(text)
|
|
109
|
+
if not fm:
|
|
110
|
+
return None
|
|
111
|
+
# MOC files etc. can also live in this folder; skip anything not
|
|
112
|
+
# explicitly typed as persona.
|
|
113
|
+
if str(fm.get("type", "")).lower() != "persona":
|
|
114
|
+
return None
|
|
115
|
+
return self._frontmatter_to_persona(fm, md_path)
|
|
116
|
+
|
|
117
|
+
@staticmethod
|
|
118
|
+
def _parse_frontmatter(text: str) -> dict[str, Any] | None:
|
|
119
|
+
match = _FRONTMATTER_RE.match(text)
|
|
120
|
+
if not match:
|
|
121
|
+
return None
|
|
122
|
+
try:
|
|
123
|
+
import yaml # noqa: PLC0415
|
|
124
|
+
except ImportError:
|
|
125
|
+
return None
|
|
126
|
+
try:
|
|
127
|
+
data = yaml.safe_load(match.group(1)) or {}
|
|
128
|
+
except Exception: # noqa: BLE001 — yaml parsers raise many shapes
|
|
129
|
+
return None
|
|
130
|
+
return data if isinstance(data, dict) else None
|
|
131
|
+
|
|
132
|
+
@staticmethod
|
|
133
|
+
def _frontmatter_to_persona(fm: dict, md_path: Path) -> Persona:
|
|
134
|
+
name = str(fm.get("name") or md_path.stem)
|
|
135
|
+
disc_fm = fm.get("disc") if isinstance(fm.get("disc"), dict) else {}
|
|
136
|
+
enneagram_fm = fm.get("enneagram") if isinstance(fm.get("enneagram"), dict) else {}
|
|
137
|
+
big_five_fm = fm.get("big_five") if isinstance(fm.get("big_five"), dict) else {}
|
|
138
|
+
comm_fm = fm.get("communication") if isinstance(fm.get("communication"), dict) else {}
|
|
139
|
+
expertise = _as_str_list(fm.get("expertise_domains") or fm.get("expertise"))
|
|
140
|
+
|
|
141
|
+
return Persona(
|
|
142
|
+
id=ObsidianPersonaStore._slug_from_name(name),
|
|
143
|
+
name=name,
|
|
144
|
+
title=str(fm.get("title") or ""),
|
|
145
|
+
tagline=str(fm.get("tagline") or ""),
|
|
146
|
+
source=str(fm.get("source") or name),
|
|
147
|
+
disc=PersonaDISC(**_filter_known(PersonaDISC, disc_fm)),
|
|
148
|
+
enneagram=PersonaEnneagram(**_filter_known(PersonaEnneagram, enneagram_fm)),
|
|
149
|
+
big_five=PersonaBigFive(**_filter_known(PersonaBigFive, big_five_fm)),
|
|
150
|
+
mbti=str(fm.get("mbti") or "INTJ"),
|
|
151
|
+
mental_models=_as_str_list(fm.get("mental_models")),
|
|
152
|
+
expertise_domains=expertise,
|
|
153
|
+
frameworks=_as_str_list(fm.get("frameworks")),
|
|
154
|
+
key_quotes=_as_str_list(fm.get("key_quotes")),
|
|
155
|
+
communication=PersonaCommunication(
|
|
156
|
+
**_filter_known(PersonaCommunication, comm_fm),
|
|
157
|
+
),
|
|
158
|
+
created_at=str(fm.get("created_at") or ""),
|
|
159
|
+
updated_at=str(fm.get("updated_at") or ""),
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
@staticmethod
|
|
163
|
+
def _slug_from_name(name: str) -> str:
|
|
164
|
+
return (
|
|
165
|
+
name.lower()
|
|
166
|
+
.replace(" ", "-")
|
|
167
|
+
.replace(".", "")
|
|
168
|
+
.replace("'", "")
|
|
169
|
+
) or str(uuid.uuid4())
|
|
170
|
+
|
|
171
|
+
# ─── Write ───────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
def write(self, persona: Persona) -> Path | None:
|
|
174
|
+
"""Persist a persona as <vault>/Personas/<name>.md.
|
|
175
|
+
|
|
176
|
+
Returns the path written, or None when the vault isn't
|
|
177
|
+
configured. Existing files are overwritten — the operator
|
|
178
|
+
is the source of truth.
|
|
179
|
+
"""
|
|
180
|
+
if not self._vault_path:
|
|
181
|
+
return None
|
|
182
|
+
self.personas_dir.mkdir(parents=True, exist_ok=True)
|
|
183
|
+
target = self.personas_dir / f"{persona.name}.md"
|
|
184
|
+
body = self._render(persona)
|
|
185
|
+
tmp = target.with_suffix(target.suffix + ".tmp")
|
|
186
|
+
try:
|
|
187
|
+
tmp.write_text(body, encoding="utf-8")
|
|
188
|
+
tmp.replace(target)
|
|
189
|
+
except OSError:
|
|
190
|
+
try:
|
|
191
|
+
tmp.unlink()
|
|
192
|
+
except FileNotFoundError:
|
|
193
|
+
pass
|
|
194
|
+
return None
|
|
195
|
+
return target
|
|
196
|
+
|
|
197
|
+
@staticmethod
|
|
198
|
+
def _render(persona: Persona) -> str:
|
|
199
|
+
try:
|
|
200
|
+
import yaml # noqa: PLC0415
|
|
201
|
+
except ImportError:
|
|
202
|
+
yaml = None # type: ignore[assignment]
|
|
203
|
+
now = datetime.now(timezone.utc).isoformat()
|
|
204
|
+
frontmatter = {
|
|
205
|
+
"type": "persona",
|
|
206
|
+
"name": persona.name,
|
|
207
|
+
"source": persona.source or persona.name,
|
|
208
|
+
"title": persona.title,
|
|
209
|
+
"tagline": persona.tagline,
|
|
210
|
+
"mbti": persona.mbti,
|
|
211
|
+
"disc": {
|
|
212
|
+
"primary": persona.disc.primary,
|
|
213
|
+
"secondary": persona.disc.secondary,
|
|
214
|
+
},
|
|
215
|
+
"enneagram": {
|
|
216
|
+
"type": persona.enneagram.type,
|
|
217
|
+
"wing": persona.enneagram.wing,
|
|
218
|
+
},
|
|
219
|
+
"big_five": {
|
|
220
|
+
"openness": persona.big_five.openness,
|
|
221
|
+
"conscientiousness": persona.big_five.conscientiousness,
|
|
222
|
+
"extraversion": persona.big_five.extraversion,
|
|
223
|
+
"agreeableness": persona.big_five.agreeableness,
|
|
224
|
+
"neuroticism": persona.big_five.neuroticism,
|
|
225
|
+
},
|
|
226
|
+
"mental_models": list(persona.mental_models),
|
|
227
|
+
"expertise_domains": list(persona.expertise_domains),
|
|
228
|
+
"frameworks": list(persona.frameworks),
|
|
229
|
+
"key_quotes": list(persona.key_quotes),
|
|
230
|
+
"communication": {
|
|
231
|
+
"tone": persona.communication.tone,
|
|
232
|
+
"vocabulary_level": persona.communication.vocabulary_level,
|
|
233
|
+
"preferred_format": persona.communication.preferred_format,
|
|
234
|
+
"avoid": list(persona.communication.avoid),
|
|
235
|
+
},
|
|
236
|
+
"created_at": persona.created_at or now,
|
|
237
|
+
"updated_at": now,
|
|
238
|
+
}
|
|
239
|
+
if yaml is not None:
|
|
240
|
+
fm_block = yaml.safe_dump(
|
|
241
|
+
frontmatter,
|
|
242
|
+
sort_keys=False,
|
|
243
|
+
allow_unicode=True,
|
|
244
|
+
default_flow_style=False,
|
|
245
|
+
)
|
|
246
|
+
else:
|
|
247
|
+
fm_block = "\n".join(f"{k}: {v}" for k, v in frontmatter.items()) + "\n"
|
|
248
|
+
body = "# " + persona.name + "\n\n"
|
|
249
|
+
if persona.tagline:
|
|
250
|
+
body += f"> {persona.tagline}\n\n"
|
|
251
|
+
body += "## Source\n\n" + (persona.source or persona.name) + "\n\n"
|
|
252
|
+
if persona.mental_models:
|
|
253
|
+
body += "## Mental Models\n\n"
|
|
254
|
+
body += "\n".join(f"- {m}" for m in persona.mental_models) + "\n\n"
|
|
255
|
+
if persona.key_quotes:
|
|
256
|
+
body += "## Key Quotes\n\n"
|
|
257
|
+
body += "\n".join(f"> {q}" for q in persona.key_quotes) + "\n\n"
|
|
258
|
+
return f"---\n{fm_block}---\n\n{body}"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
# ─── Helpers ────────────────────────────────────────────────────────────
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _as_str_list(value: Any) -> list[str]:
|
|
265
|
+
if not isinstance(value, list):
|
|
266
|
+
return []
|
|
267
|
+
return [str(item) for item in value if isinstance(item, (str, int, float))]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _filter_known(model: type, data: dict) -> dict:
|
|
271
|
+
"""Drop keys the Pydantic model doesn't declare so unknown
|
|
272
|
+
frontmatter keys (e.g. older schema variants) don't crash."""
|
|
273
|
+
if not isinstance(data, dict):
|
|
274
|
+
return {}
|
|
275
|
+
known: Iterable[str] = model.model_fields.keys() # type: ignore[attr-defined]
|
|
276
|
+
return {k: v for k, v in data.items() if k in known}
|
|
@@ -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>
|
|
@@ -325,6 +325,13 @@ function formatScore(score: number): string {
|
|
|
325
325
|
return `${(score * 100).toFixed(0)}%`
|
|
326
326
|
}
|
|
327
327
|
|
|
328
|
+
// PR73 v2.91.0 — `vec_available` is the canonical PR47-era flag from
|
|
329
|
+
// the new VectorStore; `vss_available` is the legacy field name from
|
|
330
|
+
// earlier sqlite-vss builds. Treat either as "active".
|
|
331
|
+
const vectorSearchActive = computed(() =>
|
|
332
|
+
Boolean(stats.value?.vec_available || stats.value?.vss_available),
|
|
333
|
+
)
|
|
334
|
+
|
|
328
335
|
// PR71 v2.88.0 — delete all chunks from a given source.
|
|
329
336
|
|
|
330
337
|
const deletingSource = ref<string | null>(null)
|
|
@@ -419,9 +426,9 @@ function escapeRegex(value: string): string {
|
|
|
419
426
|
</template>
|
|
420
427
|
<template #trailing>
|
|
421
428
|
<UBadge
|
|
422
|
-
v-if="stats?.vss_available !== undefined"
|
|
423
|
-
:label="
|
|
424
|
-
:color="
|
|
429
|
+
v-if="stats?.vec_available !== undefined || stats?.vss_available !== undefined"
|
|
430
|
+
:label="vectorSearchActive ? 'Vector Active' : 'Vector Off'"
|
|
431
|
+
:color="vectorSearchActive ? 'success' : 'warning'"
|
|
425
432
|
variant="subtle"
|
|
426
433
|
/>
|
|
427
434
|
</template>
|
|
@@ -768,12 +775,19 @@ function escapeRegex(value: string): string {
|
|
|
768
775
|
</div>
|
|
769
776
|
<div class="rounded-lg border border-default p-4 text-center">
|
|
770
777
|
<UBadge
|
|
771
|
-
:label="
|
|
772
|
-
:color="
|
|
778
|
+
:label="vectorSearchActive ? 'Active' : 'Unavailable'"
|
|
779
|
+
:color="vectorSearchActive ? 'success' : 'warning'"
|
|
773
780
|
variant="subtle"
|
|
774
781
|
size="sm"
|
|
775
782
|
/>
|
|
776
783
|
<p class="text-xs text-muted mt-1">Vector Search</p>
|
|
784
|
+
<p
|
|
785
|
+
v-if="!vectorSearchActive && stats?.vec_unavailable_reason"
|
|
786
|
+
class="text-xs text-yellow-400 mt-2 text-left"
|
|
787
|
+
:title="stats.vec_unavailable_reason"
|
|
788
|
+
>
|
|
789
|
+
{{ stats.vec_unavailable_reason }}
|
|
790
|
+
</p>
|
|
777
791
|
</div>
|
|
778
792
|
</div>
|
|
779
793
|
|
|
@@ -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
|
|
|
@@ -128,6 +128,10 @@ export interface KnowledgeStats {
|
|
|
128
128
|
total_chunks: number
|
|
129
129
|
total_files: number
|
|
130
130
|
vss_available?: boolean
|
|
131
|
+
// PR73 v2.91.0 — vec_available + reason surfaced by /api/knowledge/stats
|
|
132
|
+
vec_available?: boolean
|
|
133
|
+
vec_unavailable_reason?: string
|
|
134
|
+
indexed?: boolean
|
|
131
135
|
areas?: {
|
|
132
136
|
name: string
|
|
133
137
|
chunks: number
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
|
Binary file
|
package/scripts/dashboard-api.py
CHANGED
|
@@ -595,11 +595,29 @@ def task_detail(task_id: str):
|
|
|
595
595
|
|
|
596
596
|
@app.get("/api/knowledge/stats")
|
|
597
597
|
def knowledge_stats():
|
|
598
|
+
"""PR73 v2.91.0 — vec_unavailable_reason surfaced so the dashboard
|
|
599
|
+
can show *why* vector search is offline instead of just a generic
|
|
600
|
+
"Unavailable" badge."""
|
|
598
601
|
store = _get_vector_store()
|
|
599
602
|
if not store:
|
|
600
|
-
return {
|
|
603
|
+
return {
|
|
604
|
+
"total_chunks": 0,
|
|
605
|
+
"total_files": 0,
|
|
606
|
+
"vss_available": False,
|
|
607
|
+
"vec_available": False,
|
|
608
|
+
"vec_unavailable_reason": "Vector store could not be opened.",
|
|
609
|
+
"indexed": False,
|
|
610
|
+
}
|
|
601
611
|
stats = store.get_stats()
|
|
602
612
|
stats["indexed"] = stats["total_chunks"] > 0
|
|
613
|
+
if not stats.get("vec_available", False):
|
|
614
|
+
try:
|
|
615
|
+
from core.knowledge.vector_store import vec_unavailable_reason
|
|
616
|
+
stats["vec_unavailable_reason"] = vec_unavailable_reason()
|
|
617
|
+
except Exception:
|
|
618
|
+
stats["vec_unavailable_reason"] = "unknown"
|
|
619
|
+
else:
|
|
620
|
+
stats["vec_unavailable_reason"] = ""
|
|
603
621
|
return stats
|
|
604
622
|
|
|
605
623
|
|
|
@@ -722,22 +740,163 @@ def _get_persona_manager():
|
|
|
722
740
|
|
|
723
741
|
@app.get("/api/personas")
|
|
724
742
|
def personas_list():
|
|
743
|
+
"""PR73 v2.91.0 — merges JSON-store personas with the Obsidian
|
|
744
|
+
vault's ``<vaultPath>/Personas/*.md`` files. Obsidian is the
|
|
745
|
+
source of truth: when the same persona exists in both places
|
|
746
|
+
(matched by name/slug), the vault entry wins.
|
|
747
|
+
"""
|
|
748
|
+
by_id: dict[str, dict] = {}
|
|
749
|
+
|
|
750
|
+
# JSON store first (in-memory copy is fast, vault read may be slow)
|
|
725
751
|
mgr = _get_persona_manager()
|
|
726
|
-
if
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
752
|
+
if mgr:
|
|
753
|
+
for p in mgr.list_all():
|
|
754
|
+
payload = p.model_dump()
|
|
755
|
+
by_id[payload["id"]] = payload
|
|
756
|
+
|
|
757
|
+
# Obsidian vault — overwrites duplicates so the vault wins.
|
|
758
|
+
try:
|
|
759
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
760
|
+
ob_store = ObsidianPersonaStore()
|
|
761
|
+
if ob_store.available:
|
|
762
|
+
for p in ob_store.list_all():
|
|
763
|
+
payload = p.model_dump()
|
|
764
|
+
payload["_source_store"] = "obsidian"
|
|
765
|
+
by_id[payload["id"]] = payload
|
|
766
|
+
except Exception:
|
|
767
|
+
pass
|
|
768
|
+
|
|
769
|
+
personas = list(by_id.values())
|
|
770
|
+
personas.sort(key=lambda p: p.get("name", "").lower())
|
|
771
|
+
return {
|
|
772
|
+
"personas": personas,
|
|
773
|
+
"total": len(personas),
|
|
774
|
+
"obsidian_available": _obsidian_store_available(),
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def _obsidian_store_available() -> bool:
|
|
779
|
+
try:
|
|
780
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
781
|
+
return ObsidianPersonaStore().available
|
|
782
|
+
except Exception:
|
|
783
|
+
return False
|
|
730
784
|
|
|
731
785
|
|
|
732
786
|
@app.get("/api/personas/{persona_id}")
|
|
733
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
|
+
|
|
734
808
|
mgr = _get_persona_manager()
|
|
735
809
|
if not mgr:
|
|
736
810
|
return {"error": "Persona manager unavailable"}
|
|
737
811
|
p = mgr.get(persona_id)
|
|
738
812
|
if not p:
|
|
739
813
|
return {"error": "Persona not found"}
|
|
740
|
-
|
|
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
|
+
}
|
|
741
900
|
|
|
742
901
|
|
|
743
902
|
@app.post("/api/personas")
|
|
@@ -772,7 +931,23 @@ def persona_create(body: dict):
|
|
|
772
931
|
)
|
|
773
932
|
|
|
774
933
|
mgr.create(persona)
|
|
775
|
-
|
|
934
|
+
|
|
935
|
+
# PR73 v2.91.0 — also write to the Obsidian vault so the persona
|
|
936
|
+
# survives outside the JSON store and is browsable in Obsidian.
|
|
937
|
+
# Best-effort: vault unavailable / write failure does not abort
|
|
938
|
+
# the JSON-side success.
|
|
939
|
+
obsidian_path: str | None = None
|
|
940
|
+
try:
|
|
941
|
+
from core.personas.obsidian_store import ObsidianPersonaStore
|
|
942
|
+
ob_store = ObsidianPersonaStore()
|
|
943
|
+
if ob_store.available or ob_store._vault_path is not None:
|
|
944
|
+
written = ob_store.write(persona)
|
|
945
|
+
if written is not None:
|
|
946
|
+
obsidian_path = str(written)
|
|
947
|
+
except Exception:
|
|
948
|
+
pass
|
|
949
|
+
|
|
950
|
+
return {"id": persona.id, "created": True, "obsidian_path": obsidian_path}
|
|
776
951
|
|
|
777
952
|
|
|
778
953
|
@app.post("/api/personas/{persona_id}/clone")
|