@cat-factory/app 0.277.1 → 0.278.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 +46 -10
- package/app/components/board/nodes/TaskCard.vue +5 -4
- package/app/components/board/nodes/TaskLane.vue +1 -2
- package/app/components/providers/ApiKeysSection.vue +11 -1
- package/app/components/providers/VendorCredentialsModal.vue +1 -1
- package/app/composables/api/workspaces.ts +4 -2
- package/app/composables/useFrameLanes.ts +35 -32
- package/app/composables/useWorkspaceStream.ts +15 -29
- package/app/composables/workspaceStream/coarseRefresh.spec.ts +146 -0
- package/app/composables/workspaceStream/coarseRefresh.ts +121 -0
- package/app/stores/environmentTest.spec.ts +33 -0
- package/app/stores/environmentTest.ts +24 -0
- package/app/stores/execution.spec.ts +50 -0
- package/app/stores/execution.ts +30 -7
- package/app/stores/notifications.ts +16 -0
- package/app/stores/pipelines.ts +9 -2
- package/app/stores/recurringPipelines.ts +15 -1
- package/app/stores/workspace/refreshFunnel.spec.ts +242 -0
- package/app/stores/workspace/refreshFunnel.ts +156 -0
- package/app/stores/workspace.spec.ts +30 -24
- package/app/stores/workspace.ts +17 -26
- package/app/utils/laneIdentity.spec.ts +119 -0
- package/app/utils/laneIdentity.ts +102 -0
- package/app/utils/laneSort.ts +35 -3
- package/i18n/locales/de.json +7 -2
- package/i18n/locales/en.json +7 -2
- package/i18n/locales/es.json +7 -2
- package/i18n/locales/fr.json +7 -2
- package/i18n/locales/he.json +7 -2
- package/i18n/locales/it.json +7 -2
- package/i18n/locales/ja.json +7 -2
- package/i18n/locales/pl.json +7 -2
- package/i18n/locales/tr.json +7 -2
- package/i18n/locales/uk.json +7 -2
- package/package.json +2 -2
|
@@ -45,12 +45,17 @@ beforeEach(() => {
|
|
|
45
45
|
|
|
46
46
|
// Regression for the live-push CLOBBER race: `board`-type stream events (and the on-connect
|
|
47
47
|
// resync) each trigger a full-snapshot `refresh()`, and `hydrate` REPLACES the block list. Two
|
|
48
|
-
// refreshes
|
|
49
|
-
// slower/staler fetch
|
|
50
|
-
//
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
//
|
|
48
|
+
// refreshes used to be in flight at once (events >300ms apart, or a resync + a board event), and a
|
|
49
|
+
// slower/staler fetch resolving AFTER a newer one clobbered the newer state, dropping a
|
|
50
|
+
// just-spawned block whose only live delivery was the coarse board event, so its card never
|
|
51
|
+
// reappeared (no further event to restore it). That surfaced as an intermittent e2e timeout where a
|
|
52
|
+
// spawned task/document card never rendered.
|
|
53
|
+
//
|
|
54
|
+
// `refresh()` now goes through the funnel, which SERIALIZES: a call arriving during a fetch waits
|
|
55
|
+
// for a follow-up fetch issued after it rather than racing beside it. So the out-of-order clobber
|
|
56
|
+
// is structurally unreachable rather than detected and discarded, and what this test pins is the
|
|
57
|
+
// property that replaced the sequence stamp: overlapping refreshes never interleave, and the board
|
|
58
|
+
// ends on the LAST snapshot fetched. `refreshFunnel.spec.ts` covers the coalescing rules directly.
|
|
54
59
|
|
|
55
60
|
/** Minimal block — only the fields the board store's index getters read. */
|
|
56
61
|
function block(id: string, over: Partial<Block> = {}): Block {
|
|
@@ -102,20 +107,19 @@ function snapshot(
|
|
|
102
107
|
}
|
|
103
108
|
|
|
104
109
|
describe('workspace store refresh ordering', () => {
|
|
105
|
-
it('
|
|
110
|
+
it('serializes overlapping refreshes so the board ends on the last snapshot fetched', async () => {
|
|
106
111
|
const frame = block('f1')
|
|
107
112
|
const spawned = block('spawned', { level: 'task', parentId: 'f1' })
|
|
108
|
-
let
|
|
109
|
-
let resolveFresh!: (s: WorkspaceSnapshot) => void
|
|
113
|
+
let resolveFirst!: (s: WorkspaceSnapshot) => void
|
|
110
114
|
|
|
111
115
|
const getWorkspace = vi
|
|
112
116
|
.fn()
|
|
113
117
|
// 1) switchTo establishes the active board (no spawned block yet).
|
|
114
118
|
.mockResolvedValueOnce(snapshot('ws1', [frame]))
|
|
115
|
-
// 2) the
|
|
116
|
-
.mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (
|
|
117
|
-
// 3) the
|
|
118
|
-
.
|
|
119
|
+
// 2) the first refresh's fetch: still no spawned block, and held open below.
|
|
120
|
+
.mockReturnValueOnce(new Promise<WorkspaceSnapshot>((r) => (resolveFirst = r)))
|
|
121
|
+
// 3) the follow-up fetch, ISSUED ONLY AFTER (2) settles, which is why it sees the spawn.
|
|
122
|
+
.mockResolvedValueOnce(snapshot('ws1', [frame, spawned]))
|
|
119
123
|
vi.stubGlobal('useApi', () => ({ getWorkspace }))
|
|
120
124
|
|
|
121
125
|
const ws = useWorkspaceStore()
|
|
@@ -123,21 +127,23 @@ describe('workspace store refresh ordering', () => {
|
|
|
123
127
|
await ws.switchTo('ws1')
|
|
124
128
|
expect(board.getBlock('spawned')).toBeUndefined()
|
|
125
129
|
|
|
126
|
-
//
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
resolveFresh(snapshot('ws1', [frame, spawned]))
|
|
131
|
-
resolveStale(snapshot('ws1', [frame]))
|
|
132
|
-
await Promise.all([stalePass, freshPass])
|
|
130
|
+
// A second refresh arriving mid-fetch does NOT start a racing fetch: one is outstanding.
|
|
131
|
+
const first = ws.refresh()
|
|
132
|
+
const second = ws.refresh()
|
|
133
|
+
expect(getWorkspace).toHaveBeenCalledTimes(2)
|
|
133
134
|
|
|
134
|
-
|
|
135
|
+
resolveFirst(snapshot('ws1', [frame]))
|
|
136
|
+
await Promise.all([first, second])
|
|
137
|
+
|
|
138
|
+
// The follow-up ran after the first settled and its snapshot is what the board holds, so a
|
|
139
|
+
// caller that refreshed after spawning the block sees it.
|
|
140
|
+
expect(getWorkspace).toHaveBeenCalledTimes(3)
|
|
135
141
|
expect(board.getBlock('spawned')).toBeDefined()
|
|
136
142
|
})
|
|
137
143
|
|
|
138
|
-
// Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`.
|
|
139
|
-
//
|
|
140
|
-
//
|
|
144
|
+
// Regression for the SECOND clobber axis: a refresh vs an interleaved live `upsert`. Serializing
|
|
145
|
+
// refreshes orders them against each OTHER and does nothing when a single refresh's (slow) fetch
|
|
146
|
+
// overlaps a targeted live event. A run's status transitions
|
|
141
147
|
// (…→ in_progress → pr_ready/done) arrive as `execution`-event `board.upsert`s; a refresh whose
|
|
142
148
|
// snapshot was FETCHED while the block was still `in_progress` must not, on resolving later,
|
|
143
149
|
// replace that block back to the stale status. This was the reliable-under-CI-latency e2e
|
package/app/stores/workspace.ts
CHANGED
|
@@ -14,6 +14,7 @@ import type { LiveWriteBaselines } from '~/stores/workspace/hydrate'
|
|
|
14
14
|
import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
|
|
15
15
|
import { createWorkspaceCommands } from '~/stores/workspace/commands'
|
|
16
16
|
import { createInfraSetupState } from '~/stores/workspace/infraSetup'
|
|
17
|
+
import { createRefreshFunnel } from '~/stores/workspace/refreshFunnel'
|
|
17
18
|
import { markBoot } from '~/utils/bootMarks'
|
|
18
19
|
import { retryWhileBackendUnreachable } from '~/utils/backendReady'
|
|
19
20
|
|
|
@@ -198,34 +199,22 @@ export const useWorkspaceStore = defineStore(
|
|
|
198
199
|
resolveActiveBoard,
|
|
199
200
|
})
|
|
200
201
|
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
//
|
|
205
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
if (!targetId) return
|
|
213
|
-
const seq = ++refreshSeq
|
|
214
|
-
// Capture the live-write baselines BEFORE the fetch: anything a live event writes while
|
|
215
|
-
// this (potentially slow) snapshot is in flight is newer than the snapshot, so `hydrate`
|
|
216
|
-
// must NOT clobber it back. The `refreshSeq` guard below only orders refreshes against each
|
|
217
|
-
// OTHER — this guards a refresh against an interleaved live write (a run's terminal status
|
|
218
|
-
// landing mid-fetch, or the inbox card it raises), the coherence hazard under CI latency.
|
|
219
|
-
const baselines: LiveWriteBaselines = {
|
|
202
|
+
// The one door every full-snapshot refresh goes through: it coalesces the ~35 direct
|
|
203
|
+
// post-mutation call sites and the stream's coarse-event resync into at most one in-flight
|
|
204
|
+
// fetch plus one queued follow-up, and exposes the coverage mark the stream's debounce uses to
|
|
205
|
+
// drop a resync a mutation's own refresh already served. Ordering (a stale snapshot's hydrate
|
|
206
|
+
// clobbering a newer one, which would drop a just-spawned block whose ONLY live delivery was
|
|
207
|
+
// the coarse `board` event) falls out of there being one fetch at a time. Rules and the
|
|
208
|
+
// reasoning: `stores/workspace/refreshFunnel.ts`.
|
|
209
|
+
const { refresh, refreshMark, hydratedSince } = createRefreshFunnel({
|
|
210
|
+
currentWorkspaceId: () => workspaceId.value,
|
|
211
|
+
fetchSnapshot: (id, signal) => api.getWorkspace(id, signal),
|
|
212
|
+
captureBaselines: (): LiveWriteBaselines => ({
|
|
220
213
|
board: useBoardStore().hydrateBaseline(),
|
|
221
214
|
notifications: useNotificationsStore().hydrateBaseline(),
|
|
222
|
-
}
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
// discard this older/staler result so it can't clobber the newer hydrate.
|
|
226
|
-
if (seq !== refreshSeq || workspaceId.value !== targetId) return
|
|
227
|
-
hydrate(snapshot, baselines)
|
|
228
|
-
}
|
|
215
|
+
}),
|
|
216
|
+
apply: hydrate,
|
|
217
|
+
})
|
|
229
218
|
|
|
230
219
|
/** The active workspace id, or throw if the app isn't bootstrapped yet. */
|
|
231
220
|
function requireId(): string {
|
|
@@ -262,6 +251,8 @@ export const useWorkspaceStore = defineStore(
|
|
|
262
251
|
rename,
|
|
263
252
|
remove,
|
|
264
253
|
refresh,
|
|
254
|
+
refreshMark,
|
|
255
|
+
hydratedSince,
|
|
265
256
|
requireId,
|
|
266
257
|
resumeSpend,
|
|
267
258
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { createLaneMemo } from '~/utils/laneIdentity'
|
|
3
|
+
import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
|
|
4
|
+
import type { Block } from '~/types/domain'
|
|
5
|
+
|
|
6
|
+
const taskA = { id: 'a', title: 'A' } as unknown as Block
|
|
7
|
+
const taskB = { id: 'b', title: 'B' } as unknown as Block
|
|
8
|
+
|
|
9
|
+
function entry(task: Block, over: Partial<LaneTaskEntry> = {}): LaneTaskEntry {
|
|
10
|
+
return {
|
|
11
|
+
task,
|
|
12
|
+
reason: 'running',
|
|
13
|
+
order: 0,
|
|
14
|
+
activityAt: null,
|
|
15
|
+
waitingSince: null,
|
|
16
|
+
moduleName: null,
|
|
17
|
+
initiativeName: null,
|
|
18
|
+
epicName: null,
|
|
19
|
+
...over,
|
|
20
|
+
} as LaneTaskEntry
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function group(id: string | null, entries: LaneTaskEntry[]): LaneGroup {
|
|
24
|
+
return { id, label: id, entries }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function lanes(...groups: LaneGroup[][]): RenderedLane[] {
|
|
28
|
+
const names = ['not_started', 'in_progress', 'needs_you', 'done'] as const
|
|
29
|
+
return groups.map((g, i) => ({
|
|
30
|
+
lane: names[i]!,
|
|
31
|
+
groups: g,
|
|
32
|
+
total: g.reduce((n, x) => n + x.entries.length, 0),
|
|
33
|
+
}))
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('lane structural sharing', () => {
|
|
37
|
+
it('returns the previous array when nothing changed', () => {
|
|
38
|
+
const share = createLaneMemo()
|
|
39
|
+
const first = share(lanes([group('g1', [entry(taskA)])]))
|
|
40
|
+
const second = share(lanes([group('g1', [entry(taskA)])]))
|
|
41
|
+
expect(second).toBe(first)
|
|
42
|
+
expect(second[0]).toBe(first[0])
|
|
43
|
+
expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
|
|
44
|
+
expect(second[0]!.groups[0]!.entries[0]).toBe(first[0]!.groups[0]!.entries[0])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('keeps the untouched lane identical while replacing the one that changed', () => {
|
|
48
|
+
const share = createLaneMemo()
|
|
49
|
+
const first = share(lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB)])]))
|
|
50
|
+
const second = share(
|
|
51
|
+
lanes([group('g1', [entry(taskA)])], [group('g2', [entry(taskB, { activityAt: 5 })])]),
|
|
52
|
+
)
|
|
53
|
+
expect(second).not.toBe(first)
|
|
54
|
+
expect(second[0]).toBe(first[0])
|
|
55
|
+
expect(second[1]).not.toBe(first[1])
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Half of the reference rule: a REPLACED block (what every `board.upsert` does) is a new object,
|
|
59
|
+
// so reuse cannot hand a renderer the pre-write one.
|
|
60
|
+
it('does not reuse an entry whose task object was replaced', () => {
|
|
61
|
+
const share = createLaneMemo()
|
|
62
|
+
const first = share(lanes([group('g1', [entry(taskA)])]))
|
|
63
|
+
const renamed = { id: 'a', title: 'A renamed' } as unknown as Block
|
|
64
|
+
const second = share(lanes([group('g1', [entry(renamed)])]))
|
|
65
|
+
expect(second).not.toBe(first)
|
|
66
|
+
expect(second[0]!.groups[0]!.entries[0]!.task).toBe(renamed)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The other half, and the case the comparison deliberately does NOT try to detect:
|
|
71
|
+
* `stores/board/placement.ts` patches a block IN PLACE for its optimistic writes, so both entries
|
|
72
|
+
* hold the same object and no comparison over it could see the difference. Reuse is still
|
|
73
|
+
* observationally identical, because the object a renderer reads through IS the patched one (and
|
|
74
|
+
* `board.blocks` is deeply reactive, so the patch invalidates whatever read it). What must not be
|
|
75
|
+
* reused is an entry whose DERIVED fields moved with the patch, which is the assertion below.
|
|
76
|
+
*/
|
|
77
|
+
it('reuses an entry whose task was patched in place, but not its derived fields', () => {
|
|
78
|
+
const share = createLaneMemo()
|
|
79
|
+
const task = { id: 'a', title: 'A', moduleName: null } as unknown as Block
|
|
80
|
+
const first = share(lanes([group('g1', [entry(task)])]))
|
|
81
|
+
Object.assign(task, { title: 'A patched', moduleName: 'billing' })
|
|
82
|
+
// Same derived fields: the entry is reused, and it carries the patched object.
|
|
83
|
+
const unchanged = share(lanes([group('g1', [entry(task)])]))
|
|
84
|
+
expect(unchanged).toBe(first)
|
|
85
|
+
expect(unchanged[0]!.groups[0]!.entries[0]!.task.title).toBe('A patched')
|
|
86
|
+
// The assembly re-derived `moduleName` from the patch: that is a fresh entry.
|
|
87
|
+
const rederived = share(lanes([group('g1', [entry(task, { moduleName: 'billing' })])]))
|
|
88
|
+
expect(rederived).not.toBe(first)
|
|
89
|
+
expect(rederived[0]!.groups[0]!.entries[0]!.moduleName).toBe('billing')
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('treats reordering, additions and removals as changes', () => {
|
|
93
|
+
const share = createLaneMemo()
|
|
94
|
+
const first = share(lanes([group('g1', [entry(taskA), entry(taskB)])]))
|
|
95
|
+
expect(share(lanes([group('g1', [entry(taskB), entry(taskA)])]))).not.toBe(first)
|
|
96
|
+
|
|
97
|
+
const share2 = createLaneMemo()
|
|
98
|
+
const base = share2(lanes([group('g1', [entry(taskA)])]))
|
|
99
|
+
expect(share2(lanes([group('g1', [entry(taskA), entry(taskB)])]))).not.toBe(base)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('reuses an unchanged group while the sibling group in the same lane changes', () => {
|
|
103
|
+
const share = createLaneMemo()
|
|
104
|
+
const first = share(lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB)])]))
|
|
105
|
+
const second = share(
|
|
106
|
+
lanes([group('g1', [entry(taskA)]), group('g2', [entry(taskB, { order: 3 })])]),
|
|
107
|
+
)
|
|
108
|
+
expect(second[0]!.groups[0]).toBe(first[0]!.groups[0])
|
|
109
|
+
expect(second[0]!.groups[1]).not.toBe(first[0]!.groups[1])
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
it('does not reuse a lane whose total changed even when its rendered groups match', () => {
|
|
113
|
+
const share = createLaneMemo()
|
|
114
|
+
const first = share(lanes([group('g1', [entry(taskA)])]))
|
|
115
|
+
const capped = lanes([group('g1', [entry(taskA)])])
|
|
116
|
+
capped[0] = { ...capped[0]!, total: 99 }
|
|
117
|
+
expect(share(capped)).not.toBe(first)
|
|
118
|
+
})
|
|
119
|
+
})
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { LaneGroup, LaneTaskEntry, RenderedLane } from '~/utils/laneSort'
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Structural sharing for the swimlane output.
|
|
5
|
+
//
|
|
6
|
+
// WHY. The lane assembly (`useFrameLanes`) is a chain of computeds over the board blocks, both
|
|
7
|
+
// pending-gate indexes, the coarse agent-run summary and the open notifications. Any ONE execution
|
|
8
|
+
// event invalidates the whole chain for EVERY mounted frame, and the chain rebuilds every entry,
|
|
9
|
+
// every group and every lane as fresh objects. Vue then sees new identities all the way down, so
|
|
10
|
+
// `TaskLane` / `LaneGroup` re-render and every card diff re-runs, even for the (common) case where
|
|
11
|
+
// the event changed one run's progress and moved nothing.
|
|
12
|
+
//
|
|
13
|
+
// WHAT THIS DOES. Compare the freshly built value against the previous one field by field and hand
|
|
14
|
+
// BACK the previous object wherever nothing changed, so `===` holds for the parts that did not
|
|
15
|
+
// move. Reuse cascades: unchanged entries let a group be reused, unchanged groups let a lane be
|
|
16
|
+
// reused, unchanged lanes let the whole array be reused.
|
|
17
|
+
//
|
|
18
|
+
// THE CORRECTNESS RULE. Reuse is only sound when the reused object is observationally identical to
|
|
19
|
+
// the fresh one, so the comparison must cover every DERIVED field: a value this assembly computed
|
|
20
|
+
// (`moduleName`, `initiativeName`, `epicName`, the activity/wait stamps) exists only on the entry,
|
|
21
|
+
// so a reused entry carrying a stale one is a lie nothing else can correct. That is why
|
|
22
|
+
// {@link sameEntry} destructures rather than looping over keys, and why a field added to
|
|
23
|
+
// `LaneTaskEntry` must be added to it in the same change.
|
|
24
|
+
//
|
|
25
|
+
// `task` is compared by REFERENCE, and that is NOT because the board store always replaces the
|
|
26
|
+
// object: `stores/board/placement.ts` patches a block IN PLACE for its optimistic drag, reparent and
|
|
27
|
+
// field edits, so the previous entry and the fresh one hold the very same object. Reference equality
|
|
28
|
+
// is still the right comparison, for a different reason on each side. A REPLACED block (every
|
|
29
|
+
// `board.upsert`, i.e. every server-sourced write) is a new reference, so it is caught here. A block
|
|
30
|
+
// patched IN PLACE is one object that both entries share, so a renderer reading through the reused
|
|
31
|
+
// entry reads the new value, and `board.blocks` is deeply reactive, so the mutation itself
|
|
32
|
+
// invalidates whatever read it. What the mutation cannot reach is a value DERIVED off it, which is
|
|
33
|
+
// exactly what the field comparison above covers.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
function sameEntry(a: LaneTaskEntry, b: LaneTaskEntry): boolean {
|
|
37
|
+
return (
|
|
38
|
+
a.task === b.task &&
|
|
39
|
+
a.reason === b.reason &&
|
|
40
|
+
a.order === b.order &&
|
|
41
|
+
a.activityAt === b.activityAt &&
|
|
42
|
+
a.waitingSince === b.waitingSince &&
|
|
43
|
+
a.moduleName === b.moduleName &&
|
|
44
|
+
a.initiativeName === b.initiativeName &&
|
|
45
|
+
a.epicName === b.epicName
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Element-wise reuse: the previous array when every member matched, else the fresh one. */
|
|
50
|
+
function shareList<T>(
|
|
51
|
+
previous: readonly T[] | undefined,
|
|
52
|
+
next: T[],
|
|
53
|
+
same: (a: T, b: T) => boolean,
|
|
54
|
+
reuse?: (previous: T, next: T) => T,
|
|
55
|
+
): T[] {
|
|
56
|
+
if (!previous || previous.length !== next.length) return next
|
|
57
|
+
let changed = false
|
|
58
|
+
const shared = next.map((item, i) => {
|
|
59
|
+
const prior = previous[i]!
|
|
60
|
+
if (same(prior, item)) return prior
|
|
61
|
+
const merged = reuse ? reuse(prior, item) : item
|
|
62
|
+
if (merged !== prior) changed = true
|
|
63
|
+
return merged
|
|
64
|
+
})
|
|
65
|
+
return changed ? shared : (previous as T[])
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function sameGroup(a: LaneGroup, b: LaneGroup): boolean {
|
|
69
|
+
return a.id === b.id && a.label === b.label && a.entries === b.entries
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function reuseGroup(previous: LaneGroup, next: LaneGroup): LaneGroup {
|
|
73
|
+
if (previous.id !== next.id || previous.label !== next.label) return next
|
|
74
|
+
const entries = shareList(previous.entries, next.entries, sameEntry)
|
|
75
|
+
return entries === previous.entries ? previous : { ...next, entries }
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function sameLane(a: RenderedLane, b: RenderedLane): boolean {
|
|
79
|
+
return a.lane === b.lane && a.total === b.total && a.groups === b.groups
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function reuseLane(previous: RenderedLane, next: RenderedLane): RenderedLane {
|
|
83
|
+
if (previous.lane !== next.lane || previous.total !== next.total) return next
|
|
84
|
+
const groups = shareList(previous.groups, next.groups, sameGroup, reuseGroup)
|
|
85
|
+
return groups === previous.groups ? previous : { ...next, groups }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* A per-frame memo that hands back the previously rendered lanes wherever the freshly assembled
|
|
90
|
+
* ones are identical.
|
|
91
|
+
*
|
|
92
|
+
* Holds ONE generation, not a cache: the previous result is the only thing a recompute can share
|
|
93
|
+
* with, so there is nothing to evict and nothing to bound. Created per `useFrameLanes` instance, so
|
|
94
|
+
* it lives and dies with the frame that owns it.
|
|
95
|
+
*/
|
|
96
|
+
export function createLaneMemo(): (next: RenderedLane[]) => RenderedLane[] {
|
|
97
|
+
let previous: RenderedLane[] | undefined
|
|
98
|
+
return (next) => {
|
|
99
|
+
previous = shareList(previous, next, sameLane, reuseLane)
|
|
100
|
+
return previous
|
|
101
|
+
}
|
|
102
|
+
}
|
package/app/utils/laneSort.ts
CHANGED
|
@@ -179,8 +179,26 @@ const NEEDS_YOU_TIER: Partial<Record<LaneReason, number>> = {
|
|
|
179
179
|
|
|
180
180
|
type Comparator = (a: LaneTaskEntry, b: LaneTaskEntry) => number
|
|
181
181
|
|
|
182
|
+
/**
|
|
183
|
+
* One collator for every text comparison in this module, built lazily and reused.
|
|
184
|
+
*
|
|
185
|
+
* `String.prototype.localeCompare` constructs a fresh collator per CALL, and these comparators run
|
|
186
|
+
* O(n log n) times per lane per frame on every board event: the `task_type` comparator alone made
|
|
187
|
+
* two of those calls per comparison. Constructing the collator once turns the dominant cost of a
|
|
188
|
+
* text sort back into the comparison itself.
|
|
189
|
+
*
|
|
190
|
+
* Built with NO options, so it is the same collation `a.localeCompare(b)` performed: this is a
|
|
191
|
+
* caching change, and a lane whose order shifted under it would be a behaviour change nobody asked
|
|
192
|
+
* for wearing a performance change's clothes.
|
|
193
|
+
*/
|
|
194
|
+
let collator: Intl.Collator | undefined
|
|
195
|
+
function compareText(a: string, b: string): number {
|
|
196
|
+
collator ??= new Intl.Collator()
|
|
197
|
+
return collator.compare(a, b)
|
|
198
|
+
}
|
|
199
|
+
|
|
182
200
|
const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> = {
|
|
183
|
-
title: (a, b) => a.task.title
|
|
201
|
+
title: (a, b) => compareText(a.task.title, b.task.title),
|
|
184
202
|
oldest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, ascending),
|
|
185
203
|
newest_activity: (a, b) => nullsLast(a.activityAt, b.activityAt, descending),
|
|
186
204
|
longest_wait: (a, b) => nullsLast(a.waitingSince, b.waitingSince, ascending),
|
|
@@ -188,8 +206,8 @@ const EXPLICIT_COMPARATORS: Record<Exclude<LaneSortKey, 'smart'>, Comparator> =
|
|
|
188
206
|
impact_desc: (a, b) =>
|
|
189
207
|
nullsLast(a.task.estimate?.impact ?? null, b.task.estimate?.impact ?? null, descending),
|
|
190
208
|
task_type: (a, b) =>
|
|
191
|
-
(a.task.taskType ?? ''
|
|
192
|
-
a.task.title
|
|
209
|
+
compareText(a.task.taskType ?? '', b.task.taskType ?? '') ||
|
|
210
|
+
compareText(a.task.title, b.task.title),
|
|
193
211
|
}
|
|
194
212
|
|
|
195
213
|
/**
|
|
@@ -242,6 +260,20 @@ export interface LaneGroup {
|
|
|
242
260
|
readonly entries: LaneTaskEntry[]
|
|
243
261
|
}
|
|
244
262
|
|
|
263
|
+
/**
|
|
264
|
+
* One rendered lane: its identity, its groups, and the count its header states.
|
|
265
|
+
*
|
|
266
|
+
* Lives beside the shapes it composes rather than in `composables/useFrameLanes`, which assembles
|
|
267
|
+
* it: `utils/laneIdentity.ts` compares these, and a util reaching into a composable for its central
|
|
268
|
+
* type points the dependency the wrong way round.
|
|
269
|
+
*/
|
|
270
|
+
export interface RenderedLane {
|
|
271
|
+
readonly lane: TaskLane
|
|
272
|
+
readonly groups: LaneGroup[]
|
|
273
|
+
/** Every task classified into this lane, BEFORE the Done lane's caps. */
|
|
274
|
+
readonly total: number
|
|
275
|
+
}
|
|
276
|
+
|
|
245
277
|
/**
|
|
246
278
|
* The value a task is grouped under, or null for the catch-all group.
|
|
247
279
|
*
|
package/i18n/locales/de.json
CHANGED
|
@@ -3426,8 +3426,8 @@
|
|
|
3426
3426
|
"proxyHeading": "Proxy-/Gateway-API-Schlüssel",
|
|
3427
3427
|
"directAccountIntro": "Verbinden Sie einen Vendor-API-Schlüssel, der von jedem Workspace in diesem Account geteilt wird. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Account-Schlüssel werden vom Administrator verwaltet.",
|
|
3428
3428
|
"directIntro": "Verbinden Sie einen Vendor-API-Schlüssel, damit Modelle direkt bei diesem Provider laufen. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Beschränken Sie einen Schlüssel auf diesen Workspace (mit dem Team geteilt) oder auf sich selbst (Ihr eigener Pool, überall nutzbar).",
|
|
3429
|
-
"proxyAccountIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, LiteLLM), der von jedem Workspace in diesem Account geteilt wird. Ein Proxy ist ein Vermittler, der viele Vendors hinter einem einzigen Schlüssel bündelt. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert.",
|
|
3430
|
-
"proxyIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, LiteLLM). Ein Proxy ist ein Vermittler, der die Modelle vieler Vendors über ein einziges Gateway erreicht, statt direkt bei einem Vendor. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Beschränken Sie einen Schlüssel auf diesen Workspace (mit dem Team geteilt) oder auf sich selbst (Ihr eigener Pool, überall nutzbar).",
|
|
3429
|
+
"proxyAccountIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, Bifrost, LiteLLM), der von jedem Workspace in diesem Account geteilt wird. Ein Proxy ist ein Vermittler, der viele Vendors hinter einem einzigen Schlüssel bündelt. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert.",
|
|
3430
|
+
"proxyIntro": "Verbinden Sie einen Proxy-Schlüssel (OpenRouter, Bifrost, LiteLLM). Ein Proxy ist ein Vermittler, der die Modelle vieler Vendors über ein einziges Gateway erreicht, statt direkt bei einem Vendor. Schlüssel werden verschlüsselt gespeichert, gepoolt und nach Nutzung rotiert. Beschränken Sie einen Schlüssel auf diesen Workspace (mit dem Team geteilt) oder auf sich selbst (Ihr eigener Pool, überall nutzbar).",
|
|
3431
3431
|
"scopeField": "Geltungsbereich",
|
|
3432
3432
|
"scopeWorkspace": "Dieser Workspace",
|
|
3433
3433
|
"scopeUser": "Meine Schlüssel (nur ich)",
|
|
@@ -3485,6 +3485,11 @@
|
|
|
3485
3485
|
"step1": "Öffnen Sie openrouter.ai → Keys und erstellen Sie einen API-Schlüssel.",
|
|
3486
3486
|
"step2": "Kopieren Sie den Schlüssel (beginnt mit sk-or-…); er erreicht über ein Gateway mehr als 300 Modelle."
|
|
3487
3487
|
},
|
|
3488
|
+
"bifrost": {
|
|
3489
|
+
"label": "Bifrost (selbst gehostetes Gateway)",
|
|
3490
|
+
"step1": "Erstellen Sie einen virtuellen Schlüssel in den Governance-Einstellungen Ihres Bifrost-Gateways (oder verwenden Sie dessen API-Schlüssel).",
|
|
3491
|
+
"step2": "Die Basis-URL des Gateways wird von Ihrem Deployment-Betreiber festgelegt (BIFROST_BASE_URL), nicht hier."
|
|
3492
|
+
},
|
|
3488
3493
|
"litellm": {
|
|
3489
3494
|
"label": "LiteLLM (selbst gehostetes Gateway)",
|
|
3490
3495
|
"step1": "Erstellen Sie einen virtuellen Schlüssel auf Ihrem LiteLLM-Gateway (oder verwenden Sie dessen Master Key).",
|
package/i18n/locales/en.json
CHANGED
|
@@ -4334,8 +4334,8 @@
|
|
|
4334
4334
|
"proxyHeading": "Proxy / gateway API keys",
|
|
4335
4335
|
"directAccountIntro": "Connect a vendor API key shared by every workspace in this account. Keys are stored encrypted, pooled, and rotated by usage. Account keys are admin-managed.",
|
|
4336
4336
|
"directIntro": "Connect a vendor API key so models run directly on that provider. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this workspace (shared with the team) or to you (your own pool, usable anywhere).",
|
|
4337
|
-
"proxyAccountIntro": "Connect a proxy key (OpenRouter, LiteLLM) shared by every workspace in this account. A proxy is an intermediary that fronts many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.",
|
|
4338
|
-
"proxyIntro": "Connect a proxy key (OpenRouter, LiteLLM). A proxy is an intermediary that reaches many vendors' models through a single gateway, rather than one vendor directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this workspace (shared with the team) or to you (your own pool, usable anywhere).",
|
|
4337
|
+
"proxyAccountIntro": "Connect a proxy key (OpenRouter, Bifrost, LiteLLM) shared by every workspace in this account. A proxy is an intermediary that fronts many vendors behind a single key. Keys are stored encrypted, pooled, and rotated by usage.",
|
|
4338
|
+
"proxyIntro": "Connect a proxy key (OpenRouter, Bifrost, LiteLLM). A proxy is an intermediary that reaches many vendors' models through a single gateway, rather than one vendor directly. Keys are stored encrypted, pooled, and rotated by usage. Scope a key to this workspace (shared with the team) or to you (your own pool, usable anywhere).",
|
|
4339
4339
|
"scopeField": "Scope",
|
|
4340
4340
|
"scopeWorkspace": "This workspace",
|
|
4341
4341
|
"scopeUser": "My keys (only me)",
|
|
@@ -4396,6 +4396,11 @@
|
|
|
4396
4396
|
"step1": "Open openrouter.ai → Keys and create an API key.",
|
|
4397
4397
|
"step2": "Copy the key (starts with sk-or-…); it reaches 300+ models through one gateway."
|
|
4398
4398
|
},
|
|
4399
|
+
"bifrost": {
|
|
4400
|
+
"label": "Bifrost (self-hosted gateway)",
|
|
4401
|
+
"step1": "Create a virtual key in your Bifrost gateway's governance settings (or use its API key).",
|
|
4402
|
+
"step2": "The gateway's base URL is set by your deployment operator (BIFROST_BASE_URL), not here."
|
|
4403
|
+
},
|
|
4399
4404
|
"litellm": {
|
|
4400
4405
|
"label": "LiteLLM (self-hosted gateway)",
|
|
4401
4406
|
"step1": "Generate a virtual key on your LiteLLM gateway (or use its master key).",
|
package/i18n/locales/es.json
CHANGED
|
@@ -4188,8 +4188,8 @@
|
|
|
4188
4188
|
"proxyHeading": "Claves de API de proxy / pasarela",
|
|
4189
4189
|
"directAccountIntro": "Conecta una clave de API de proveedor compartida por todos los espacios de trabajo de esta cuenta. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Las claves de cuenta las gestiona un administrador.",
|
|
4190
4190
|
"directIntro": "Conecta una clave de API de proveedor para que los modelos se ejecuten directamente en ese proveedor. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Asigna el alcance de una clave a este espacio de trabajo (compartida con el equipo) o a ti (tu propio grupo, usable en cualquier sitio).",
|
|
4191
|
-
"proxyAccountIntro": "Conecta una clave de proxy (OpenRouter, LiteLLM) compartida por todos los espacios de trabajo de esta cuenta. Un proxy es un intermediario que da acceso a muchos proveedores tras una sola clave. Las claves se almacenan cifradas, se agrupan y se rotan según el uso.",
|
|
4192
|
-
"proxyIntro": "Conecta una clave de proxy (OpenRouter, LiteLLM). Un proxy es un intermediario que llega a los modelos de muchos proveedores a través de una sola pasarela, en lugar de un proveedor directo. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Asigna el alcance de una clave a este espacio de trabajo (compartida con el equipo) o a ti (tu propio grupo, usable en cualquier sitio).",
|
|
4191
|
+
"proxyAccountIntro": "Conecta una clave de proxy (OpenRouter, Bifrost, LiteLLM) compartida por todos los espacios de trabajo de esta cuenta. Un proxy es un intermediario que da acceso a muchos proveedores tras una sola clave. Las claves se almacenan cifradas, se agrupan y se rotan según el uso.",
|
|
4192
|
+
"proxyIntro": "Conecta una clave de proxy (OpenRouter, Bifrost, LiteLLM). Un proxy es un intermediario que llega a los modelos de muchos proveedores a través de una sola pasarela, en lugar de un proveedor directo. Las claves se almacenan cifradas, se agrupan y se rotan según el uso. Asigna el alcance de una clave a este espacio de trabajo (compartida con el equipo) o a ti (tu propio grupo, usable en cualquier sitio).",
|
|
4193
4193
|
"scopeField": "Alcance",
|
|
4194
4194
|
"scopeWorkspace": "Este espacio de trabajo",
|
|
4195
4195
|
"scopeUser": "Mis claves (solo yo)",
|
|
@@ -4247,6 +4247,11 @@
|
|
|
4247
4247
|
"step1": "Abre openrouter.ai → Keys y crea una clave de API.",
|
|
4248
4248
|
"step2": "Copia la clave (empieza por sk-or-…); llega a más de 300 modelos a través de una sola pasarela."
|
|
4249
4249
|
},
|
|
4250
|
+
"bifrost": {
|
|
4251
|
+
"label": "Bifrost (pasarela autoalojada)",
|
|
4252
|
+
"step1": "Genera una clave virtual en los ajustes de gobernanza de tu pasarela Bifrost (o usa su clave de API).",
|
|
4253
|
+
"step2": "La URL base de la pasarela la establece el operador de tu despliegue (BIFROST_BASE_URL), no aquí."
|
|
4254
|
+
},
|
|
4250
4255
|
"litellm": {
|
|
4251
4256
|
"label": "LiteLLM (pasarela autoalojada)",
|
|
4252
4257
|
"step1": "Genera una clave virtual en tu pasarela LiteLLM (o usa su clave maestra).",
|
package/i18n/locales/fr.json
CHANGED
|
@@ -4188,8 +4188,8 @@
|
|
|
4188
4188
|
"proxyHeading": "Clés d'API de proxy / passerelle",
|
|
4189
4189
|
"directAccountIntro": "Connectez une clé d'API de fournisseur partagée par tous les espaces de travail de ce compte. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Les clés de compte sont gérées par un administrateur.",
|
|
4190
4190
|
"directIntro": "Connectez une clé d'API de fournisseur pour que les modèles s'exécutent directement chez ce fournisseur. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Définissez la portée d'une clé sur cet espace de travail (partagée avec l'équipe) ou sur vous (votre propre pool, utilisable partout).",
|
|
4191
|
-
"proxyAccountIntro": "Connectez une clé de proxy (OpenRouter, LiteLLM) partagée par tous les espaces de travail de ce compte. Un proxy est un intermédiaire qui donne accès à de nombreux fournisseurs derrière une seule clé. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage.",
|
|
4192
|
-
"proxyIntro": "Connectez une clé de proxy (OpenRouter, LiteLLM). Un proxy est un intermédiaire qui atteint les modèles de nombreux fournisseurs via une seule passerelle, plutôt qu'un fournisseur direct. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Définissez la portée d'une clé sur cet espace de travail (partagée avec l'équipe) ou sur vous (votre propre pool, utilisable partout).",
|
|
4191
|
+
"proxyAccountIntro": "Connectez une clé de proxy (OpenRouter, Bifrost, LiteLLM) partagée par tous les espaces de travail de ce compte. Un proxy est un intermédiaire qui donne accès à de nombreux fournisseurs derrière une seule clé. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage.",
|
|
4192
|
+
"proxyIntro": "Connectez une clé de proxy (OpenRouter, Bifrost, LiteLLM). Un proxy est un intermédiaire qui atteint les modèles de nombreux fournisseurs via une seule passerelle, plutôt qu'un fournisseur direct. Les clés sont stockées chiffrées, mutualisées et alternées selon l'usage. Définissez la portée d'une clé sur cet espace de travail (partagée avec l'équipe) ou sur vous (votre propre pool, utilisable partout).",
|
|
4193
4193
|
"scopeField": "Portée",
|
|
4194
4194
|
"scopeWorkspace": "Cet espace de travail",
|
|
4195
4195
|
"scopeUser": "Mes clés (moi uniquement)",
|
|
@@ -4247,6 +4247,11 @@
|
|
|
4247
4247
|
"step1": "Ouvrez openrouter.ai → Keys et créez une clé d'API.",
|
|
4248
4248
|
"step2": "Copiez la clé (commence par sk-or-…) ; elle atteint plus de 300 modèles via une seule passerelle."
|
|
4249
4249
|
},
|
|
4250
|
+
"bifrost": {
|
|
4251
|
+
"label": "Bifrost (passerelle auto-hébergée)",
|
|
4252
|
+
"step1": "Générez une clé virtuelle dans les paramètres de gouvernance de votre passerelle Bifrost (ou utilisez sa clé d'API).",
|
|
4253
|
+
"step2": "L'URL de base de la passerelle est définie par l'opérateur de votre déploiement (BIFROST_BASE_URL), pas ici."
|
|
4254
|
+
},
|
|
4250
4255
|
"litellm": {
|
|
4251
4256
|
"label": "LiteLLM (passerelle auto-hébergée)",
|
|
4252
4257
|
"step1": "Générez une clé virtuelle sur votre passerelle LiteLLM (ou utilisez sa clé maîtresse).",
|
package/i18n/locales/he.json
CHANGED
|
@@ -4188,8 +4188,8 @@
|
|
|
4188
4188
|
"proxyHeading": "מפתחות API של פרוקסי / שער",
|
|
4189
4189
|
"directAccountIntro": "חבר מפתח API של ספק המשותף לכל סביבות העבודה בחשבון זה. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. מפתחות חשבון מנוהלים על ידי מנהל.",
|
|
4190
4190
|
"directIntro": "חבר מפתח API של ספק כדי שמודלים ירוצו ישירות על אותו ספק. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. הגדר היקף למפתח לסביבת עבודה זו (משותף עם הצוות) או לעצמך (המאגר שלך, שמיש בכל מקום).",
|
|
4191
|
-
"proxyAccountIntro": "חבר מפתח פרוקסי (OpenRouter, LiteLLM) המשותף לכל סביבות העבודה בחשבון זה. פרוקסי הוא מתווך החזית בפני ספקים רבים מאחורי מפתח יחיד. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש.",
|
|
4192
|
-
"proxyIntro": "חבר מפתח פרוקסי (OpenRouter, LiteLLM). פרוקסי הוא מתווך המגיע למודלים של ספקים רבים דרך שער יחיד, במקום אל ספק יחיד ישירות. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. הגדר היקף למפתח לסביבת עבודה זו (משותף עם הצוות) או לעצמך (המאגר שלך, שמיש בכל מקום).",
|
|
4191
|
+
"proxyAccountIntro": "חבר מפתח פרוקסי (OpenRouter, Bifrost, LiteLLM) המשותף לכל סביבות העבודה בחשבון זה. פרוקסי הוא מתווך החזית בפני ספקים רבים מאחורי מפתח יחיד. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש.",
|
|
4192
|
+
"proxyIntro": "חבר מפתח פרוקסי (OpenRouter, Bifrost, LiteLLM). פרוקסי הוא מתווך המגיע למודלים של ספקים רבים דרך שער יחיד, במקום אל ספק יחיד ישירות. המפתחות נשמרים מוצפנים, מאוגדים ומסובבים לפי שימוש. הגדר היקף למפתח לסביבת עבודה זו (משותף עם הצוות) או לעצמך (המאגר שלך, שמיש בכל מקום).",
|
|
4193
4193
|
"scopeField": "היקף",
|
|
4194
4194
|
"scopeWorkspace": "סביבת עבודה זו",
|
|
4195
4195
|
"scopeUser": "המפתחות שלי (רק אני)",
|
|
@@ -4247,6 +4247,11 @@
|
|
|
4247
4247
|
"step1": "פתח את openrouter.ai → Keys וצור מפתח API.",
|
|
4248
4248
|
"step2": "העתק את המפתח (מתחיל ב-sk-or-…); הוא מגיע ל-300+ מודלים דרך שער יחיד."
|
|
4249
4249
|
},
|
|
4250
|
+
"bifrost": {
|
|
4251
|
+
"label": "Bifrost (שער באירוח עצמי)",
|
|
4252
|
+
"step1": "צור מפתח וירטואלי בהגדרות הבקרה של שער Bifrost שלך (או השתמש במפתח ה-API שלו).",
|
|
4253
|
+
"step2": "כתובת הבסיס של השער נקבעת על ידי מפעיל הפריסה שלך (BIFROST_BASE_URL), ולא כאן."
|
|
4254
|
+
},
|
|
4250
4255
|
"litellm": {
|
|
4251
4256
|
"label": "LiteLLM (שער בארחה עצמית)",
|
|
4252
4257
|
"step1": "צור מפתח וירטואלי בשער LiteLLM שלך (או השתמש במפתח המאסטר שלו).",
|
package/i18n/locales/it.json
CHANGED
|
@@ -3426,8 +3426,8 @@
|
|
|
3426
3426
|
"proxyHeading": "Chiavi API proxy / gateway",
|
|
3427
3427
|
"directAccountIntro": "Collega una chiave API di un vendor condivisa da ogni workspace di questo account. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Le chiavi dell'account sono gestite dall'amministratore.",
|
|
3428
3428
|
"directIntro": "Collega una chiave API di un vendor così i modelli vengono eseguiti direttamente su quel provider. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Assegna l'ambito di una chiave a questo workspace (condivisa con il team) o a te (il tuo pool personale, utilizzabile ovunque).",
|
|
3429
|
-
"proxyAccountIntro": "Collega una chiave proxy (OpenRouter, LiteLLM) condivisa da ogni workspace di questo account. Un proxy è un intermediario che fa da facciata a molti vendor dietro un'unica chiave. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo.",
|
|
3430
|
-
"proxyIntro": "Collega una chiave proxy (OpenRouter, LiteLLM). Un proxy è un intermediario che raggiunge i modelli di molti vendor attraverso un unico gateway, anziché un singolo vendor direttamente. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Assegna l'ambito di una chiave a questo workspace (condivisa con il team) o a te (il tuo pool personale, utilizzabile ovunque).",
|
|
3429
|
+
"proxyAccountIntro": "Collega una chiave proxy (OpenRouter, Bifrost, LiteLLM) condivisa da ogni workspace di questo account. Un proxy è un intermediario che fa da facciata a molti vendor dietro un'unica chiave. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo.",
|
|
3430
|
+
"proxyIntro": "Collega una chiave proxy (OpenRouter, Bifrost, LiteLLM). Un proxy è un intermediario che raggiunge i modelli di molti vendor attraverso un unico gateway, anziché un singolo vendor direttamente. Le chiavi vengono memorizzate in forma cifrata, raggruppate e ruotate in base all'utilizzo. Assegna l'ambito di una chiave a questo workspace (condivisa con il team) o a te (il tuo pool personale, utilizzabile ovunque).",
|
|
3431
3431
|
"scopeField": "Ambito",
|
|
3432
3432
|
"scopeWorkspace": "Questo workspace",
|
|
3433
3433
|
"scopeUser": "Le mie chiavi (solo io)",
|
|
@@ -3485,6 +3485,11 @@
|
|
|
3485
3485
|
"step1": "Apri openrouter.ai → Keys e crea una chiave API.",
|
|
3486
3486
|
"step2": "Copia la chiave (inizia con sk-or-…); raggiunge oltre 300 modelli attraverso un unico gateway."
|
|
3487
3487
|
},
|
|
3488
|
+
"bifrost": {
|
|
3489
|
+
"label": "Bifrost (gateway self-hosted)",
|
|
3490
|
+
"step1": "Genera una chiave virtuale nelle impostazioni di governance del tuo gateway Bifrost (oppure usa la sua chiave API).",
|
|
3491
|
+
"step2": "L'URL di base del gateway viene impostato dall'operatore del tuo deployment (BIFROST_BASE_URL), non qui."
|
|
3492
|
+
},
|
|
3488
3493
|
"litellm": {
|
|
3489
3494
|
"label": "LiteLLM (gateway self-hosted)",
|
|
3490
3495
|
"step1": "Genera una chiave virtuale sul tuo gateway LiteLLM (oppure usa la sua chiave master).",
|
package/i18n/locales/ja.json
CHANGED
|
@@ -4188,8 +4188,8 @@
|
|
|
4188
4188
|
"proxyHeading": "プロキシ / ゲートウェイの API キー",
|
|
4189
4189
|
"directAccountIntro": "このアカウントの全ワークスペースで共有するベンダー API キーを接続します。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。アカウントキーは管理者が管理します。",
|
|
4190
4190
|
"directIntro": "ベンダー API キーを接続すると、モデルがそのプロバイダー上で直接実行されます。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。キーのスコープをこのワークスペース (チームで共有) または自分専用 (どこでも使える自分のプール) に設定できます。",
|
|
4191
|
-
"proxyAccountIntro": "このアカウントの全ワークスペースで共有するプロキシキー (OpenRouter、LiteLLM) を接続します。プロキシは、単一のキーで多数のベンダーをまとめる仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。",
|
|
4192
|
-
"proxyIntro": "プロキシキー (OpenRouter、LiteLLM) を接続します。プロキシは、各ベンダーに直接ではなく、単一のゲートウェイを通じて多数のベンダーのモデルに到達する仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。キーのスコープをこのワークスペース (チームで共有) または自分専用 (どこでも使える自分のプール) に設定できます。",
|
|
4191
|
+
"proxyAccountIntro": "このアカウントの全ワークスペースで共有するプロキシキー (OpenRouter、Bifrost、LiteLLM) を接続します。プロキシは、単一のキーで多数のベンダーをまとめる仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。",
|
|
4192
|
+
"proxyIntro": "プロキシキー (OpenRouter、Bifrost、LiteLLM) を接続します。プロキシは、各ベンダーに直接ではなく、単一のゲートウェイを通じて多数のベンダーのモデルに到達する仲介役です。キーは暗号化して保存され、プールされて利用状況に応じてローテーションされます。キーのスコープをこのワークスペース (チームで共有) または自分専用 (どこでも使える自分のプール) に設定できます。",
|
|
4193
4193
|
"scopeField": "スコープ",
|
|
4194
4194
|
"scopeWorkspace": "このワークスペース",
|
|
4195
4195
|
"scopeUser": "自分のキー (自分のみ)",
|
|
@@ -4247,6 +4247,11 @@
|
|
|
4247
4247
|
"step1": "openrouter.ai → Keys を開き、API キーを作成します。",
|
|
4248
4248
|
"step2": "キー (sk-or-… で始まる) をコピーします。1 つのゲートウェイで 300 以上のモデルに到達します。"
|
|
4249
4249
|
},
|
|
4250
|
+
"bifrost": {
|
|
4251
|
+
"label": "Bifrost (セルフホストゲートウェイ)",
|
|
4252
|
+
"step1": "Bifrost ゲートウェイのガバナンス設定で仮想キーを生成します (または API キーを使用します)。",
|
|
4253
|
+
"step2": "ゲートウェイのベース URL は、ここではなくデプロイ運用者が設定します (BIFROST_BASE_URL)。"
|
|
4254
|
+
},
|
|
4250
4255
|
"litellm": {
|
|
4251
4256
|
"label": "LiteLLM (セルフホストゲートウェイ)",
|
|
4252
4257
|
"step1": "LiteLLM ゲートウェイで仮想キーを生成します (またはマスターキーを使用します)。",
|