@cat-factory/app 0.15.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/board/AddTaskModal.vue +26 -46
- package/app/components/documents/ContextDocumentPicker.vue +245 -0
- 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
|
@@ -5,13 +5,14 @@
|
|
|
5
5
|
// pipeline on it explicitly (and can keep editing it until they do).
|
|
6
6
|
//
|
|
7
7
|
// The form also shows ungated "Context documents" / "Context issues" sections
|
|
8
|
-
// (mirroring the task inspector):
|
|
9
|
-
//
|
|
10
|
-
//
|
|
8
|
+
// (mirroring the task inspector): an inline search picker (ContextDocumentPicker /
|
|
9
|
+
// ContextIssuePicker) finds already-imported items, search hits, or a pasted ref to
|
|
10
|
+
// attach as agent context. When the relevant integration isn't connected the Attach
|
|
11
|
+
// button is disabled with a hint. Linking needs the block id,
|
|
11
12
|
// so chosen items are staged locally and import-and-linked once the task is created
|
|
12
13
|
// (see useContextLinking) — the same context the agents see for every step of the run.
|
|
13
|
-
import type { DropdownMenuItem } from '@nuxt/ui'
|
|
14
14
|
import type { CreateTaskType, TaskTypeFields } from '~/types/domain'
|
|
15
|
+
import ContextDocumentPicker from '~/components/documents/ContextDocumentPicker.vue'
|
|
15
16
|
import ContextIssuePicker from '~/components/tasks/ContextIssuePicker.vue'
|
|
16
17
|
|
|
17
18
|
const ui = useUiStore()
|
|
@@ -200,41 +201,13 @@ function removePending(item: PendingContext) {
|
|
|
200
201
|
pendingContext.value = pendingContext.value.filter((c) => contextKey(c) !== contextKey(item))
|
|
201
202
|
}
|
|
202
203
|
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
(d) =>
|
|
211
|
-
!chosen.has(contextKey({ kind: 'document', source: d.source, externalId: d.externalId })),
|
|
212
|
-
)
|
|
213
|
-
.map((d) => ({
|
|
214
|
-
label: d.title,
|
|
215
|
-
icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
|
|
216
|
-
onSelect: () =>
|
|
217
|
-
addPending({
|
|
218
|
-
kind: 'document',
|
|
219
|
-
source: d.source,
|
|
220
|
-
externalId: d.externalId,
|
|
221
|
-
title: d.title,
|
|
222
|
-
icon: documents.descriptorFor(d.source)?.icon ?? 'i-lucide-file-text',
|
|
223
|
-
needsImport: false,
|
|
224
|
-
}),
|
|
225
|
-
}))
|
|
226
|
-
items.push({
|
|
227
|
-
label: 'Import a page…',
|
|
228
|
-
icon: 'i-lucide-file-down',
|
|
229
|
-
onSelect: () => ui.openDocumentImport(null),
|
|
230
|
-
})
|
|
231
|
-
return [items]
|
|
232
|
-
})
|
|
233
|
-
|
|
234
|
-
// Context issues are picked through an inline search picker (ContextIssuePicker)
|
|
235
|
-
// rather than a dropdown that opens a second modal — stacked page-level modals
|
|
236
|
-
// don't interact here, which is why the old "Import an issue…" path appeared to
|
|
237
|
-
// do nothing. The "Attach" button toggles the picker open.
|
|
204
|
+
// Context documents and issues are both picked through an inline search picker
|
|
205
|
+
// (ContextDocumentPicker / ContextIssuePicker) rather than a dropdown that opens a
|
|
206
|
+
// second modal — stacked page-level modals don't interact here, which is why the
|
|
207
|
+
// old "Import a page…" / "Import an issue…" entries appeared to open something but
|
|
208
|
+
// nothing was clickable. The "Attach" button toggles the relevant picker open.
|
|
209
|
+
const showDocPicker = ref(false)
|
|
210
|
+
const chosenDocKeys = computed(() => pendingDocs.value.map(contextKey))
|
|
238
211
|
const showIssuePicker = ref(false)
|
|
239
212
|
const chosenIssueKeys = computed(() => pendingIssues.value.map(contextKey))
|
|
240
213
|
|
|
@@ -255,6 +228,7 @@ watch(open, (isOpen) => {
|
|
|
255
228
|
pipelineId.value = ''
|
|
256
229
|
agentConfigValues.value = {}
|
|
257
230
|
pendingContext.value = []
|
|
231
|
+
showDocPicker.value = false
|
|
258
232
|
showIssuePicker.value = false
|
|
259
233
|
documents.loadDocuments().catch(() => {})
|
|
260
234
|
tasks.loadTasks().catch(() => {})
|
|
@@ -508,15 +482,16 @@ async function add() {
|
|
|
508
482
|
<span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
509
483
|
Context documents
|
|
510
484
|
</span>
|
|
511
|
-
<
|
|
485
|
+
<UButton
|
|
512
486
|
v-if="docsConnected"
|
|
513
|
-
|
|
514
|
-
|
|
487
|
+
color="neutral"
|
|
488
|
+
variant="soft"
|
|
489
|
+
size="xs"
|
|
490
|
+
:icon="showDocPicker ? 'i-lucide-x' : 'i-lucide-plus'"
|
|
491
|
+
@click="showDocPicker = !showDocPicker"
|
|
515
492
|
>
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
</UButton>
|
|
519
|
-
</UDropdownMenu>
|
|
493
|
+
{{ showDocPicker ? 'Done' : 'Attach' }}
|
|
494
|
+
</UButton>
|
|
520
495
|
<UButton
|
|
521
496
|
v-else
|
|
522
497
|
color="neutral"
|
|
@@ -533,6 +508,11 @@ async function add() {
|
|
|
533
508
|
Attach
|
|
534
509
|
</UButton>
|
|
535
510
|
</div>
|
|
511
|
+
<ContextDocumentPicker
|
|
512
|
+
v-if="showDocPicker && docsConnected"
|
|
513
|
+
:chosen-keys="chosenDocKeys"
|
|
514
|
+
@pick="addPending"
|
|
515
|
+
/>
|
|
536
516
|
<div v-if="pendingDocs.length" class="space-y-1">
|
|
537
517
|
<div
|
|
538
518
|
v-for="item in pendingDocs"
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Inline picker for attaching a document (Confluence / Notion / GitHub page) as
|
|
3
|
+
// task context. It searches the connected source by free text, lists
|
|
4
|
+
// already-imported documents for quick re-use, and accepts a pasted URL/ID as a
|
|
5
|
+
// reference — all inline, with NO second modal (stacked page-level modals don't
|
|
6
|
+
// interact here, which is why the old "Import a page…" path appeared to open
|
|
7
|
+
// something but nothing was clickable). It only *stages* a choice: the caller
|
|
8
|
+
// collects PendingContext items and links them once the block exists (see
|
|
9
|
+
// useContextLinking). A search hit / pasted ref carries `needsImport: true` so
|
|
10
|
+
// it's fetched + persisted before linking. Mirrors ContextIssuePicker.
|
|
11
|
+
import type { DocumentSearchResult, DocumentSourceKind } from '~/types/domain'
|
|
12
|
+
|
|
13
|
+
const props = defineProps<{
|
|
14
|
+
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
15
|
+
chosenKeys?: string[]
|
|
16
|
+
}>()
|
|
17
|
+
const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
18
|
+
|
|
19
|
+
const documents = useDocumentsStore()
|
|
20
|
+
|
|
21
|
+
const chosen = computed(() => new Set(props.chosenKeys ?? []))
|
|
22
|
+
|
|
23
|
+
// Source: default to the first connected source; a selector appears only when
|
|
24
|
+
// more than one is connected (the common case is a single source).
|
|
25
|
+
const source = ref<DocumentSourceKind | undefined>(documents.connectedSources[0]?.source)
|
|
26
|
+
const sourceItems = computed(() =>
|
|
27
|
+
documents.connectedSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
|
+
)
|
|
29
|
+
const descriptor = computed(() =>
|
|
30
|
+
source.value ? documents.descriptorFor(source.value) : undefined,
|
|
31
|
+
)
|
|
32
|
+
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
33
|
+
|
|
34
|
+
const query = ref('')
|
|
35
|
+
const results = ref<DocumentSearchResult[]>([])
|
|
36
|
+
const searching = ref(false)
|
|
37
|
+
const searchError = ref<string | null>(null)
|
|
38
|
+
|
|
39
|
+
// Debounced search: free text hits the source; a query that's clearly a URL/ID
|
|
40
|
+
// is left to the explicit "by reference" row below (search won't surface it).
|
|
41
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
42
|
+
watch([query, source], () => {
|
|
43
|
+
if (timer) clearTimeout(timer)
|
|
44
|
+
results.value = []
|
|
45
|
+
searchError.value = null
|
|
46
|
+
const q = query.value.trim()
|
|
47
|
+
if (!q || !searchable.value) return
|
|
48
|
+
timer = setTimeout(runSearch, 300)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
async function runSearch() {
|
|
52
|
+
const q = query.value.trim()
|
|
53
|
+
if (!q || !source.value) return
|
|
54
|
+
searching.value = true
|
|
55
|
+
searchError.value = null
|
|
56
|
+
try {
|
|
57
|
+
results.value = await documents.search(source.value, q)
|
|
58
|
+
} catch (e) {
|
|
59
|
+
results.value = []
|
|
60
|
+
searchError.value = e instanceof Error ? e.message : String(e)
|
|
61
|
+
} finally {
|
|
62
|
+
searching.value = false
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const icon = computed(() => descriptor.value?.icon ?? 'i-lucide-file-text')
|
|
67
|
+
|
|
68
|
+
function keyFor(externalId: string): string {
|
|
69
|
+
return source.value
|
|
70
|
+
? contextKey({ kind: 'document', source: source.value, externalId })
|
|
71
|
+
: `document::${externalId}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Already-imported documents for this source, filtered by the query and never
|
|
75
|
+
// re-offering one the caller already staged.
|
|
76
|
+
const importedRows = computed(() => {
|
|
77
|
+
if (!source.value) return []
|
|
78
|
+
const q = query.value.trim().toLowerCase()
|
|
79
|
+
return documents.documents
|
|
80
|
+
.filter((d) => d.source === source.value)
|
|
81
|
+
.filter((d) => !chosen.value.has(keyFor(d.externalId)))
|
|
82
|
+
.filter((d) => !q || d.title.toLowerCase().includes(q) || d.excerpt.toLowerCase().includes(q))
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
// Search hits not already imported (those show in importedRows) and not staged.
|
|
86
|
+
const searchRows = computed(() => {
|
|
87
|
+
if (!source.value) return []
|
|
88
|
+
const importedIds = new Set(
|
|
89
|
+
documents.documents.filter((d) => d.source === source.value).map((d) => d.externalId),
|
|
90
|
+
)
|
|
91
|
+
return results.value
|
|
92
|
+
.filter((r) => !importedIds.has(r.externalId))
|
|
93
|
+
.filter((r) => !chosen.value.has(keyFor(r.externalId)))
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
// A pasted URL / ID the search won't match: offer it as an explicit reference.
|
|
97
|
+
// When the source isn't searchable, any non-empty query is treated as a ref (the
|
|
98
|
+
// only way to attach a page there, mirroring the import modal's single input).
|
|
99
|
+
const refRow = computed(() => {
|
|
100
|
+
const q = query.value.trim()
|
|
101
|
+
if (!q || !source.value) return null
|
|
102
|
+
const known =
|
|
103
|
+
importedRows.value.some((d) => d.externalId === q) ||
|
|
104
|
+
searchRows.value.some((r) => r.externalId === q) ||
|
|
105
|
+
chosen.value.has(keyFor(q))
|
|
106
|
+
if (known) return null
|
|
107
|
+
if (!searchable.value) return q
|
|
108
|
+
// Only worth offering when it looks like a reference, not a search phrase.
|
|
109
|
+
const looksLikeRef = q.includes('#') || q.includes('/') || /^https?:\/\//i.test(q)
|
|
110
|
+
return looksLikeRef ? q : null
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
const empty = computed(
|
|
114
|
+
() =>
|
|
115
|
+
!searching.value &&
|
|
116
|
+
!searchError.value &&
|
|
117
|
+
importedRows.value.length === 0 &&
|
|
118
|
+
searchRows.value.length === 0 &&
|
|
119
|
+
refRow.value === null,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
function pickImported(externalId: string, title: string, excerpt: string) {
|
|
123
|
+
if (!source.value) return
|
|
124
|
+
emit('pick', {
|
|
125
|
+
kind: 'document',
|
|
126
|
+
source: source.value,
|
|
127
|
+
externalId,
|
|
128
|
+
title,
|
|
129
|
+
subtitle: excerpt || undefined,
|
|
130
|
+
icon: icon.value,
|
|
131
|
+
needsImport: false,
|
|
132
|
+
})
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pickSearch(r: DocumentSearchResult) {
|
|
136
|
+
emit('pick', {
|
|
137
|
+
kind: 'document',
|
|
138
|
+
source: r.source,
|
|
139
|
+
externalId: r.externalId,
|
|
140
|
+
title: r.title,
|
|
141
|
+
subtitle: r.excerpt || undefined,
|
|
142
|
+
icon: icon.value,
|
|
143
|
+
needsImport: true,
|
|
144
|
+
})
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function pickRef(q: string) {
|
|
148
|
+
if (!source.value) return
|
|
149
|
+
emit('pick', {
|
|
150
|
+
kind: 'document',
|
|
151
|
+
source: source.value,
|
|
152
|
+
externalId: q,
|
|
153
|
+
title: q,
|
|
154
|
+
subtitle: descriptor.value?.label,
|
|
155
|
+
icon: icon.value,
|
|
156
|
+
needsImport: true,
|
|
157
|
+
})
|
|
158
|
+
query.value = ''
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
onMounted(() => {
|
|
162
|
+
// Keep the quick-pick list current (cheap; the store dedupes).
|
|
163
|
+
documents.loadDocuments().catch(() => {})
|
|
164
|
+
})
|
|
165
|
+
</script>
|
|
166
|
+
|
|
167
|
+
<template>
|
|
168
|
+
<div class="space-y-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2">
|
|
169
|
+
<USelect
|
|
170
|
+
v-if="sourceItems.length > 1"
|
|
171
|
+
v-model="source"
|
|
172
|
+
:items="sourceItems"
|
|
173
|
+
size="xs"
|
|
174
|
+
class="w-full"
|
|
175
|
+
/>
|
|
176
|
+
|
|
177
|
+
<UInput
|
|
178
|
+
v-model="query"
|
|
179
|
+
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
180
|
+
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
181
|
+
size="sm"
|
|
182
|
+
class="w-full"
|
|
183
|
+
:placeholder="
|
|
184
|
+
searchable
|
|
185
|
+
? 'Search pages or paste a URL/ID…'
|
|
186
|
+
: (descriptor?.refPlaceholder ?? 'Paste a page URL or ID…')
|
|
187
|
+
"
|
|
188
|
+
@keydown.enter="refRow && pickRef(refRow)"
|
|
189
|
+
/>
|
|
190
|
+
|
|
191
|
+
<p v-if="searchError" class="px-1 text-[11px] text-amber-400">
|
|
192
|
+
Search failed: {{ searchError }}
|
|
193
|
+
</p>
|
|
194
|
+
|
|
195
|
+
<div class="max-h-56 space-y-0.5 overflow-y-auto">
|
|
196
|
+
<!-- Already-imported documents (linked directly, no re-fetch). -->
|
|
197
|
+
<button
|
|
198
|
+
v-for="d in importedRows"
|
|
199
|
+
:key="`imp:${d.externalId}`"
|
|
200
|
+
type="button"
|
|
201
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
202
|
+
@click="pickImported(d.externalId, d.title, d.excerpt)"
|
|
203
|
+
>
|
|
204
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-indigo-400" />
|
|
205
|
+
<span class="truncate">{{ d.title }}</span>
|
|
206
|
+
<UBadge color="neutral" variant="soft" size="xs" class="ml-auto shrink-0">imported</UBadge>
|
|
207
|
+
</button>
|
|
208
|
+
|
|
209
|
+
<!-- Source search hits (imported on add). -->
|
|
210
|
+
<button
|
|
211
|
+
v-for="r in searchRows"
|
|
212
|
+
:key="`hit:${r.externalId}`"
|
|
213
|
+
type="button"
|
|
214
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
215
|
+
@click="pickSearch(r)"
|
|
216
|
+
>
|
|
217
|
+
<UIcon :name="icon" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
218
|
+
<span class="truncate">{{ r.title }}</span>
|
|
219
|
+
</button>
|
|
220
|
+
|
|
221
|
+
<!-- Explicit URL/ID reference (imported on add). -->
|
|
222
|
+
<button
|
|
223
|
+
v-if="refRow"
|
|
224
|
+
type="button"
|
|
225
|
+
class="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-left text-xs text-slate-300 hover:bg-slate-800/70"
|
|
226
|
+
@click="pickRef(refRow)"
|
|
227
|
+
>
|
|
228
|
+
<UIcon name="i-lucide-link" class="h-3.5 w-3.5 shrink-0 text-slate-400" />
|
|
229
|
+
<span class="truncate"
|
|
230
|
+
>Attach <span class="text-slate-200">{{ refRow }}</span> by reference</span
|
|
231
|
+
>
|
|
232
|
+
</button>
|
|
233
|
+
|
|
234
|
+
<p v-if="empty" class="px-2 py-1.5 text-[11px] text-slate-500">
|
|
235
|
+
{{
|
|
236
|
+
query.trim()
|
|
237
|
+
? 'No matching pages.'
|
|
238
|
+
: searchable
|
|
239
|
+
? 'Search by title, or pick an imported document.'
|
|
240
|
+
: 'Paste a page URL or ID to attach it.'
|
|
241
|
+
}}
|
|
242
|
+
</p>
|
|
243
|
+
</div>
|
|
244
|
+
</div>
|
|
245
|
+
</template>
|
|
@@ -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.
|
|
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",
|