@cat-factory/app 0.193.0 → 0.195.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/components/board/nodes/BlockNode.vue +28 -46
- package/app/components/board/nodes/ModuleFrame.vue +4 -33
- package/app/components/board/nodes/ResizeGrips.vue +130 -0
- package/app/components/pipeline/AgentPromptEditor.vue +40 -0
- package/app/components/pipeline/OutputBudgetInput.vue +69 -0
- package/app/components/pipeline/PipelineBuilder.vue +42 -0
- package/app/components/requirements/RequirementsReviewWindow.vue +34 -0
- package/app/composables/api/agentSettings.ts +32 -0
- package/app/composables/api/board.ts +7 -1
- package/app/composables/api/context.ts +2 -0
- package/app/composables/useApi.ts +2 -0
- package/app/composables/useFrameResize.ts +116 -22
- package/app/stores/agentSettings.ts +86 -0
- package/app/stores/board/placement.ts +64 -1
- package/app/stores/board.spec.ts +51 -0
- package/app/stores/outputBudget.spec.ts +55 -0
- package/app/stores/pipelines/draftStepConfig.ts +23 -0
- package/app/types/agent-settings.ts +12 -0
- package/app/types/requirements.ts +1 -0
- package/i18n/locales/de.json +16 -2
- package/i18n/locales/en.json +20 -2
- package/i18n/locales/es.json +16 -2
- package/i18n/locales/fr.json +16 -2
- package/i18n/locales/he.json +16 -2
- package/i18n/locales/it.json +16 -2
- package/i18n/locales/ja.json +16 -2
- package/i18n/locales/pl.json +16 -2
- package/i18n/locales/tr.json +16 -2
- package/i18n/locales/uk.json +16 -2
- package/package.json +2 -2
|
@@ -3,6 +3,7 @@ import { createApiClient, createSend, createSendWith } from './api/client'
|
|
|
3
3
|
import type { ApiContext } from './api/context'
|
|
4
4
|
import { accountsApi } from './api/accounts'
|
|
5
5
|
import { agentPromptsApi } from './api/agentPrompts'
|
|
6
|
+
import { agentSettingsApi } from './api/agentSettings'
|
|
6
7
|
import { platformObservabilityApi } from './api/platformObservability'
|
|
7
8
|
import { reportsApi } from './api/reports'
|
|
8
9
|
import { authApi } from './api/auth'
|
|
@@ -136,6 +137,7 @@ export function useApi() {
|
|
|
136
137
|
...notificationsApi(ctx),
|
|
137
138
|
...presetsApi(ctx),
|
|
138
139
|
...agentPromptsApi(ctx),
|
|
140
|
+
...agentSettingsApi(ctx),
|
|
139
141
|
...preflightsApi(ctx),
|
|
140
142
|
...publicApiKeysApi(ctx),
|
|
141
143
|
...sharedStacksApi(ctx),
|
|
@@ -2,56 +2,150 @@ import { ref } from 'vue'
|
|
|
2
2
|
import type { Block } from '~/types/domain'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
5
|
+
* The eight border/corner grips, each as the pair of unit factors saying how far the drag moves
|
|
6
|
+
* the container's ORIGIN versus its far edge. `1` on an origin axis means that axis's border is
|
|
7
|
+
* the one being dragged (west/north), so the box grows the OPPOSITE way from the pointer delta.
|
|
8
|
+
*
|
|
9
|
+
* Encoding the geometry as data rather than a `switch` per axis is what keeps the corners honest:
|
|
10
|
+
* `nw` is exactly `n` and `w` applied together, and there is no eighth case to forget.
|
|
11
|
+
*/
|
|
12
|
+
const HANDLES = {
|
|
13
|
+
n: { ox: 0, oy: 1, sx: 0, sy: -1, cursor: 'ns-resize' },
|
|
14
|
+
s: { ox: 0, oy: 0, sx: 0, sy: 1, cursor: 'ns-resize' },
|
|
15
|
+
e: { ox: 0, oy: 0, sx: 1, sy: 0, cursor: 'ew-resize' },
|
|
16
|
+
w: { ox: 1, oy: 0, sx: -1, sy: 0, cursor: 'ew-resize' },
|
|
17
|
+
ne: { ox: 0, oy: 1, sx: 1, sy: -1, cursor: 'nesw-resize' },
|
|
18
|
+
nw: { ox: 1, oy: 1, sx: -1, sy: -1, cursor: 'nwse-resize' },
|
|
19
|
+
se: { ox: 0, oy: 0, sx: 1, sy: 1, cursor: 'nwse-resize' },
|
|
20
|
+
sw: { ox: 1, oy: 0, sx: -1, sy: 1, cursor: 'nesw-resize' },
|
|
21
|
+
} as const
|
|
22
|
+
|
|
23
|
+
export type ResizeEdge = keyof typeof HANDLES
|
|
24
|
+
|
|
25
|
+
/** The grips in render order, so a component can `v-for` them instead of listing eight blocks. */
|
|
26
|
+
export const RESIZE_EDGES = Object.keys(HANDLES) as ResizeEdge[]
|
|
27
|
+
|
|
28
|
+
/** The `cursor` a given grip shows, and holds on `<body>` while its drag runs. */
|
|
29
|
+
export function resizeCursor(edge: ResizeEdge): string {
|
|
30
|
+
return HANDLES[edge].cursor
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Pointer-driven resizing for containers (service frames and modules) by dragging any border or
|
|
35
|
+
* corner, Miro-style. The drag delta is divided by the board zoom so the border tracks the
|
|
36
|
+
* cursor, and the new size is clamped to the container's content extent so dragging inwards never
|
|
37
|
+
* clips the tasks/modules inside.
|
|
38
|
+
*
|
|
39
|
+
* Dragging the north or west border also moves the container's ORIGIN, and a child's position is
|
|
40
|
+
* stored relative to that origin — so the store translates the children by the inverse (see
|
|
41
|
+
* `previewResize`) and the backend does the same on commit, which is what makes the border extend
|
|
42
|
+
* past the contents instead of dragging them along. The origin is derived from the CLAMPED size
|
|
43
|
+
* rather than from the raw pointer delta: once the box has hit its content floor the border must
|
|
44
|
+
* stop dead, and a separately-clamped origin would keep sliding, walking the whole container
|
|
45
|
+
* across the board.
|
|
46
|
+
*
|
|
47
|
+
* The container grows live off the store's optimistic geometry, and the final bounds are
|
|
48
|
+
* persisted ONCE on release rather than on every move.
|
|
11
49
|
*/
|
|
12
50
|
export function useFrameResize() {
|
|
13
51
|
const board = useBoardStore()
|
|
14
52
|
const ui = useUiStore()
|
|
15
53
|
const access = useWorkspaceAccess()
|
|
16
|
-
/** Id of the
|
|
54
|
+
/** Id of the container currently being resized, for cursor/grip styling. */
|
|
17
55
|
const resizingId = ref<string | null>(null)
|
|
18
56
|
|
|
19
57
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
58
|
+
* How far the origin may travel INWARD before the nearest child would land at a negative
|
|
59
|
+
* offset — i.e. outside the box, spilling over the very border being dragged. `contentSize` is
|
|
60
|
+
* no help here: it measures only the FAR edge of the contents, which a north/west shrink moves
|
|
61
|
+
* inward in step with the border, so nothing there ever objects. `Infinity` for an empty
|
|
62
|
+
* container, which is then bounded by `contentSize`'s empty floor alone.
|
|
22
63
|
*/
|
|
23
|
-
function
|
|
64
|
+
function originSlack(id: string): { x: number; y: number } {
|
|
65
|
+
const children = board.childrenOf(id)
|
|
66
|
+
if (!children.length) return { x: Number.POSITIVE_INFINITY, y: Number.POSITIVE_INFINITY }
|
|
67
|
+
return {
|
|
68
|
+
x: Math.min(...children.map((c) => c.position.x)),
|
|
69
|
+
y: Math.min(...children.map((c) => c.position.y)),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Begin a resize from one of the container's borders or corners. */
|
|
74
|
+
function startResize(block: Block, e: PointerEvent, edge: ResizeEdge) {
|
|
24
75
|
if (e.button !== 0) return
|
|
25
|
-
// Resizing
|
|
26
|
-
//
|
|
76
|
+
// Resizing persists geometry — a `board.write` mutation, so a read-only viewer's resize
|
|
77
|
+
// no-ops (the grips are hidden for them at the component level).
|
|
27
78
|
if (!access.canWriteBoard.value) return
|
|
28
79
|
e.preventDefault()
|
|
29
80
|
e.stopPropagation()
|
|
81
|
+
const handle = HANDLES[edge]
|
|
30
82
|
const startX = e.clientX
|
|
31
83
|
const startY = e.clientY
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
// Seed from the current rendered size so the first move doesn't jump.
|
|
84
|
+
// Seed from the current rendered geometry so the first move doesn't jump. `size` may be
|
|
85
|
+
// absent (an auto-sized container), which is also what a rejected resize must restore.
|
|
35
86
|
const start = board.containerSize(block.id)
|
|
87
|
+
const from = {
|
|
88
|
+
position: { ...block.position },
|
|
89
|
+
size: block.size ? { ...block.size } : undefined,
|
|
90
|
+
}
|
|
91
|
+
// The floors, snapshotted once: on an origin-axis drag the children MOVE, so a floor re-read
|
|
92
|
+
// mid-drag would chase them. `contentSize` bounds the far edge; the near-edge bound applies
|
|
93
|
+
// only where the origin travels, and only inwards.
|
|
94
|
+
const min = board.contentSize(block.id)
|
|
95
|
+
const slack = originSlack(block.id)
|
|
96
|
+
const floor = {
|
|
97
|
+
w: handle.ox ? Math.max(min.w, start.w - slack.x) : min.w,
|
|
98
|
+
h: handle.oy ? Math.max(min.h, start.h - slack.y) : min.h,
|
|
99
|
+
}
|
|
36
100
|
resizingId.value = block.id
|
|
37
101
|
|
|
102
|
+
// Hold the resize cursor on `<body>` (and kill text selection) for the whole drag: the
|
|
103
|
+
// pointer routinely outruns the 12px grip, and without this the cursor flips back to the
|
|
104
|
+
// default mid-drag, which reads as "the grab was dropped" even though the border is still
|
|
105
|
+
// tracking.
|
|
106
|
+
const body = document.body
|
|
107
|
+
const priorCursor = body.style.cursor
|
|
108
|
+
const priorUserSelect = body.style.userSelect
|
|
109
|
+
body.style.cursor = handle.cursor
|
|
110
|
+
body.style.userSelect = 'none'
|
|
111
|
+
|
|
112
|
+
let bounds = { position: from.position, size: start }
|
|
113
|
+
let moved = false
|
|
38
114
|
const onMove = (ev: PointerEvent) => {
|
|
39
115
|
const z = ui.zoom || 1
|
|
40
|
-
const
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
116
|
+
const dx = (ev.clientX - startX) / z
|
|
117
|
+
const dy = (ev.clientY - startY) / z
|
|
118
|
+
const w = Math.round(Math.max(floor.w, start.w + handle.sx * dx))
|
|
119
|
+
const h = Math.round(Math.max(floor.h, start.h + handle.sy * dy))
|
|
120
|
+
// A grown box on an origin axis extends BACKWARDS from where the far edge stays put, so
|
|
121
|
+
// the origin moves by whatever the size actually gained after clamping.
|
|
122
|
+
bounds = {
|
|
123
|
+
position: {
|
|
124
|
+
x: from.position.x - handle.ox * (w - start.w),
|
|
125
|
+
y: from.position.y - handle.oy * (h - start.h),
|
|
126
|
+
},
|
|
127
|
+
size: { w, h },
|
|
128
|
+
}
|
|
129
|
+
moved = true
|
|
130
|
+
// Optimistic, local-only (the store also translates the children): no round-trip per move.
|
|
131
|
+
board.previewResize(block.id, bounds.position, bounds.size)
|
|
45
132
|
}
|
|
46
133
|
const onUp = () => {
|
|
47
134
|
window.removeEventListener('pointermove', onMove)
|
|
48
135
|
window.removeEventListener('pointerup', onUp)
|
|
136
|
+
window.removeEventListener('pointercancel', onUp)
|
|
137
|
+
body.style.cursor = priorCursor
|
|
138
|
+
body.style.userSelect = priorUserSelect
|
|
49
139
|
resizingId.value = null
|
|
50
|
-
//
|
|
51
|
-
|
|
140
|
+
// A press with no movement is not a resize: committing it would emit a coarse board signal
|
|
141
|
+
// (every other client re-hydrates) to store the geometry it already had.
|
|
142
|
+
if (moved) void board.resizeBlock(block.id, bounds, from)
|
|
52
143
|
}
|
|
53
144
|
window.addEventListener('pointermove', onMove)
|
|
54
145
|
window.addEventListener('pointerup', onUp)
|
|
146
|
+
// A cancelled pointer (touch interrupted by a gesture, window losing the pointer) never fires
|
|
147
|
+
// `pointerup`, so without this the body cursor stays stuck on `ew-resize` for the session.
|
|
148
|
+
window.addEventListener('pointercancel', onUp)
|
|
55
149
|
}
|
|
56
150
|
|
|
57
151
|
return { resizingId, startResize }
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { defineStore } from 'pinia'
|
|
2
|
+
import { computed, ref } from 'vue'
|
|
3
|
+
import type { WorkspaceAgentSettings } from '~/types/agent-settings'
|
|
4
|
+
import { useWorkspaceStore } from '~/stores/workspace'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The workspace's per-agent-kind generation settings — the pipeline builder's output-budget
|
|
8
|
+
* control, sitting beside the prompt editor.
|
|
9
|
+
*
|
|
10
|
+
* Unlike the prompt store there is only ONE shape to load: the whole configured set is a handful
|
|
11
|
+
* of small rows (a kind that inherits has no row at all), so the index IS the detail and the
|
|
12
|
+
* builder can badge every step and populate its editor from one request. There is nothing here
|
|
13
|
+
* worth deferring the way a prompt body is.
|
|
14
|
+
*/
|
|
15
|
+
export const useAgentSettingsStore = defineStore('agentSettings', () => {
|
|
16
|
+
const api = useApi()
|
|
17
|
+
|
|
18
|
+
const settings = ref<WorkspaceAgentSettings[]>([])
|
|
19
|
+
const loading = ref(false)
|
|
20
|
+
const saving = ref(false)
|
|
21
|
+
|
|
22
|
+
/** Configured ceilings by agent kind, for O(1) lookup while rendering a pipeline's steps. */
|
|
23
|
+
const ceilingByKind = computed(() => {
|
|
24
|
+
const out = new Map<string, number>()
|
|
25
|
+
for (const row of settings.value) {
|
|
26
|
+
if (row.maxOutputTokens != null) out.set(row.agentKind, row.maxOutputTokens)
|
|
27
|
+
}
|
|
28
|
+
return out
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
/** This kind's configured ceiling, or undefined when it inherits the deployment default. */
|
|
32
|
+
function maxOutputTokensFor(agentKind: string): number | undefined {
|
|
33
|
+
return ceilingByKind.value.get(agentKind)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load the configured set. Best-effort like the prompt index: the builder is fully usable
|
|
38
|
+
* without it (every kind simply runs the deployment ceiling), and the endpoint 503s on a
|
|
39
|
+
* deployment that wires no settings store at all.
|
|
40
|
+
*/
|
|
41
|
+
async function load() {
|
|
42
|
+
const ws = useWorkspaceStore()
|
|
43
|
+
if (!ws.workspaceId) return
|
|
44
|
+
loading.value = true
|
|
45
|
+
try {
|
|
46
|
+
settings.value = await api.listWorkspaceAgentSettings(ws.requireId())
|
|
47
|
+
} finally {
|
|
48
|
+
loading.value = false
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Set (or clear, with `null`) one kind's output-token ceiling.
|
|
54
|
+
*
|
|
55
|
+
* Reconciles from the row the SERVER returned rather than the value sent: it answers `null`
|
|
56
|
+
* once the kind is back to inheriting, which is the same signal the row should disappear —
|
|
57
|
+
* so a clear and a set both land through one code path and the store can never keep a row the
|
|
58
|
+
* server has dropped.
|
|
59
|
+
*/
|
|
60
|
+
async function setMaxOutputTokens(agentKind: string, maxOutputTokens: number | null) {
|
|
61
|
+
const ws = useWorkspaceStore()
|
|
62
|
+
saving.value = true
|
|
63
|
+
try {
|
|
64
|
+
const updated = await api.updateWorkspaceAgentSettings(ws.requireId(), agentKind, {
|
|
65
|
+
maxOutputTokens,
|
|
66
|
+
})
|
|
67
|
+
const rest = settings.value.filter((s) => s.agentKind !== agentKind)
|
|
68
|
+
settings.value = updated
|
|
69
|
+
? [...rest, updated].sort((a, b) => a.agentKind.localeCompare(b.agentKind))
|
|
70
|
+
: rest
|
|
71
|
+
return updated
|
|
72
|
+
} finally {
|
|
73
|
+
saving.value = false
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
settings,
|
|
79
|
+
loading,
|
|
80
|
+
saving,
|
|
81
|
+
ceilingByKind,
|
|
82
|
+
maxOutputTokensFor,
|
|
83
|
+
load,
|
|
84
|
+
setMaxOutputTokens,
|
|
85
|
+
}
|
|
86
|
+
})
|
|
@@ -12,7 +12,7 @@ import { UNDO_WINDOW_MS } from './context'
|
|
|
12
12
|
* in-closure functions, and the split is purely to keep every function within the size budget.
|
|
13
13
|
*/
|
|
14
14
|
export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
15
|
-
const { getBlock, upsert, api, toast, tr } = ctx
|
|
15
|
+
const { blocks, getBlock, upsert, api, toast, tr } = ctx
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
18
|
* Move a block into a new container at a new local position. Drag-reparent commits
|
|
@@ -120,6 +120,67 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
122
|
|
|
123
|
+
/**
|
|
124
|
+
* Translate every DIRECT child of a container — the client half of the compensation the
|
|
125
|
+
* backend's `shiftChildPositions` applies. A child's position is relative to its container's
|
|
126
|
+
* content origin, so moving that origin (a north/west border drag) has to move the children
|
|
127
|
+
* the other way or the contents slide with the border. Grandchildren ride their module.
|
|
128
|
+
*/
|
|
129
|
+
function shiftChildren(parentId: string, dx: number, dy: number) {
|
|
130
|
+
if (!dx && !dy) return
|
|
131
|
+
for (const child of blocks.value) {
|
|
132
|
+
if (child.parentId !== parentId) continue
|
|
133
|
+
child.position = { x: child.position.x + dx, y: child.position.y + dy }
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Local-only geometry update during an active border drag — the resize counterpart of
|
|
139
|
+
* {@link previewMove}, and for the same reason: persisting every pointer move would let an
|
|
140
|
+
* out-of-order response land a stale size after the user let go. Takes ABSOLUTE bounds and
|
|
141
|
+
* derives the origin delta itself, so a caller can drive it from a running drag without
|
|
142
|
+
* tracking what it has already applied. {@link resizeBlock} commits the final bounds once.
|
|
143
|
+
*/
|
|
144
|
+
function previewResize(
|
|
145
|
+
id: string,
|
|
146
|
+
position: { x: number; y: number },
|
|
147
|
+
size?: { w: number; h: number },
|
|
148
|
+
) {
|
|
149
|
+
const b = getBlock(id)
|
|
150
|
+
if (!b) return
|
|
151
|
+
shiftChildren(id, b.position.x - position.x, b.position.y - position.y)
|
|
152
|
+
b.position = position
|
|
153
|
+
b.size = size
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Commit a border-drag resize: ONE call carrying both halves of the geometry, because only an
|
|
158
|
+
* operation that sees the origin delta can translate the container's children with it (see
|
|
159
|
+
* `BoardService.resizeBlock`). `from` is the pre-drag geometry — a rejected resize replays it
|
|
160
|
+
* through {@link previewResize}, which undoes the child translation by the same arithmetic that
|
|
161
|
+
* applied it, so a failure can't leave the contents offset from a box the server never stored.
|
|
162
|
+
*/
|
|
163
|
+
async function resizeBlock(
|
|
164
|
+
id: string,
|
|
165
|
+
bounds: { position: { x: number; y: number }; size: { w: number; h: number } },
|
|
166
|
+
from: { position: { x: number; y: number }; size?: { w: number; h: number } },
|
|
167
|
+
) {
|
|
168
|
+
const b = getBlock(id)
|
|
169
|
+
if (!b) return
|
|
170
|
+
previewResize(id, bounds.position, bounds.size)
|
|
171
|
+
try {
|
|
172
|
+
upsert(await api.resizeBlock(useWorkspaceStore().requireId(), id, bounds))
|
|
173
|
+
} catch (e) {
|
|
174
|
+
previewResize(id, from.position, from.size)
|
|
175
|
+
toast.add({
|
|
176
|
+
title: tr('board.toast.resizeFailed'),
|
|
177
|
+
description: e instanceof Error ? e.message : String(e),
|
|
178
|
+
icon: 'i-lucide-triangle-alert',
|
|
179
|
+
color: 'error',
|
|
180
|
+
})
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
123
184
|
/** Patch the user-editable fields of a block (title, features, threshold…). */
|
|
124
185
|
async function updateBlock(id: string, patch: UpdateBlockInput) {
|
|
125
186
|
const b = getBlock(id)
|
|
@@ -196,6 +257,8 @@ export function createBoardPlacement(ctx: BoardWriteContext) {
|
|
|
196
257
|
reparentBlock,
|
|
197
258
|
previewMove,
|
|
198
259
|
moveBlock,
|
|
260
|
+
previewResize,
|
|
261
|
+
resizeBlock,
|
|
199
262
|
updateBlock,
|
|
200
263
|
toggleDependency,
|
|
201
264
|
removeDependency,
|
package/app/stores/board.spec.ts
CHANGED
|
@@ -310,6 +310,57 @@ describe('board store optimistic rollback', () => {
|
|
|
310
310
|
expect(store.getBlock('t1')?.description).toBe('keep')
|
|
311
311
|
})
|
|
312
312
|
|
|
313
|
+
it('previewResize translates the children when the drag moves the content origin', () => {
|
|
314
|
+
// A child's position is relative to its container's content origin, so growing the frame
|
|
315
|
+
// 40px west (origin -40) has to move every direct child +40 or the whole content slides with
|
|
316
|
+
// the border. A grandchild rides its module and must NOT move on its own.
|
|
317
|
+
const store = useBoardStore()
|
|
318
|
+
store.hydrate([
|
|
319
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
320
|
+
moduleBlock('m1', 'f1', { position: { x: 20, y: 30 } }),
|
|
321
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
322
|
+
task('t2', 'm1', { position: { x: 5, y: 5 } }),
|
|
323
|
+
])
|
|
324
|
+
store.previewResize('f1', { x: 60, y: 100 }, { w: 640, h: 400 })
|
|
325
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 50, y: 20 })
|
|
326
|
+
expect(store.getBlock('m1')?.position).toEqual({ x: 60, y: 30 })
|
|
327
|
+
expect(store.getBlock('t2')?.position).toEqual({ x: 5, y: 5 })
|
|
328
|
+
})
|
|
329
|
+
|
|
330
|
+
it('previewResize leaves the children alone when only the far border moved', () => {
|
|
331
|
+
const store = useBoardStore()
|
|
332
|
+
store.hydrate([
|
|
333
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
334
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
335
|
+
])
|
|
336
|
+
store.previewResize('f1', { x: 100, y: 100 }, { w: 700, h: 500 })
|
|
337
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
338
|
+
expect(store.getBlock('f1')?.size).toEqual({ w: 700, h: 500 })
|
|
339
|
+
})
|
|
340
|
+
|
|
341
|
+
it('resizeBlock rolls the bounds AND the child translation back when the API rejects', async () => {
|
|
342
|
+
// The rollback has to undo both halves: a restored box with its contents still offset is the
|
|
343
|
+
// one failure mode that looks fine until the next refresh moves everything.
|
|
344
|
+
vi.stubGlobal('useApi', () => ({
|
|
345
|
+
resizeBlock: () => Promise.reject(new Error('conflict')),
|
|
346
|
+
}))
|
|
347
|
+
setActivePinia(createPinia())
|
|
348
|
+
useWorkspaceStore().workspaceId = 'ws1'
|
|
349
|
+
const store = useBoardStore()
|
|
350
|
+
store.hydrate([
|
|
351
|
+
frame('f1', { position: { x: 100, y: 100 }, size: { w: 600, h: 400 } }),
|
|
352
|
+
task('t1', 'f1', { position: { x: 10, y: 20 } }),
|
|
353
|
+
])
|
|
354
|
+
await store.resizeBlock(
|
|
355
|
+
'f1',
|
|
356
|
+
{ position: { x: 60, y: 70 }, size: { w: 640, h: 430 } },
|
|
357
|
+
{ position: { x: 100, y: 100 }, size: { w: 600, h: 400 } },
|
|
358
|
+
)
|
|
359
|
+
expect(store.getBlock('f1')?.position).toEqual({ x: 100, y: 100 })
|
|
360
|
+
expect(store.getBlock('f1')?.size).toEqual({ w: 600, h: 400 })
|
|
361
|
+
expect(store.getBlock('t1')?.position).toEqual({ x: 10, y: 20 })
|
|
362
|
+
})
|
|
363
|
+
|
|
313
364
|
it('reparentBlock offers an undo that moves the block back to its previous home', async () => {
|
|
314
365
|
vi.stubGlobal('useApi', () => ({
|
|
315
366
|
reparentBlock: async (
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { usePipelinesStore } from '~/stores/pipelines'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The per-step output-token ceiling rides the shared `StepOptions` bag, so its helpers owe the
|
|
6
|
+
* same normalization every other field in there follows: merge rather than clobber, and drop the
|
|
7
|
+
* whole entry once the bag empties. That last part is what keeps an all-default pipeline from
|
|
8
|
+
* persisting a `step_options` array of empty objects.
|
|
9
|
+
*/
|
|
10
|
+
describe('pipelines store — per-step output budget', () => {
|
|
11
|
+
it('sets, reads and clears a draft step’s ceiling', () => {
|
|
12
|
+
const pipelines = usePipelinesStore()
|
|
13
|
+
pipelines.addToDraft('doc-researcher')
|
|
14
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
|
|
15
|
+
|
|
16
|
+
pipelines.setDraftMaxOutputTokens(0, 24_000)
|
|
17
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
|
|
18
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ maxOutputTokens: 24_000 })
|
|
19
|
+
|
|
20
|
+
// Clearing drops the field and, with the bag now empty, normalizes the entry back to null —
|
|
21
|
+
// so a step back on the inherited budget persists no options at all.
|
|
22
|
+
pipelines.setDraftMaxOutputTokens(0, undefined)
|
|
23
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBeUndefined()
|
|
24
|
+
expect(pipelines.draftStepOptions[0]).toBeNull()
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('merges with the other options on the step rather than clobbering the bag', () => {
|
|
28
|
+
const pipelines = usePipelinesStore()
|
|
29
|
+
pipelines.addToDraft('requirements-review')
|
|
30
|
+
pipelines.toggleDraftAutoRecommend(0)
|
|
31
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
|
|
32
|
+
|
|
33
|
+
pipelines.setDraftMaxOutputTokens(0, 12_000)
|
|
34
|
+
expect(pipelines.draftStepOptions[0]).toEqual({
|
|
35
|
+
autoRecommend: false,
|
|
36
|
+
maxOutputTokens: 12_000,
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
// And clearing one field leaves the other standing (the entry stays, since the bag is not empty).
|
|
40
|
+
pipelines.setDraftMaxOutputTokens(0, undefined)
|
|
41
|
+
expect(pipelines.draftStepOptions[0]).toEqual({ autoRecommend: false })
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('keeps each step’s ceiling independent', () => {
|
|
45
|
+
const pipelines = usePipelinesStore()
|
|
46
|
+
pipelines.addToDraft('doc-researcher')
|
|
47
|
+
pipelines.addToDraft('doc-outliner')
|
|
48
|
+
|
|
49
|
+
pipelines.setDraftMaxOutputTokens(0, 24_000)
|
|
50
|
+
pipelines.setDraftMaxOutputTokens(1, 10_000)
|
|
51
|
+
|
|
52
|
+
expect(pipelines.draftMaxOutputTokens(0)).toBe(24_000)
|
|
53
|
+
expect(pipelines.draftMaxOutputTokens(1)).toBe(10_000)
|
|
54
|
+
})
|
|
55
|
+
})
|
|
@@ -139,6 +139,27 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
139
139
|
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/**
|
|
143
|
+
* The output-token ceiling pinned on the draft step at `index`, or undefined when the step
|
|
144
|
+
* inherits (the workspace's per-kind setting, else the deployment default).
|
|
145
|
+
*/
|
|
146
|
+
function draftMaxOutputTokens(index: number): number | undefined {
|
|
147
|
+
return draftStepOptions.value[index]?.maxOutputTokens
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Set (or clear) this step's own output-token ceiling. Merges into the step's `StepOptions`
|
|
152
|
+
* bag rather than clobbering it; clearing drops the field and, if the bag empties, the whole
|
|
153
|
+
* entry — so a step back on the inherited budget persists no options at all, exactly like the
|
|
154
|
+
* other fields here.
|
|
155
|
+
*/
|
|
156
|
+
function setDraftMaxOutputTokens(index: number, maxOutputTokens: number | undefined) {
|
|
157
|
+
const next: StepOptions = { ...draftStepOptions.value[index] }
|
|
158
|
+
if (maxOutputTokens != null) next.maxOutputTokens = maxOutputTokens
|
|
159
|
+
else delete next.maxOutputTokens
|
|
160
|
+
draftStepOptions.value[index] = Object.keys(next).length ? next : null
|
|
161
|
+
}
|
|
162
|
+
|
|
142
163
|
return {
|
|
143
164
|
toggleDraftGating,
|
|
144
165
|
toggleDraftConsensus,
|
|
@@ -153,5 +174,7 @@ export function createPipelineStepConfigActions(ctx: PipelinesContext) {
|
|
|
153
174
|
toggleDraftAutoRecommend,
|
|
154
175
|
draftSkillId,
|
|
155
176
|
setDraftSkillId,
|
|
177
|
+
draftMaxOutputTokens,
|
|
178
|
+
setDraftMaxOutputTokens,
|
|
156
179
|
}
|
|
157
180
|
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
// Per-workspace, per-agent-kind generation settings, mirroring `@cat-factory/contracts`
|
|
2
|
+
// (agent-settings.ts). Today one knob: the output-token ceiling a kind's inline calls run under.
|
|
3
|
+
// A kind absent from the store inherits the deployment routing default, and a pipeline step's own
|
|
4
|
+
// `stepOptions.maxOutputTokens` still overrides whatever is set here.
|
|
5
|
+
//
|
|
6
|
+
// All wire shapes are sourced from @cat-factory/contracts (single source of truth).
|
|
7
|
+
|
|
8
|
+
export type {
|
|
9
|
+
UpdateWorkspaceAgentSettingsInput,
|
|
10
|
+
WorkspaceAgentSettings,
|
|
11
|
+
} from '@cat-factory/contracts'
|
|
12
|
+
export { MAX_AGENT_MAX_OUTPUT_TOKENS, MIN_AGENT_MAX_OUTPUT_TOKENS } from '@cat-factory/contracts'
|
package/i18n/locales/de.json
CHANGED
|
@@ -2313,6 +2313,7 @@
|
|
|
2313
2313
|
"updateFailed": "Änderungen konnten nicht gespeichert werden",
|
|
2314
2314
|
"epicFailed": "Epic konnte nicht geändert werden",
|
|
2315
2315
|
"moveFailed": "Verschieben nicht möglich",
|
|
2316
|
+
"resizeFailed": "Größe konnte nicht geändert werden",
|
|
2316
2317
|
"deleteFailed": "Löschen nicht möglich",
|
|
2317
2318
|
"linkFailed": "Aufgaben konnten nicht verknüpft werden",
|
|
2318
2319
|
"unlinkFailed": "Abhängigkeit konnte nicht entfernt werden",
|
|
@@ -2566,7 +2567,6 @@
|
|
|
2566
2567
|
"mergedOfTotal": "{merged}/{total} gemergt",
|
|
2567
2568
|
"noTasksYet": "Noch keine Aufgaben",
|
|
2568
2569
|
"prCount": "{count} PR",
|
|
2569
|
-
"implemented": "{merged}/{total} implementiert",
|
|
2570
2570
|
"prReadyCount": "{count} PR bereit",
|
|
2571
2571
|
"taskCount": "{count} Aufgabe | {count} Aufgaben",
|
|
2572
2572
|
"moduleCount": "{count} Modul | {count} Module",
|
|
@@ -3750,6 +3750,13 @@
|
|
|
3750
3750
|
"deleteFailed": "Pipeline konnte nicht gelöscht werden",
|
|
3751
3751
|
"removeFailed": "Pipeline konnte nicht entfernt werden"
|
|
3752
3752
|
}
|
|
3753
|
+
},
|
|
3754
|
+
"outputBudget": {
|
|
3755
|
+
"stepLabel": "Ausgabebudget",
|
|
3756
|
+
"kindLabel": "Ausgabebudget",
|
|
3757
|
+
"kindHint": "Gilt für jeden Lauf dieses Agenten. Ein Pipeline-Schritt kann es überschreiben.",
|
|
3758
|
+
"inherits": "Übernommen",
|
|
3759
|
+
"inheritsValue": "Übernommen ({tokens})"
|
|
3753
3760
|
}
|
|
3754
3761
|
},
|
|
3755
3762
|
"agentPrompt": {
|
|
@@ -3784,7 +3791,8 @@
|
|
|
3784
3791
|
"reverted": "Zurück zum Standardprompt",
|
|
3785
3792
|
"saveFailed": "Prompt konnte nicht gespeichert werden",
|
|
3786
3793
|
"loadFailed": "Prompt konnte nicht geladen werden",
|
|
3787
|
-
"conflict": "Jemand anderes hat diesen Prompt geändert. Neu geladen - wenden Sie Ihre Änderung erneut an."
|
|
3794
|
+
"conflict": "Jemand anderes hat diesen Prompt geändert. Neu geladen - wenden Sie Ihre Änderung erneut an.",
|
|
3795
|
+
"budgetFailed": "Ausgabebudget konnte nicht gespeichert werden"
|
|
3788
3796
|
}
|
|
3789
3797
|
},
|
|
3790
3798
|
"agentTier": {
|
|
@@ -3864,6 +3872,12 @@
|
|
|
3864
3872
|
"recommendationProgress": "{ready} / {total} bereit",
|
|
3865
3873
|
"generatingSuggestion": "Ein fundierter Vorschlag wird generiert…",
|
|
3866
3874
|
"currentStandard": "Aktueller Standard: {title}",
|
|
3875
|
+
"grounding": {
|
|
3876
|
+
"standard": "Team-Standard",
|
|
3877
|
+
"project-spec": "Projektspezifikation",
|
|
3878
|
+
"web": "Webquelle",
|
|
3879
|
+
"general-practice": "Allgemeine Praxis"
|
|
3880
|
+
},
|
|
3867
3881
|
"accept": "Annehmen",
|
|
3868
3882
|
"reject": "Ablehnen",
|
|
3869
3883
|
"reRequestPlaceholder": "Nach einer anderen Empfehlung fragen…",
|
package/i18n/locales/en.json
CHANGED
|
@@ -152,6 +152,7 @@
|
|
|
152
152
|
"updateFailed": "Could not save changes",
|
|
153
153
|
"epicFailed": "Could not change epic",
|
|
154
154
|
"moveFailed": "Could not move",
|
|
155
|
+
"resizeFailed": "Could not resize",
|
|
155
156
|
"deleteFailed": "Could not delete",
|
|
156
157
|
"linkFailed": "Could not link tasks",
|
|
157
158
|
"unlinkFailed": "Could not remove dependency",
|
|
@@ -417,7 +418,6 @@
|
|
|
417
418
|
"mergedOfTotal": "{merged}/{total} merged",
|
|
418
419
|
"noTasksYet": "No tasks yet",
|
|
419
420
|
"prCount": "{count} PR",
|
|
420
|
-
"implemented": "{merged}/{total} implemented",
|
|
421
421
|
"prReadyCount": "{count} PR ready",
|
|
422
422
|
"taskCount": "{count} task | {count} tasks",
|
|
423
423
|
"@taskCount": {
|
|
@@ -4188,6 +4188,13 @@
|
|
|
4188
4188
|
"deleteFailed": "Could not delete pipeline",
|
|
4189
4189
|
"removeFailed": "Could not remove pipeline"
|
|
4190
4190
|
}
|
|
4191
|
+
},
|
|
4192
|
+
"outputBudget": {
|
|
4193
|
+
"stepLabel": "Output budget",
|
|
4194
|
+
"kindLabel": "Output budget",
|
|
4195
|
+
"kindHint": "Applies to every run of this agent. A pipeline step can override it.",
|
|
4196
|
+
"inherits": "Inherited",
|
|
4197
|
+
"inheritsValue": "Inherited ({tokens})"
|
|
4191
4198
|
}
|
|
4192
4199
|
},
|
|
4193
4200
|
"agentPrompt": {
|
|
@@ -4231,7 +4238,8 @@
|
|
|
4231
4238
|
"reverted": "Back to the built-in prompt",
|
|
4232
4239
|
"saveFailed": "Couldn't save the prompt",
|
|
4233
4240
|
"loadFailed": "Couldn't load the prompt",
|
|
4234
|
-
"conflict": "Someone else changed this prompt. Reloaded - re-apply your edit."
|
|
4241
|
+
"conflict": "Someone else changed this prompt. Reloaded - re-apply your edit.",
|
|
4242
|
+
"budgetFailed": "Could not save the output budget"
|
|
4235
4243
|
}
|
|
4236
4244
|
},
|
|
4237
4245
|
"agentTier": {
|
|
@@ -4484,6 +4492,16 @@
|
|
|
4484
4492
|
"recommendationProgress": "{ready} / {total} ready",
|
|
4485
4493
|
"generatingSuggestion": "Generating a grounded suggestion…",
|
|
4486
4494
|
"currentStandard": "Current standard: {title}",
|
|
4495
|
+
"grounding": {
|
|
4496
|
+
"standard": "Team standard",
|
|
4497
|
+
"@standard": "Badge on a suggested answer: it came from the team's own best-practice standard. Noun phrase, short — fits a small badge.",
|
|
4498
|
+
"project-spec": "Project spec",
|
|
4499
|
+
"@project-spec": "Badge on a suggested answer: it came from the project's own committed specification documents. Noun phrase, short.",
|
|
4500
|
+
"web": "Web source",
|
|
4501
|
+
"@web": "Badge on a suggested answer: it came from a web search result. Noun phrase, short.",
|
|
4502
|
+
"general-practice": "General practice",
|
|
4503
|
+
"@general-practice": "Badge on a suggested answer: it rests only on the model's general knowledge, with no team standard, project spec or source behind it — the weakest grounding, so the wording should not sound authoritative. Noun phrase, short."
|
|
4504
|
+
},
|
|
4487
4505
|
"accept": "Accept",
|
|
4488
4506
|
"reject": "Reject",
|
|
4489
4507
|
"reRequestPlaceholder": "Ask for a different recommendation…",
|