karajan-code 2.29.0 → 2.30.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/README.md
CHANGED
|
@@ -345,6 +345,13 @@ Opt out: set `telemetry: false` in `~/.karajan/kj.config.yml`
|
|
|
345
345
|
|
|
346
346
|
## Recent releases
|
|
347
347
|
|
|
348
|
+
> **v2.30.0 released** — Minor. **Writable config UI on HU Board** — settings modal with grouped sections, atomic-write backend, and a global vs per-project scope toggle. No more hand-editing the YAML.
|
|
349
|
+
>
|
|
350
|
+
> - **Pipeline role toggles** (PR #854): 8 nuevos booleanos en el modal (`planner`, `researcher`, `architect`, `tester`, `security`, `refactorer`, `impeccable`, `brain`) reflejan los defaults reales de `src/config/defaults.js`.
|
|
351
|
+
> - **RAG controls** (PR #855): `rag.preload.{enabled,topK,scope}` + `rag.embedder.provider` editables sin abrir el archivo. El dropdown del provider lista los 6 embedders (ollama / openai / voyage / cohere / mistral / onnx).
|
|
352
|
+
> - **Grouped sections** (PR #856): los campos se agrupan por categoría (Agentes y modelos, Roles del pipeline, RAG, Tiempos de sesión, Calidad) con iconos y orden determinista. Campos nuevos sin categoría caen en "Otros" — defensivo.
|
|
353
|
+
> - **Scope toggle global vs per-project** (PR #857): pill toggle en el header del modal. Switchea entre `~/.karajan/kj.config.yml` (global, default) y `<projectDir>/.karajan/kj.config.yml` (override del proyecto, se crea al guardar). Atomic-write + `.bak` aplican a ambos.
|
|
354
|
+
>
|
|
348
355
|
> **v2.29.0 released** — Minor. **RAG quality lift** — dashboard + three new providers + metadata filter + rerank.
|
|
349
356
|
>
|
|
350
357
|
> - **Retrieval dashboard on HU Board** (PR #843): nuevo `/rag.html` con embedder activo, tamaño de DB, last-index, chunks por kind, chunks por proyecto. Primera pieza del config UI que llega completo en v2.30.
|
package/package.json
CHANGED
|
@@ -3272,10 +3272,14 @@ function handleRoute() {
|
|
|
3272
3272
|
// row. `select` → <select>, `boolean` → <input type=checkbox>,
|
|
3273
3273
|
// `number` → <input type=number min/max>. Help text under each
|
|
3274
3274
|
// label as a small grey paragraph.
|
|
3275
|
-
async function showConfigEditor() {
|
|
3275
|
+
async function showConfigEditor(scope = 'global') {
|
|
3276
|
+
// v2.30.0 PR4 — scope toggle. 'global' edita ~/.karajan/kj.config.yml
|
|
3277
|
+
// (afecta a todos los proyectos); 'project' edita
|
|
3278
|
+
// <projectDir>/.karajan/kj.config.yml (sólo el repo actual). El backend
|
|
3279
|
+
// resuelve <projectDir> desde KJ_PROJECT_DIR || cwd.
|
|
3276
3280
|
let cfg;
|
|
3277
3281
|
try {
|
|
3278
|
-
const r = await fetch(
|
|
3282
|
+
const r = await fetch(`/api/config?scope=${encodeURIComponent(scope)}`);
|
|
3279
3283
|
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
|
3280
3284
|
cfg = await r.json();
|
|
3281
3285
|
} catch (err) {
|
|
@@ -3306,14 +3310,66 @@ async function showConfigEditor() {
|
|
|
3306
3310
|
</div>
|
|
3307
3311
|
`;
|
|
3308
3312
|
};
|
|
3313
|
+
|
|
3314
|
+
// v2.30.0 — agrupa los campos por `f.category`. El backend declara
|
|
3315
|
+
// las categorías ordenadas en `cfg.categories`; cualquier campo sin
|
|
3316
|
+
// categoría cae a "Otros" (defensivo, no se pierde si olvidamos
|
|
3317
|
+
// etiquetar uno nuevo). Mantiene el orden interno del whitelist
|
|
3318
|
+
// dentro de cada sección.
|
|
3319
|
+
const cats = Array.isArray(cfg.categories) && cfg.categories.length > 0
|
|
3320
|
+
? cfg.categories
|
|
3321
|
+
: [{ id: 'misc', label: 'Otros', icon: '⚙', order: 999 }];
|
|
3322
|
+
const byCat = new Map(cats.map((c) => [c.id, []]));
|
|
3323
|
+
byCat.set('misc', byCat.get('misc') || []);
|
|
3324
|
+
for (const f of cfg.fields) {
|
|
3325
|
+
const id = byCat.has(f.category) ? f.category : 'misc';
|
|
3326
|
+
if (!byCat.has(id)) byCat.set(id, []);
|
|
3327
|
+
byCat.get(id).push(f);
|
|
3328
|
+
}
|
|
3329
|
+
const renderSection = (cat) => {
|
|
3330
|
+
const fields = byCat.get(cat.id) || [];
|
|
3331
|
+
if (fields.length === 0) return '';
|
|
3332
|
+
return `
|
|
3333
|
+
<section style="margin-bottom:22px;">
|
|
3334
|
+
<h3 style="display:flex;align-items:center;gap:8px;margin:0 0 12px;padding-bottom:6px;border-bottom:1px solid var(--border);font-size:0.95rem;font-weight:700;color:var(--text);">
|
|
3335
|
+
<span aria-hidden="true">${esc(cat.icon || '⚙')}</span>
|
|
3336
|
+
<span>${esc(cat.label)}</span>
|
|
3337
|
+
</h3>
|
|
3338
|
+
${fields.map(renderField).join('')}
|
|
3339
|
+
</section>
|
|
3340
|
+
`;
|
|
3341
|
+
};
|
|
3342
|
+
const allCats = [...cats];
|
|
3343
|
+
if ((byCat.get('misc') || []).length > 0 && !allCats.some((c) => c.id === 'misc')) {
|
|
3344
|
+
allCats.push({ id: 'misc', label: 'Otros', icon: '⚙', order: 999 });
|
|
3345
|
+
}
|
|
3346
|
+
const sectionsHtml = allCats.map(renderSection).join('');
|
|
3347
|
+
|
|
3348
|
+
// PR4 — toggle global / proyecto. Cambiarlo recarga el modal contra el
|
|
3349
|
+
// otro fichero. Visual: dos pills mutuamente excluyentes.
|
|
3350
|
+
const pill = (id, value, label, active) => `
|
|
3351
|
+
<button data-scope="${value}" id="${id}" type="button"
|
|
3352
|
+
style="padding:4px 10px;border-radius:999px;border:1px solid var(--border);
|
|
3353
|
+
background:${active ? 'var(--color-blue)' : 'var(--bg-primary)'};
|
|
3354
|
+
color:${active ? '#fff' : 'var(--text)'};font-size:0.75rem;cursor:pointer;">
|
|
3355
|
+
${esc(label)}
|
|
3356
|
+
</button>`;
|
|
3357
|
+
const scopeToggle = `
|
|
3358
|
+
<div style="display:flex;align-items:center;gap:4px;margin-left:auto;" role="radiogroup" aria-label="Ámbito de configuración">
|
|
3359
|
+
${pill('cfg-scope-global', 'global', 'Global', scope === 'global')}
|
|
3360
|
+
${pill('cfg-scope-project', 'project', 'Este proyecto', scope === 'project')}
|
|
3361
|
+
</div>`;
|
|
3309
3362
|
dlg.innerHTML = `
|
|
3310
3363
|
<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;display:flex;align-items:center;gap:10px;">
|
|
3311
3364
|
<span style="font-size:1.2rem;">⚙</span>
|
|
3312
3365
|
<span>Configuración de Karajan</span>
|
|
3313
|
-
|
|
3366
|
+
${scopeToggle}
|
|
3367
|
+
</div>
|
|
3368
|
+
<div style="padding:6px 18px 0;font-family:var(--font-mono,monospace);font-size:0.7rem;color:var(--text-muted);">
|
|
3369
|
+
${esc(cfg.path || '')}${cfg.exists === false ? ' · (aún no existe, se creará al guardar)' : ''}
|
|
3314
3370
|
</div>
|
|
3315
3371
|
<div style="padding:14px 18px;max-height:65vh;overflow:auto;">
|
|
3316
|
-
${
|
|
3372
|
+
${sectionsHtml}
|
|
3317
3373
|
<p style="margin:14px 0 0;font-size:0.75rem;color:var(--text-muted);">
|
|
3318
3374
|
Los cambios se guardan en <code>kj.config.yml</code> de forma atómica (con copia de seguridad <code>.bak</code>).
|
|
3319
3375
|
El próximo <code>kj run</code> los aplicará — no hace falta reiniciar el board.
|
|
@@ -3325,6 +3381,15 @@ async function showConfigEditor() {
|
|
|
3325
3381
|
</div>
|
|
3326
3382
|
`;
|
|
3327
3383
|
if (typeof dlg.showModal === 'function' && !dlg.open) dlg.showModal();
|
|
3384
|
+
// PR4 — al cambiar de pill, recargar el modal con el otro scope.
|
|
3385
|
+
for (const btn of dlg.querySelectorAll('[data-scope]')) {
|
|
3386
|
+
btn.addEventListener('click', () => {
|
|
3387
|
+
const next = btn.getAttribute('data-scope');
|
|
3388
|
+
if (next === scope) return;
|
|
3389
|
+
try { dlg.close(); } catch { /* ignore */ }
|
|
3390
|
+
showConfigEditor(next);
|
|
3391
|
+
}, { once: true });
|
|
3392
|
+
}
|
|
3328
3393
|
dlg.querySelector('#config-cancel')?.addEventListener('click', () => { try { dlg.close(); } catch { /* ignore */ } }, { once: true });
|
|
3329
3394
|
dlg.querySelector('#config-save')?.addEventListener('click', async () => {
|
|
3330
3395
|
// Build the patch from the form, comparing against initial values
|
|
@@ -3347,7 +3412,7 @@ async function showConfigEditor() {
|
|
|
3347
3412
|
const r = await fetch('/api/config', {
|
|
3348
3413
|
method: 'PUT',
|
|
3349
3414
|
headers: { 'Content-Type': 'application/json' },
|
|
3350
|
-
body: JSON.stringify({ patch }),
|
|
3415
|
+
body: JSON.stringify({ patch, scope }),
|
|
3351
3416
|
});
|
|
3352
3417
|
if (!r.ok) {
|
|
3353
3418
|
const body = await r.json().catch(() => ({}));
|
|
@@ -34,7 +34,29 @@ import { tmpdir } from 'node:os';
|
|
|
34
34
|
import yaml from 'js-yaml';
|
|
35
35
|
import { getKjHome } from './db.js';
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
/**
|
|
38
|
+
* v2.30.0 PR4 — scope resolver.
|
|
39
|
+
*
|
|
40
|
+
* 'global' → ~/.karajan/kj.config.yml (mismo path que el resto de
|
|
41
|
+
* Karajan; afecta a todos los proyectos).
|
|
42
|
+
* 'project' → <projectDir>/.karajan/kj.config.yml (override por
|
|
43
|
+
* proyecto; cuando existe, el loader del padre lo prefiere
|
|
44
|
+
* sobre el global).
|
|
45
|
+
*
|
|
46
|
+
* projectDir se resuelve desde `KJ_PROJECT_DIR` (ya usado por
|
|
47
|
+
* journal-parser.js) con fallback a `process.cwd()` — el board se
|
|
48
|
+
* arranca desde el repo del usuario, así que cwd es el proyecto.
|
|
49
|
+
*/
|
|
50
|
+
export const SCOPES = ['global', 'project'];
|
|
51
|
+
|
|
52
|
+
function getProjectDir() {
|
|
53
|
+
return process.env.KJ_PROJECT_DIR || process.cwd();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function configPath(scope = 'global') {
|
|
57
|
+
if (scope === 'project') {
|
|
58
|
+
return join(getProjectDir(), '.karajan', 'kj.config.yml');
|
|
59
|
+
}
|
|
38
60
|
// Same path the parent uses (src/utils/paths.js::getKarajanHome).
|
|
39
61
|
// KJC-TSK-0420: delegate to db.js::getKjHome — gives us KARAJAN_HOME
|
|
40
62
|
// priority + KJ_HOME fallback + VITEST guard from one place, instead
|
|
@@ -42,13 +64,28 @@ function configPath() {
|
|
|
42
64
|
return join(getKjHome(), 'kj.config.yml');
|
|
43
65
|
}
|
|
44
66
|
|
|
45
|
-
function backupPath() { return `${configPath()}.bak`; }
|
|
67
|
+
function backupPath(scope = 'global') { return `${configPath(scope)}.bak`; }
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* v2.30.0 — Categorías para agrupar los campos en el modal. El backend
|
|
71
|
+
* sólo declara la metadata (id, label, icon, order). El front (app.js)
|
|
72
|
+
* itera sobre EDITABLE_FIELDS, agrupa por `f.category` y dibuja un
|
|
73
|
+
* <h3> por sección. Si un campo nuevo no declara categoría, cae en
|
|
74
|
+
* "Otros" (defensivo: no se pierde aunque se nos olvide etiquetarlo).
|
|
75
|
+
*/
|
|
76
|
+
export const CATEGORIES = [
|
|
77
|
+
{ id: 'agents', label: 'Agentes y modelos', icon: '🤖', order: 1 },
|
|
78
|
+
{ id: 'pipeline', label: 'Roles del pipeline', icon: '🔌', order: 2 },
|
|
79
|
+
{ id: 'rag', label: 'RAG (búsqueda de contexto)', icon: '📚', order: 3 },
|
|
80
|
+
{ id: 'session', label: 'Tiempos de sesión', icon: '⏱', order: 4 },
|
|
81
|
+
{ id: 'quality', label: 'Calidad', icon: '✅', order: 5 },
|
|
82
|
+
];
|
|
46
83
|
|
|
47
84
|
/**
|
|
48
85
|
* Whitelist of fields the board's modal exposes. Everything else
|
|
49
86
|
* stays untouched in the yml. Each entry says where in the yml the
|
|
50
87
|
* field lives (`path`) and how to render it for the UI (`type`,
|
|
51
|
-
* `label`, `options` for selects).
|
|
88
|
+
* `label`, `options` for selects, `category` for grouping).
|
|
52
89
|
*
|
|
53
90
|
* The path is a dotted notation we resolve manually (so we never
|
|
54
91
|
* need to take a runtime dep on lodash for `_.get`/`_.set`).
|
|
@@ -57,6 +94,7 @@ export const EDITABLE_FIELDS = [
|
|
|
57
94
|
{
|
|
58
95
|
key: 'coder',
|
|
59
96
|
path: 'coder',
|
|
97
|
+
category: 'agents',
|
|
60
98
|
type: 'select',
|
|
61
99
|
label: 'Agente para el rol "coder"',
|
|
62
100
|
help: 'Quién escribe el código. Claude es el más fiable, Codex es más rápido.',
|
|
@@ -66,6 +104,7 @@ export const EDITABLE_FIELDS = [
|
|
|
66
104
|
{
|
|
67
105
|
key: 'reviewer',
|
|
68
106
|
path: 'reviewer',
|
|
107
|
+
category: 'agents',
|
|
69
108
|
type: 'select',
|
|
70
109
|
label: 'Agente para el rol "reviewer"',
|
|
71
110
|
help: 'Quién revisa el código del coder. Conviene que sea un agente distinto al coder para detectar errores.',
|
|
@@ -75,6 +114,7 @@ export const EDITABLE_FIELDS = [
|
|
|
75
114
|
{
|
|
76
115
|
key: 'modelMode',
|
|
77
116
|
path: '_modelMode_virtual',
|
|
117
|
+
category: 'agents',
|
|
78
118
|
type: 'select',
|
|
79
119
|
label: 'Estrategia de modelos',
|
|
80
120
|
help: 'Auto = Karajan elige el modelo según la complejidad (haiku/sonnet/opus). Sonnet siempre = barato, ideal para pruebas. Opus siempre = caro, máxima calidad.',
|
|
@@ -86,6 +126,7 @@ export const EDITABLE_FIELDS = [
|
|
|
86
126
|
{
|
|
87
127
|
key: 'maxIterationMinutes',
|
|
88
128
|
path: 'session.max_iteration_minutes',
|
|
129
|
+
category: 'session',
|
|
89
130
|
type: 'number',
|
|
90
131
|
label: 'Tope de minutos por iteración',
|
|
91
132
|
help: 'Si una iteración (coder + reviewer) supera este tiempo, Karajan la corta y Solomon decide qué hacer.',
|
|
@@ -96,6 +137,7 @@ export const EDITABLE_FIELDS = [
|
|
|
96
137
|
{
|
|
97
138
|
key: 'maxTotalMinutes',
|
|
98
139
|
path: 'session.max_total_minutes',
|
|
140
|
+
category: 'session',
|
|
99
141
|
type: 'number',
|
|
100
142
|
label: 'Tope de minutos total por sesión',
|
|
101
143
|
help: 'Tope absoluto del wall-clock de la sesión. Si se llega aquí, Karajan para aunque queden HUs.',
|
|
@@ -106,11 +148,133 @@ export const EDITABLE_FIELDS = [
|
|
|
106
148
|
{
|
|
107
149
|
key: 'sonarEnabled',
|
|
108
150
|
path: 'sonarqube.enabled',
|
|
151
|
+
category: 'quality',
|
|
109
152
|
type: 'boolean',
|
|
110
153
|
label: 'Activar análisis SonarQube',
|
|
111
154
|
help: 'Pasa el código por SonarQube y bloquea si el quality gate falla. Requiere SonarQube corriendo.',
|
|
112
155
|
default: false,
|
|
113
156
|
},
|
|
157
|
+
// v2.30.0 — pipeline role toggles. Cada uno controla si una etapa
|
|
158
|
+
// del pipeline se ejecuta. Brain está OFF por defecto porque sigue
|
|
159
|
+
// siendo opt-in (v2 feature); el resto reflejan los defaults de
|
|
160
|
+
// src/config/defaults.js.
|
|
161
|
+
{
|
|
162
|
+
key: 'plannerEnabled',
|
|
163
|
+
path: 'pipeline.planner.enabled',
|
|
164
|
+
category: 'pipeline',
|
|
165
|
+
type: 'boolean',
|
|
166
|
+
label: 'Activar rol planner',
|
|
167
|
+
help: 'Genera un plan de implementación antes de codificar. Útil en tareas con >2 archivos o devPoints ≥ 3.',
|
|
168
|
+
default: true,
|
|
169
|
+
},
|
|
170
|
+
{
|
|
171
|
+
key: 'researcherEnabled',
|
|
172
|
+
path: 'pipeline.researcher.enabled',
|
|
173
|
+
category: 'pipeline',
|
|
174
|
+
type: 'boolean',
|
|
175
|
+
label: 'Activar rol researcher',
|
|
176
|
+
help: 'Investiga la codebase antes del coder (lectura de archivos clave, patrones existentes).',
|
|
177
|
+
default: true,
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
key: 'architectEnabled',
|
|
181
|
+
path: 'pipeline.architect.enabled',
|
|
182
|
+
category: 'pipeline',
|
|
183
|
+
type: 'boolean',
|
|
184
|
+
label: 'Activar rol architect',
|
|
185
|
+
help: 'Diseña la arquitectura (capas, patrones, contratos) antes de codificar. Recomendado en cambios estructurales.',
|
|
186
|
+
default: true,
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
key: 'testerEnabled',
|
|
190
|
+
path: 'pipeline.tester.enabled',
|
|
191
|
+
category: 'pipeline',
|
|
192
|
+
type: 'boolean',
|
|
193
|
+
label: 'Activar rol tester',
|
|
194
|
+
help: 'Genera tests automáticos durante o después del coder. Refuerza la calidad.',
|
|
195
|
+
default: true,
|
|
196
|
+
},
|
|
197
|
+
{
|
|
198
|
+
key: 'securityEnabled',
|
|
199
|
+
path: 'pipeline.security.enabled',
|
|
200
|
+
category: 'pipeline',
|
|
201
|
+
type: 'boolean',
|
|
202
|
+
label: 'Activar rol security',
|
|
203
|
+
help: 'Audita el código contra OWASP-style issues, secret leaks, dependencias vulnerables.',
|
|
204
|
+
default: true,
|
|
205
|
+
},
|
|
206
|
+
{
|
|
207
|
+
key: 'refactorerEnabled',
|
|
208
|
+
path: 'pipeline.refactorer.enabled',
|
|
209
|
+
category: 'pipeline',
|
|
210
|
+
type: 'boolean',
|
|
211
|
+
label: 'Activar rol refactorer',
|
|
212
|
+
help: 'Pasa una iteración de limpieza tras el coder (extract function, rename, etc.).',
|
|
213
|
+
default: false,
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
key: 'impeccableEnabled',
|
|
217
|
+
path: 'pipeline.impeccable.enabled',
|
|
218
|
+
category: 'pipeline',
|
|
219
|
+
type: 'boolean',
|
|
220
|
+
label: 'Activar rol impeccable',
|
|
221
|
+
help: 'Auditoría UI/UX automática (Lighthouse, a11y, contraste). Solo útil en frontend.',
|
|
222
|
+
default: false,
|
|
223
|
+
},
|
|
224
|
+
{
|
|
225
|
+
key: 'brainEnabled',
|
|
226
|
+
path: 'pipeline.brain.enabled',
|
|
227
|
+
category: 'pipeline',
|
|
228
|
+
type: 'boolean',
|
|
229
|
+
label: 'Activar Karajan Brain (v2)',
|
|
230
|
+
help: 'Orquestador IA central: enriquece feedback, comprime outputs y verifica cambios reales. Opt-in.',
|
|
231
|
+
default: false,
|
|
232
|
+
},
|
|
233
|
+
// v2.30.0 — RAG controls. Sólo se exponen las claves que el código
|
|
234
|
+
// realmente lee hoy (rag.preload.* en rag-context-stage.js y
|
|
235
|
+
// rag.embedder.provider en embedders/factory.js). mode/alpha/rerank
|
|
236
|
+
// se controlan vía flags CLI; cuando se cableen en config se añadirán
|
|
237
|
+
// aquí (ver task #99).
|
|
238
|
+
{
|
|
239
|
+
key: 'ragPreloadEnabled',
|
|
240
|
+
path: 'rag.preload.enabled',
|
|
241
|
+
category: 'rag',
|
|
242
|
+
type: 'boolean',
|
|
243
|
+
label: 'Activar pre-carga RAG en el pipeline',
|
|
244
|
+
help: 'Si está activo, antes de cada iteración se inyecta contexto del vector store en el prompt del coder.',
|
|
245
|
+
default: false,
|
|
246
|
+
},
|
|
247
|
+
{
|
|
248
|
+
key: 'ragPreloadTopK',
|
|
249
|
+
path: 'rag.preload.topK',
|
|
250
|
+
category: 'rag',
|
|
251
|
+
type: 'number',
|
|
252
|
+
label: 'Nº de chunks a pre-cargar (topK)',
|
|
253
|
+
help: 'Cuántos fragmentos del vector store se inyectan como contexto. 3-5 funciona bien; subir gasta tokens.',
|
|
254
|
+
min: 1,
|
|
255
|
+
max: 20,
|
|
256
|
+
default: 5,
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
key: 'ragPreloadScope',
|
|
260
|
+
path: 'rag.preload.scope',
|
|
261
|
+
category: 'rag',
|
|
262
|
+
type: 'select',
|
|
263
|
+
label: 'Scope de la pre-carga RAG',
|
|
264
|
+
help: '"all" mezcla todo. "code" solo busca en el código indexado, "plans" en planes previos, "onboarding" en el brief del proyecto.',
|
|
265
|
+
options: ['all', 'code', 'plans', 'onboarding'],
|
|
266
|
+
default: 'all',
|
|
267
|
+
},
|
|
268
|
+
{
|
|
269
|
+
key: 'ragEmbedderProvider',
|
|
270
|
+
path: 'rag.embedder.provider',
|
|
271
|
+
category: 'rag',
|
|
272
|
+
type: 'select',
|
|
273
|
+
label: 'Proveedor de embeddings RAG',
|
|
274
|
+
help: 'Ollama local es gratis. OpenAI/Voyage/Cohere/Mistral son cloud (requieren API key). ONNX corre 100% local con @huggingface/transformers.',
|
|
275
|
+
options: ['ollama', 'openai', 'voyage', 'cohere', 'mistral', 'onnx'],
|
|
276
|
+
default: 'ollama',
|
|
277
|
+
},
|
|
114
278
|
];
|
|
115
279
|
|
|
116
280
|
const ROLES_FOR_MODEL_MODE = [
|
|
@@ -203,8 +367,13 @@ function applyModelMode(parsed, mode) {
|
|
|
203
367
|
*
|
|
204
368
|
* Returns sane defaults when the file doesn't exist yet.
|
|
205
369
|
*/
|
|
206
|
-
export function readConfig() {
|
|
207
|
-
|
|
370
|
+
export function readConfig({ scope = 'global' } = {}) {
|
|
371
|
+
// PR4 — accept scope. Default 'global' preserves the previous behavior;
|
|
372
|
+
// 'project' reads <projectDir>/.karajan/kj.config.yml.
|
|
373
|
+
if (!SCOPES.includes(scope)) {
|
|
374
|
+
throw new Error(`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`);
|
|
375
|
+
}
|
|
376
|
+
const p = configPath(scope);
|
|
208
377
|
let parsed = {};
|
|
209
378
|
let exists = false;
|
|
210
379
|
if (existsSync(p)) {
|
|
@@ -222,7 +391,11 @@ export function readConfig() {
|
|
|
222
391
|
if (value === undefined) value = f.default;
|
|
223
392
|
return { ...f, value };
|
|
224
393
|
});
|
|
225
|
-
|
|
394
|
+
// v2.30.0 — also expose CATEGORIES so the modal can render grouped
|
|
395
|
+
// sections without re-importing the constant client-side. Ordered
|
|
396
|
+
// by `order` for deterministic UI.
|
|
397
|
+
const categories = [...CATEGORIES].sort((a, b) => (a.order ?? 999) - (b.order ?? 999));
|
|
398
|
+
return { path: p, scope, exists, fields, categories, raw: parsed };
|
|
226
399
|
}
|
|
227
400
|
|
|
228
401
|
/**
|
|
@@ -232,16 +405,20 @@ export function readConfig() {
|
|
|
232
405
|
*
|
|
233
406
|
* @returns {{ path: string, written: boolean, applied: object[], errors: string[] }}
|
|
234
407
|
*/
|
|
235
|
-
export function writeConfigPatch(patch) {
|
|
408
|
+
export function writeConfigPatch(patch, { scope = 'global' } = {}) {
|
|
409
|
+
// PR4 — same shape as readConfig: scope decides target file.
|
|
410
|
+
if (!SCOPES.includes(scope)) {
|
|
411
|
+
return { path: '', scope, written: false, applied: [], errors: [`Scope inválido: ${scope}. Permitidos: ${SCOPES.join(', ')}`] };
|
|
412
|
+
}
|
|
236
413
|
if (!patch || typeof patch !== 'object') {
|
|
237
|
-
return { path: configPath(), written: false, applied: [], errors: ['patch debe ser un objeto'] };
|
|
414
|
+
return { path: configPath(scope), scope, written: false, applied: [], errors: ['patch debe ser un objeto'] };
|
|
238
415
|
}
|
|
239
416
|
// Read current state (or empty if file doesn't exist yet).
|
|
240
|
-
const p = configPath();
|
|
417
|
+
const p = configPath(scope);
|
|
241
418
|
let parsed = {};
|
|
242
419
|
if (existsSync(p)) {
|
|
243
420
|
try { parsed = yaml.load(readFileSync(p, 'utf8'), { json: true }) || {}; }
|
|
244
|
-
catch (err) { return { path: p, written: false, applied: [], errors: [`No se pudo parsear el yml actual: ${err.message}`] }; }
|
|
421
|
+
catch (err) { return { path: p, scope, written: false, applied: [], errors: [`No se pudo parsear el yml actual: ${err.message}`] }; }
|
|
245
422
|
}
|
|
246
423
|
const errors = [];
|
|
247
424
|
const applied = [];
|
|
@@ -268,18 +445,18 @@ export function writeConfigPatch(patch) {
|
|
|
268
445
|
else setDeep(parsed, field.path, field.type === 'number' ? Number(val) : val);
|
|
269
446
|
applied.push({ key, value: val });
|
|
270
447
|
}
|
|
271
|
-
if (errors.length > 0) return { path: p, written: false, applied, errors };
|
|
448
|
+
if (errors.length > 0) return { path: p, scope, written: false, applied, errors };
|
|
272
449
|
|
|
273
450
|
// Write atomically: serialize → write to .tmp → rename. Backup the
|
|
274
451
|
// existing file first so the user can recover.
|
|
275
452
|
const dir = dirname(p);
|
|
276
453
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
277
|
-
if (existsSync(p)) { try { copyFileSync(p, backupPath()); } catch { /* best-effort */ } }
|
|
454
|
+
if (existsSync(p)) { try { copyFileSync(p, backupPath(scope)); } catch { /* best-effort */ } }
|
|
278
455
|
const dump = yaml.dump(parsed, { lineWidth: 120 });
|
|
279
456
|
// mkstemp would be nicer but we want to stay in the same dir for
|
|
280
457
|
// a guaranteed-atomic rename across the same filesystem.
|
|
281
458
|
const tmpFile = join(dir, `.kj.config.yml.${process.pid}.${Date.now()}.tmp`);
|
|
282
459
|
writeFileSync(tmpFile, dump, 'utf8');
|
|
283
460
|
renameSync(tmpFile, p);
|
|
284
|
-
return { path: p, written: true, applied, errors: [] };
|
|
461
|
+
return { path: p, scope, written: true, applied, errors: [] };
|
|
285
462
|
}
|
|
@@ -1031,29 +1031,41 @@ router.post('/projects/:id/run', (req, res) => {
|
|
|
1031
1031
|
});
|
|
1032
1032
|
|
|
1033
1033
|
/**
|
|
1034
|
-
* GET /api/config — read the editable subset of
|
|
1034
|
+
* GET /api/config — read the editable subset of the kj.config.yml.
|
|
1035
1035
|
* Powers the board's settings modal; non-editable keys in the yml are
|
|
1036
1036
|
* left untouched on PUT (see writeConfigPatch's whitelist semantics).
|
|
1037
|
+
*
|
|
1038
|
+
* v2.30.0 PR4 — supports `?scope=global|project`:
|
|
1039
|
+
* - `global` (default): reads ~/.karajan/kj.config.yml.
|
|
1040
|
+
* - `project`: reads <projectDir>/.karajan/kj.config.yml.
|
|
1041
|
+
* Unknown scopes are 400'd. Response includes the resolved `scope` and
|
|
1042
|
+
* `path` so the UI can show which file backs the current view.
|
|
1037
1043
|
*/
|
|
1038
|
-
router.get('/config', (
|
|
1044
|
+
router.get('/config', (req, res) => {
|
|
1039
1045
|
try {
|
|
1040
|
-
const
|
|
1046
|
+
const scope = req.query?.scope || 'global';
|
|
1047
|
+
const data = readConfig({ scope });
|
|
1041
1048
|
res.json(data);
|
|
1042
1049
|
} catch (err) {
|
|
1043
|
-
res.status(
|
|
1050
|
+
res.status(400).json({ error: err.message });
|
|
1044
1051
|
}
|
|
1045
1052
|
});
|
|
1046
1053
|
|
|
1047
1054
|
/**
|
|
1048
|
-
* PUT /api/config — apply a patch to the yml. Body is
|
|
1049
|
-
*
|
|
1050
|
-
*
|
|
1051
|
-
*
|
|
1055
|
+
* PUT /api/config — apply a patch to the yml. Body is
|
|
1056
|
+
* `{ patch: { key: value }, scope?: 'global'|'project' }` with the keys
|
|
1057
|
+
* defined in EDITABLE_FIELDS. Unknown keys are 400'd; type errors are
|
|
1058
|
+
* 400'd; validation errors are 400'd. Writes are atomic (tmp + rename)
|
|
1059
|
+
* and a `.bak` is kept of the previous content.
|
|
1060
|
+
*
|
|
1061
|
+
* v2.30.0 PR4 — `scope` decides which file is patched. Default 'global'
|
|
1062
|
+
* preserves the previous behavior.
|
|
1052
1063
|
*/
|
|
1053
1064
|
router.put('/config', (req, res) => {
|
|
1054
1065
|
try {
|
|
1055
1066
|
const patch = req.body?.patch;
|
|
1056
|
-
const
|
|
1067
|
+
const scope = req.body?.scope || 'global';
|
|
1068
|
+
const result = writeConfigPatch(patch, { scope });
|
|
1057
1069
|
if (!result.written) {
|
|
1058
1070
|
return res.status(400).json({ error: 'No se pudo guardar la configuración', details: result });
|
|
1059
1071
|
}
|