@cat-factory/app 0.299.0 → 0.300.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/assistant/AssistantModal.logic.spec.ts +80 -0
- package/app/components/assistant/AssistantModal.logic.ts +45 -0
- package/app/components/assistant/AssistantModal.vue +260 -0
- package/app/components/outcome/OutcomeSummaryWindow.vue +1 -0
- package/app/composables/api/assistant.ts +29 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +2 -0
- package/app/modular/nav-contributions.spec.ts +2 -0
- package/app/modular/nav-contributions.ts +22 -0
- package/app/pages/index.vue +2 -0
- package/app/stores/assistant.ts +69 -0
- package/app/stores/ui/modals.ts +15 -0
- package/app/types/assistant.ts +24 -0
- package/app/types/domain.ts +1 -0
- package/i18n/locales/de.json +52 -3
- package/i18n/locales/en.json +52 -3
- package/i18n/locales/es.json +52 -3
- package/i18n/locales/fr.json +52 -3
- package/i18n/locales/he.json +52 -3
- package/i18n/locales/it.json +52 -3
- package/i18n/locales/ja.json +52 -3
- package/i18n/locales/pl.json +52 -3
- package/i18n/locales/tr.json +52 -3
- package/i18n/locales/uk.json +52 -3
- package/package.json +2 -2
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { answerFor, revealTarget } from './AssistantModal.logic'
|
|
3
|
+
|
|
4
|
+
describe('revealTarget', () => {
|
|
5
|
+
it('reveals the CONSUMER of a declared dependency, the frame the edge was written onto', () => {
|
|
6
|
+
expect(
|
|
7
|
+
revealTarget({
|
|
8
|
+
actionId: 'declare-service-dependency',
|
|
9
|
+
consumer: { blockId: 'f1', title: 'Checkout' },
|
|
10
|
+
provider: { blockId: 'f2', title: 'Payments' },
|
|
11
|
+
created: true,
|
|
12
|
+
}),
|
|
13
|
+
).toBe('f1')
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('reveals the new service frame', () => {
|
|
17
|
+
expect(
|
|
18
|
+
revealTarget({
|
|
19
|
+
actionId: 'add-service-from-repo',
|
|
20
|
+
service: { blockId: 'f3', title: 'payments' },
|
|
21
|
+
repo: { owner: 'acme', name: 'payments', directory: null },
|
|
22
|
+
created: true,
|
|
23
|
+
}),
|
|
24
|
+
).toBe('f3')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('reveals the task, not the service it landed in', () => {
|
|
28
|
+
expect(
|
|
29
|
+
revealTarget({
|
|
30
|
+
actionId: 'create-task-from-issue',
|
|
31
|
+
task: { blockId: 't1', title: 'Card charges time out' },
|
|
32
|
+
service: { blockId: 'f2', title: 'Payments' },
|
|
33
|
+
issue: { source: 'github', externalId: 'acme/payments#12', url: 'https://example.test' },
|
|
34
|
+
}),
|
|
35
|
+
).toBe('t1')
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('answerFor', () => {
|
|
40
|
+
it('puts the chosen candidate in the field the platform named, keeping the rest', () => {
|
|
41
|
+
expect(
|
|
42
|
+
answerFor(
|
|
43
|
+
{
|
|
44
|
+
status: 'needs_input',
|
|
45
|
+
actionId: 'declare-service-dependency',
|
|
46
|
+
reason: 'ambiguous_service',
|
|
47
|
+
field: 'consumer',
|
|
48
|
+
candidates: ['Payments API', 'API Gateway'],
|
|
49
|
+
arguments: { consumer: 'api', provider: 'Ledger', description: 'reads balances' },
|
|
50
|
+
},
|
|
51
|
+
'Payments API',
|
|
52
|
+
),
|
|
53
|
+
).toEqual({
|
|
54
|
+
actionId: 'declare-service-dependency',
|
|
55
|
+
arguments: {
|
|
56
|
+
consumer: 'Payments API',
|
|
57
|
+
provider: 'Ledger',
|
|
58
|
+
description: 'reads balances',
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('answers a field the failed turn never resolved a value for', () => {
|
|
64
|
+
// The ambiguous-tracker case: `source` is absent from the arguments precisely because the
|
|
65
|
+
// request never named one, and the answer is what supplies it.
|
|
66
|
+
expect(
|
|
67
|
+
answerFor(
|
|
68
|
+
{
|
|
69
|
+
status: 'needs_input',
|
|
70
|
+
actionId: 'create-task-from-issue',
|
|
71
|
+
reason: 'ambiguous_issue_source',
|
|
72
|
+
field: 'source',
|
|
73
|
+
candidates: ['jira', 'linear'],
|
|
74
|
+
arguments: { issueUrl: 'PROJ-12' },
|
|
75
|
+
},
|
|
76
|
+
'jira',
|
|
77
|
+
).arguments,
|
|
78
|
+
).toEqual({ issueUrl: 'PROJ-12', source: 'jira' })
|
|
79
|
+
})
|
|
80
|
+
})
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { AssistantActionResult, AssistantAnswer, AssistantOutcome } from '~/types/domain'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The block a performed turn should reveal on the board.
|
|
5
|
+
*
|
|
6
|
+
* Per action, the SUBJECT of what happened rather than the first id in the result: a filed task
|
|
7
|
+
* is the task, an added service is its frame, and a declared dependency is the CONSUMER, which is
|
|
8
|
+
* the frame the edge was written onto and the one whose panel shows it. Selecting the provider
|
|
9
|
+
* there would open the service that did not change.
|
|
10
|
+
*
|
|
11
|
+
* Exhaustive over the action union, so a fourth action fails to compile until it says what its
|
|
12
|
+
* turn produced. That is the point of extracting three lines: a `default` that fell back to some
|
|
13
|
+
* id would silently reveal the wrong block for whatever is added next.
|
|
14
|
+
*/
|
|
15
|
+
export function revealTarget(result: AssistantActionResult): string {
|
|
16
|
+
switch (result.actionId) {
|
|
17
|
+
case 'declare-service-dependency':
|
|
18
|
+
return result.consumer.blockId
|
|
19
|
+
case 'add-service-from-repo':
|
|
20
|
+
return result.service.blockId
|
|
21
|
+
case 'create-task-from-issue':
|
|
22
|
+
return result.task.blockId
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The answer to a clarification, once a person has picked one of its candidates.
|
|
28
|
+
*
|
|
29
|
+
* The chosen value REPLACES the field the platform named and everything else is carried over
|
|
30
|
+
* untouched, so the next turn re-runs the same action with the one unresolved argument settled.
|
|
31
|
+
*
|
|
32
|
+
* Appending the candidate to the prompt and routing it again is what this replaces, and it could
|
|
33
|
+
* not terminate: the words that produced the question are still in the sentence (a repository
|
|
34
|
+
* under the wrong owner sits there beside the right one), and a candidate with no declared
|
|
35
|
+
* argument to land in is dropped on the way through.
|
|
36
|
+
*/
|
|
37
|
+
export function answerFor(
|
|
38
|
+
outcome: Extract<AssistantOutcome, { status: 'needs_input' }>,
|
|
39
|
+
candidate: string,
|
|
40
|
+
): AssistantAnswer {
|
|
41
|
+
return {
|
|
42
|
+
actionId: outcome.actionId,
|
|
43
|
+
arguments: { ...outcome.arguments, [outcome.field]: candidate },
|
|
44
|
+
}
|
|
45
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The in-app assistant: type what you want done, and the platform does it.
|
|
3
|
+
//
|
|
4
|
+
// The whole surface is one prompt box and one OUTCOME, and the outcome is rendered from the
|
|
5
|
+
// turn's DATA rather than from anything a model wrote. That is not a stylistic choice: the
|
|
6
|
+
// backend deliberately puts no model prose on the wire (a turn answers with the action it
|
|
7
|
+
// performed, or a machine-readable reason it could not), so every sentence here comes out of the
|
|
8
|
+
// i18n catalog and reads the same in every locale.
|
|
9
|
+
//
|
|
10
|
+
// Two of the three outcomes are not failures and must not look like one:
|
|
11
|
+
// - `needs_input` is a QUESTION. Where the platform had candidates (two services matching the
|
|
12
|
+
// name, a repository under a different owner, two trackers that both read the reference) each
|
|
13
|
+
// is a chip that ANSWERS it: one click re-runs the same action with that value in the field
|
|
14
|
+
// the platform named, with no model call and nothing retyped.
|
|
15
|
+
// - `declined` means nothing in the catalog matches. It renders the catalog, because "I can't
|
|
16
|
+
// do that" without saying what it CAN do is the least useful answer this surface can give.
|
|
17
|
+
// Everything that IS a failure (no model wired, the budget spent, an unconfigured tracker, an
|
|
18
|
+
// issue already filed) arrives as an error and goes through the shared toast funnel with its
|
|
19
|
+
// reason, its detail and its request id, the same path a failed button press takes.
|
|
20
|
+
import type { AssistantActionId, AssistantOutcome } from '~/types/domain'
|
|
21
|
+
import { answerFor, revealTarget } from './AssistantModal.logic'
|
|
22
|
+
|
|
23
|
+
const { t } = useI18n()
|
|
24
|
+
const ui = useUiStore()
|
|
25
|
+
const assistant = useAssistantStore()
|
|
26
|
+
const { present } = usePipelineErrorToast()
|
|
27
|
+
|
|
28
|
+
const open = computed({
|
|
29
|
+
get: () => ui.assistantOpen,
|
|
30
|
+
set: (value: boolean) => (value ? ui.openAssistant() : ui.closeAssistant()),
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
const prompt = ref('')
|
|
34
|
+
|
|
35
|
+
/** The outcome of the last turn, or null before the first one / after the prompt is edited. */
|
|
36
|
+
const outcome = computed<AssistantOutcome | null>(() => assistant.turn?.outcome ?? null)
|
|
37
|
+
|
|
38
|
+
/** The example prompts offered as a starting point, one per action this deployment can perform. */
|
|
39
|
+
const examples = computed(() =>
|
|
40
|
+
assistant.actions.map((actionId: AssistantActionId) => ({
|
|
41
|
+
actionId,
|
|
42
|
+
label: t(`assistant.actions.${actionId}.label`),
|
|
43
|
+
example: t(`assistant.actions.${actionId}.example`),
|
|
44
|
+
})),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
// Read the capability on open rather than on mount: the modal is lazily loaded, and a deployment
|
|
48
|
+
// that wires a provider while the tab is open should not have to be reloaded to offer the box.
|
|
49
|
+
watch(open, (isOpen) => {
|
|
50
|
+
if (!isOpen) return
|
|
51
|
+
assistant.reset()
|
|
52
|
+
void assistant.loadCapability().catch((error: unknown) => present(error, 'assistant.title'))
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
/** Editing the prompt clears the previous answer, so an outcome never sits under a new question. */
|
|
56
|
+
watch(prompt, () => {
|
|
57
|
+
if (outcome.value) assistant.reset()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
const canSubmit = computed(
|
|
61
|
+
() => assistant.available && !assistant.running && prompt.value.trim().length > 0,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
async function submit(): Promise<void> {
|
|
65
|
+
if (!canSubmit.value) return
|
|
66
|
+
try {
|
|
67
|
+
await assistant.run(prompt.value.trim())
|
|
68
|
+
} catch (error) {
|
|
69
|
+
present(error, 'assistant.title')
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Put an example in the box, ready to edit, never submitted for the person. */
|
|
74
|
+
function useExample(example: string): void {
|
|
75
|
+
prompt.value = example
|
|
76
|
+
assistant.reset()
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Answer a clarification with one of the candidates the platform offered.
|
|
81
|
+
*
|
|
82
|
+
* Submitted as DATA, not as more prose: the turn re-runs the action it was already heading for
|
|
83
|
+
* with the chosen value in the field it named. Appending the candidate to the sentence and asking
|
|
84
|
+
* the model again is what this replaces, and it could not settle the question (see `answerFor`).
|
|
85
|
+
* The prompt box is left exactly as the person typed it, because the request has not changed.
|
|
86
|
+
*/
|
|
87
|
+
async function useCandidate(
|
|
88
|
+
question: Extract<AssistantOutcome, { status: 'needs_input' }>,
|
|
89
|
+
candidate: string,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
if (assistant.running) return
|
|
92
|
+
try {
|
|
93
|
+
await assistant.answer(answerFor(question, candidate))
|
|
94
|
+
} catch (error) {
|
|
95
|
+
present(error, 'assistant.title')
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Select what a performed turn produced, and close, so the board shows it straight away. */
|
|
100
|
+
function reveal(blockId: string): void {
|
|
101
|
+
ui.select(blockId)
|
|
102
|
+
ui.focus(blockId)
|
|
103
|
+
ui.closeAssistant()
|
|
104
|
+
}
|
|
105
|
+
</script>
|
|
106
|
+
|
|
107
|
+
<template>
|
|
108
|
+
<UModal v-model:open="open" :title="t('assistant.title')" :ui="{ content: 'max-w-2xl' }">
|
|
109
|
+
<template #body>
|
|
110
|
+
<div class="space-y-4">
|
|
111
|
+
<p class="text-sm text-slate-400">{{ t('assistant.intro') }}</p>
|
|
112
|
+
|
|
113
|
+
<!-- No model wired: say so, rather than offering a box whose every submit would 503. -->
|
|
114
|
+
<div
|
|
115
|
+
v-if="assistant.capability && !assistant.available"
|
|
116
|
+
class="flex items-start gap-2 rounded-md bg-slate-800/60 p-3 text-sm text-slate-300"
|
|
117
|
+
>
|
|
118
|
+
<UIcon name="i-lucide-plug" class="mt-0.5 h-4 w-4 shrink-0 text-slate-500" />
|
|
119
|
+
<span>{{ t('assistant.unavailable') }}</span>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
<template v-else>
|
|
123
|
+
<UTextarea
|
|
124
|
+
v-model="prompt"
|
|
125
|
+
:rows="3"
|
|
126
|
+
autofocus
|
|
127
|
+
:disabled="assistant.running"
|
|
128
|
+
:placeholder="t('assistant.placeholder')"
|
|
129
|
+
data-testid="assistant-prompt"
|
|
130
|
+
@keydown.enter.meta.prevent="submit"
|
|
131
|
+
@keydown.enter.ctrl.prevent="submit"
|
|
132
|
+
/>
|
|
133
|
+
|
|
134
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
135
|
+
<UButton
|
|
136
|
+
color="primary"
|
|
137
|
+
icon="i-lucide-sparkles"
|
|
138
|
+
:loading="assistant.running"
|
|
139
|
+
:disabled="!canSubmit"
|
|
140
|
+
data-testid="assistant-submit"
|
|
141
|
+
@click="submit"
|
|
142
|
+
>
|
|
143
|
+
{{ t('assistant.submit') }}
|
|
144
|
+
</UButton>
|
|
145
|
+
<span class="text-xs text-slate-500">{{ t('assistant.submitHint') }}</span>
|
|
146
|
+
</div>
|
|
147
|
+
|
|
148
|
+
<!-- What it can do, always visible: the catalog is the affordance. -->
|
|
149
|
+
<div v-if="examples.length" class="space-y-2">
|
|
150
|
+
<p class="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
151
|
+
{{ t('assistant.examplesTitle') }}
|
|
152
|
+
</p>
|
|
153
|
+
<div class="flex flex-col gap-1">
|
|
154
|
+
<button
|
|
155
|
+
v-for="entry in examples"
|
|
156
|
+
:key="entry.actionId"
|
|
157
|
+
type="button"
|
|
158
|
+
class="rounded-md px-2 py-1 text-left text-sm text-slate-300 hover:bg-slate-800"
|
|
159
|
+
@click="useExample(entry.example)"
|
|
160
|
+
>
|
|
161
|
+
<span class="text-slate-400">{{ entry.label }}</span>
|
|
162
|
+
<span class="block text-xs text-slate-500">“{{ entry.example }}”</span>
|
|
163
|
+
</button>
|
|
164
|
+
</div>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<!-- The outcome. Rendered from the turn's data; no model prose reaches this. -->
|
|
168
|
+
<div
|
|
169
|
+
v-if="outcome"
|
|
170
|
+
class="rounded-md border border-slate-700 p-3 text-sm"
|
|
171
|
+
data-testid="assistant-outcome"
|
|
172
|
+
>
|
|
173
|
+
<template v-if="outcome.status === 'performed'">
|
|
174
|
+
<div class="flex items-start gap-2 text-slate-200">
|
|
175
|
+
<UIcon name="i-lucide-check" class="mt-0.5 h-4 w-4 shrink-0 text-emerald-400" />
|
|
176
|
+
<div class="space-y-2">
|
|
177
|
+
<p v-if="outcome.result.actionId === 'declare-service-dependency'">
|
|
178
|
+
{{
|
|
179
|
+
t(
|
|
180
|
+
outcome.result.created
|
|
181
|
+
? 'assistant.done.dependencyDeclared'
|
|
182
|
+
: 'assistant.done.dependencyAlready',
|
|
183
|
+
{
|
|
184
|
+
consumer: outcome.result.consumer.title,
|
|
185
|
+
provider: outcome.result.provider.title,
|
|
186
|
+
},
|
|
187
|
+
)
|
|
188
|
+
}}
|
|
189
|
+
</p>
|
|
190
|
+
<p v-else-if="outcome.result.actionId === 'add-service-from-repo'">
|
|
191
|
+
{{
|
|
192
|
+
t(
|
|
193
|
+
outcome.result.created
|
|
194
|
+
? 'assistant.done.serviceAdded'
|
|
195
|
+
: 'assistant.done.serviceMounted',
|
|
196
|
+
{
|
|
197
|
+
service: outcome.result.service.title,
|
|
198
|
+
repo: `${outcome.result.repo.owner}/${outcome.result.repo.name}`,
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
}}
|
|
202
|
+
</p>
|
|
203
|
+
<p v-else>
|
|
204
|
+
{{
|
|
205
|
+
t('assistant.done.taskCreated', {
|
|
206
|
+
task: outcome.result.task.title,
|
|
207
|
+
service: outcome.result.service.title,
|
|
208
|
+
issue: outcome.result.issue.externalId,
|
|
209
|
+
})
|
|
210
|
+
}}
|
|
211
|
+
</p>
|
|
212
|
+
<UButton
|
|
213
|
+
size="xs"
|
|
214
|
+
variant="soft"
|
|
215
|
+
icon="i-lucide-crosshair"
|
|
216
|
+
@click="reveal(revealTarget(outcome.result))"
|
|
217
|
+
>
|
|
218
|
+
{{ t('assistant.showOnBoard') }}
|
|
219
|
+
</UButton>
|
|
220
|
+
</div>
|
|
221
|
+
</div>
|
|
222
|
+
</template>
|
|
223
|
+
|
|
224
|
+
<template v-else-if="outcome.status === 'needs_input'">
|
|
225
|
+
<div class="flex items-start gap-2 text-slate-200">
|
|
226
|
+
<UIcon name="i-lucide-help-circle" class="mt-0.5 h-4 w-4 shrink-0 text-amber-400" />
|
|
227
|
+
<div class="space-y-2">
|
|
228
|
+
<p>{{ t(`assistant.needsInput.${outcome.reason}`) }}</p>
|
|
229
|
+
<div v-if="outcome.candidates.length" class="flex flex-wrap gap-1">
|
|
230
|
+
<UButton
|
|
231
|
+
v-for="candidate in outcome.candidates"
|
|
232
|
+
:key="candidate"
|
|
233
|
+
size="xs"
|
|
234
|
+
variant="soft"
|
|
235
|
+
:disabled="assistant.running"
|
|
236
|
+
data-testid="assistant-candidate"
|
|
237
|
+
@click="useCandidate(outcome, candidate)"
|
|
238
|
+
>
|
|
239
|
+
{{ candidate }}
|
|
240
|
+
</UButton>
|
|
241
|
+
</div>
|
|
242
|
+
</div>
|
|
243
|
+
</div>
|
|
244
|
+
</template>
|
|
245
|
+
|
|
246
|
+
<template v-else>
|
|
247
|
+
<div class="flex items-start gap-2 text-slate-200">
|
|
248
|
+
<UIcon
|
|
249
|
+
name="i-lucide-circle-slash"
|
|
250
|
+
class="mt-0.5 h-4 w-4 shrink-0 text-slate-500"
|
|
251
|
+
/>
|
|
252
|
+
<p>{{ t('assistant.declined') }}</p>
|
|
253
|
+
</div>
|
|
254
|
+
</template>
|
|
255
|
+
</div>
|
|
256
|
+
</template>
|
|
257
|
+
</div>
|
|
258
|
+
</template>
|
|
259
|
+
</UModal>
|
|
260
|
+
</template>
|
|
@@ -141,6 +141,7 @@ const TESTS_GAP_KEYS: Record<TestsGap, string> = {
|
|
|
141
141
|
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
142
142
|
no_tester_step: 'outcome.tests.gap.no_tester_step',
|
|
143
143
|
tester_not_reported: 'outcome.tests.gap.tester_not_reported',
|
|
144
|
+
verified_by_committed_tests: 'outcome.tests.gap.verified_by_committed_tests',
|
|
144
145
|
}
|
|
145
146
|
const SOURCES_GAP_KEYS: Record<SourcesGap, string> = {
|
|
146
147
|
run_unavailable: RUN_UNAVAILABLE_KEY,
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { getAssistantCapabilityContract, runAssistantTurnContract } from '@cat-factory/contracts'
|
|
2
|
+
import type { AssistantAnswer } from '~/types/domain'
|
|
3
|
+
import type { ApiContext } from './context'
|
|
4
|
+
|
|
5
|
+
/** In-app assistant: what it can do here, and one prompt-to-action turn. */
|
|
6
|
+
export function assistantApi({ send, ws }: ApiContext) {
|
|
7
|
+
return {
|
|
8
|
+
// Whether a model is wired and which actions this deployment offers. Read before the prompt
|
|
9
|
+
// box is shown, so an unconfigured deployment says so instead of failing on submit.
|
|
10
|
+
getAssistantCapability: (workspaceId: string) =>
|
|
11
|
+
send(getAssistantCapabilityContract, { pathPrefix: ws(workspaceId) }),
|
|
12
|
+
|
|
13
|
+
// Run one turn. A live model call plus a board write, so it can take a couple of seconds:
|
|
14
|
+
// the modal shows progress and the outcome is rendered from the returned data.
|
|
15
|
+
runAssistantTurn: (workspaceId: string, prompt: string) =>
|
|
16
|
+
send(runAssistantTurnContract, {
|
|
17
|
+
pathPrefix: ws(workspaceId),
|
|
18
|
+
body: { kind: 'prompt', prompt },
|
|
19
|
+
}),
|
|
20
|
+
|
|
21
|
+
// Answer a question the last turn asked. The same endpoint, and deliberately: it performs one
|
|
22
|
+
// catalog action exactly as a prompt does. It reaches no model, so it is the fast half.
|
|
23
|
+
answerAssistantTurn: (workspaceId: string, answer: AssistantAnswer) =>
|
|
24
|
+
send(runAssistantTurnContract, {
|
|
25
|
+
pathPrefix: ws(workspaceId),
|
|
26
|
+
body: { kind: 'answer', answer },
|
|
27
|
+
}),
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -54,6 +54,7 @@ import { reviewsApi } from './api/reviews'
|
|
|
54
54
|
import { slackApi } from './api/slack'
|
|
55
55
|
import { specApi } from './api/spec'
|
|
56
56
|
import { tasksApi } from './api/tasks'
|
|
57
|
+
import { assistantApi } from './api/assistant'
|
|
57
58
|
import { bugHuntApi } from './api/bugHunt'
|
|
58
59
|
import { testSecretsApi } from './api/testSecrets'
|
|
59
60
|
import { userSecretsApi } from './api/userSecrets'
|
|
@@ -131,6 +132,7 @@ export function useApi() {
|
|
|
131
132
|
...executionApi(ctx),
|
|
132
133
|
...documentsApi(ctx),
|
|
133
134
|
...tasksApi(ctx),
|
|
135
|
+
...assistantApi(ctx),
|
|
134
136
|
...bugHuntApi(ctx),
|
|
135
137
|
...reviewsApi(ctx),
|
|
136
138
|
...followUpsApi(ctx),
|
|
@@ -40,6 +40,7 @@ export function useNavContributions() {
|
|
|
40
40
|
// with no catalog entry) is a compile error, not a silently dead button.
|
|
41
41
|
// Consumer items bypass this map entirely via their own `run` closure.
|
|
42
42
|
const actions: Record<NavActionId, () => void> = {
|
|
43
|
+
assistant: () => ui.openAssistant(),
|
|
43
44
|
buildPipeline: () => ui.openBuilder(),
|
|
44
45
|
addFromRepo: () => ui.openAddService(),
|
|
45
46
|
bootstrapRepo: () => ui.openBootstrap(),
|
|
@@ -440,6 +440,8 @@ const REASON_DESCRIPTION_KEYS: Record<UnavailableReason | BootstrapReferenceReas
|
|
|
440
440
|
service_catalog_filter_missing: 'errors.unavailable.description.service_catalog_filter_missing',
|
|
441
441
|
service_catalog_response_too_large:
|
|
442
442
|
'errors.unavailable.description.service_catalog_response_too_large',
|
|
443
|
+
assistant_generation_failed: 'errors.unavailable.description.assistant_generation_failed',
|
|
444
|
+
assistant_reply_unreadable: 'errors.unavailable.description.assistant_reply_unreadable',
|
|
443
445
|
}
|
|
444
446
|
|
|
445
447
|
/**
|
|
@@ -219,6 +219,8 @@ describe('navSlotFilter', () => {
|
|
|
219
219
|
// surface has quietly stopped being simple. The table is the claim; adding `intake: true`
|
|
220
220
|
// fails here until the reason is written down.
|
|
221
221
|
const REASON: Record<string, string> = {
|
|
222
|
+
assistant:
|
|
223
|
+
'the shortest route to the two things this role is here to do (put a repository on the board, file a task from a ticket) and it configures nothing',
|
|
222
224
|
tutorial: 'the walkthroughs - the surface with the fewest destinations needs them most',
|
|
223
225
|
'keyboard-shortcuts': 'the cheatsheet covers the board and the palette, which every role has',
|
|
224
226
|
'ui-role': 'the way BACK out of the narrowed role',
|
|
@@ -196,6 +196,7 @@ export interface NavCommandSpec {
|
|
|
196
196
|
* dead button. Consumer modules don't use these — they carry their own `run`.
|
|
197
197
|
*/
|
|
198
198
|
export const NAV_ACTIONS = [
|
|
199
|
+
'assistant',
|
|
199
200
|
'buildPipeline',
|
|
200
201
|
'addFromRepo',
|
|
201
202
|
'bootstrapRepo',
|
|
@@ -341,6 +342,27 @@ const S = (...s: NavSurface[]) => s as readonly NavSurface[]
|
|
|
341
342
|
* the whole sidebar costs it nothing it is there to do.
|
|
342
343
|
*/
|
|
343
344
|
export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
345
|
+
{
|
|
346
|
+
// The in-app assistant. `intake: true` and not `advanced`, because it is the shortest route
|
|
347
|
+
// to the two things a narrowed role is on the board to do (put a repository on it, file a
|
|
348
|
+
// task from a ticket) and it configures nothing: every action behind it is a board write the
|
|
349
|
+
// member tier already allows, refused server-side for anyone it should not be.
|
|
350
|
+
id: 'assistant',
|
|
351
|
+
labelKey: 'nav.assistant',
|
|
352
|
+
icon: 'i-lucide-sparkles',
|
|
353
|
+
surfaces: S('sidebar', 'command'),
|
|
354
|
+
gate: (g) => g.canWriteBoard,
|
|
355
|
+
action: 'assistant',
|
|
356
|
+
intake: true,
|
|
357
|
+
testId: 'nav-assistant',
|
|
358
|
+
sidebar: { group: 'create', order: 5 },
|
|
359
|
+
command: {
|
|
360
|
+
group: 'create',
|
|
361
|
+
order: 5,
|
|
362
|
+
labelKey: 'layout.commandBar.cmd.assistant',
|
|
363
|
+
keywordsKey: 'layout.commandBar.keywords.assistant',
|
|
364
|
+
},
|
|
365
|
+
},
|
|
344
366
|
{
|
|
345
367
|
id: 'build-pipeline',
|
|
346
368
|
labelKey: 'nav.buildPipeline',
|
package/app/pages/index.vue
CHANGED
|
@@ -48,6 +48,7 @@ const TaskSourceConnectModal = defineAsyncView(
|
|
|
48
48
|
)
|
|
49
49
|
const TaskImportModal = defineAsyncView(() => import('~/components/tasks/TaskImportModal.vue'))
|
|
50
50
|
const BugHuntModal = defineAsyncView(() => import('~/components/tasks/BugHuntModal.vue'))
|
|
51
|
+
const AssistantModal = defineAsyncView(() => import('~/components/assistant/AssistantModal.vue'))
|
|
51
52
|
const RecurringPipelineModal = defineAsyncView(
|
|
52
53
|
() => import('~/components/board/RecurringPipelineModal.vue'),
|
|
53
54
|
)
|
|
@@ -501,6 +502,7 @@ watch(
|
|
|
501
502
|
<TaskSourceConnectModal v-if="ui.taskConnect" />
|
|
502
503
|
<TaskImportModal v-if="ui.taskImport" />
|
|
503
504
|
<BugHuntModal v-if="ui.bugHunt" />
|
|
505
|
+
<AssistantModal v-if="ui.assistantOpen" />
|
|
504
506
|
<RecurringPipelineModal v-if="ui.addRecurringFrameId" />
|
|
505
507
|
<ObservabilityPanel v-if="ui.observabilityInstanceId" />
|
|
506
508
|
<OperatorDashboardPanel v-if="ui.operatorDashboardOpen" />
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { AssistantAnswer, AssistantCapability, AssistantTurn } from '~/types/domain'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* In-app assistant state: what this deployment's assistant can do, and the last turn's outcome.
|
|
8
|
+
*
|
|
9
|
+
* Nothing is persisted server-side and nothing accumulates here: a turn is one prompt and one
|
|
10
|
+
* outcome, and the board itself is where the result lives afterwards (the write arrives over the
|
|
11
|
+
* live stream like any other, so the frame or task shows up without this store touching the board).
|
|
12
|
+
* Keeping a transcript would be a second, staler record of changes the board already carries.
|
|
13
|
+
*
|
|
14
|
+
* Failures are NOT held here: every refusal a turn can raise is a `DomainError` the shared error
|
|
15
|
+
* funnel (`usePipelineErrorToast`) already renders translated, copyable and with its request id.
|
|
16
|
+
* The three OUTCOMES are the ones this store keeps, because they are answers rather than errors.
|
|
17
|
+
*/
|
|
18
|
+
export const useAssistantStore = defineStore('assistant', () => {
|
|
19
|
+
const api = useApi()
|
|
20
|
+
const workspace = useWorkspaceStore()
|
|
21
|
+
|
|
22
|
+
const capability = ref<AssistantCapability | null>(null)
|
|
23
|
+
const turn = ref<AssistantTurn | null>(null)
|
|
24
|
+
const running = ref(false)
|
|
25
|
+
|
|
26
|
+
/** Whether a model is wired at all; unknown (not yet read) reads as unavailable. */
|
|
27
|
+
const available = computed(() => capability.value?.available === true)
|
|
28
|
+
const actions = computed(() => capability.value?.actions ?? [])
|
|
29
|
+
|
|
30
|
+
/** Read what the assistant can do here. Idempotent: re-reading replaces the answer. */
|
|
31
|
+
async function loadCapability(): Promise<void> {
|
|
32
|
+
capability.value = await api.getAssistantCapability(workspace.requireId())
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Run one turn. Throws on a refusal so the caller can hand it to the error funnel; the three
|
|
37
|
+
* outcomes (performed / needs_input / declined) come back as the resolved value.
|
|
38
|
+
*/
|
|
39
|
+
async function run(prompt: string): Promise<AssistantTurn> {
|
|
40
|
+
return record(() => api.runAssistantTurn(workspace.requireId(), prompt))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Answer the question the last turn asked, by re-running its action with the chosen value in the
|
|
45
|
+
* field it named. Same outcomes, same funnel, and no model call.
|
|
46
|
+
*/
|
|
47
|
+
async function answer(chosen: AssistantAnswer): Promise<AssistantTurn> {
|
|
48
|
+
return record(() => api.answerAssistantTurn(workspace.requireId(), chosen))
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The half both turns share: hold `running`, keep the outcome, let a refusal through. */
|
|
52
|
+
async function record(send: () => Promise<AssistantTurn>): Promise<AssistantTurn> {
|
|
53
|
+
running.value = true
|
|
54
|
+
try {
|
|
55
|
+
const result = await send()
|
|
56
|
+
turn.value = result
|
|
57
|
+
return result
|
|
58
|
+
} finally {
|
|
59
|
+
running.value = false
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Forget the last outcome (the modal closing, or a fresh prompt being typed). */
|
|
64
|
+
function reset(): void {
|
|
65
|
+
turn.value = null
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { capability, turn, running, available, actions, loadCapability, run, answer, reset }
|
|
69
|
+
})
|
package/app/stores/ui/modals.ts
CHANGED
|
@@ -216,6 +216,11 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
216
216
|
// the create-in target AND scopes the issue search to the frame's linked repo.
|
|
217
217
|
// Null → the unscoped "import an issue" surface (workspace-wide search).
|
|
218
218
|
const taskImport = ref<{ source: TaskSourceKind | null; containerId: string | null } | null>(null)
|
|
219
|
+
// In-app assistant: the prompt box that routes one sentence to one board action. It carries no
|
|
220
|
+
// subject (a turn resolves every name it needs from the board itself), so a plain flag says
|
|
221
|
+
// everything the host needs to know.
|
|
222
|
+
const assistantOpen = ref(false)
|
|
223
|
+
|
|
219
224
|
// Bug hunt: pick a tracker + one of its boards, rank its open unassigned bugs, adopt one.
|
|
220
225
|
// `containerId` (a service frame or module) preselects where an adopted bug lands; null →
|
|
221
226
|
// opened standalone, and the modal offers every container on the board.
|
|
@@ -293,6 +298,13 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
293
298
|
function closeTaskImport() {
|
|
294
299
|
taskImport.value = null
|
|
295
300
|
}
|
|
301
|
+
function openAssistant() {
|
|
302
|
+
resetHubReturn()
|
|
303
|
+
assistantOpen.value = true
|
|
304
|
+
}
|
|
305
|
+
function closeAssistant() {
|
|
306
|
+
assistantOpen.value = false
|
|
307
|
+
}
|
|
296
308
|
function openBugHunt(source: TaskSourceKind | null = null, containerId: string | null = null) {
|
|
297
309
|
resetHubReturn()
|
|
298
310
|
bugHunt.value = { source, containerId }
|
|
@@ -341,6 +353,7 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
341
353
|
spawnPreview,
|
|
342
354
|
taskConnect,
|
|
343
355
|
taskImport,
|
|
356
|
+
assistantOpen,
|
|
344
357
|
bugHunt,
|
|
345
358
|
startFromDesign,
|
|
346
359
|
addTaskContainerId,
|
|
@@ -360,6 +373,8 @@ function createDocumentTaskModals(resetHubReturn: ResetHubReturn) {
|
|
|
360
373
|
closeTaskConnect,
|
|
361
374
|
openTaskImport,
|
|
362
375
|
closeTaskImport,
|
|
376
|
+
openAssistant,
|
|
377
|
+
closeAssistant,
|
|
363
378
|
openBugHunt,
|
|
364
379
|
closeBugHunt,
|
|
365
380
|
openStartFromDesign,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// The in-app assistant: one natural-language prompt, one action the platform performs on the
|
|
3
|
+
// person's behalf (declare a service dependency, add a service from a repository URL, file a task
|
|
4
|
+
// from a tracker issue).
|
|
5
|
+
//
|
|
6
|
+
// A turn answers with DATA, never with model prose: the outcome variant carries the ids and names
|
|
7
|
+
// of what was touched, or the machine-readable reason it could not act, and the SPA renders every
|
|
8
|
+
// sentence from those members through the i18n catalog. All wire shapes are sourced from
|
|
9
|
+
// @cat-factory/contracts (single source of truth).
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
|
|
12
|
+
export type {
|
|
13
|
+
AssistantActionId,
|
|
14
|
+
AssistantActionResult,
|
|
15
|
+
AssistantAnswer,
|
|
16
|
+
AssistantArguments,
|
|
17
|
+
AssistantCapability,
|
|
18
|
+
AssistantClarificationReason,
|
|
19
|
+
AssistantDeclineReason,
|
|
20
|
+
AssistantOutcome,
|
|
21
|
+
AssistantServiceRef,
|
|
22
|
+
AssistantTurn,
|
|
23
|
+
AssistantTurnInput,
|
|
24
|
+
} from '@cat-factory/contracts'
|
package/app/types/domain.ts
CHANGED
|
@@ -220,6 +220,7 @@ export type * from './skills'
|
|
|
220
220
|
export type * from './foundationalServices'
|
|
221
221
|
export type * from './documents'
|
|
222
222
|
export type * from './tasks'
|
|
223
|
+
export type * from './assistant'
|
|
223
224
|
export type * from './bugHunt'
|
|
224
225
|
export type * from './bootstrap'
|
|
225
226
|
export type * from './envConfigRepair'
|