@cat-factory/app 0.187.0 → 0.188.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 +26 -0
- package/app/components/consensus/ConsensusSessionWindow.vue +11 -0
- package/app/components/palettes/AgentPalette.vue +12 -1
- package/app/components/palettes/AgentTierSelect.vue +67 -0
- package/app/components/pipeline/PipelineBuilder.vue +166 -92
- package/app/components/settings/ConsensusGroupsSection.vue +498 -0
- package/app/components/settings/ModelConfigurationPanel.vue +34 -2
- package/app/composables/api/presets.ts +22 -0
- package/app/modular/agent-kinds.spec.ts +9 -3
- package/app/modular/agent-kinds.ts +4 -0
- package/app/stores/agentTier.spec.ts +24 -0
- package/app/stores/agentTier.ts +42 -0
- package/app/stores/consensusGroups.ts +77 -0
- package/app/stores/pipelines/draftActions.ts +11 -113
- package/app/stores/pipelines/draftStepConfig.ts +157 -0
- package/app/stores/workspace/hydrate.ts +2 -0
- package/app/types/consensus.ts +3 -0
- package/app/types/domain.ts +8 -1
- package/app/utils/agentTier.spec.ts +59 -0
- package/app/utils/agentTier.ts +49 -0
- package/app/utils/catalog.spec.ts +12 -0
- package/app/utils/catalog.ts +58 -2
- package/i18n/locales/de.json +59 -2
- package/i18n/locales/en.json +65 -2
- package/i18n/locales/es.json +59 -2
- package/i18n/locales/fr.json +59 -2
- package/i18n/locales/he.json +59 -2
- package/i18n/locales/it.json +59 -2
- package/i18n/locales/ja.json +59 -2
- package/i18n/locales/pl.json +59 -2
- package/i18n/locales/tr.json +59 -2
- package/i18n/locales/uk.json +59 -2
- package/package.json +2 -2
|
@@ -0,0 +1,498 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The workspace's CONSENSUS-GROUP library, rendered as a section of the Model Configuration
|
|
3
|
+
// screen. A group is a reusable review panel — participants (role + perspective framing +
|
|
4
|
+
// model), the strategy that runs them, a synthesizer — plus the ESTIMATE BAR a task must clear
|
|
5
|
+
// to earn it. A pipeline step names a SET of groups and the engine runs the most demanding tier
|
|
6
|
+
// the task clears, so the library is where "which models review our risky work" is decided once
|
|
7
|
+
// instead of per pipeline.
|
|
8
|
+
//
|
|
9
|
+
// It lives beside the model presets rather than in its own destination because it answers the
|
|
10
|
+
// same question those do — which models do the work — and splitting them is what sends people
|
|
11
|
+
// hunting through Integrations for a model setting.
|
|
12
|
+
import { computed, ref } from 'vue'
|
|
13
|
+
import type { ConsensusGroup, ConsensusStrategy } from '~/types/consensus'
|
|
14
|
+
import { isSelectable } from '~/stores/models'
|
|
15
|
+
|
|
16
|
+
const { t } = useI18n()
|
|
17
|
+
const groups = useConsensusGroupsStore()
|
|
18
|
+
const models = useModelsStore()
|
|
19
|
+
const creds = useVendorCredentialsStore()
|
|
20
|
+
const uiMode = useUiModeStore()
|
|
21
|
+
const toast = useToast()
|
|
22
|
+
const { confirm } = useConfirm()
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Advanced-tier authoring that REVEALS itself once the workspace has a library. Hiding a
|
|
26
|
+
* populated library in basic mode would leave a basic-mode user looking at tier chips in the
|
|
27
|
+
* pipeline builder with no way to find out what they contain — the same failure the override
|
|
28
|
+
* rules exist to prevent.
|
|
29
|
+
*/
|
|
30
|
+
const visible = computed(() => uiMode.isAdvanced || groups.hasGroups)
|
|
31
|
+
|
|
32
|
+
const STRATEGIES = computed<{ value: ConsensusStrategy; label: string }[]>(() => [
|
|
33
|
+
{ value: 'specialist-panel', label: t('pipeline.builder.strategyOption.specialist-panel') },
|
|
34
|
+
{ value: 'debate', label: t('pipeline.builder.strategyOption.debate') },
|
|
35
|
+
{ value: 'ranked-voting', label: t('pipeline.builder.strategyOption.ranked-voting') },
|
|
36
|
+
])
|
|
37
|
+
|
|
38
|
+
interface EditorState {
|
|
39
|
+
id?: string
|
|
40
|
+
name: string
|
|
41
|
+
description: string
|
|
42
|
+
strategy: ConsensusStrategy
|
|
43
|
+
participants: { id: string; role: string; systemFraming?: string; modelId?: string }[]
|
|
44
|
+
synthesizerModelId: string
|
|
45
|
+
rounds: number | null
|
|
46
|
+
gated: boolean
|
|
47
|
+
minComplexity: number | null
|
|
48
|
+
minRisk: number | null
|
|
49
|
+
minImpact: number | null
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const editor = ref<EditorState | null>(null)
|
|
53
|
+
const busy = ref(false)
|
|
54
|
+
|
|
55
|
+
function uid(prefix: string) {
|
|
56
|
+
return `${prefix}_${Math.random().toString(36).slice(2, 9)}`
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A fresh group starts as a gated two-model panel — the shape the feature is for. */
|
|
60
|
+
function startCreate() {
|
|
61
|
+
editor.value = {
|
|
62
|
+
name: '',
|
|
63
|
+
description: '',
|
|
64
|
+
strategy: 'specialist-panel',
|
|
65
|
+
participants: [
|
|
66
|
+
{ id: uid('cnp'), role: 'Pragmatist', systemFraming: 'Favour the simplest viable approach.' },
|
|
67
|
+
{
|
|
68
|
+
id: uid('cnp'),
|
|
69
|
+
role: 'Skeptic',
|
|
70
|
+
systemFraming: 'Probe risks, edge cases and failure modes.',
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
synthesizerModelId: '',
|
|
74
|
+
rounds: null,
|
|
75
|
+
gated: true,
|
|
76
|
+
minComplexity: null,
|
|
77
|
+
minRisk: 0.7,
|
|
78
|
+
minImpact: null,
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function startEdit(group: ConsensusGroup) {
|
|
83
|
+
editor.value = {
|
|
84
|
+
id: group.id,
|
|
85
|
+
name: group.name,
|
|
86
|
+
description: group.description ?? '',
|
|
87
|
+
strategy: group.strategy,
|
|
88
|
+
participants: group.participants.map((p) => ({ ...p })),
|
|
89
|
+
synthesizerModelId: group.synthesizerModelId ?? '',
|
|
90
|
+
rounds: group.rounds ?? null,
|
|
91
|
+
gated: group.gating.enabled,
|
|
92
|
+
minComplexity: group.gating.minComplexity ?? null,
|
|
93
|
+
minRisk: group.gating.minRisk ?? null,
|
|
94
|
+
minImpact: group.gating.minImpact ?? null,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function addParticipant() {
|
|
99
|
+
editor.value?.participants.push({ id: uid('cnp'), role: '' })
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function removeParticipant(index: number) {
|
|
103
|
+
editor.value?.participants.splice(index, 1)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The bar a stored group sets, for the list rows. */
|
|
107
|
+
function barLabel(group: ConsensusGroup): string {
|
|
108
|
+
const bar = groups.barFor(group)
|
|
109
|
+
return bar === null
|
|
110
|
+
? t('settings.consensusGroups.list.always')
|
|
111
|
+
: t('settings.consensusGroups.list.bar', { bar })
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const selectableModelIds = computed(() => {
|
|
115
|
+
const configured = creds.configuredVendors
|
|
116
|
+
return models.models
|
|
117
|
+
.filter((m) => isSelectable(m, configured))
|
|
118
|
+
.map((m) => ({ id: m.id, label: m.label }))
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* A threshold field's value as the contract wants it, or undefined when the author left it blank.
|
|
123
|
+
*
|
|
124
|
+
* `v-model.number` does NOT yield null for an emptied input: Vue's coercion returns the RAW value
|
|
125
|
+
* when it cannot parse a number, so clearing a box a user had typed in leaves `''` behind. Read
|
|
126
|
+
* back with a `!== null` test that empty string passes every guard, reaches the wire as
|
|
127
|
+
* `minRisk: ''`, and comes back a 422 behind a generic "could not save" toast. Anything that is
|
|
128
|
+
* not a finite number is therefore treated as absent, at the one place the value crosses out of
|
|
129
|
+
* the editor.
|
|
130
|
+
*/
|
|
131
|
+
function threshold(value: unknown): number | undefined {
|
|
132
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : undefined
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The thresholds the author actually set, in contract shape. */
|
|
136
|
+
function thresholds(e: EditorState) {
|
|
137
|
+
return {
|
|
138
|
+
...(threshold(e.minComplexity) !== undefined
|
|
139
|
+
? { minComplexity: threshold(e.minComplexity) }
|
|
140
|
+
: {}),
|
|
141
|
+
...(threshold(e.minRisk) !== undefined ? { minRisk: threshold(e.minRisk) } : {}),
|
|
142
|
+
...(threshold(e.minImpact) !== undefined ? { minImpact: threshold(e.minImpact) } : {}),
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The gating payload. A gated group with no threshold is refused by the backend (it could never
|
|
148
|
+
* be selected), so surface that here rather than as a save failure.
|
|
149
|
+
*/
|
|
150
|
+
function gatingPayload(e: EditorState) {
|
|
151
|
+
if (!e.gated) return { enabled: false as const, onMissingEstimate: 'consensus' as const }
|
|
152
|
+
return {
|
|
153
|
+
enabled: true as const,
|
|
154
|
+
...thresholds(e),
|
|
155
|
+
onMissingEstimate: 'consensus' as const,
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const gatingIncomplete = computed(
|
|
160
|
+
() => !!editor.value?.gated && Object.keys(thresholds(editor.value)).length === 0,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
async function save() {
|
|
164
|
+
const e = editor.value
|
|
165
|
+
if (!e) return
|
|
166
|
+
if (!e.name.trim() || gatingIncomplete.value) return
|
|
167
|
+
const body = {
|
|
168
|
+
name: e.name.trim(),
|
|
169
|
+
...(e.description.trim() ? { description: e.description.trim() } : {}),
|
|
170
|
+
strategy: e.strategy,
|
|
171
|
+
participants: e.participants.map((p) => ({
|
|
172
|
+
id: p.id,
|
|
173
|
+
role: p.role.trim() || t('settings.consensusGroups.editor.unnamedRole'),
|
|
174
|
+
...(p.systemFraming?.trim() ? { systemFraming: p.systemFraming.trim() } : {}),
|
|
175
|
+
...(p.modelId?.trim() ? { modelId: p.modelId.trim() } : {}),
|
|
176
|
+
})),
|
|
177
|
+
...(e.synthesizerModelId.trim() ? { synthesizerModelId: e.synthesizerModelId.trim() } : {}),
|
|
178
|
+
...(e.rounds !== null ? { rounds: e.rounds } : {}),
|
|
179
|
+
gating: gatingPayload(e),
|
|
180
|
+
}
|
|
181
|
+
busy.value = true
|
|
182
|
+
try {
|
|
183
|
+
if (e.id) await groups.update(e.id, body)
|
|
184
|
+
else await groups.create(body)
|
|
185
|
+
editor.value = null
|
|
186
|
+
} catch (err) {
|
|
187
|
+
fail(t('settings.consensusGroups.toast.saveFailed'), err)
|
|
188
|
+
} finally {
|
|
189
|
+
busy.value = false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function remove(group: ConsensusGroup) {
|
|
194
|
+
const ok = await confirm({
|
|
195
|
+
title: t('settings.consensusGroups.confirmDelete.title'),
|
|
196
|
+
description: t('settings.consensusGroups.confirmDelete.body', { name: group.name }),
|
|
197
|
+
variant: 'destructive',
|
|
198
|
+
confirmLabel: t('common.delete'),
|
|
199
|
+
icon: 'i-lucide-trash-2',
|
|
200
|
+
})
|
|
201
|
+
if (!ok) return
|
|
202
|
+
busy.value = true
|
|
203
|
+
try {
|
|
204
|
+
await groups.remove(group.id)
|
|
205
|
+
} catch (err) {
|
|
206
|
+
fail(t('settings.consensusGroups.toast.deleteFailed'), err)
|
|
207
|
+
} finally {
|
|
208
|
+
busy.value = false
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function fail(title: string, e: unknown) {
|
|
213
|
+
toast.add({
|
|
214
|
+
title,
|
|
215
|
+
description: e instanceof Error ? e.message : String(e),
|
|
216
|
+
icon: 'i-lucide-triangle-alert',
|
|
217
|
+
color: 'error',
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
</script>
|
|
221
|
+
|
|
222
|
+
<template>
|
|
223
|
+
<section v-if="visible" class="space-y-3 border-t border-slate-800 pt-5">
|
|
224
|
+
<div class="flex items-start justify-between gap-3">
|
|
225
|
+
<div class="min-w-0">
|
|
226
|
+
<h2 class="text-sm font-semibold text-slate-100">
|
|
227
|
+
{{ t('settings.consensusGroups.title') }}
|
|
228
|
+
</h2>
|
|
229
|
+
<p class="mt-1 text-xs leading-relaxed text-slate-500">
|
|
230
|
+
{{ t('settings.consensusGroups.intro') }}
|
|
231
|
+
</p>
|
|
232
|
+
</div>
|
|
233
|
+
<UButton
|
|
234
|
+
v-if="!editor"
|
|
235
|
+
icon="i-lucide-plus"
|
|
236
|
+
color="neutral"
|
|
237
|
+
variant="soft"
|
|
238
|
+
size="sm"
|
|
239
|
+
class="shrink-0"
|
|
240
|
+
data-testid="consensus-group-new"
|
|
241
|
+
@click="startCreate"
|
|
242
|
+
>
|
|
243
|
+
{{ t('settings.consensusGroups.new') }}
|
|
244
|
+
</UButton>
|
|
245
|
+
</div>
|
|
246
|
+
|
|
247
|
+
<!-- ===== list ===== -->
|
|
248
|
+
<template v-if="!editor">
|
|
249
|
+
<div v-if="groups.groups.length" class="space-y-2">
|
|
250
|
+
<div
|
|
251
|
+
v-for="g in groups.groups"
|
|
252
|
+
:key="g.id"
|
|
253
|
+
class="rounded-xl border border-slate-800 bg-slate-900/50 p-3"
|
|
254
|
+
data-testid="consensus-group-row"
|
|
255
|
+
>
|
|
256
|
+
<div class="flex items-center gap-2">
|
|
257
|
+
<span class="truncate text-sm font-semibold text-slate-100">{{ g.name }}</span>
|
|
258
|
+
<UBadge color="neutral" variant="subtle" size="xs">{{ barLabel(g) }}</UBadge>
|
|
259
|
+
<div class="ms-auto flex items-center gap-1">
|
|
260
|
+
<UButton
|
|
261
|
+
size="xs"
|
|
262
|
+
variant="ghost"
|
|
263
|
+
color="neutral"
|
|
264
|
+
icon="i-lucide-pencil"
|
|
265
|
+
:title="t('settings.consensusGroups.list.editTitle')"
|
|
266
|
+
@click="startEdit(g)"
|
|
267
|
+
/>
|
|
268
|
+
<UButton
|
|
269
|
+
size="xs"
|
|
270
|
+
variant="ghost"
|
|
271
|
+
color="error"
|
|
272
|
+
icon="i-lucide-trash-2"
|
|
273
|
+
:loading="busy"
|
|
274
|
+
:title="t('settings.consensusGroups.list.deleteTitle')"
|
|
275
|
+
@click="remove(g)"
|
|
276
|
+
/>
|
|
277
|
+
</div>
|
|
278
|
+
</div>
|
|
279
|
+
<p v-if="g.description" class="mt-1 text-[11px] text-slate-400">{{ g.description }}</p>
|
|
280
|
+
<div class="mt-1.5 text-[11px] text-slate-400">
|
|
281
|
+
{{ t(`pipeline.builder.strategyOption.${g.strategy}`) }}
|
|
282
|
+
·
|
|
283
|
+
{{
|
|
284
|
+
t(
|
|
285
|
+
'settings.consensusGroups.list.participantCount',
|
|
286
|
+
{ count: g.participants.length },
|
|
287
|
+
g.participants.length,
|
|
288
|
+
)
|
|
289
|
+
}}
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
292
|
+
</div>
|
|
293
|
+
<p v-else class="py-4 text-center text-sm text-slate-500">
|
|
294
|
+
{{ t('settings.consensusGroups.list.empty') }}
|
|
295
|
+
</p>
|
|
296
|
+
</template>
|
|
297
|
+
|
|
298
|
+
<!-- ===== editor ===== -->
|
|
299
|
+
<div v-else class="space-y-4 rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
|
300
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
301
|
+
<div>
|
|
302
|
+
<label
|
|
303
|
+
class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
304
|
+
>
|
|
305
|
+
{{ t('settings.consensusGroups.editor.nameLabel') }}
|
|
306
|
+
</label>
|
|
307
|
+
<UInput
|
|
308
|
+
v-model="editor.name"
|
|
309
|
+
size="sm"
|
|
310
|
+
class="w-full"
|
|
311
|
+
:placeholder="t('settings.consensusGroups.editor.namePlaceholder')"
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
<div>
|
|
315
|
+
<label
|
|
316
|
+
class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
317
|
+
>
|
|
318
|
+
{{ t('settings.consensusGroups.editor.strategyLabel') }}
|
|
319
|
+
</label>
|
|
320
|
+
<select
|
|
321
|
+
v-model="editor.strategy"
|
|
322
|
+
class="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
|
|
323
|
+
>
|
|
324
|
+
<option v-for="s in STRATEGIES" :key="s.value" :value="s.value">{{ s.label }}</option>
|
|
325
|
+
</select>
|
|
326
|
+
</div>
|
|
327
|
+
</div>
|
|
328
|
+
|
|
329
|
+
<div>
|
|
330
|
+
<label class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
331
|
+
{{ t('settings.consensusGroups.editor.descriptionLabel') }}
|
|
332
|
+
</label>
|
|
333
|
+
<UInput
|
|
334
|
+
v-model="editor.description"
|
|
335
|
+
size="sm"
|
|
336
|
+
class="w-full"
|
|
337
|
+
:placeholder="t('settings.consensusGroups.editor.descriptionPlaceholder')"
|
|
338
|
+
/>
|
|
339
|
+
</div>
|
|
340
|
+
|
|
341
|
+
<!-- participants -->
|
|
342
|
+
<div class="space-y-2">
|
|
343
|
+
<div class="flex items-center justify-between">
|
|
344
|
+
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
345
|
+
{{ t('settings.consensusGroups.editor.participantsLabel') }}
|
|
346
|
+
</span>
|
|
347
|
+
<UButton
|
|
348
|
+
icon="i-lucide-plus"
|
|
349
|
+
color="neutral"
|
|
350
|
+
variant="ghost"
|
|
351
|
+
size="xs"
|
|
352
|
+
:label="t('settings.consensusGroups.editor.addParticipant')"
|
|
353
|
+
@click="addParticipant"
|
|
354
|
+
/>
|
|
355
|
+
</div>
|
|
356
|
+
<p class="text-[11px] text-slate-500">
|
|
357
|
+
{{ t('settings.consensusGroups.editor.participantsHint') }}
|
|
358
|
+
</p>
|
|
359
|
+
<div
|
|
360
|
+
v-for="(p, index) in editor.participants"
|
|
361
|
+
:key="p.id"
|
|
362
|
+
class="flex flex-wrap items-center gap-1.5"
|
|
363
|
+
>
|
|
364
|
+
<UInput
|
|
365
|
+
v-model="p.role"
|
|
366
|
+
size="xs"
|
|
367
|
+
class="w-32"
|
|
368
|
+
:placeholder="t('pipeline.builder.rolePlaceholder')"
|
|
369
|
+
/>
|
|
370
|
+
<UInput
|
|
371
|
+
v-model="p.systemFraming"
|
|
372
|
+
size="xs"
|
|
373
|
+
class="min-w-40 flex-1"
|
|
374
|
+
:placeholder="t('settings.consensusGroups.editor.framingPlaceholder')"
|
|
375
|
+
/>
|
|
376
|
+
<select
|
|
377
|
+
v-model="p.modelId"
|
|
378
|
+
class="w-44 rounded border border-slate-700 bg-slate-900 px-1.5 py-1 text-xs text-slate-300"
|
|
379
|
+
>
|
|
380
|
+
<option :value="undefined">{{ t('settings.consensusGroups.editor.stepModel') }}</option>
|
|
381
|
+
<option v-for="m in selectableModelIds" :key="m.id" :value="m.id">{{ m.label }}</option>
|
|
382
|
+
</select>
|
|
383
|
+
<UButton
|
|
384
|
+
icon="i-lucide-x"
|
|
385
|
+
color="error"
|
|
386
|
+
variant="ghost"
|
|
387
|
+
size="xs"
|
|
388
|
+
:disabled="editor.participants.length <= 2"
|
|
389
|
+
:title="t('pipeline.builder.removeParticipant')"
|
|
390
|
+
@click="removeParticipant(index)"
|
|
391
|
+
/>
|
|
392
|
+
</div>
|
|
393
|
+
</div>
|
|
394
|
+
|
|
395
|
+
<div class="grid gap-3 sm:grid-cols-2">
|
|
396
|
+
<div>
|
|
397
|
+
<label
|
|
398
|
+
class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
399
|
+
>
|
|
400
|
+
{{ t('settings.consensusGroups.editor.synthesizerLabel') }}
|
|
401
|
+
</label>
|
|
402
|
+
<select
|
|
403
|
+
v-model="editor.synthesizerModelId"
|
|
404
|
+
class="w-full rounded border border-slate-700 bg-slate-900 px-2 py-1.5 text-sm text-slate-100"
|
|
405
|
+
>
|
|
406
|
+
<option value="">{{ t('settings.consensusGroups.editor.stepModel') }}</option>
|
|
407
|
+
<option v-for="m in selectableModelIds" :key="m.id" :value="m.id">{{ m.label }}</option>
|
|
408
|
+
</select>
|
|
409
|
+
</div>
|
|
410
|
+
<div v-if="editor.strategy === 'debate'">
|
|
411
|
+
<label
|
|
412
|
+
class="mb-1 block text-[11px] font-semibold uppercase tracking-wide text-slate-400"
|
|
413
|
+
>
|
|
414
|
+
{{ t('pipeline.builder.rounds') }}
|
|
415
|
+
</label>
|
|
416
|
+
<UInput v-model.number="editor.rounds" type="number" min="1" max="5" size="sm" />
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
|
|
420
|
+
<!-- the estimate bar -->
|
|
421
|
+
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-950/40 p-3">
|
|
422
|
+
<label class="flex items-center gap-2 text-xs text-slate-300">
|
|
423
|
+
<input v-model="editor.gated" type="checkbox" class="accent-emerald-500" />
|
|
424
|
+
{{ t('settings.consensusGroups.editor.gatedLabel') }}
|
|
425
|
+
</label>
|
|
426
|
+
<p class="text-[11px] text-slate-500">
|
|
427
|
+
{{ t('settings.consensusGroups.editor.gatedHint') }}
|
|
428
|
+
</p>
|
|
429
|
+
<div v-if="editor.gated" class="flex flex-wrap items-center gap-3 text-xs">
|
|
430
|
+
<label class="flex items-center gap-1.5 text-slate-400">
|
|
431
|
+
{{ t('pipeline.builder.riskThreshold') }}
|
|
432
|
+
<UInput
|
|
433
|
+
v-model.number="editor.minRisk"
|
|
434
|
+
type="number"
|
|
435
|
+
min="0"
|
|
436
|
+
max="1"
|
|
437
|
+
step="0.1"
|
|
438
|
+
size="xs"
|
|
439
|
+
class="w-20"
|
|
440
|
+
/>
|
|
441
|
+
</label>
|
|
442
|
+
<label class="flex items-center gap-1.5 text-slate-400">
|
|
443
|
+
{{ t('pipeline.builder.impactThreshold') }}
|
|
444
|
+
<UInput
|
|
445
|
+
v-model.number="editor.minImpact"
|
|
446
|
+
type="number"
|
|
447
|
+
min="0"
|
|
448
|
+
max="1"
|
|
449
|
+
step="0.1"
|
|
450
|
+
size="xs"
|
|
451
|
+
class="w-20"
|
|
452
|
+
/>
|
|
453
|
+
</label>
|
|
454
|
+
<label class="flex items-center gap-1.5 text-slate-400">
|
|
455
|
+
{{ t('settings.consensusGroups.editor.complexityThreshold') }}
|
|
456
|
+
<UInput
|
|
457
|
+
v-model.number="editor.minComplexity"
|
|
458
|
+
type="number"
|
|
459
|
+
min="0"
|
|
460
|
+
max="1"
|
|
461
|
+
step="0.1"
|
|
462
|
+
size="xs"
|
|
463
|
+
class="w-20"
|
|
464
|
+
/>
|
|
465
|
+
</label>
|
|
466
|
+
</div>
|
|
467
|
+
<p v-if="gatingIncomplete" class="text-[11px] text-amber-400">
|
|
468
|
+
{{ t('settings.consensusGroups.editor.gatingIncomplete') }}
|
|
469
|
+
</p>
|
|
470
|
+
</div>
|
|
471
|
+
|
|
472
|
+
<div class="flex items-center justify-end gap-2">
|
|
473
|
+
<UButton
|
|
474
|
+
color="neutral"
|
|
475
|
+
variant="ghost"
|
|
476
|
+
size="sm"
|
|
477
|
+
@click="
|
|
478
|
+
() => {
|
|
479
|
+
editor = null
|
|
480
|
+
}
|
|
481
|
+
"
|
|
482
|
+
>
|
|
483
|
+
{{ t('settings.consensusGroups.editor.cancel') }}
|
|
484
|
+
</UButton>
|
|
485
|
+
<UButton
|
|
486
|
+
color="primary"
|
|
487
|
+
size="sm"
|
|
488
|
+
:loading="busy"
|
|
489
|
+
:disabled="!editor.name.trim() || gatingIncomplete"
|
|
490
|
+
data-testid="consensus-group-save"
|
|
491
|
+
@click="save"
|
|
492
|
+
>
|
|
493
|
+
{{ t('settings.consensusGroups.editor.save') }}
|
|
494
|
+
</UButton>
|
|
495
|
+
</div>
|
|
496
|
+
</div>
|
|
497
|
+
</section>
|
|
498
|
+
</template>
|
|
@@ -14,14 +14,18 @@ import { computed, ref, watch } from 'vue'
|
|
|
14
14
|
import { onKeyStroke } from '@vueuse/core'
|
|
15
15
|
import type { AgentKind } from '~/types/domain'
|
|
16
16
|
import type { ModelPreset } from '~/types/model-presets'
|
|
17
|
+
import AgentTierSelect from '~/components/palettes/AgentTierSelect.vue'
|
|
18
|
+
import { filterByAgentTierKeeping } from '~/utils/agentTier'
|
|
17
19
|
import { MODEL_CONFIGURABLE_SYSTEM_KINDS } from '~/utils/catalog'
|
|
18
20
|
import { cachingLabel, contextLabel, costLabel, displayFlavor, isSelectable } from '~/stores/models'
|
|
21
|
+
import ConsensusGroupsSection from '~/components/settings/ConsensusGroupsSection.vue'
|
|
19
22
|
|
|
20
23
|
const { t } = useI18n()
|
|
21
24
|
const ui = useUiStore()
|
|
22
25
|
const models = useModelsStore()
|
|
23
26
|
const presets = useModelPresetsStore()
|
|
24
27
|
const agents = useAgentsStore()
|
|
28
|
+
const agentTier = useAgentTierStore()
|
|
25
29
|
const creds = useVendorCredentialsStore()
|
|
26
30
|
const workspace = useWorkspaceStore()
|
|
27
31
|
const toast = useToast()
|
|
@@ -49,14 +53,36 @@ const filter = ref('')
|
|
|
49
53
|
// (spec-writer, merger, the fixers/resolver). The pure gates run no model, so they
|
|
50
54
|
// stay out — exactly the set the per-agent override list should cover.
|
|
51
55
|
const configurableKinds = computed(() => [...agents.archetypes, ...MODEL_CONFIGURABLE_SYSTEM_KINDS])
|
|
56
|
+
|
|
57
|
+
// Narrowed to the selected agent tier, EXCEPT that a kind the preset being edited already
|
|
58
|
+
// pins a model for is always kept: that override may have been written by a teammate, by the
|
|
59
|
+
// API, or by this user at a wider tier, and a row hidden here is one they can neither read
|
|
60
|
+
// nor clear. Same rule the interface mode's `showOverrideField` states for a single field.
|
|
61
|
+
const tieredKinds = computed(() =>
|
|
62
|
+
filterByAgentTierKeeping(
|
|
63
|
+
configurableKinds.value,
|
|
64
|
+
agentTier.tier,
|
|
65
|
+
(a) => editor.value?.overrides[a.kind] !== undefined,
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
|
|
52
69
|
const filteredKinds = computed(() => {
|
|
53
70
|
const q = filter.value.trim().toLowerCase()
|
|
54
|
-
if (!q) return
|
|
71
|
+
if (!q) return tieredKinds.value
|
|
72
|
+
// A typed query searches the WHOLE catalog, not the tiered slice: naming an agent is a
|
|
73
|
+
// stronger statement of intent than the tier default, and "No agents match" for a kind the
|
|
74
|
+
// user can spell would read as "this deployment doesn't have it".
|
|
55
75
|
return configurableKinds.value.filter(
|
|
56
76
|
(a) => a.label.toLowerCase().includes(q) || String(a.kind).toLowerCase().includes(q),
|
|
57
77
|
)
|
|
58
78
|
})
|
|
59
79
|
|
|
80
|
+
// Nothing is being held back while a search is running (it spans every kind), so the hint
|
|
81
|
+
// reports 0 rather than a count the visible list contradicts.
|
|
82
|
+
const hiddenByTier = computed(() =>
|
|
83
|
+
filter.value.trim() ? 0 : configurableKinds.value.length - tieredKinds.value.length,
|
|
84
|
+
)
|
|
85
|
+
|
|
60
86
|
watch(
|
|
61
87
|
open,
|
|
62
88
|
(isOpen) => {
|
|
@@ -405,6 +431,11 @@ function fail(title: string, e: unknown) {
|
|
|
405
431
|
{{ t('settings.modelConfiguration.list.empty') }}
|
|
406
432
|
</p>
|
|
407
433
|
</div>
|
|
434
|
+
|
|
435
|
+
<!-- The consensus-GROUP library: which model PANELS review the workspace's heavier
|
|
436
|
+
tasks, and at what estimate bar. Beside the presets because both answer "which
|
|
437
|
+
models do the work"; its own component so this screen keeps its size budget. -->
|
|
438
|
+
<ConsensusGroupsSection />
|
|
408
439
|
</template>
|
|
409
440
|
|
|
410
441
|
<!-- ===== editor view ===== -->
|
|
@@ -453,10 +484,11 @@ function fail(title: string, e: unknown) {
|
|
|
453
484
|
</div>
|
|
454
485
|
|
|
455
486
|
<div>
|
|
456
|
-
<div class="mb-1 flex items-
|
|
487
|
+
<div class="mb-1 flex items-start justify-between gap-3">
|
|
457
488
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
458
489
|
{{ t('settings.modelConfiguration.editor.perAgentOverrides') }}
|
|
459
490
|
</span>
|
|
491
|
+
<AgentTierSelect class="w-56 shrink-0" :hidden-count="hiddenByTier" />
|
|
460
492
|
</div>
|
|
461
493
|
<UInput
|
|
462
494
|
v-model="filter"
|
|
@@ -2,6 +2,10 @@ import {
|
|
|
2
2
|
createRiskPolicyContract,
|
|
3
3
|
listMergeClassRollupsContract,
|
|
4
4
|
tagMergeReviewEffortContract,
|
|
5
|
+
createConsensusGroupContract,
|
|
6
|
+
deleteConsensusGroupContract,
|
|
7
|
+
listConsensusGroupsContract,
|
|
8
|
+
updateConsensusGroupContract,
|
|
5
9
|
createModelPresetContract,
|
|
6
10
|
deleteRiskPolicyContract,
|
|
7
11
|
deleteModelPresetContract,
|
|
@@ -14,6 +18,7 @@ import {
|
|
|
14
18
|
} from '@cat-factory/contracts'
|
|
15
19
|
import type { ReviewEffort, UpdateRiskPolicyInput } from '~/types/merge'
|
|
16
20
|
import type { CreateModelPresetInput, UpdateModelPresetInput } from '~/types/model-presets'
|
|
21
|
+
import type { CreateConsensusGroupInput, UpdateConsensusGroupInput } from '~/types/consensus'
|
|
17
22
|
import type { SendParams } from './client'
|
|
18
23
|
import type { ApiContext } from './context'
|
|
19
24
|
|
|
@@ -86,5 +91,22 @@ export function presetsApi({ send, ws }: ApiContext) {
|
|
|
86
91
|
// a drifted one, or materialise a new built-in that appeared). Custom presets reject this.
|
|
87
92
|
reseedModelPreset: (workspaceId: string, presetId: string) =>
|
|
88
93
|
send(reseedModelPresetContract, { pathPrefix: ws(workspaceId), pathParams: { presetId } }),
|
|
94
|
+
|
|
95
|
+
// ---- consensus groups (the estimate-gated review panels a step escalates to) ----
|
|
96
|
+
listConsensusGroups: (workspaceId: string) =>
|
|
97
|
+
send(listConsensusGroupsContract, { pathPrefix: ws(workspaceId) }),
|
|
98
|
+
|
|
99
|
+
createConsensusGroup: (workspaceId: string, body: CreateConsensusGroupInput) =>
|
|
100
|
+
send(createConsensusGroupContract, { pathPrefix: ws(workspaceId), body }),
|
|
101
|
+
|
|
102
|
+
updateConsensusGroup: (workspaceId: string, groupId: string, body: UpdateConsensusGroupInput) =>
|
|
103
|
+
send(updateConsensusGroupContract, {
|
|
104
|
+
pathPrefix: ws(workspaceId),
|
|
105
|
+
pathParams: { groupId },
|
|
106
|
+
body,
|
|
107
|
+
}),
|
|
108
|
+
|
|
109
|
+
deleteConsensusGroup: (workspaceId: string, groupId: string) =>
|
|
110
|
+
send(deleteConsensusGroupContract, { pathPrefix: ws(workspaceId), pathParams: { groupId } }),
|
|
89
111
|
}
|
|
90
112
|
}
|
|
@@ -28,15 +28,21 @@ describe('customKindToArchetype', () => {
|
|
|
28
28
|
})
|
|
29
29
|
})
|
|
30
30
|
|
|
31
|
-
it('carries category and resultView through when present', () => {
|
|
32
|
-
const a = customKindToArchetype(
|
|
31
|
+
it('carries category, tier and resultView through when present', () => {
|
|
32
|
+
const a = customKindToArchetype(
|
|
33
|
+
kind({ category: 'review', tier: 'basic', resultView: 'acme:audit' }),
|
|
34
|
+
)
|
|
33
35
|
expect(a.category).toBe('review')
|
|
36
|
+
expect(a.tier).toBe('basic')
|
|
34
37
|
expect(a.resultView).toBe('acme:audit')
|
|
35
38
|
})
|
|
36
39
|
|
|
37
|
-
it('omits category/resultView when absent (no undefined keys)', () => {
|
|
40
|
+
it('omits category/tier/resultView when absent (no undefined keys)', () => {
|
|
38
41
|
const a = customKindToArchetype(kind())
|
|
39
42
|
expect('category' in a).toBe(false)
|
|
43
|
+
// Left UNSET rather than stamped with the default, so the fallback stays in one place
|
|
44
|
+
// (`agentTierVisibleAt`) instead of being forked into this projection.
|
|
45
|
+
expect('tier' in a).toBe(false)
|
|
40
46
|
expect('resultView' in a).toBe(false)
|
|
41
47
|
})
|
|
42
48
|
})
|
|
@@ -27,6 +27,10 @@ export function customKindToArchetype(kind: CustomAgentKind): AgentArchetype {
|
|
|
27
27
|
color: p.color,
|
|
28
28
|
description: p.description,
|
|
29
29
|
...(p.category ? { category: p.category } : {}),
|
|
30
|
+
// A kind that declares no tier is left WITHOUT one rather than stamped with the default
|
|
31
|
+
// here, so the single fallback stays in `agentTierVisibleAt` — filling it in at the
|
|
32
|
+
// projection would fork the rule the moment the default changes.
|
|
33
|
+
...(p.tier ? { tier: p.tier } : {}),
|
|
30
34
|
...(p.resultView ? { resultView: p.resultView } : {}),
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import type { AgentTier } from '~/types/domain'
|
|
3
|
+
import { useAgentTierStore } from '~/stores/agentTier'
|
|
4
|
+
|
|
5
|
+
describe('agentTier store', () => {
|
|
6
|
+
it('opens on the everyday-loop tier and records a widening', () => {
|
|
7
|
+
const store = useAgentTierStore()
|
|
8
|
+
expect(store.tier).toBe('basic')
|
|
9
|
+
expect(store.showsAll).toBe(false)
|
|
10
|
+
|
|
11
|
+
store.setTier('advanced')
|
|
12
|
+
expect(store.tier).toBe('advanced')
|
|
13
|
+
// The widest level is the "show everything" setting the surfaces advertise.
|
|
14
|
+
expect(store.showsAll).toBe(true)
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('falls back to the default when the persisted value is not a known tier', () => {
|
|
18
|
+
const store = useAgentTierStore()
|
|
19
|
+
// What a blob written by an older build (or hand-edited) looks like coming back in. A
|
|
20
|
+
// catalog filtered on an unknown level would list nothing at all, so this must not pass through.
|
|
21
|
+
store.storedTier = 'expert' as AgentTier
|
|
22
|
+
expect(store.tier).toBe('basic')
|
|
23
|
+
})
|
|
24
|
+
})
|