@cat-factory/app 0.16.0 → 0.17.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/app/components/board/AddTaskModal.vue +10 -0
- package/app/components/board/nodes/BlockNode.vue +18 -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/components/tasks/ContextIssuePicker.vue +7 -1
- package/app/components/tasks/TaskImportModal.vue +110 -94
- package/app/composables/api/tasks.ts +7 -2
- package/app/composables/useAiReadiness.ts +62 -0
- package/app/pages/index.vue +64 -0
- package/app/stores/models.ts +35 -1
- package/app/stores/tasks.ts +12 -3
- package/app/stores/ui.ts +77 -4
- package/package.json +1 -1
|
@@ -230,6 +230,15 @@ watch(open, (isOpen) => {
|
|
|
230
230
|
pendingContext.value = []
|
|
231
231
|
showDocPicker.value = false
|
|
232
232
|
showIssuePicker.value = false
|
|
233
|
+
// Seed from a prefill when opened from another surface (e.g. "create task from
|
|
234
|
+
// issue" sets the title + stages the issue as linked context). Pipeline / preset
|
|
235
|
+
// are intentionally left at their defaults so the user confirms them here.
|
|
236
|
+
const prefill = ui.addTaskPrefill
|
|
237
|
+
if (prefill) {
|
|
238
|
+
if (prefill.title) title.value = prefill.title
|
|
239
|
+
if (prefill.description) description.value = prefill.description
|
|
240
|
+
if (prefill.context?.length) pendingContext.value = [...prefill.context]
|
|
241
|
+
}
|
|
233
242
|
documents.loadDocuments().catch(() => {})
|
|
234
243
|
tasks.loadTasks().catch(() => {})
|
|
235
244
|
})
|
|
@@ -582,6 +591,7 @@ async function add() {
|
|
|
582
591
|
<ContextIssuePicker
|
|
583
592
|
v-if="showIssuePicker && issuesConnected"
|
|
584
593
|
:chosen-keys="chosenIssueKeys"
|
|
594
|
+
:scope-block-id="ui.addTaskContainerId ?? undefined"
|
|
585
595
|
@pick="addPending"
|
|
586
596
|
/>
|
|
587
597
|
<div v-if="pendingIssues.length" class="space-y-1">
|
|
@@ -16,6 +16,7 @@ const props = defineProps<{ id: string }>()
|
|
|
16
16
|
const board = useBoardStore()
|
|
17
17
|
const execution = useExecutionStore()
|
|
18
18
|
const ui = useUiStore()
|
|
19
|
+
const tasks = useTasksStore()
|
|
19
20
|
const agentRuns = useAgentRunsStore()
|
|
20
21
|
const services = useServicesStore()
|
|
21
22
|
const reviews = useReviewStage()
|
|
@@ -108,6 +109,13 @@ function addTask() {
|
|
|
108
109
|
ui.openAddTask(props.id)
|
|
109
110
|
}
|
|
110
111
|
|
|
112
|
+
// Open the tracker-issue modal scoped to THIS service: the create-in target and the
|
|
113
|
+
// repo-scoped issue search are both pinned to this frame (see TaskImportModal).
|
|
114
|
+
function createTaskFromIssue() {
|
|
115
|
+
ui.expandFrame(props.id)
|
|
116
|
+
ui.openTaskImport(null, props.id)
|
|
117
|
+
}
|
|
118
|
+
|
|
111
119
|
function addRecurring() {
|
|
112
120
|
ui.openAddRecurring(props.id)
|
|
113
121
|
}
|
|
@@ -360,6 +368,16 @@ const ITEM_ICON: Record<string, string> = {
|
|
|
360
368
|
title="Add task"
|
|
361
369
|
@click.stop="addTask"
|
|
362
370
|
/>
|
|
371
|
+
<UButton
|
|
372
|
+
v-if="tasks.anyOffered"
|
|
373
|
+
class="nodrag"
|
|
374
|
+
size="xs"
|
|
375
|
+
variant="ghost"
|
|
376
|
+
color="neutral"
|
|
377
|
+
icon="i-lucide-ticket"
|
|
378
|
+
title="Create task from issue"
|
|
379
|
+
@click.stop="createTaskFromIssue"
|
|
380
|
+
/>
|
|
363
381
|
<UButton
|
|
364
382
|
class="nodrag"
|
|
365
383
|
size="xs"
|
|
@@ -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>
|
|
@@ -11,6 +11,12 @@ import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
|
11
11
|
const props = defineProps<{
|
|
12
12
|
/** contextKeys already staged by the caller, so they're filtered out / not re-offered. */
|
|
13
13
|
chosenKeys?: string[]
|
|
14
|
+
/**
|
|
15
|
+
* The block the picker is attaching context to (a service frame or a task/module
|
|
16
|
+
* under one). Scopes a GitHub search to that service's linked repo, so hits stay
|
|
17
|
+
* in-repo and a pasted URL / bare issue number resolves to the exact issue.
|
|
18
|
+
*/
|
|
19
|
+
scopeBlockId?: string
|
|
14
20
|
}>()
|
|
15
21
|
const emit = defineEmits<{ pick: [item: PendingContext] }>()
|
|
16
22
|
|
|
@@ -50,7 +56,7 @@ async function runSearch() {
|
|
|
50
56
|
searching.value = true
|
|
51
57
|
searchError.value = null
|
|
52
58
|
try {
|
|
53
|
-
results.value = await tasks.search(source.value, q)
|
|
59
|
+
results.value = await tasks.search(source.value, q, props.scopeBlockId)
|
|
54
60
|
} catch (e) {
|
|
55
61
|
results.value = []
|
|
56
62
|
searchError.value = e instanceof Error ? e.message : String(e)
|
|
@@ -2,10 +2,12 @@
|
|
|
2
2
|
// Import an issue from a connected task source (by key or URL) and review the
|
|
3
3
|
// issues already imported into the workspace. An imported issue can be attached
|
|
4
4
|
// to an existing task for context from the inspector (see TaskContextIssues.vue),
|
|
5
|
-
// or turned
|
|
6
|
-
//
|
|
7
|
-
// the
|
|
8
|
-
|
|
5
|
+
// or turned into a new board task here: pick a container (service frame or module),
|
|
6
|
+
// then click an issue to open the prefilled add-task form (title seeded, issue
|
|
7
|
+
// staged as linked context) where the user confirms the pipeline / presets before
|
|
8
|
+
// creating it. A separate icon button on each row opens the issue on GitHub.
|
|
9
|
+
import type { TaskSearchResult, TaskSourceKind } from '~/types/domain'
|
|
10
|
+
import type { AddTaskPrefill } from '~/stores/ui'
|
|
9
11
|
|
|
10
12
|
const ui = useUiStore()
|
|
11
13
|
const tasks = useTasksStore()
|
|
@@ -23,12 +25,22 @@ const source = ref<TaskSourceKind | undefined>(undefined)
|
|
|
23
25
|
const ref_ = ref('')
|
|
24
26
|
const importing = ref(false)
|
|
25
27
|
|
|
28
|
+
// When opened from a service frame the modal is the "create a task from an issue"
|
|
29
|
+
// surface; opened standalone it's the general tracker-issue browser/importer.
|
|
30
|
+
const title = computed(() =>
|
|
31
|
+
ui.taskImport?.containerId ? 'Create task from issue' : 'Tracker issues',
|
|
32
|
+
)
|
|
33
|
+
|
|
26
34
|
const sourceItems = computed(() =>
|
|
27
35
|
tasks.offeredSources.map((s) => ({ label: s.label, value: s.source })),
|
|
28
36
|
)
|
|
29
37
|
const descriptor = computed(() => (source.value ? tasks.descriptorFor(source.value) : undefined))
|
|
30
38
|
const searchable = computed(() => descriptor.value?.searchable ?? false)
|
|
31
39
|
|
|
40
|
+
// The container (service frame or module) a new task is created in. Also the repo
|
|
41
|
+
// scope for the issue search — declared up here so the search watch can read it.
|
|
42
|
+
const containerId = ref<string | undefined>(undefined)
|
|
43
|
+
|
|
32
44
|
// Browse the tracker by free text so an issue can be turned into a task without
|
|
33
45
|
// knowing its key. Debounced; a created/imported hit also lands in the list below.
|
|
34
46
|
const searchQuery = ref('')
|
|
@@ -37,7 +49,9 @@ const searching = ref(false)
|
|
|
37
49
|
const searchError = ref<string | null>(null)
|
|
38
50
|
|
|
39
51
|
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
|
40
|
-
|
|
52
|
+
// Re-run when the chosen container changes too: a GitHub search is scoped to the
|
|
53
|
+
// selected service's repo, so switching containers re-scopes the results.
|
|
54
|
+
watch([searchQuery, source, () => containerId.value], () => {
|
|
41
55
|
if (searchTimer) clearTimeout(searchTimer)
|
|
42
56
|
searchResults.value = []
|
|
43
57
|
searchError.value = null
|
|
@@ -52,7 +66,9 @@ async function runSearch() {
|
|
|
52
66
|
searching.value = true
|
|
53
67
|
searchError.value = null
|
|
54
68
|
try {
|
|
55
|
-
|
|
69
|
+
// Scope to the selected container's repo so hits stay in-repo and a pasted
|
|
70
|
+
// URL / bare issue number resolves to the exact issue.
|
|
71
|
+
searchResults.value = await tasks.search(source.value, q, containerId.value)
|
|
56
72
|
} catch (e) {
|
|
57
73
|
searchResults.value = []
|
|
58
74
|
searchError.value = e instanceof Error ? e.message : String(e)
|
|
@@ -75,7 +91,6 @@ const sourceTasks = computed(() =>
|
|
|
75
91
|
|
|
76
92
|
// Containers a new task can be created in: every service frame and module on the
|
|
77
93
|
// board. Modules are labelled with their parent frame so the choice is unambiguous.
|
|
78
|
-
const containerId = ref<string | undefined>(undefined)
|
|
79
94
|
const containerItems = computed(() =>
|
|
80
95
|
board.blocks
|
|
81
96
|
.filter((b) => b.level === 'frame' || b.level === 'module')
|
|
@@ -87,9 +102,6 @@ const containerItems = computed(() =>
|
|
|
87
102
|
value: b.id,
|
|
88
103
|
})),
|
|
89
104
|
)
|
|
90
|
-
// The issue currently being turned into a task (its row shows a spinner).
|
|
91
|
-
const creatingId = ref<string | null>(null)
|
|
92
|
-
|
|
93
105
|
watch(open, (isOpen) => {
|
|
94
106
|
if (isOpen) {
|
|
95
107
|
ref_.value = ''
|
|
@@ -97,33 +109,38 @@ watch(open, (isOpen) => {
|
|
|
97
109
|
searchResults.value = []
|
|
98
110
|
searchError.value = null
|
|
99
111
|
source.value = ui.taskImport?.source ?? tasks.offeredSources[0]?.source ?? undefined
|
|
100
|
-
|
|
101
|
-
|
|
112
|
+
// Opened from a service frame → preselect it as the create-in target (and the
|
|
113
|
+
// search's repo scope); otherwise fall back to the first container on the board.
|
|
114
|
+
containerId.value = ui.taskImport?.containerId ?? containerItems.value[0]?.value
|
|
102
115
|
tasks.loadTasks().catch(() => {})
|
|
103
116
|
}
|
|
104
117
|
})
|
|
105
118
|
|
|
106
|
-
//
|
|
107
|
-
// and
|
|
108
|
-
//
|
|
109
|
-
|
|
119
|
+
// Selecting an issue hands off to the add-task form, prefilled with the issue title
|
|
120
|
+
// and the issue staged as linked context (so agents see its description + comments).
|
|
121
|
+
// The user still confirms pipeline / preset there before the task is created — we do
|
|
122
|
+
// NOT dump the issue body into the description; the link is enough.
|
|
123
|
+
function selectIssue(
|
|
124
|
+
issue: { externalId: string; title: string; status?: string },
|
|
125
|
+
needsImport: boolean,
|
|
126
|
+
) {
|
|
110
127
|
if (!source.value || !containerId.value) return
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
} finally {
|
|
125
|
-
creatingId.value = null
|
|
128
|
+
const prefill: AddTaskPrefill = {
|
|
129
|
+
title: issue.title,
|
|
130
|
+
context: [
|
|
131
|
+
{
|
|
132
|
+
kind: 'task',
|
|
133
|
+
source: source.value,
|
|
134
|
+
externalId: issue.externalId,
|
|
135
|
+
title: `${issue.externalId} · ${issue.title}`,
|
|
136
|
+
subtitle: issue.status || undefined,
|
|
137
|
+
icon: descriptor.value?.icon,
|
|
138
|
+
needsImport,
|
|
139
|
+
},
|
|
140
|
+
],
|
|
126
141
|
}
|
|
142
|
+
ui.closeTaskImport()
|
|
143
|
+
ui.openAddTask(containerId.value, prefill)
|
|
127
144
|
}
|
|
128
145
|
|
|
129
146
|
async function doImport() {
|
|
@@ -148,7 +165,7 @@ async function doImport() {
|
|
|
148
165
|
</script>
|
|
149
166
|
|
|
150
167
|
<template>
|
|
151
|
-
<UModal v-model:open="open" title="
|
|
168
|
+
<UModal v-model:open="open" :title="title">
|
|
152
169
|
<template #body>
|
|
153
170
|
<!-- Empty state: no source offered (none connected/installed, or all disabled) -->
|
|
154
171
|
<div v-if="!tasks.anyOffered" class="space-y-3 text-center">
|
|
@@ -201,7 +218,7 @@ async function doImport() {
|
|
|
201
218
|
v-model="searchQuery"
|
|
202
219
|
:icon="searching ? 'i-lucide-loader-circle' : 'i-lucide-search'"
|
|
203
220
|
:ui="{ leadingIcon: searching ? 'animate-spin' : '' }"
|
|
204
|
-
placeholder="Search by title…"
|
|
221
|
+
placeholder="Search by title, paste an issue URL, or type an issue number…"
|
|
205
222
|
class="w-full"
|
|
206
223
|
/>
|
|
207
224
|
</UFormField>
|
|
@@ -226,7 +243,8 @@ async function doImport() {
|
|
|
226
243
|
Add a service frame to the board first to create tasks from issues.
|
|
227
244
|
</p>
|
|
228
245
|
|
|
229
|
-
<!-- Search results (not yet imported):
|
|
246
|
+
<!-- Search results (not yet imported): click a hit to create a task from it
|
|
247
|
+
(opens the prefilled add-task form); the icon button views it on GitHub. -->
|
|
230
248
|
<div v-if="searchError" class="text-[11px] text-amber-400">
|
|
231
249
|
Search failed: {{ searchError }}
|
|
232
250
|
</div>
|
|
@@ -237,38 +255,36 @@ async function doImport() {
|
|
|
237
255
|
<div
|
|
238
256
|
v-for="hit in freshHits"
|
|
239
257
|
:key="`hit:${hit.source}:${hit.externalId}`"
|
|
240
|
-
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
258
|
+
class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
|
|
241
259
|
>
|
|
242
|
-
<
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
</UButton>
|
|
271
|
-
</div>
|
|
260
|
+
<button
|
|
261
|
+
type="button"
|
|
262
|
+
class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
|
263
|
+
:disabled="!containerId"
|
|
264
|
+
:title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
|
|
265
|
+
@click="selectIssue(hit, true)"
|
|
266
|
+
>
|
|
267
|
+
<span class="block truncate text-sm font-medium text-white">
|
|
268
|
+
{{ hit.externalId }} · {{ hit.title }}
|
|
269
|
+
</span>
|
|
270
|
+
<span v-if="hit.excerpt" class="mt-0.5 line-clamp-2 block text-xs text-slate-500">
|
|
271
|
+
{{ hit.excerpt }}
|
|
272
|
+
</span>
|
|
273
|
+
</button>
|
|
274
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
275
|
+
<UBadge v-if="hit.status" color="neutral" variant="soft" size="xs">
|
|
276
|
+
{{ hit.status }}
|
|
277
|
+
</UBadge>
|
|
278
|
+
<UButton
|
|
279
|
+
color="neutral"
|
|
280
|
+
variant="ghost"
|
|
281
|
+
size="xs"
|
|
282
|
+
icon="i-lucide-external-link"
|
|
283
|
+
:to="hit.url"
|
|
284
|
+
target="_blank"
|
|
285
|
+
rel="noopener"
|
|
286
|
+
:aria-label="`View ${hit.externalId} on GitHub`"
|
|
287
|
+
/>
|
|
272
288
|
</div>
|
|
273
289
|
</div>
|
|
274
290
|
</div>
|
|
@@ -281,36 +297,36 @@ async function doImport() {
|
|
|
281
297
|
<div
|
|
282
298
|
v-for="task in sourceTasks"
|
|
283
299
|
:key="`${task.source}:${task.externalId}`"
|
|
284
|
-
class="rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
300
|
+
class="flex items-start justify-between gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-3 transition-colors hover:border-primary-500/60 hover:bg-slate-900"
|
|
285
301
|
>
|
|
286
|
-
<
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
302
|
+
<button
|
|
303
|
+
type="button"
|
|
304
|
+
class="min-w-0 flex-1 text-left disabled:cursor-not-allowed disabled:opacity-60"
|
|
305
|
+
:disabled="!containerId"
|
|
306
|
+
:title="containerId ? 'Create a task from this issue' : 'Pick a container first'"
|
|
307
|
+
@click="selectIssue(task, false)"
|
|
308
|
+
>
|
|
309
|
+
<span class="block truncate text-sm font-medium text-white">
|
|
310
|
+
{{ task.externalId }} · {{ task.title }}
|
|
311
|
+
</span>
|
|
312
|
+
<span class="mt-0.5 line-clamp-2 block text-xs text-slate-500">{{
|
|
313
|
+
task.excerpt
|
|
314
|
+
}}</span>
|
|
315
|
+
</button>
|
|
316
|
+
<div class="flex shrink-0 items-center gap-2">
|
|
317
|
+
<UBadge color="neutral" variant="soft" size="xs">
|
|
318
|
+
{{ task.status }}
|
|
319
|
+
</UBadge>
|
|
320
|
+
<UButton
|
|
321
|
+
color="neutral"
|
|
322
|
+
variant="ghost"
|
|
323
|
+
size="xs"
|
|
324
|
+
icon="i-lucide-external-link"
|
|
325
|
+
:to="task.url"
|
|
326
|
+
target="_blank"
|
|
327
|
+
rel="noopener"
|
|
328
|
+
:aria-label="`View ${task.externalId} on GitHub`"
|
|
329
|
+
/>
|
|
314
330
|
</div>
|
|
315
331
|
</div>
|
|
316
332
|
</div>
|
|
@@ -57,10 +57,15 @@ export function tasksApi({ http, ws }: ApiContext) {
|
|
|
57
57
|
body,
|
|
58
58
|
}),
|
|
59
59
|
|
|
60
|
-
searchTaskSource: (
|
|
60
|
+
searchTaskSource: (
|
|
61
|
+
workspaceId: string,
|
|
62
|
+
source: TaskSourceKind,
|
|
63
|
+
query: string,
|
|
64
|
+
blockId?: string,
|
|
65
|
+
) =>
|
|
61
66
|
http<{ results: TaskSearchResult[] }>(`${ws(workspaceId)}/task-sources/${source}/search`, {
|
|
62
67
|
method: 'POST',
|
|
63
|
-
body: { query },
|
|
68
|
+
body: { query, ...(blockId ? { blockId } : {}) },
|
|
64
69
|
}),
|
|
65
70
|
|
|
66
71
|
linkTask: (
|
|
@@ -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/tasks.ts
CHANGED
|
@@ -170,9 +170,18 @@ export const useTasksStore = defineStore('tasks', () => {
|
|
|
170
170
|
}
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
173
|
+
/**
|
|
174
|
+
* Search a connected tracker's issues by free text (title/content). `blockId`
|
|
175
|
+
* (a service frame or a task/module under one) scopes a GitHub search to that
|
|
176
|
+
* service's linked repo — so hits stay in-repo and a pasted URL / bare issue
|
|
177
|
+
* number resolves to the exact issue. Omitted → an unscoped workspace search.
|
|
178
|
+
*/
|
|
179
|
+
async function search(
|
|
180
|
+
source: TaskSourceKind,
|
|
181
|
+
query: string,
|
|
182
|
+
blockId?: string,
|
|
183
|
+
): Promise<TaskSearchResult[]> {
|
|
184
|
+
const { results } = await api.searchTaskSource(workspace.requireId(), source, query, blockId)
|
|
176
185
|
return results
|
|
177
186
|
}
|
|
178
187
|
|
package/app/stores/ui.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
1
|
import { defineStore } from 'pinia'
|
|
2
2
|
import { ref, computed } from 'vue'
|
|
3
3
|
import type { DocumentSourceKind, TaskSourceKind, LodLevel } from '~/types/domain'
|
|
4
|
+
import type { PendingContext } from '~/composables/useContextLinking'
|
|
4
5
|
import { zoomToLod, lodAtLeast } from '~/composables/useSemanticZoom'
|
|
5
6
|
import { useExecutionStore } from '~/stores/execution'
|
|
6
7
|
import { agentKindMeta } from '~/utils/catalog'
|
|
7
8
|
|
|
9
|
+
/** Values used to seed the add-task form when it is opened from another surface. */
|
|
10
|
+
export interface AddTaskPrefill {
|
|
11
|
+
title?: string
|
|
12
|
+
description?: string
|
|
13
|
+
/** Context items staged on the new task (e.g. the source issue), linked once created. */
|
|
14
|
+
context?: PendingContext[]
|
|
15
|
+
}
|
|
16
|
+
|
|
8
17
|
/** Transient UI state: selection, panels, zoom level. */
|
|
9
18
|
export const useUiStore = defineStore('ui', () => {
|
|
10
19
|
const selectedBlockId = ref<string | null>(null)
|
|
@@ -33,12 +42,19 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
33
42
|
// the modal pick a connected one (there is no spawn target — issues are linked
|
|
34
43
|
// to a block for context, not expanded into structure).
|
|
35
44
|
const taskConnect = ref<{ source: TaskSourceKind } | null>(null)
|
|
36
|
-
|
|
45
|
+
// `containerId` (a service frame) scopes the modal: it preselects that frame as
|
|
46
|
+
// the create-in target AND scopes the issue search to the frame's linked repo.
|
|
47
|
+
// Null → the unscoped "import an issue" surface (workspace-wide search).
|
|
48
|
+
const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
37
49
|
|
|
38
50
|
// Add-task modal: the container (service frame or module) a new task is being
|
|
39
51
|
// added to, or null when closed. The user types the title + description; nothing
|
|
40
52
|
// is launched until they explicitly start the created task.
|
|
41
53
|
const addTaskContainerId = ref<string | null>(null)
|
|
54
|
+
// Optional values to seed the add-task form with when it is opened from another
|
|
55
|
+
// surface (e.g. "create task from issue" prefills the title + stages the issue as
|
|
56
|
+
// linked context). The user still confirms pipeline / preset before adding.
|
|
57
|
+
const addTaskPrefill = ref<AddTaskPrefill | null>(null)
|
|
42
58
|
|
|
43
59
|
// Add-recurring-pipeline modal: the service frame a new recurring pipeline is
|
|
44
60
|
// being added to, or null when closed (mirrors the add-task flow — a button on
|
|
@@ -88,6 +104,16 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
88
104
|
// Per-workspace settings panel: the OpenRouter dynamic catalog (browse/enable gateway models).
|
|
89
105
|
const openRouterOpen = ref(false)
|
|
90
106
|
|
|
107
|
+
// AI-onboarding surfaces (driven by `useAiReadiness`). `aiProviderSetupOpen` is the
|
|
108
|
+
// "no usable AI source" dialog; `aiPresetMismatchOpen` is the "default preset points at
|
|
109
|
+
// unavailable models" dialog. The `*Dismissed` flags are per-session: they suppress the
|
|
110
|
+
// auto-open (and let the banner be dismissed) without permanently hiding the prompt — it
|
|
111
|
+
// re-evaluates on the next load. Both clear themselves once the underlying gap is closed.
|
|
112
|
+
const aiProviderSetupOpen = ref(false)
|
|
113
|
+
const aiPresetMismatchOpen = ref(false)
|
|
114
|
+
const aiSetupDismissed = ref(false)
|
|
115
|
+
const aiPresetDismissed = ref(false)
|
|
116
|
+
|
|
91
117
|
// Dedicated result-view overlay: a step whose agent kind declares a bespoke
|
|
92
118
|
// visualization (via the archetype's `resultView`) opens here instead of the generic
|
|
93
119
|
// prose step-detail panel. `view` is the registry id (e.g. 'requirements-review');
|
|
@@ -227,17 +253,19 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
227
253
|
function closeTaskConnect() {
|
|
228
254
|
taskConnect.value = null
|
|
229
255
|
}
|
|
230
|
-
function openTaskImport(source: TaskSourceKind | null = null) {
|
|
231
|
-
taskImport.value = { source }
|
|
256
|
+
function openTaskImport(source: TaskSourceKind | null = null, containerId: string | null = null) {
|
|
257
|
+
taskImport.value = { source, containerId }
|
|
232
258
|
}
|
|
233
259
|
function closeTaskImport() {
|
|
234
260
|
taskImport.value = null
|
|
235
261
|
}
|
|
236
|
-
function openAddTask(containerId: string) {
|
|
262
|
+
function openAddTask(containerId: string, prefill: AddTaskPrefill | null = null) {
|
|
263
|
+
addTaskPrefill.value = prefill
|
|
237
264
|
addTaskContainerId.value = containerId
|
|
238
265
|
}
|
|
239
266
|
function closeAddTask() {
|
|
240
267
|
addTaskContainerId.value = null
|
|
268
|
+
addTaskPrefill.value = null
|
|
241
269
|
}
|
|
242
270
|
function openAddRecurring(frameId: string) {
|
|
243
271
|
addRecurringFrameId.value = frameId
|
|
@@ -330,6 +358,39 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
330
358
|
function closeOpenRouter() {
|
|
331
359
|
openRouterOpen.value = false
|
|
332
360
|
}
|
|
361
|
+
function openAiProviderSetup() {
|
|
362
|
+
aiProviderSetupOpen.value = true
|
|
363
|
+
}
|
|
364
|
+
function closeAiProviderSetup() {
|
|
365
|
+
aiProviderSetupOpen.value = false
|
|
366
|
+
}
|
|
367
|
+
function openAiPresetMismatch() {
|
|
368
|
+
aiPresetMismatchOpen.value = true
|
|
369
|
+
}
|
|
370
|
+
function closeAiPresetMismatch() {
|
|
371
|
+
aiPresetMismatchOpen.value = false
|
|
372
|
+
}
|
|
373
|
+
// Banner dismissal is distinct from closing the dialog: closing the dialog leaves the
|
|
374
|
+
// banner so the user can reopen it; dismissing the banner hides the whole prompt for
|
|
375
|
+
// the session (it re-evaluates on the next load).
|
|
376
|
+
function dismissAiSetup() {
|
|
377
|
+
aiProviderSetupOpen.value = false
|
|
378
|
+
aiSetupDismissed.value = true
|
|
379
|
+
}
|
|
380
|
+
function dismissAiPresetMismatch() {
|
|
381
|
+
aiPresetMismatchOpen.value = false
|
|
382
|
+
aiPresetDismissed.value = true
|
|
383
|
+
}
|
|
384
|
+
// Clear the per-session AI-onboarding state (open dialogs + dismissed flags). Called on
|
|
385
|
+
// workspace switch: dismissals are per-session-per-workspace, so a prompt dismissed in one
|
|
386
|
+
// workspace must not suppress the (independent) prompt for another workspace that also
|
|
387
|
+
// lacks a usable AI source / has a broken default preset.
|
|
388
|
+
function resetAiOnboarding() {
|
|
389
|
+
aiProviderSetupOpen.value = false
|
|
390
|
+
aiPresetMismatchOpen.value = false
|
|
391
|
+
aiSetupDismissed.value = false
|
|
392
|
+
aiPresetDismissed.value = false
|
|
393
|
+
}
|
|
333
394
|
function openRequirementReview(blockId: string) {
|
|
334
395
|
resultView.value = { view: 'requirements-review', blockId, instanceId: null, stepIndex: null }
|
|
335
396
|
}
|
|
@@ -369,6 +430,7 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
369
430
|
taskConnect,
|
|
370
431
|
taskImport,
|
|
371
432
|
addTaskContainerId,
|
|
433
|
+
addTaskPrefill,
|
|
372
434
|
addRecurringFrameId,
|
|
373
435
|
bootstrapOpen,
|
|
374
436
|
addServiceOpen,
|
|
@@ -384,6 +446,10 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
384
446
|
vendorCredentialsOpen,
|
|
385
447
|
localModelsOpen,
|
|
386
448
|
openRouterOpen,
|
|
449
|
+
aiProviderSetupOpen,
|
|
450
|
+
aiPresetMismatchOpen,
|
|
451
|
+
aiSetupDismissed,
|
|
452
|
+
aiPresetDismissed,
|
|
387
453
|
resultView,
|
|
388
454
|
closeResultView,
|
|
389
455
|
stepDetail,
|
|
@@ -442,6 +508,13 @@ export const useUiStore = defineStore('ui', () => {
|
|
|
442
508
|
closeLocalModels,
|
|
443
509
|
openOpenRouter,
|
|
444
510
|
closeOpenRouter,
|
|
511
|
+
openAiProviderSetup,
|
|
512
|
+
closeAiProviderSetup,
|
|
513
|
+
openAiPresetMismatch,
|
|
514
|
+
closeAiPresetMismatch,
|
|
515
|
+
dismissAiSetup,
|
|
516
|
+
dismissAiPresetMismatch,
|
|
517
|
+
resetAiOnboarding,
|
|
445
518
|
openRequirementReview,
|
|
446
519
|
openClarityReview,
|
|
447
520
|
openServiceSpec,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
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",
|