@cat-factory/app 0.182.1 → 0.184.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/README.md +9 -0
- package/app/components/layout/IntegrationsHub.vue +6 -19
- 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/components/settings/InfrastructureWindow.logic.spec.ts +100 -0
- package/app/components/settings/InfrastructureWindow.logic.ts +78 -0
- package/app/components/settings/InfrastructureWindow.vue +65 -46
- package/app/components/settings/PackageRegistriesPanel.vue +131 -123
- package/app/composables/api/agentPrompts.ts +41 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/usePipelineErrorToast.ts +8 -0
- package/app/pages/index.vue +0 -1
- package/app/stores/agentPrompts.ts +100 -0
- package/app/stores/packageRegistries.spec.ts +111 -0
- package/app/stores/packageRegistries.ts +21 -8
- package/app/stores/sandbox.ts +15 -0
- package/app/stores/ui/modals.ts +20 -21
- package/app/types/agent-prompts.ts +13 -0
- package/app/types/providerConnections.ts +12 -0
- package/i18n/locales/de.json +53 -11
- package/i18n/locales/en.json +68 -11
- package/i18n/locales/es.json +53 -11
- package/i18n/locales/fr.json +53 -11
- package/i18n/locales/he.json +53 -11
- package/i18n/locales/it.json +53 -11
- package/i18n/locales/ja.json +53 -11
- package/i18n/locales/pl.json +53 -11
- package/i18n/locales/tr.json +53 -11
- package/i18n/locales/uk.json +53 -11
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -192,6 +192,15 @@ wrong place is invisible until a user cannot find it:
|
|
|
192
192
|
It also carries a full "how it works / when you need this / when you can skip it"
|
|
193
193
|
explanation there rather than a one-line hint, since the decision to run it has to
|
|
194
194
|
be made before opening a five-minute wizard.
|
|
195
|
+
- **"It talks to an external service" is not what puts a surface in `integrations`.**
|
|
196
|
+
Private package registries connect to npmjs.com and GitHub Packages and still belong
|
|
197
|
+
in Infrastructure, because the question they answer is _what may a container install
|
|
198
|
+
from_ — a property of where agents RUN, which is what the Infrastructure window is
|
|
199
|
+
for. `integrations` is for a system the WORKSPACE links in and would still be a
|
|
200
|
+
coherent product without. Ask which question the destination answers, not whether a
|
|
201
|
+
credential leaves the building. A surface moved between sections must also move its
|
|
202
|
+
entry point: leaving a hub row behind as a shortcut splits the answer across two
|
|
203
|
+
places, so the row goes and the window's tab becomes the single route in.
|
|
195
204
|
|
|
196
205
|
## Develop & test
|
|
197
206
|
|
|
@@ -30,7 +30,6 @@ const documents = useDocumentsStore()
|
|
|
30
30
|
const tasks = useTasksStore()
|
|
31
31
|
const tracker = useTrackerStore()
|
|
32
32
|
const releaseHealth = useReleaseHealthStore()
|
|
33
|
-
const packageRegistries = usePackageRegistriesStore()
|
|
34
33
|
const publicApiKeys = usePublicApiKeysStore()
|
|
35
34
|
const userSecrets = useUserSecretsStore()
|
|
36
35
|
const uiMode = useUiModeStore()
|
|
@@ -60,7 +59,6 @@ watch(
|
|
|
60
59
|
if (isOpen) {
|
|
61
60
|
query.value = ''
|
|
62
61
|
void releaseHealth.ensureLoaded().catch(() => {})
|
|
63
|
-
void packageRegistries.ensureLoaded().catch(() => {})
|
|
64
62
|
void publicApiKeys.ensureLoaded().catch(() => {})
|
|
65
63
|
void userSecrets.load().catch(() => {})
|
|
66
64
|
}
|
|
@@ -271,22 +269,10 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
271
269
|
})
|
|
272
270
|
}
|
|
273
271
|
|
|
274
|
-
// --- Development (
|
|
275
|
-
//
|
|
276
|
-
//
|
|
272
|
+
// --- Development (API access tokens) ---------------------------------------
|
|
273
|
+
// Gated like observability: hidden until a probe confirms its module is wired
|
|
274
|
+
// (`available === true`), so an unconfigured backend doesn't show a dead row.
|
|
277
275
|
const development: IntegrationItem[] = []
|
|
278
|
-
if (packageRegistries.available) {
|
|
279
|
-
const hasEntries = packageRegistries.entries.length > 0
|
|
280
|
-
development.push({
|
|
281
|
-
key: 'package-registries',
|
|
282
|
-
icon: 'i-lucide-package',
|
|
283
|
-
label: t('layout.integrationsHub.items.packageRegistries.label'),
|
|
284
|
-
description: t('layout.integrationsHub.items.packageRegistries.description'),
|
|
285
|
-
status: hasEntries ? t('layout.integrationsHub.status.connected') : undefined,
|
|
286
|
-
connected: hasEntries,
|
|
287
|
-
onClick: () => go(ui.openPackageRegistries),
|
|
288
|
-
})
|
|
289
|
-
}
|
|
290
276
|
if (publicApiKeys.available) {
|
|
291
277
|
const hasKeys = publicApiKeys.keys.length > 0
|
|
292
278
|
development.push({
|
|
@@ -303,8 +289,9 @@ const groups = computed<IntegrationGroup[]>(() => {
|
|
|
303
289
|
out.push({ title: t('layout.integrationsHub.groups.development'), items: development })
|
|
304
290
|
|
|
305
291
|
// NOTE: Infrastructure (agent-container execution + Tester environments + the local-mode
|
|
306
|
-
// warm pool/checkout
|
|
307
|
-
// (SideBar → "Infrastructure" → the
|
|
292
|
+
// warm pool/checkout + the private package registries a checkout installs from) is no longer
|
|
293
|
+
// listed here — it moved to its OWN top-level navbar menu (SideBar → "Infrastructure" → the
|
|
294
|
+
// tabbed Infrastructure window). See `ui.openInfrastructure`.
|
|
308
295
|
|
|
309
296
|
// --- Personal (only you) — fallback when there is no UserMenu to host "My setup" -------
|
|
310
297
|
// Per-user connections normally live in the My-setup hub; with auth disabled they fold in
|
|
@@ -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>
|