@cat-factory/app 0.16.0 → 0.16.1
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/app/components/layout/AiProvidersBanner.vue +113 -0
- package/app/components/panels/inspector/TaskRunSettings.vue +46 -0
- package/app/components/providers/AiPresetMismatchDialog.vue +84 -0
- package/app/components/providers/AiProviderOnboardingModal.vue +106 -0
- package/app/composables/useAiReadiness.ts +62 -0
- package/app/pages/index.vue +64 -0
- package/app/stores/models.ts +35 -1
- package/app/stores/ui.ts +54 -0
- package/package.json +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Persistent prompt that AI isn't ready, mirroring GitHubPatBanner. Two states, in priority
|
|
3
|
+
// order: (1) no usable model source at all → "Configure AI" reopens the onboarding dialog;
|
|
4
|
+
// (2) usable models exist but the default model preset points at unavailable ones → a milder
|
|
5
|
+
// warning reopening the preset-mismatch dialog. Dismissible per session (the dismissed flags
|
|
6
|
+
// live on the ui store, so the auto-open watcher and this banner share one source of truth).
|
|
7
|
+
import { computed } from 'vue'
|
|
8
|
+
|
|
9
|
+
const ui = useUiStore()
|
|
10
|
+
const { ready, hasUsableModel, defaultPresetBroken } = useAiReadiness()
|
|
11
|
+
|
|
12
|
+
const showSetup = computed(() => ready.value && !hasUsableModel.value && !ui.aiSetupDismissed)
|
|
13
|
+
const showPreset = computed(() => ready.value && defaultPresetBroken.value && !ui.aiPresetDismissed)
|
|
14
|
+
// The no-AI prompt owns the screen when nothing works; the preset prompt is secondary.
|
|
15
|
+
const show = computed(() => showSetup.value || showPreset.value)
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<template>
|
|
19
|
+
<Transition name="fade">
|
|
20
|
+
<div v-if="show" class="absolute inset-x-0 top-0 z-40 flex justify-center px-4 pt-4">
|
|
21
|
+
<!-- (1) No usable AI source -->
|
|
22
|
+
<div
|
|
23
|
+
v-if="showSetup"
|
|
24
|
+
class="w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
|
|
25
|
+
role="alert"
|
|
26
|
+
>
|
|
27
|
+
<div class="flex items-start gap-4">
|
|
28
|
+
<UIcon name="i-lucide-cpu" class="mt-0.5 h-9 w-9 shrink-0 text-amber-400" />
|
|
29
|
+
<div class="min-w-0 flex-1">
|
|
30
|
+
<div class="flex items-start justify-between gap-3">
|
|
31
|
+
<h2 class="text-lg font-semibold text-amber-100">No AI model configured</h2>
|
|
32
|
+
<UButton
|
|
33
|
+
color="neutral"
|
|
34
|
+
variant="ghost"
|
|
35
|
+
size="xs"
|
|
36
|
+
icon="i-lucide-x"
|
|
37
|
+
aria-label="Dismiss"
|
|
38
|
+
@click="ui.dismissAiSetup()"
|
|
39
|
+
/>
|
|
40
|
+
</div>
|
|
41
|
+
<p class="mt-1 text-sm text-amber-200/90">
|
|
42
|
+
Agents need a model to run. AI works out of the box only on a Cloudflare deployment
|
|
43
|
+
with Workers AI enabled — otherwise connect a provider key, subscription, proxy, or
|
|
44
|
+
local runner to continue.
|
|
45
|
+
</p>
|
|
46
|
+
<div class="mt-4">
|
|
47
|
+
<UButton
|
|
48
|
+
color="warning"
|
|
49
|
+
variant="solid"
|
|
50
|
+
icon="i-lucide-settings"
|
|
51
|
+
@click="ui.openAiProviderSetup()"
|
|
52
|
+
>
|
|
53
|
+
Configure AI
|
|
54
|
+
</UButton>
|
|
55
|
+
</div>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- (2) Default preset references unavailable models -->
|
|
61
|
+
<div
|
|
62
|
+
v-else
|
|
63
|
+
class="w-full max-w-3xl rounded-2xl border border-amber-500/50 bg-amber-950/90 p-4 shadow-xl backdrop-blur"
|
|
64
|
+
role="alert"
|
|
65
|
+
>
|
|
66
|
+
<div class="flex items-start gap-3">
|
|
67
|
+
<UIcon name="i-lucide-triangle-alert" class="mt-0.5 h-7 w-7 shrink-0 text-amber-400" />
|
|
68
|
+
<div class="min-w-0 flex-1">
|
|
69
|
+
<div class="flex items-start justify-between gap-3">
|
|
70
|
+
<h2 class="text-sm font-semibold text-amber-100">
|
|
71
|
+
Default model preset uses unavailable models
|
|
72
|
+
</h2>
|
|
73
|
+
<UButton
|
|
74
|
+
color="neutral"
|
|
75
|
+
variant="ghost"
|
|
76
|
+
size="xs"
|
|
77
|
+
icon="i-lucide-x"
|
|
78
|
+
aria-label="Dismiss"
|
|
79
|
+
@click="ui.dismissAiPresetMismatch()"
|
|
80
|
+
/>
|
|
81
|
+
</div>
|
|
82
|
+
<p class="mt-1 text-[13px] text-amber-200/90">
|
|
83
|
+
Tasks using the default preset would fail. Edit the preset or configure the missing
|
|
84
|
+
provider.
|
|
85
|
+
</p>
|
|
86
|
+
<div class="mt-3">
|
|
87
|
+
<UButton
|
|
88
|
+
size="sm"
|
|
89
|
+
color="warning"
|
|
90
|
+
variant="solid"
|
|
91
|
+
icon="i-lucide-cpu"
|
|
92
|
+
@click="ui.openAiPresetMismatch()"
|
|
93
|
+
>
|
|
94
|
+
Review preset
|
|
95
|
+
</UButton>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</Transition>
|
|
102
|
+
</template>
|
|
103
|
+
|
|
104
|
+
<style scoped>
|
|
105
|
+
.fade-enter-active,
|
|
106
|
+
.fade-leave-active {
|
|
107
|
+
transition: opacity 0.2s ease;
|
|
108
|
+
}
|
|
109
|
+
.fade-enter-from,
|
|
110
|
+
.fade-leave-to {
|
|
111
|
+
opacity: 0;
|
|
112
|
+
}
|
|
113
|
+
</style>
|
|
@@ -8,9 +8,12 @@ const props = defineProps<{ block: Block }>()
|
|
|
8
8
|
const board = useBoardStore()
|
|
9
9
|
const mergePresets = useMergePresetsStore()
|
|
10
10
|
const modelPresets = useModelPresetsStore()
|
|
11
|
+
const models = useModelsStore()
|
|
11
12
|
const pipelines = usePipelinesStore()
|
|
12
13
|
const accounts = useAccountsStore()
|
|
13
14
|
const tracker = useTrackerStore()
|
|
15
|
+
const ui = useUiStore()
|
|
16
|
+
const { ready, unavailableInPreset } = useAiReadiness()
|
|
14
17
|
|
|
15
18
|
// ---- responsible product person --------------------------------------------
|
|
16
19
|
// The account member (a `product` role-holder) accountable for this task; they are
|
|
@@ -73,6 +76,17 @@ function setPreset(id: string) {
|
|
|
73
76
|
// (a running step keeps the model it was dispatched with). A model pinned directly on
|
|
74
77
|
// the task still overrides the preset.
|
|
75
78
|
const selectedModelPreset = computed(() => modelPresets.resolve(props.block.modelPresetId))
|
|
79
|
+
|
|
80
|
+
// Model ids in the chosen preset that aren't usable under the current configuration — the
|
|
81
|
+
// task would fail when a step dispatches onto one. Labelled for the inline warning below.
|
|
82
|
+
// Gated on `ready`: until the per-workspace catalog has loaded, `isUsableId` reports every
|
|
83
|
+
// model unusable, which would surface a spurious "this task would fail" warning (e.g. while
|
|
84
|
+
// the catalog fetch is in flight, or if it failed) — so only flag once the catalog is known.
|
|
85
|
+
const unavailablePresetModels = computed(() =>
|
|
86
|
+
ready.value
|
|
87
|
+
? unavailableInPreset(selectedModelPreset.value).map((id) => models.labelForId(id))
|
|
88
|
+
: [],
|
|
89
|
+
)
|
|
76
90
|
const modelPresetMenu = computed(() => [
|
|
77
91
|
[
|
|
78
92
|
{
|
|
@@ -243,6 +257,38 @@ const resolveOnMergeLabel = computed(() =>
|
|
|
243
257
|
<div v-else class="text-[11px] text-slate-500">
|
|
244
258
|
No preset configured — agents run on the deployment's default routing.
|
|
245
259
|
</div>
|
|
260
|
+
<div
|
|
261
|
+
v-if="unavailablePresetModels.length"
|
|
262
|
+
class="mt-2 rounded-md border border-amber-500/40 bg-amber-950/40 p-2 text-[11px] text-amber-200/90"
|
|
263
|
+
>
|
|
264
|
+
<div class="flex items-start gap-1.5">
|
|
265
|
+
<UIcon
|
|
266
|
+
name="i-lucide-triangle-alert"
|
|
267
|
+
class="mt-0.5 h-3.5 w-3.5 shrink-0 text-amber-400"
|
|
268
|
+
/>
|
|
269
|
+
<div class="min-w-0">
|
|
270
|
+
<p>
|
|
271
|
+
Not available under the current configuration:
|
|
272
|
+
<span class="text-amber-100">{{ unavailablePresetModels.join(', ') }}</span
|
|
273
|
+
>. This task would fail on those steps.
|
|
274
|
+
</p>
|
|
275
|
+
<div class="mt-1.5 flex flex-wrap gap-2">
|
|
276
|
+
<button
|
|
277
|
+
class="font-medium text-amber-100 underline-offset-2 hover:underline"
|
|
278
|
+
@click="ui.openModelConfig()"
|
|
279
|
+
>
|
|
280
|
+
Edit presets
|
|
281
|
+
</button>
|
|
282
|
+
<button
|
|
283
|
+
class="font-medium text-amber-100 underline-offset-2 hover:underline"
|
|
284
|
+
@click="ui.openVendorCredentials()"
|
|
285
|
+
>
|
|
286
|
+
Configure vendors
|
|
287
|
+
</button>
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
</div>
|
|
291
|
+
</div>
|
|
246
292
|
<p class="mt-1 text-[11px] text-slate-500">
|
|
247
293
|
Changing this affects only steps that haven't started yet.
|
|
248
294
|
</p>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shown when the workspace HAS usable AI models but its DEFAULT model preset still points
|
|
3
|
+
// at one or more that aren't usable under the current configuration (e.g. the built-in
|
|
4
|
+
// "Kimi K2.7" default with no Kimi source connected). Tasks fall back to the default
|
|
5
|
+
// preset, so they would dispatch onto an unavailable model and fail. This dialog names the
|
|
6
|
+
// offending models and offers to edit/switch the preset or configure more vendors. It
|
|
7
|
+
// auto-opens once per session (driven from pages/index.vue) and clears once the preset is
|
|
8
|
+
// fixed (or all its models become available).
|
|
9
|
+
import { computed } from 'vue'
|
|
10
|
+
|
|
11
|
+
const ui = useUiStore()
|
|
12
|
+
const models = useModelsStore()
|
|
13
|
+
const modelPresets = useModelPresetsStore()
|
|
14
|
+
const { defaultPresetUnavailable } = useAiReadiness()
|
|
15
|
+
|
|
16
|
+
const open = computed({
|
|
17
|
+
get: () => ui.aiPresetMismatchOpen,
|
|
18
|
+
set: (v: boolean) => (v ? ui.openAiPresetMismatch() : ui.closeAiPresetMismatch()),
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const presetName = computed(() => modelPresets.defaultPreset?.name ?? 'default preset')
|
|
22
|
+
|
|
23
|
+
/** Readable labels for the unavailable model ids (catalog label, else the raw id). */
|
|
24
|
+
const unavailableLabels = computed(() =>
|
|
25
|
+
defaultPresetUnavailable.value.map((id) => models.labelForId(id)),
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
function go(action: () => void) {
|
|
29
|
+
ui.closeAiPresetMismatch()
|
|
30
|
+
action()
|
|
31
|
+
}
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<UModal v-model:open="open" title="Preset uses unavailable models" :ui="{ content: 'max-w-xl' }">
|
|
36
|
+
<template #body>
|
|
37
|
+
<div class="space-y-5">
|
|
38
|
+
<p class="text-sm text-slate-300">
|
|
39
|
+
The workspace default model preset
|
|
40
|
+
<span class="font-medium text-slate-100">“{{ presetName }}”</span>
|
|
41
|
+
assigns models that aren't available under the current configuration. Tasks that use this
|
|
42
|
+
preset would fail when they reach those steps.
|
|
43
|
+
</p>
|
|
44
|
+
|
|
45
|
+
<div class="rounded-lg border border-slate-700 bg-slate-900/50 p-3">
|
|
46
|
+
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
|
47
|
+
Unavailable
|
|
48
|
+
</p>
|
|
49
|
+
<div class="flex flex-wrap gap-1.5">
|
|
50
|
+
<UBadge
|
|
51
|
+
v-for="label in unavailableLabels"
|
|
52
|
+
:key="label"
|
|
53
|
+
color="warning"
|
|
54
|
+
variant="subtle"
|
|
55
|
+
size="sm"
|
|
56
|
+
>
|
|
57
|
+
{{ label }}
|
|
58
|
+
</UBadge>
|
|
59
|
+
</div>
|
|
60
|
+
</div>
|
|
61
|
+
|
|
62
|
+
<p class="text-[13px] text-slate-400">
|
|
63
|
+
Either repoint the preset at models you have configured, or add the missing provider.
|
|
64
|
+
</p>
|
|
65
|
+
|
|
66
|
+
<div class="flex flex-wrap justify-end gap-2">
|
|
67
|
+
<UButton color="neutral" variant="ghost" size="sm" @click="open = false"> Later </UButton>
|
|
68
|
+
<UButton
|
|
69
|
+
color="neutral"
|
|
70
|
+
variant="subtle"
|
|
71
|
+
size="sm"
|
|
72
|
+
icon="i-lucide-key-round"
|
|
73
|
+
@click="go(ui.openVendorCredentials)"
|
|
74
|
+
>
|
|
75
|
+
Configure vendors
|
|
76
|
+
</UButton>
|
|
77
|
+
<UButton color="primary" size="sm" icon="i-lucide-cpu" @click="go(ui.openModelConfig)">
|
|
78
|
+
Edit presets
|
|
79
|
+
</UButton>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
</UModal>
|
|
84
|
+
</template>
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Shown when the workspace has NO usable AI model source. cat-factory only ships a working
|
|
3
|
+
// model out of the box on a Cloudflare deployment with Workers AI enabled; every other
|
|
4
|
+
// deployment needs the user to onboard at least one source. This dialog explains that and
|
|
5
|
+
// routes to each configuration surface (reusing the existing credential panels rather than
|
|
6
|
+
// duplicating them). It auto-opens once per session (driven from pages/index.vue) and, like
|
|
7
|
+
// the banner, disappears automatically the moment a usable source exists.
|
|
8
|
+
import { computed } from 'vue'
|
|
9
|
+
|
|
10
|
+
const ui = useUiStore()
|
|
11
|
+
|
|
12
|
+
const open = computed({
|
|
13
|
+
get: () => ui.aiProviderSetupOpen,
|
|
14
|
+
set: (v: boolean) => (v ? ui.openAiProviderSetup() : ui.closeAiProviderSetup()),
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
// Each route closes this dialog first so we never stack two modals, then opens the target
|
|
18
|
+
// panel. All of these panels are mounted in pages/index.vue alongside this one.
|
|
19
|
+
function go(action: () => void) {
|
|
20
|
+
ui.closeAiProviderSetup()
|
|
21
|
+
action()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface Route {
|
|
25
|
+
icon: string
|
|
26
|
+
title: string
|
|
27
|
+
body: string
|
|
28
|
+
cta: string
|
|
29
|
+
onSelect: () => void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const routes = computed<Route[]>(() => [
|
|
33
|
+
{
|
|
34
|
+
icon: 'i-lucide-key-round',
|
|
35
|
+
title: 'Provider keys & subscriptions',
|
|
36
|
+
body: 'Add a direct provider API key (OpenAI, Anthropic, Qwen, …) or connect a commercial coding-plan subscription (Kimi, DeepSeek) or a personal one (Claude, GLM, Codex).',
|
|
37
|
+
cta: 'Open LLM vendors',
|
|
38
|
+
onSelect: () => go(ui.openVendorCredentials),
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
icon: 'i-lucide-route',
|
|
42
|
+
title: 'OpenRouter gateway',
|
|
43
|
+
body: 'Enable models through the OpenRouter gateway with a single key — browse and turn on the models you want.',
|
|
44
|
+
cta: 'Browse OpenRouter models',
|
|
45
|
+
onSelect: () => go(ui.openOpenRouter),
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
icon: 'i-lucide-server',
|
|
49
|
+
title: 'My local runners',
|
|
50
|
+
body: 'Point cat-factory at a model you run yourself (Ollama, LM Studio, llama.cpp, vLLM, …). No API key, no spend.',
|
|
51
|
+
cta: 'Configure local runners',
|
|
52
|
+
onSelect: () => go(ui.openLocalModels),
|
|
53
|
+
},
|
|
54
|
+
])
|
|
55
|
+
</script>
|
|
56
|
+
|
|
57
|
+
<template>
|
|
58
|
+
<UModal v-model:open="open" title="Set up an AI model provider" :ui="{ content: 'max-w-2xl' }">
|
|
59
|
+
<template #body>
|
|
60
|
+
<div class="space-y-5">
|
|
61
|
+
<div
|
|
62
|
+
class="flex items-start gap-3 rounded-lg border border-amber-500/40 bg-amber-950/40 p-4"
|
|
63
|
+
>
|
|
64
|
+
<UIcon name="i-lucide-cpu" class="mt-0.5 h-6 w-6 shrink-0 text-amber-400" />
|
|
65
|
+
<div class="min-w-0 text-sm text-amber-100/90">
|
|
66
|
+
<p class="font-medium text-amber-100">
|
|
67
|
+
No AI model is available on this workspace yet.
|
|
68
|
+
</p>
|
|
69
|
+
<p class="mt-1">
|
|
70
|
+
Agents need a model to run. AI works out of the box only on a Cloudflare deployment
|
|
71
|
+
with Workers AI enabled — otherwise connect at least one source below.
|
|
72
|
+
</p>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div class="space-y-3">
|
|
77
|
+
<div
|
|
78
|
+
v-for="r in routes"
|
|
79
|
+
:key="r.title"
|
|
80
|
+
class="flex items-start gap-3 rounded-xl border border-slate-700 bg-slate-900/50 p-4"
|
|
81
|
+
>
|
|
82
|
+
<UIcon :name="r.icon" class="mt-0.5 h-5 w-5 shrink-0 text-indigo-300" />
|
|
83
|
+
<div class="min-w-0 flex-1">
|
|
84
|
+
<p class="text-sm font-semibold text-slate-100">{{ r.title }}</p>
|
|
85
|
+
<p class="mt-0.5 text-[13px] leading-relaxed text-slate-400">{{ r.body }}</p>
|
|
86
|
+
</div>
|
|
87
|
+
<UButton
|
|
88
|
+
size="sm"
|
|
89
|
+
color="primary"
|
|
90
|
+
variant="subtle"
|
|
91
|
+
class="shrink-0"
|
|
92
|
+
@click="r.onSelect()"
|
|
93
|
+
>
|
|
94
|
+
{{ r.cta }}
|
|
95
|
+
</UButton>
|
|
96
|
+
</div>
|
|
97
|
+
</div>
|
|
98
|
+
|
|
99
|
+
<p class="text-[11px] leading-relaxed text-slate-500">
|
|
100
|
+
AWS Bedrock and Cloudflare Workers AI are enabled by the deployment operator via
|
|
101
|
+
environment configuration, not from this screen.
|
|
102
|
+
</p>
|
|
103
|
+
</div>
|
|
104
|
+
</template>
|
|
105
|
+
</UModal>
|
|
106
|
+
</template>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ModelPreset } from '~/types/model-presets'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AI-configuration readiness for the active workspace. cat-factory only ships a usable
|
|
5
|
+
* AI model out of the box on a Cloudflare deployment with Workers AI enabled; every other
|
|
6
|
+
* deployment needs the user to onboard at least one source (a provider key, a pooled /
|
|
7
|
+
* personal subscription, a proxy, Bedrock, or a local runner). This composable turns the
|
|
8
|
+
* per-workspace model catalog (whose `available` flag already reflects all of those) plus
|
|
9
|
+
* the workspace's model presets into the two signals the onboarding surfaces consume:
|
|
10
|
+
*
|
|
11
|
+
* - `hasUsableModel` — is ANY AI source configured at all? (false ⇒ the no-AI prompt)
|
|
12
|
+
* - `defaultPresetBroken` — the workspace has usable models, but its DEFAULT model preset
|
|
13
|
+
* still points at one or more that aren't usable (⇒ the preset-mismatch prompt). Gated on
|
|
14
|
+
* `hasUsableModel` so the no-AI prompt owns the "nothing works" case on its own.
|
|
15
|
+
*
|
|
16
|
+
* Read-only over the existing stores; the catalog is loaded elsewhere (on workspace-ready
|
|
17
|
+
* and after credential edits), so `ready` simply reports whether that load has landed for
|
|
18
|
+
* the active workspace.
|
|
19
|
+
*/
|
|
20
|
+
export function useAiReadiness() {
|
|
21
|
+
const models = useModelsStore()
|
|
22
|
+
const modelPresets = useModelPresetsStore()
|
|
23
|
+
const workspace = useWorkspaceStore()
|
|
24
|
+
|
|
25
|
+
/** The per-workspace catalog has loaded for the workspace currently open. */
|
|
26
|
+
const ready = computed(
|
|
27
|
+
() =>
|
|
28
|
+
models.loaded &&
|
|
29
|
+
workspace.workspaceId != null &&
|
|
30
|
+
models.loadedWorkspaceId === workspace.workspaceId,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
const hasUsableModel = computed(() => models.hasUsableModel)
|
|
34
|
+
|
|
35
|
+
/** Every distinct catalog model id a preset assigns (base + overrides). */
|
|
36
|
+
function presetModelIds(preset: ModelPreset | null): string[] {
|
|
37
|
+
if (!preset) return []
|
|
38
|
+
return [...new Set([preset.baseModelId, ...Object.values(preset.overrides)])]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The preset's assigned model ids that aren't usable under the current configuration. */
|
|
42
|
+
function unavailableInPreset(preset: ModelPreset | null): string[] {
|
|
43
|
+
return presetModelIds(preset).filter((id) => !models.isUsableId(id))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Unusable model ids in the workspace DEFAULT preset (what tasks fall back to). */
|
|
47
|
+
const defaultPresetUnavailable = computed(() => unavailableInPreset(modelPresets.defaultPreset))
|
|
48
|
+
|
|
49
|
+
/** Usable models exist, but the default preset still references unusable ones. */
|
|
50
|
+
const defaultPresetBroken = computed(
|
|
51
|
+
() => hasUsableModel.value && defaultPresetUnavailable.value.length > 0,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
ready,
|
|
56
|
+
hasUsableModel,
|
|
57
|
+
presetModelIds,
|
|
58
|
+
unavailableInPreset,
|
|
59
|
+
defaultPresetUnavailable,
|
|
60
|
+
defaultPresetBroken,
|
|
61
|
+
}
|
|
62
|
+
}
|
package/app/pages/index.vue
CHANGED
|
@@ -4,6 +4,7 @@ import SideBar from '~/components/layout/SideBar.vue'
|
|
|
4
4
|
import BoardToolbar from '~/components/layout/BoardToolbar.vue'
|
|
5
5
|
import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
|
|
6
6
|
import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
|
|
7
|
+
import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
|
|
7
8
|
import PipelineBuilder from '~/components/pipeline/PipelineBuilder.vue'
|
|
8
9
|
import InspectorPanel from '~/components/panels/InspectorPanel.vue'
|
|
9
10
|
import DecisionModal from '~/components/panels/DecisionModal.vue'
|
|
@@ -33,13 +34,72 @@ import LocalModelEndpointsPanel from '~/components/settings/LocalModelEndpointsP
|
|
|
33
34
|
import OpenRouterCatalogPanel from '~/components/settings/OpenRouterCatalogPanel.vue'
|
|
34
35
|
import VendorCredentialsModal from '~/components/providers/VendorCredentialsModal.vue'
|
|
35
36
|
import PersonalCredentialModal from '~/components/providers/PersonalCredentialModal.vue'
|
|
37
|
+
import AiProviderOnboardingModal from '~/components/providers/AiProviderOnboardingModal.vue'
|
|
38
|
+
import AiPresetMismatchDialog from '~/components/providers/AiPresetMismatchDialog.vue'
|
|
36
39
|
|
|
37
40
|
const workspace = useWorkspaceStore()
|
|
38
41
|
const github = useGitHubStore()
|
|
42
|
+
const models = useModelsStore()
|
|
43
|
+
const ui = useUiStore()
|
|
44
|
+
const aiReadiness = useAiReadiness()
|
|
39
45
|
|
|
40
46
|
// Load the board from the backend before rendering it.
|
|
41
47
|
onMounted(() => workspace.init())
|
|
42
48
|
|
|
49
|
+
// Per-session guards so each AI-onboarding dialog auto-opens at most once (later opens are
|
|
50
|
+
// user-driven from the banner). Reset on workspace switch by the catalog watcher below.
|
|
51
|
+
const autoOpenedSetup = ref(false)
|
|
52
|
+
const autoOpenedPreset = ref(false)
|
|
53
|
+
|
|
54
|
+
// Load the per-workspace model catalog as soon as a board is active (re-loaded per board —
|
|
55
|
+
// availability reflects that workspace's keys/subscriptions). This populates the AI-readiness
|
|
56
|
+
// signals regardless of which lazy picker happens to mount, so the onboarding prompts below
|
|
57
|
+
// can fire. Credential edits re-fetch via `models.refresh()` in the provider panels.
|
|
58
|
+
watch(
|
|
59
|
+
() => workspace.workspaceId,
|
|
60
|
+
(id, prev) => {
|
|
61
|
+
if (id) void models.ensureLoaded(id)
|
|
62
|
+
// Switching workspaces resets the per-session AI-onboarding state: dismissals and the
|
|
63
|
+
// auto-open guards are scoped to one workspace, so a prompt dismissed in workspace A must
|
|
64
|
+
// not suppress the (independent) prompt for workspace B that also lacks a usable source.
|
|
65
|
+
if (prev !== undefined && id !== prev) {
|
|
66
|
+
autoOpenedSetup.value = false
|
|
67
|
+
autoOpenedPreset.value = false
|
|
68
|
+
ui.resetAiOnboarding()
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
{ immediate: true },
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
// Auto-open the right AI-onboarding dialog once per session: the no-source prompt takes
|
|
75
|
+
// precedence over the preset-mismatch prompt. Honour the per-session dismissed flags so a
|
|
76
|
+
// user who closed the banner isn't re-interrupted, and only auto-open once each (later opens
|
|
77
|
+
// are user-driven from the banner). The prompts clear themselves once the gap is closed.
|
|
78
|
+
watch(
|
|
79
|
+
() => [
|
|
80
|
+
aiReadiness.ready.value,
|
|
81
|
+
aiReadiness.hasUsableModel.value,
|
|
82
|
+
aiReadiness.defaultPresetBroken.value,
|
|
83
|
+
],
|
|
84
|
+
() => {
|
|
85
|
+
if (!aiReadiness.ready.value) return
|
|
86
|
+
if (!aiReadiness.hasUsableModel.value) {
|
|
87
|
+
if (!autoOpenedSetup.value && !ui.aiSetupDismissed) {
|
|
88
|
+
autoOpenedSetup.value = true
|
|
89
|
+
ui.openAiProviderSetup()
|
|
90
|
+
}
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
if (aiReadiness.defaultPresetBroken.value) {
|
|
94
|
+
if (!autoOpenedPreset.value && !ui.aiPresetDismissed) {
|
|
95
|
+
autoOpenedPreset.value = true
|
|
96
|
+
ui.openAiPresetMismatch()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
{ immediate: true },
|
|
101
|
+
)
|
|
102
|
+
|
|
43
103
|
// Probe the GitHub integration as soon as a board is active (re-probe per board —
|
|
44
104
|
// connections are per workspace). The result drives the onboarding gate below
|
|
45
105
|
// before the board mounts, so an unconnected user can't slip past it. SideBar
|
|
@@ -75,6 +135,8 @@ watch(
|
|
|
75
135
|
<div class="flex h-screen w-screen overflow-hidden bg-slate-950 text-slate-100">
|
|
76
136
|
<!-- Local-mode setup prompt (missing GitHub PAT); floats over whatever is shown below. -->
|
|
77
137
|
<GitHubPatBanner />
|
|
138
|
+
<!-- AI-readiness prompt (no usable model source, or default preset uses unavailable models). -->
|
|
139
|
+
<AiProvidersBanner v-if="workspace.ready && !needsGitHubInstall && !githubProbePending" />
|
|
78
140
|
|
|
79
141
|
<!-- Resolving whether the GitHub App is installed, before we decide what to show. -->
|
|
80
142
|
<div
|
|
@@ -124,6 +186,8 @@ watch(
|
|
|
124
186
|
<OpenRouterCatalogPanel />
|
|
125
187
|
<VendorCredentialsModal />
|
|
126
188
|
<PersonalCredentialModal />
|
|
189
|
+
<AiProviderOnboardingModal />
|
|
190
|
+
<AiPresetMismatchDialog />
|
|
127
191
|
</template>
|
|
128
192
|
|
|
129
193
|
<!-- Backend unreachable / bootstrap failed -->
|
package/app/stores/models.ts
CHANGED
|
@@ -116,6 +116,28 @@ export const useModelsStore = defineStore('models', () => {
|
|
|
116
116
|
return id ? byId.value.get(id) : undefined
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
/** Readable label for a catalog model id: the catalog label, else the raw id. */
|
|
120
|
+
function labelForId(id: string): string {
|
|
121
|
+
return byId.value.get(id)?.label ?? id
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Whether the workspace has at least one usable AI model source right now. The
|
|
126
|
+
* per-workspace catalog already resolves `available` from the configured keys /
|
|
127
|
+
* subscriptions / Cloudflare opt-in / local runners, so this is the single signal for
|
|
128
|
+
* "is any AI configured at all". Only meaningful once the per-workspace catalog has
|
|
129
|
+
* loaded (`loadedWorkspaceId` set); the deployment catalog leaves `available` undefined.
|
|
130
|
+
*/
|
|
131
|
+
const hasUsableModel = computed(() => models.value.some((m) => m.available === true))
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether a specific catalog model id is usable under the current configuration. An id
|
|
135
|
+
* absent from the catalog (e.g. a model that was removed) counts as not usable.
|
|
136
|
+
*/
|
|
137
|
+
function isUsableId(id: string): boolean {
|
|
138
|
+
return byId.value.get(id)?.available === true
|
|
139
|
+
}
|
|
140
|
+
|
|
119
141
|
/**
|
|
120
142
|
* Friendly label for a recorded `provider:model` identifier (as carried on a
|
|
121
143
|
* pipeline step). Matches it against the catalog's effective refs; falls back
|
|
@@ -130,5 +152,17 @@ export const useModelsStore = defineStore('models', () => {
|
|
|
130
152
|
return hit ? `${hit.label} · ${hit.providerLabel}` : model || ref
|
|
131
153
|
}
|
|
132
154
|
|
|
133
|
-
return {
|
|
155
|
+
return {
|
|
156
|
+
models,
|
|
157
|
+
loaded,
|
|
158
|
+
loadedWorkspaceId,
|
|
159
|
+
ensureLoaded,
|
|
160
|
+
refresh,
|
|
161
|
+
byId,
|
|
162
|
+
getModel,
|
|
163
|
+
labelForId,
|
|
164
|
+
hasUsableModel,
|
|
165
|
+
isUsableId,
|
|
166
|
+
labelForRef,
|
|
167
|
+
}
|
|
134
168
|
})
|
package/app/stores/ui.ts
CHANGED
|
@@ -88,6 +88,16 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
88
88
|
// Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
|
|
89
89
|
const openRouterOpen = ref(false)
|
|
90
90
|
|
|
91
|
+
// AI-onboarding surfaces (driven by `useAiReadiness`). `aiProviderSetupOpen` is the
|
|
92
|
+
// "no usable AI source" dialog; `aiPresetMismatchOpen` is the "default preset points at
|
|
93
|
+
// unavailable models" dialog. The `*Dismissed` flags are per-session: they suppress the
|
|
94
|
+
// auto-open (and let the banner be dismissed) without permanently hiding the prompt — it
|
|
95
|
+
// re-evaluates on the next load. Both clear themselves once the underlying gap is closed.
|
|
96
|
+
const aiProviderSetupOpen = ref(false)
|
|
97
|
+
const aiPresetMismatchOpen = ref(false)
|
|
98
|
+
const aiSetupDismissed = ref(false)
|
|
99
|
+
const aiPresetDismissed = ref(false)
|
|
100
|
+
|
|
91
101
|
// Dedicated result-view overlay: a step whose agent kind declares a bespoke
|
|
92
102
|
// visualization (via the archetype's `resultView`) opens here instead of the generic
|
|
93
103
|
// prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
|
|
@@ -330,6 +340,39 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
330
340
|
function closeOpenRouter() {
|
|
331
341
|
openRouterOpen.value = false
|
|
332
342
|
}
|
|
343
|
+
function openAiProviderSetup() {
|
|
344
|
+
aiProviderSetupOpen.value = true
|
|
345
|
+
}
|
|
346
|
+
function closeAiProviderSetup() {
|
|
347
|
+
aiProviderSetupOpen.value = false
|
|
348
|
+
}
|
|
349
|
+
function openAiPresetMismatch() {
|
|
350
|
+
aiPresetMismatchOpen.value = true
|
|
351
|
+
}
|
|
352
|
+
function closeAiPresetMismatch() {
|
|
353
|
+
aiPresetMismatchOpen.value = false
|
|
354
|
+
}
|
|
355
|
+
// Banner dismissal is distinct from closing the dialog: closing the dialog leaves the
|
|
356
|
+
// banner so the user can reopen it; dismissing the banner hides the whole prompt for
|
|
357
|
+
// the session (it re-evaluates on the next load).
|
|
358
|
+
function dismissAiSetup() {
|
|
359
|
+
aiProviderSetupOpen.value = false
|
|
360
|
+
aiSetupDismissed.value = true
|
|
361
|
+
}
|
|
362
|
+
function dismissAiPresetMismatch() {
|
|
363
|
+
aiPresetMismatchOpen.value = false
|
|
364
|
+
aiPresetDismissed.value = true
|
|
365
|
+
}
|
|
366
|
+
// Clear the per-session AI-onboarding state (open dialogs + dismissed flags). Called on
|
|
367
|
+
// workspace switch: dismissals are per-session-per-workspace, so a prompt dismissed in one
|
|
368
|
+
// workspace must not suppress the (independent) prompt for another workspace that also
|
|
369
|
+
// lacks a usable AI source / has a broken default preset.
|
|
370
|
+
function resetAiOnboarding() {
|
|
371
|
+
aiProviderSetupOpen.value = false
|
|
372
|
+
aiPresetMismatchOpen.value = false
|
|
373
|
+
aiSetupDismissed.value = false
|
|
374
|
+
aiPresetDismissed.value = false
|
|
375
|
+
}
|
|
333
376
|
function openRequirementReview(blockId: string) {
|
|
334
377
|
resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
|
|
335
378
|
}
|
|
@@ -384,6 +427,10 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
384
427
|
vendorCredentialsOpen,
|
|
385
428
|
localModelsOpen,
|
|
386
429
|
openRouterOpen,
|
|
430
|
+
aiProviderSetupOpen,
|
|
431
|
+
aiPresetMismatchOpen,
|
|
432
|
+
aiSetupDismissed,
|
|
433
|
+
aiPresetDismissed,
|
|
387
434
|
resultView,
|
|
388
435
|
closeResultView,
|
|
389
436
|
stepDetail,
|
|
@@ -442,6 +489,13 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
442
489
|
closeLocalModels,
|
|
443
490
|
openOpenRouter,
|
|
444
491
|
closeOpenRouter,
|
|
492
|
+
openAiProviderSetup,
|
|
493
|
+
closeAiProviderSetup,
|
|
494
|
+
openAiPresetMismatch,
|
|
495
|
+
closeAiPresetMismatch,
|
|
496
|
+
dismissAiSetup,
|
|
497
|
+
dismissAiPresetMismatch,
|
|
498
|
+
resetAiOnboarding,
|
|
445
499
|
openRequirementReview,
|
|
446
500
|
openClarityReview,
|
|
447
501
|
openServiceSpec,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.16.
|
|
3
|
+
"version": "0.16.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|