@cat-factory/app 0.183.0 → 0.185.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/panels/inspector/ServiceValidationConfig.vue +42 -4
- package/app/components/pipeline/AgentPromptEditor.logic.spec.ts +93 -0
- package/app/components/pipeline/AgentPromptEditor.logic.ts +60 -0
- package/app/components/pipeline/AgentPromptEditor.vue +298 -0
- package/app/components/pipeline/PipelineBuilder.vue +62 -0
- package/app/components/sandbox/SandboxPanel.vue +63 -5
- package/app/composables/api/agentPrompts.ts +41 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/stores/agentPrompts.ts +100 -0
- package/app/stores/sandbox.ts +15 -0
- package/app/stores/validationChecks.ts +8 -4
- package/app/types/agent-prompts.ts +13 -0
- package/i18n/locales/de.json +51 -4
- package/i18n/locales/en.json +67 -4
- package/i18n/locales/es.json +51 -4
- package/i18n/locales/fr.json +51 -4
- package/i18n/locales/he.json +51 -4
- package/i18n/locales/it.json +51 -4
- package/i18n/locales/ja.json +51 -4
- package/i18n/locales/pl.json +51 -4
- package/i18n/locales/tr.json +51 -4
- package/i18n/locales/uk.json +51 -4
- package/package.json +2 -2
|
@@ -27,9 +27,15 @@ const busy = ref(false)
|
|
|
27
27
|
const detecting = ref(false)
|
|
28
28
|
const rows = ref<ValidationCheck[]>([])
|
|
29
29
|
const maxAttempts = ref(VALIDATION_DEFAULT_MAX_ATTEMPTS)
|
|
30
|
+
// DEPENDENCY PREPOPULATION: the install run BEFORE the agent's first turn, so it reads a tree
|
|
31
|
+
// whose dependencies are present. Edited beside the checks because it comes from the same
|
|
32
|
+
// per-service row, but it is independent of them — a service may declare only this.
|
|
33
|
+
const dependencyInstall = ref('')
|
|
30
34
|
|
|
31
35
|
const saved = computed(() => store.forBlock(props.block.id))
|
|
32
|
-
const configured = computed(
|
|
36
|
+
const configured = computed(
|
|
37
|
+
() => saved.value.checks.length > 0 || Boolean(saved.value.dependencyInstall),
|
|
38
|
+
)
|
|
33
39
|
const canAdd = computed(() => rows.value.length < VALIDATION_MAX_CHECKS)
|
|
34
40
|
/** A row is only submittable once it has a command; the label falls back to the command. */
|
|
35
41
|
const submittable = computed(() =>
|
|
@@ -46,6 +52,7 @@ watch(
|
|
|
46
52
|
(config) => {
|
|
47
53
|
rows.value = config.checks.map((c) => ({ ...c }))
|
|
48
54
|
maxAttempts.value = config.maxAttempts
|
|
55
|
+
dependencyInstall.value = config.dependencyInstall ?? ''
|
|
49
56
|
},
|
|
50
57
|
{ immediate: true },
|
|
51
58
|
)
|
|
@@ -114,7 +121,13 @@ async function detect() {
|
|
|
114
121
|
}
|
|
115
122
|
const merged = mergeDetectedChecks(rows.value, result.checks, VALIDATION_MAX_CHECKS)
|
|
116
123
|
rows.value = merged.rows
|
|
117
|
-
|
|
124
|
+
// Fill the install only when the operator has not written one. Detection is assistive, and
|
|
125
|
+
// overwriting a hand-tuned install (a workspace filter, an offline flag) with the generic
|
|
126
|
+
// guess is the same failure `mergeDetectedChecks` refuses to make on the check rows.
|
|
127
|
+
const suggestedInstall = result.dependencyInstall?.trim() ?? ''
|
|
128
|
+
const filledInstall = suggestedInstall !== '' && dependencyInstall.value.trim() === ''
|
|
129
|
+
if (filledInstall) dependencyInstall.value = suggestedInstall
|
|
130
|
+
if (merged.added === 0 && !filledInstall) {
|
|
118
131
|
toast.add({
|
|
119
132
|
title: t('inspector.validationChecks.detect.nothingNew'),
|
|
120
133
|
description:
|
|
@@ -128,7 +141,13 @@ async function detect() {
|
|
|
128
141
|
}
|
|
129
142
|
const names = result.ecosystems.map(ecosystemLabel).join(', ')
|
|
130
143
|
toast.add({
|
|
131
|
-
|
|
144
|
+
// An install-only detection fills nothing but the install field, and reporting it as
|
|
145
|
+
// "0 checks added" would read as a failed press on the one repo shape prepopulation is
|
|
146
|
+
// most for (dependencies to install, nothing declared to verify).
|
|
147
|
+
title:
|
|
148
|
+
merged.added === 0
|
|
149
|
+
? t('inspector.validationChecks.detect.installOnly')
|
|
150
|
+
: t('inspector.validationChecks.detect.added', { count: merged.added }, merged.added),
|
|
132
151
|
// Name what was recognised AND what was left out: a cap that silently swallowed a
|
|
133
152
|
// suggestion reads as "that is everything your repo has".
|
|
134
153
|
description: [
|
|
@@ -152,7 +171,12 @@ async function detect() {
|
|
|
152
171
|
async function save() {
|
|
153
172
|
busy.value = true
|
|
154
173
|
try {
|
|
155
|
-
await store.save(
|
|
174
|
+
await store.save(
|
|
175
|
+
props.block.id,
|
|
176
|
+
submittable.value,
|
|
177
|
+
maxAttempts.value,
|
|
178
|
+
dependencyInstall.value.trim() || undefined,
|
|
179
|
+
)
|
|
156
180
|
toast.add({
|
|
157
181
|
title: t('inspector.validationChecks.savedToast'),
|
|
158
182
|
icon: 'i-lucide-check',
|
|
@@ -172,6 +196,7 @@ async function clear() {
|
|
|
172
196
|
try {
|
|
173
197
|
await store.remove(props.block.id)
|
|
174
198
|
rows.value = []
|
|
199
|
+
dependencyInstall.value = ''
|
|
175
200
|
toastDone('clear', noun)
|
|
176
201
|
} catch (e) {
|
|
177
202
|
notifyError(t('inspector.validationChecks.clearFailed'), e)
|
|
@@ -204,6 +229,19 @@ async function clear() {
|
|
|
204
229
|
</template>
|
|
205
230
|
|
|
206
231
|
<div class="space-y-2">
|
|
232
|
+
<UFormField
|
|
233
|
+
:label="t('inspector.validationChecks.dependencyInstall')"
|
|
234
|
+
:hint="t('inspector.validationChecks.dependencyInstallHint')"
|
|
235
|
+
>
|
|
236
|
+
<UInput
|
|
237
|
+
v-model="dependencyInstall"
|
|
238
|
+
placeholder="pnpm install --frozen-lockfile"
|
|
239
|
+
size="sm"
|
|
240
|
+
class="w-full"
|
|
241
|
+
data-testid="validation-dependency-install"
|
|
242
|
+
/>
|
|
243
|
+
</UFormField>
|
|
244
|
+
|
|
207
245
|
<p class="text-[11px] text-slate-500">
|
|
208
246
|
{{ t('inspector.validationChecks.hint') }}
|
|
209
247
|
</p>
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import type { AgentPromptDetail, AgentPromptRevision } from '~/types/agent-prompts'
|
|
3
|
+
import {
|
|
4
|
+
draftForRevision,
|
|
5
|
+
isDirty,
|
|
6
|
+
isRevisionConflict,
|
|
7
|
+
saveIntent,
|
|
8
|
+
} from './AgentPromptEditor.logic'
|
|
9
|
+
|
|
10
|
+
const BUILTIN = 'You are a careful engineer.'
|
|
11
|
+
|
|
12
|
+
function rev(overrides: Partial<AgentPromptRevision> = {}): AgentPromptRevision {
|
|
13
|
+
return { agentKind: 'coder', revision: 1, text: 'v1 text', createdAt: 1, ...overrides }
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function detail(overrides: Partial<AgentPromptDetail> = {}): AgentPromptDetail {
|
|
17
|
+
return {
|
|
18
|
+
agentKind: 'coder',
|
|
19
|
+
builtinText: BUILTIN,
|
|
20
|
+
appendedText: '',
|
|
21
|
+
effectiveText: BUILTIN,
|
|
22
|
+
customized: false,
|
|
23
|
+
revisions: [],
|
|
24
|
+
...overrides,
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
describe('saveIntent', () => {
|
|
29
|
+
it('sends the typed text as an override', () => {
|
|
30
|
+
expect(saveIntent('Be terse.', detail(), undefined)).toEqual({ text: 'Be terse.' })
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('sends null when the draft is the built-in, so the workspace keeps tracking it', () => {
|
|
34
|
+
// Storing a copy of the built-in would pin the workspace to today's wording — the exact
|
|
35
|
+
// thing the null revision exists to avoid.
|
|
36
|
+
expect(saveIntent(` ${BUILTIN} `, detail(), undefined)).toEqual({ text: null })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps restoredFrom while the draft still is that revision', () => {
|
|
40
|
+
const d = detail({ revisions: [rev({ revision: 3, text: 'v3 text' })] })
|
|
41
|
+
expect(saveIntent('v3 text', d, 3)).toEqual({ text: 'v3 text', restoredFrom: 3 })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('DROPS restoredFrom once the draft has been edited away from that revision', () => {
|
|
45
|
+
// The regression this exists for: restore v3, tweak a line, save — and the log claims the
|
|
46
|
+
// new entry is v3 restored, so anyone tracing "what were we running" is misled by a record
|
|
47
|
+
// that reads as authoritative. Nothing errors; it is only wrong.
|
|
48
|
+
const d = detail({ revisions: [rev({ revision: 3, text: 'v3 text' })] })
|
|
49
|
+
expect(saveIntent('v3 text plus my edit', d, 3)).toEqual({ text: 'v3 text plus my edit' })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('drops a restoredFrom naming a revision the (reloaded) log no longer has', () => {
|
|
53
|
+
// After a 409 the store holds the SERVER's log. A stale pick from before that reload must
|
|
54
|
+
// not be sent — the server would refuse it as `unknown_revision` and lose the user's text.
|
|
55
|
+
expect(saveIntent('anything', detail({ revisions: [rev({ revision: 1 })] }), 9)).toEqual({
|
|
56
|
+
text: 'anything',
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('keeps restoredFrom when restoring a revert revision, and still sends null', () => {
|
|
61
|
+
const d = detail({ revisions: [rev({ revision: 2, text: null })] })
|
|
62
|
+
expect(saveIntent(BUILTIN, d, 2)).toEqual({ text: null, restoredFrom: 2 })
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('isDirty', () => {
|
|
67
|
+
it('ignores whitespace-only differences, which the payload would trim away anyway', () => {
|
|
68
|
+
expect(isDirty(` ${BUILTIN}\n`, detail())).toBe(false)
|
|
69
|
+
expect(isDirty(`${BUILTIN} and more`, detail())).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
describe('draftForRevision', () => {
|
|
74
|
+
it('loads a revert revision as the built-in text', () => {
|
|
75
|
+
expect(draftForRevision(rev({ text: null }), detail())).toBe(BUILTIN)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('loads an edited revision as its own text', () => {
|
|
79
|
+
expect(draftForRevision(rev({ text: 'mine' }), detail())).toBe('mine')
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
describe('isRevisionConflict', () => {
|
|
84
|
+
it('recognises the append-only log’s refusal, and nothing else', () => {
|
|
85
|
+
expect(
|
|
86
|
+
isRevisionConflict({ data: { error: { details: { reason: 'prompt_revision_conflict' } } } }),
|
|
87
|
+
).toBe(true)
|
|
88
|
+
expect(
|
|
89
|
+
isRevisionConflict({ data: { error: { details: { reason: 'unknown_revision' } } } }),
|
|
90
|
+
).toBe(false)
|
|
91
|
+
expect(isRevisionConflict(new Error('offline'))).toBe(false)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { AgentPromptDetail, AgentPromptRevision } from '~/types/agent-prompts'
|
|
2
|
+
|
|
3
|
+
// Pure decision logic for the agent system-prompt editor, split out of the component so the
|
|
4
|
+
// rules below are unit-testable without mounting Nuxt. Each one is a rule about the append-only
|
|
5
|
+
// revision log rather than about rendering, and each has a wrong answer that is silent rather
|
|
6
|
+
// than visible — which is why they are pinned rather than left inline in the template.
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What a save should send for the current draft.
|
|
10
|
+
*
|
|
11
|
+
* Two rules, both non-obvious:
|
|
12
|
+
*
|
|
13
|
+
* - **Text identical to the built-in is a REVERT, not a copy of it.** Storing the copy would pin
|
|
14
|
+
* the workspace to today's wording and quietly stop it tracking the product's prompt as that is
|
|
15
|
+
* improved — the whole reason the null revision exists.
|
|
16
|
+
* - **`restoredFrom` only survives while the draft still IS that revision.** It is a claim the
|
|
17
|
+
* history renders ("restored from version 3"), so carrying it across an edit would label an
|
|
18
|
+
* entry as text it does not contain, and the next person tracing the log is misled by a record
|
|
19
|
+
* that looks authoritative. Comparing the text is what makes this total: it cannot be defeated
|
|
20
|
+
* by an edit-then-undo, and it needs no keystroke tracking in the component.
|
|
21
|
+
*/
|
|
22
|
+
export function saveIntent(
|
|
23
|
+
draft: string,
|
|
24
|
+
detail: Pick<AgentPromptDetail, 'builtinText' | 'revisions'> | null,
|
|
25
|
+
restoredFrom: number | undefined,
|
|
26
|
+
): { text: string | null; restoredFrom?: number } {
|
|
27
|
+
const text = draft.trim()
|
|
28
|
+
const builtin = (detail?.builtinText ?? '').trim()
|
|
29
|
+
const payload: { text: string | null } = { text: text === builtin ? null : text }
|
|
30
|
+
const source = detail?.revisions.find((r) => r.revision === restoredFrom)
|
|
31
|
+
if (!source) return payload
|
|
32
|
+
// A null-text revision restores the built-in, so its "text" for this comparison is that.
|
|
33
|
+
const sourceText = (source.text ?? detail?.builtinText ?? '').trim()
|
|
34
|
+
return sourceText === text ? { ...payload, restoredFrom } : payload
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether the save button does anything. Trimmed on both sides because the payload is trimmed,
|
|
39
|
+
* so trailing whitespace alone is not an edit — a save button that enables on it appends a
|
|
40
|
+
* revision indistinguishable from its predecessor.
|
|
41
|
+
*/
|
|
42
|
+
export function isDirty(draft: string, detail: Pick<AgentPromptDetail, 'effectiveText'> | null) {
|
|
43
|
+
return draft.trim() !== (detail?.effectiveText ?? '').trim()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The text `pick`ing a revision loads into the editor: its own, or the built-in for a revert. */
|
|
47
|
+
export function draftForRevision(
|
|
48
|
+
revision: AgentPromptRevision,
|
|
49
|
+
detail: Pick<AgentPromptDetail, 'builtinText'> | null,
|
|
50
|
+
): string {
|
|
51
|
+
return revision.text ?? detail?.builtinText ?? ''
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** True when an error envelope is the append-only log's concurrent-editor refusal. */
|
|
55
|
+
export function isRevisionConflict(error: unknown): boolean {
|
|
56
|
+
return (
|
|
57
|
+
(error as { data?: { error?: { details?: { reason?: string } } } })?.data?.error?.details
|
|
58
|
+
?.reason === 'prompt_revision_conflict'
|
|
59
|
+
)
|
|
60
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed, ref, watch } from 'vue'
|
|
3
|
+
import type { AgentPromptRevision } from '~/types/agent-prompts'
|
|
4
|
+
import { agentKindMeta } from '~/utils/catalog'
|
|
5
|
+
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
6
|
+
import {
|
|
7
|
+
draftForRevision,
|
|
8
|
+
isDirty,
|
|
9
|
+
isRevisionConflict,
|
|
10
|
+
saveIntent,
|
|
11
|
+
} from '~/components/pipeline/AgentPromptEditor.logic'
|
|
12
|
+
|
|
13
|
+
// The per-workspace system-prompt editor for ONE agent kind, opened from the pipeline builder
|
|
14
|
+
// (where the kinds are actually chosen). It edits the SHIPPED track prompt only: the platform
|
|
15
|
+
// re-applies its own directives on top of whatever is saved here, so they cannot be deleted by
|
|
16
|
+
// accident — and it SHOWS that appended text (`detail.appendedText`, measured server-side from
|
|
17
|
+
// the real composition) rather than describing it, so what the editor promises can never drift
|
|
18
|
+
// from what the dispatch actually sends.
|
|
19
|
+
//
|
|
20
|
+
// History is the point, not a nicety: every save appends a revision and going back is another
|
|
21
|
+
// append, so nothing a user does here can lose the prompt their runs were on last week.
|
|
22
|
+
//
|
|
23
|
+
// The rules about WHAT to send live in ./AgentPromptEditor.logic.ts, unit-tested there: each has
|
|
24
|
+
// a wrong answer that is silently wrong (a mislabelled history entry, a revert stored as a copy)
|
|
25
|
+
// rather than visibly broken.
|
|
26
|
+
|
|
27
|
+
const props = defineProps<{ agentKind: string | null }>()
|
|
28
|
+
const emit = defineEmits<{ close: [] }>()
|
|
29
|
+
|
|
30
|
+
const { t } = useI18n()
|
|
31
|
+
const toast = useToast()
|
|
32
|
+
const prompts = useAgentPromptsStore()
|
|
33
|
+
|
|
34
|
+
const open = computed({
|
|
35
|
+
get: () => props.agentKind !== null,
|
|
36
|
+
set: (v: boolean) => {
|
|
37
|
+
if (!v) emit('close')
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/** The textarea's working copy. Seeded from the effective prompt each time the editor opens. */
|
|
42
|
+
const draft = ref('')
|
|
43
|
+
/**
|
|
44
|
+
* The revision the working copy was lifted out of, when the user picked one from the history.
|
|
45
|
+
* Only a CANDIDATE: `saveIntent` drops it unless the draft still matches that revision's text,
|
|
46
|
+
* so editing after a restore cannot mislabel the entry the next reader tries to trace.
|
|
47
|
+
*/
|
|
48
|
+
const restoredFrom = ref<number | undefined>(undefined)
|
|
49
|
+
/** Whether the built-in is shown beside the editor for comparison. */
|
|
50
|
+
const showBuiltin = ref(false)
|
|
51
|
+
/** Whether the non-editable text the platform appends is expanded. */
|
|
52
|
+
const showDirectives = ref(false)
|
|
53
|
+
|
|
54
|
+
watch(
|
|
55
|
+
() => props.agentKind,
|
|
56
|
+
async (kind) => {
|
|
57
|
+
if (!kind) {
|
|
58
|
+
prompts.reset()
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
showBuiltin.value = false
|
|
62
|
+
showDirectives.value = false
|
|
63
|
+
restoredFrom.value = undefined
|
|
64
|
+
draft.value = ''
|
|
65
|
+
try {
|
|
66
|
+
const detail = await prompts.load(kind)
|
|
67
|
+
draft.value = detail?.effectiveText ?? ''
|
|
68
|
+
} catch {
|
|
69
|
+
toast.add({ title: t('agentPrompt.toast.loadFailed'), color: 'error' })
|
|
70
|
+
emit('close')
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
{ immediate: true },
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
const detail = computed(() => prompts.detail)
|
|
77
|
+
const label = computed(() => (props.agentKind ? agentKindMeta(props.agentKind).label : ''))
|
|
78
|
+
|
|
79
|
+
/** The live prompt, so "no change" can be reported instead of appending an identical revision. */
|
|
80
|
+
const dirty = computed(() => isDirty(draft.value, detail.value))
|
|
81
|
+
/** Nothing to revert to when the workspace is already running the shipped prompt. */
|
|
82
|
+
const canRevert = computed(() => detail.value?.customized === true)
|
|
83
|
+
/** What the platform appends to whatever is saved. Empty ⇒ the panel is not offered at all. */
|
|
84
|
+
const directives = computed(() => detail.value?.appendedText ?? '')
|
|
85
|
+
|
|
86
|
+
function pick(revision: AgentPromptRevision) {
|
|
87
|
+
draft.value = draftForRevision(revision, detail.value)
|
|
88
|
+
restoredFrom.value = revision.revision
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function useBuiltin() {
|
|
92
|
+
draft.value = detail.value?.builtinText ?? ''
|
|
93
|
+
restoredFrom.value = undefined
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function save() {
|
|
97
|
+
const kind = props.agentKind
|
|
98
|
+
if (!kind) return
|
|
99
|
+
const intent = saveIntent(draft.value, detail.value, restoredFrom.value)
|
|
100
|
+
try {
|
|
101
|
+
const saved = await prompts.save(kind, intent.text, intent.restoredFrom)
|
|
102
|
+
draft.value = saved?.effectiveText ?? draft.value
|
|
103
|
+
restoredFrom.value = undefined
|
|
104
|
+
toast.add({ title: t('agentPrompt.toast.saved'), color: 'success', icon: 'i-lucide-check' })
|
|
105
|
+
} catch (error) {
|
|
106
|
+
const conflict = isRevisionConflict(error)
|
|
107
|
+
toast.add({
|
|
108
|
+
title: conflict ? t('agentPrompt.toast.conflict') : t('agentPrompt.toast.saveFailed'),
|
|
109
|
+
color: 'error',
|
|
110
|
+
})
|
|
111
|
+
// The server's view already replaced the store's on a conflict, so re-seed the textarea
|
|
112
|
+
// from what actually landed rather than leaving the user editing a lost revision — and drop
|
|
113
|
+
// the restore candidate with it, since it names a revision from the log we just replaced.
|
|
114
|
+
if (conflict) {
|
|
115
|
+
draft.value = prompts.detail?.effectiveText ?? draft.value
|
|
116
|
+
restoredFrom.value = undefined
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function revert() {
|
|
122
|
+
const kind = props.agentKind
|
|
123
|
+
if (!kind) return
|
|
124
|
+
try {
|
|
125
|
+
const saved = await prompts.save(kind, null)
|
|
126
|
+
draft.value = saved?.effectiveText ?? draft.value
|
|
127
|
+
restoredFrom.value = undefined
|
|
128
|
+
toast.add({ title: t('agentPrompt.toast.reverted'), color: 'success' })
|
|
129
|
+
} catch {
|
|
130
|
+
toast.add({ title: t('agentPrompt.toast.saveFailed'), color: 'error' })
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const { d } = useI18n()
|
|
135
|
+
function revisionLabel(revision: AgentPromptRevision): string {
|
|
136
|
+
return revision.text === null
|
|
137
|
+
? t('agentPrompt.revision.builtin', { n: revision.revision })
|
|
138
|
+
: revision.restoredFrom !== undefined
|
|
139
|
+
? t('agentPrompt.revision.restored', { n: revision.revision, from: revision.restoredFrom })
|
|
140
|
+
: t('agentPrompt.revision.edit', { n: revision.revision })
|
|
141
|
+
}
|
|
142
|
+
</script>
|
|
143
|
+
|
|
144
|
+
<template>
|
|
145
|
+
<UModal
|
|
146
|
+
v-model:open="open"
|
|
147
|
+
:title="t('agentPrompt.title', { agent: label })"
|
|
148
|
+
:description="t('agentPrompt.description')"
|
|
149
|
+
:ui="{ content: 'max-w-[92vw] sm:max-w-3xl lg:max-w-5xl' }"
|
|
150
|
+
>
|
|
151
|
+
<template #body>
|
|
152
|
+
<div v-if="prompts.loadingDetail" class="py-8 text-center text-sm text-slate-400">
|
|
153
|
+
{{ t('common.loading') }}
|
|
154
|
+
</div>
|
|
155
|
+
<div v-else-if="detail" class="flex flex-col gap-3">
|
|
156
|
+
<div class="flex flex-wrap items-center gap-2 text-xs">
|
|
157
|
+
<AgentKindIcon v-if="agentKind" :kind="agentKind" icon-class="h-4 w-4" />
|
|
158
|
+
<span class="font-medium text-slate-200">{{ label }}</span>
|
|
159
|
+
<UBadge v-if="detail.builtinVersionLabel" color="neutral" variant="subtle" size="sm">
|
|
160
|
+
{{ detail.builtinVersionLabel }}
|
|
161
|
+
</UBadge>
|
|
162
|
+
<UBadge :color="detail.customized ? 'warning' : 'neutral'" variant="subtle" size="sm">
|
|
163
|
+
{{ detail.customized ? t('agentPrompt.customized') : t('agentPrompt.usingBuiltin') }}
|
|
164
|
+
</UBadge>
|
|
165
|
+
</div>
|
|
166
|
+
|
|
167
|
+
<!-- What the platform appends is SHOWN, not described. A prose summary of it is copy
|
|
168
|
+
that silently goes stale the moment a directive is added, and a user who does not
|
|
169
|
+
know what is already there writes a prompt that fights it. -->
|
|
170
|
+
<p v-if="directives" class="text-[11px] leading-relaxed text-slate-500">
|
|
171
|
+
{{ t('agentPrompt.managedNotice') }}
|
|
172
|
+
<UButton
|
|
173
|
+
variant="link"
|
|
174
|
+
size="xs"
|
|
175
|
+
class="px-1 align-baseline"
|
|
176
|
+
@click="showDirectives = !showDirectives"
|
|
177
|
+
>
|
|
178
|
+
{{ showDirectives ? t('agentPrompt.hideAppended') : t('agentPrompt.showAppended') }}
|
|
179
|
+
</UButton>
|
|
180
|
+
</p>
|
|
181
|
+
<div
|
|
182
|
+
v-if="directives && showDirectives"
|
|
183
|
+
class="rounded-md border border-slate-800 bg-slate-950/60 p-2"
|
|
184
|
+
>
|
|
185
|
+
<h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
186
|
+
{{ t('agentPrompt.appendedHeading') }}
|
|
187
|
+
</h4>
|
|
188
|
+
<pre
|
|
189
|
+
class="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-slate-300"
|
|
190
|
+
>{{ directives.trim() }}</pre>
|
|
191
|
+
</div>
|
|
192
|
+
|
|
193
|
+
<UTextarea
|
|
194
|
+
v-model="draft"
|
|
195
|
+
:rows="16"
|
|
196
|
+
autoresize
|
|
197
|
+
:maxrows="24"
|
|
198
|
+
class="font-mono"
|
|
199
|
+
:placeholder="t('agentPrompt.placeholder')"
|
|
200
|
+
/>
|
|
201
|
+
|
|
202
|
+
<div class="flex flex-wrap items-center gap-2">
|
|
203
|
+
<UButton
|
|
204
|
+
color="primary"
|
|
205
|
+
size="sm"
|
|
206
|
+
icon="i-lucide-save"
|
|
207
|
+
:loading="prompts.saving"
|
|
208
|
+
:disabled="!dirty || !draft.trim()"
|
|
209
|
+
@click="save"
|
|
210
|
+
>
|
|
211
|
+
{{ t('agentPrompt.save') }}
|
|
212
|
+
</UButton>
|
|
213
|
+
<UButton
|
|
214
|
+
color="neutral"
|
|
215
|
+
variant="soft"
|
|
216
|
+
size="sm"
|
|
217
|
+
icon="i-lucide-rotate-ccw"
|
|
218
|
+
:disabled="prompts.saving || !canRevert"
|
|
219
|
+
@click="revert"
|
|
220
|
+
>
|
|
221
|
+
{{ t('agentPrompt.revert') }}
|
|
222
|
+
</UButton>
|
|
223
|
+
<UButton
|
|
224
|
+
color="neutral"
|
|
225
|
+
variant="ghost"
|
|
226
|
+
size="sm"
|
|
227
|
+
icon="i-lucide-file-text"
|
|
228
|
+
@click="useBuiltin"
|
|
229
|
+
>
|
|
230
|
+
{{ t('agentPrompt.loadBuiltin') }}
|
|
231
|
+
</UButton>
|
|
232
|
+
<UButton
|
|
233
|
+
color="neutral"
|
|
234
|
+
variant="ghost"
|
|
235
|
+
size="sm"
|
|
236
|
+
:icon="showBuiltin ? 'i-lucide-eye-off' : 'i-lucide-eye'"
|
|
237
|
+
@click="showBuiltin = !showBuiltin"
|
|
238
|
+
>
|
|
239
|
+
{{ showBuiltin ? t('agentPrompt.hideBuiltin') : t('agentPrompt.showBuiltin') }}
|
|
240
|
+
</UButton>
|
|
241
|
+
<span v-if="restoredFrom !== undefined" class="text-[11px] text-slate-400">
|
|
242
|
+
{{ t('agentPrompt.restoringFrom', { n: restoredFrom }) }}
|
|
243
|
+
</span>
|
|
244
|
+
</div>
|
|
245
|
+
|
|
246
|
+
<div v-if="showBuiltin" class="rounded-md border border-slate-800 bg-slate-950/60 p-2">
|
|
247
|
+
<h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
248
|
+
{{ t('agentPrompt.builtinHeading') }}
|
|
249
|
+
</h4>
|
|
250
|
+
<pre
|
|
251
|
+
class="max-h-64 overflow-auto whitespace-pre-wrap break-words font-mono text-[11px] text-slate-300"
|
|
252
|
+
>{{ detail.builtinText }}</pre>
|
|
253
|
+
</div>
|
|
254
|
+
|
|
255
|
+
<div>
|
|
256
|
+
<h4 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
257
|
+
{{ t('agentPrompt.historyHeading') }}
|
|
258
|
+
</h4>
|
|
259
|
+
<p v-if="!detail.revisions.length" class="text-[11px] text-slate-500">
|
|
260
|
+
{{ t('agentPrompt.historyEmpty') }}
|
|
261
|
+
</p>
|
|
262
|
+
<ul v-else class="max-h-52 divide-y divide-slate-800 overflow-y-auto text-xs">
|
|
263
|
+
<li
|
|
264
|
+
v-for="revision in detail.revisions"
|
|
265
|
+
:key="revision.revision"
|
|
266
|
+
class="flex items-center gap-2 py-1.5"
|
|
267
|
+
>
|
|
268
|
+
<UBadge
|
|
269
|
+
v-if="revision.revision === detail.revisions[0]?.revision"
|
|
270
|
+
color="primary"
|
|
271
|
+
variant="subtle"
|
|
272
|
+
size="sm"
|
|
273
|
+
>
|
|
274
|
+
{{ t('agentPrompt.live') }}
|
|
275
|
+
</UBadge>
|
|
276
|
+
<span class="min-w-0 flex-1 truncate text-slate-300">
|
|
277
|
+
{{ revisionLabel(revision) }}
|
|
278
|
+
</span>
|
|
279
|
+
<span class="shrink-0 text-[11px] text-slate-500">
|
|
280
|
+
{{ d(new Date(revision.createdAt), 'short') }}
|
|
281
|
+
</span>
|
|
282
|
+
<UButton
|
|
283
|
+
color="neutral"
|
|
284
|
+
variant="ghost"
|
|
285
|
+
size="xs"
|
|
286
|
+
icon="i-lucide-history"
|
|
287
|
+
:title="t('agentPrompt.restoreTooltip')"
|
|
288
|
+
@click="pick(revision)"
|
|
289
|
+
>
|
|
290
|
+
{{ t('agentPrompt.restore') }}
|
|
291
|
+
</UButton>
|
|
292
|
+
</li>
|
|
293
|
+
</ul>
|
|
294
|
+
</div>
|
|
295
|
+
</div>
|
|
296
|
+
</template>
|
|
297
|
+
</UModal>
|
|
298
|
+
</template>
|
|
@@ -4,6 +4,8 @@ import { purposeAllowsAgentCategory } from '@cat-factory/contracts'
|
|
|
4
4
|
import type { AgentKind, Pipeline, PipelinePurpose } from '~/types/domain'
|
|
5
5
|
import AgentPalette from '~/components/palettes/AgentPalette.vue'
|
|
6
6
|
import AgentKindIcon from '~/components/pipeline/AgentKindIcon.vue'
|
|
7
|
+
import AgentPromptEditor from '~/components/pipeline/AgentPromptEditor.vue'
|
|
8
|
+
import { showOverrideField } from '~/utils/uiMode'
|
|
7
9
|
import {
|
|
8
10
|
agentKindMeta,
|
|
9
11
|
companionForProducer,
|
|
@@ -54,6 +56,24 @@ function toggleGating(i: number) {
|
|
|
54
56
|
}
|
|
55
57
|
const agents = useAgentsStore()
|
|
56
58
|
const ui = useUiStore()
|
|
59
|
+
const uiMode = useUiModeStore()
|
|
60
|
+
const agentPrompts = useAgentPromptsStore()
|
|
61
|
+
|
|
62
|
+
// The agent kind whose system prompt is open in the editor (null = closed). Per-KIND, not
|
|
63
|
+
// per-step: an override applies to every run of that agent in the workspace, so two steps of
|
|
64
|
+
// the same kind are two views of one prompt.
|
|
65
|
+
const promptEditorKind = ref<AgentKind | null>(null)
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Whether the "edit this agent's prompt" affordance shows for a kind. Editing a prompt is an
|
|
69
|
+
* OVERRIDE of what the product ships, so it follows the override rule: hidden in basic mode
|
|
70
|
+
* while the kind is running the shipped prompt, and revealed as soon as the workspace actually
|
|
71
|
+
* carries an override — otherwise a basic-mode user would be running on an edited prompt they
|
|
72
|
+
* can neither see nor clear.
|
|
73
|
+
*/
|
|
74
|
+
function showPromptEditor(kind: AgentKind): boolean {
|
|
75
|
+
return showOverrideField(uiMode.isAdvanced, agentPrompts.isCustomized(kind) || null)
|
|
76
|
+
}
|
|
57
77
|
const releaseHealth = useReleaseHealthStore()
|
|
58
78
|
const skills = useSkillsStore()
|
|
59
79
|
|
|
@@ -99,6 +119,10 @@ const open = computed({
|
|
|
99
119
|
// the snapshot). Best-effort: a failure just leaves the gate hidden.
|
|
100
120
|
watch(open, (isOpen) => {
|
|
101
121
|
if (isOpen) releaseHealth.load().catch(() => {})
|
|
122
|
+
// The prompt-override index badges the steps whose agent no longer runs the shipped prompt.
|
|
123
|
+
// Best-effort: the builder is fully usable without it, and a deployment that wires no
|
|
124
|
+
// override store answers 503 here.
|
|
125
|
+
if (isOpen) agentPrompts.loadIndex().catch(() => {})
|
|
102
126
|
})
|
|
103
127
|
|
|
104
128
|
function add(kind: AgentKind) {
|
|
@@ -578,6 +602,21 @@ async function clone(p: Pipeline) {
|
|
|
578
602
|
"
|
|
579
603
|
@click="pipelines.toggleDraftAutoRecommend(unit.index)"
|
|
580
604
|
/>
|
|
605
|
+
<!-- System prompt: replace what this agent kind ships with, for every run in
|
|
606
|
+
this workspace, with the full revision history to switch back through. -->
|
|
607
|
+
<UButton
|
|
608
|
+
v-if="showPromptEditor(unit.kind)"
|
|
609
|
+
icon="i-lucide-file-pen-line"
|
|
610
|
+
:color="agentPrompts.isCustomized(unit.kind) ? 'warning' : 'neutral'"
|
|
611
|
+
variant="ghost"
|
|
612
|
+
size="xs"
|
|
613
|
+
:title="
|
|
614
|
+
agentPrompts.isCustomized(unit.kind)
|
|
615
|
+
? t('pipeline.builder.promptEditedTooltip')
|
|
616
|
+
: t('pipeline.builder.promptEditTooltip')
|
|
617
|
+
"
|
|
618
|
+
@click="promptEditorKind = unit.kind"
|
|
619
|
+
/>
|
|
581
620
|
<UButton
|
|
582
621
|
icon="i-lucide-chevron-up"
|
|
583
622
|
color="neutral"
|
|
@@ -642,6 +681,25 @@ async function clone(p: Pipeline) {
|
|
|
642
681
|
<span class="min-w-0 flex-1 truncate text-slate-200">
|
|
643
682
|
{{ agentKindMeta(pipelines.draft[unit.companionIndex]!).label }}
|
|
644
683
|
</span>
|
|
684
|
+
<!-- A companion is an agent kind with a prompt of its own, and this row is its
|
|
685
|
+
only route to it — so the affordance belongs here too, not only on producers. -->
|
|
686
|
+
<UButton
|
|
687
|
+
v-if="showPromptEditor(pipelines.draft[unit.companionIndex]!)"
|
|
688
|
+
icon="i-lucide-file-pen-line"
|
|
689
|
+
:color="
|
|
690
|
+
agentPrompts.isCustomized(pipelines.draft[unit.companionIndex]!)
|
|
691
|
+
? 'warning'
|
|
692
|
+
: 'neutral'
|
|
693
|
+
"
|
|
694
|
+
variant="ghost"
|
|
695
|
+
size="xs"
|
|
696
|
+
:title="
|
|
697
|
+
agentPrompts.isCustomized(pipelines.draft[unit.companionIndex]!)
|
|
698
|
+
? t('pipeline.builder.promptEditedTooltip')
|
|
699
|
+
: t('pipeline.builder.promptEditTooltip')
|
|
700
|
+
"
|
|
701
|
+
@click="promptEditorKind = pipelines.draft[unit.companionIndex]!"
|
|
702
|
+
/>
|
|
645
703
|
<UButton
|
|
646
704
|
:icon="
|
|
647
705
|
pipelines.draftGating[unit.companionIndex]?.enabled
|
|
@@ -1147,4 +1205,8 @@ async function clone(p: Pipeline) {
|
|
|
1147
1205
|
</div>
|
|
1148
1206
|
</template>
|
|
1149
1207
|
</UModal>
|
|
1208
|
+
|
|
1209
|
+
<!-- The per-workspace system-prompt editor for one agent kind. Mounted alongside the builder
|
|
1210
|
+
(not inside its slideover body) so its own modal isn't nested inside the scrolling column. -->
|
|
1211
|
+
<AgentPromptEditor :agent-kind="promptEditorKind" @close="promptEditorKind = null" />
|
|
1150
1212
|
</template>
|