@cat-factory/app 0.101.1 → 0.103.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.
@@ -129,7 +129,11 @@ async function saveStorage() {
129
129
  ...(cs.forcePathStyle ? { forcePathStyle: true } : {}),
130
130
  }
131
131
  }
132
- const input: Parameters<typeof store.save>[1] = { config: { contentStorage: config } }
132
+ // `config` fully REPLACES the stored non-secret config, so carry the rest forward
133
+ // (e.g. the model-family policy) — only `contentStorage` is edited here.
134
+ const input: Parameters<typeof store.save>[1] = {
135
+ config: { ...store.view?.config, contentStorage: config },
136
+ }
133
137
  if (backend === 's3') {
134
138
  const id = cs.accessKeyId.trim()
135
139
  const key = cs.secretAccessKey.trim()
@@ -0,0 +1,247 @@
1
+ <script setup lang="ts">
2
+ import { computed, onMounted, ref, watch } from 'vue'
3
+ import { MODEL_FAMILY_POLICY_PRESETS } from '@cat-factory/contracts'
4
+ import type {
5
+ AccountRegion,
6
+ ModelFamily,
7
+ ModelFamilyPolicy,
8
+ ModelPolicyMode,
9
+ } from '~/types/accountSettings'
10
+
11
+ // Account-wide model-family allow/block policy (admin only). Constrains which LLM families
12
+ // the account's teams may run; a residency-guaranteed route (`trustedProviders`) can exempt
13
+ // an otherwise-blocked family. Region-grouped built-in presets are one-click templates the
14
+ // admin applies into the editable policy. Mounted only where the deployment supports it
15
+ // (hosted / mothership — never plain local mode), gated by the parent on the infra flag.
16
+ const props = defineProps<{ accountId: string }>()
17
+
18
+ const store = useAccountSettingsStore()
19
+ const toast = useToast()
20
+ const { t } = useI18n()
21
+
22
+ // The family / region / mode / trusted-provider domains, pinned to the contract unions so a
23
+ // new enum member fails the typecheck here (via the exhaustive label maps below).
24
+ const FAMILIES = [
25
+ 'claude',
26
+ 'openai',
27
+ 'gemini',
28
+ 'llama',
29
+ 'qwen',
30
+ 'kimi',
31
+ 'deepseek',
32
+ 'glm',
33
+ ] as const satisfies readonly ModelFamily[]
34
+ const REGIONS = ['usa', 'europe', 'china', 'other'] as const satisfies readonly AccountRegion[]
35
+ const MODES = ['off', 'blocklist', 'allowlist'] as const satisfies readonly ModelPolicyMode[]
36
+ const TRUSTED_PROVIDERS = ['bedrock'] as const
37
+
38
+ // Exhaustive enum→key maps (drift guard): every member resolves to a static literal `t()`
39
+ // key, so adding a family/region/mode without a label fails the typecheck on the Record.
40
+ const familyLabels = computed<Record<ModelFamily, string>>(() => ({
41
+ claude: t('settings.modelPolicy.families.claude'),
42
+ openai: t('settings.modelPolicy.families.openai'),
43
+ gemini: t('settings.modelPolicy.families.gemini'),
44
+ llama: t('settings.modelPolicy.families.llama'),
45
+ qwen: t('settings.modelPolicy.families.qwen'),
46
+ kimi: t('settings.modelPolicy.families.kimi'),
47
+ deepseek: t('settings.modelPolicy.families.deepseek'),
48
+ glm: t('settings.modelPolicy.families.glm'),
49
+ }))
50
+ const regionLabels = computed<Record<AccountRegion, string>>(() => ({
51
+ usa: t('settings.modelPolicy.regions.usa'),
52
+ europe: t('settings.modelPolicy.regions.europe'),
53
+ china: t('settings.modelPolicy.regions.china'),
54
+ other: t('settings.modelPolicy.regions.other'),
55
+ }))
56
+ const modeLabels = computed<Record<ModelPolicyMode, string>>(() => ({
57
+ off: t('settings.modelPolicy.modes.off'),
58
+ blocklist: t('settings.modelPolicy.modes.blocklist'),
59
+ allowlist: t('settings.modelPolicy.modes.allowlist'),
60
+ }))
61
+ const providerLabels = computed<Record<(typeof TRUSTED_PROVIDERS)[number], string>>(() => ({
62
+ bedrock: t('settings.modelPolicy.providers.bedrock'),
63
+ }))
64
+
65
+ const modeItems = computed(() => MODES.map((m) => ({ label: modeLabels.value[m], value: m })))
66
+ const regionItems = computed(() => REGIONS.map((r) => ({ label: regionLabels.value[r], value: r })))
67
+
68
+ // Editable local state (hydrated from the stored policy).
69
+ const mode = ref<ModelPolicyMode>('off')
70
+ const region = ref<AccountRegion>('other')
71
+ const families = ref<ModelFamily[]>([])
72
+ const trusted = ref<string[]>([])
73
+ const saving = ref(false)
74
+
75
+ function hydrate() {
76
+ const p = store.view?.config?.modelPolicy
77
+ mode.value = p?.mode ?? 'off'
78
+ region.value = p?.region ?? 'other'
79
+ families.value = [...(p?.families ?? [])]
80
+ trusted.value = [...(p?.trustedProviders ?? [])]
81
+ }
82
+
83
+ // The presets offered for the currently-selected region.
84
+ const regionPresets = computed(() =>
85
+ MODEL_FAMILY_POLICY_PRESETS.filter((preset) => preset.region === region.value),
86
+ )
87
+
88
+ function toggleFamily(fam: ModelFamily) {
89
+ families.value = families.value.includes(fam)
90
+ ? families.value.filter((x) => x !== fam)
91
+ : [...families.value, fam]
92
+ }
93
+ function toggleTrusted(provider: string) {
94
+ trusted.value = trusted.value.includes(provider)
95
+ ? trusted.value.filter((x) => x !== provider)
96
+ : [...trusted.value, provider]
97
+ }
98
+
99
+ function applyPreset(policy: ModelFamilyPolicy) {
100
+ mode.value = policy.mode
101
+ families.value = [...policy.families]
102
+ trusted.value = [...policy.trustedProviders]
103
+ }
104
+
105
+ onMounted(async () => {
106
+ // The sibling deployment-settings panel loads the store on mount too; guard against a
107
+ // double-load being unnecessary by only loading when the view isn't present yet.
108
+ if (!store.view && store.available !== false) {
109
+ try {
110
+ await store.load(props.accountId)
111
+ } catch {
112
+ // The deployment-settings panel surfaces the load error; stay quiet here.
113
+ }
114
+ }
115
+ hydrate()
116
+ })
117
+ watch(() => store.view, hydrate)
118
+
119
+ async function save() {
120
+ const policy: ModelFamilyPolicy = {
121
+ mode: mode.value,
122
+ // Families are irrelevant when the policy is off; keep them so a later re-enable
123
+ // restores the admin's selection rather than silently emptying it.
124
+ families: families.value,
125
+ trustedProviders: trusted.value,
126
+ region: region.value,
127
+ }
128
+ saving.value = true
129
+ try {
130
+ // `config` fully replaces the stored non-secret config, so carry the rest forward.
131
+ await store.save(props.accountId, { config: { ...store.view?.config, modelPolicy: policy } })
132
+ toast.add({
133
+ title: t('settings.modelPolicy.saved'),
134
+ icon: 'i-lucide-check',
135
+ color: 'success',
136
+ })
137
+ } catch (e) {
138
+ toast.add({
139
+ title: t('settings.modelPolicy.saveFailed'),
140
+ description: e instanceof Error ? e.message : String(e),
141
+ color: 'error',
142
+ })
143
+ } finally {
144
+ saving.value = false
145
+ }
146
+ }
147
+ </script>
148
+
149
+ <template>
150
+ <section
151
+ v-if="store.available !== false"
152
+ data-testid="account-model-policy"
153
+ class="space-y-3 border-t border-slate-800 pt-6"
154
+ >
155
+ <div>
156
+ <h4 class="text-sm font-semibold text-slate-200">{{ t('settings.modelPolicy.title') }}</h4>
157
+ <p class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.description') }}</p>
158
+ </div>
159
+
160
+ <!-- Region + apply-preset templates -->
161
+ <div class="space-y-2">
162
+ <label class="text-[11px] font-medium text-slate-300">
163
+ {{ t('settings.modelPolicy.regionLabel') }}
164
+ </label>
165
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
166
+ <USelect v-model="region" :items="regionItems" value-key="value" size="sm" />
167
+ </div>
168
+ <div v-if="regionPresets.length" class="flex flex-wrap items-center gap-2">
169
+ <span class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.applyPreset') }}</span>
170
+ <UButton
171
+ v-for="preset in regionPresets"
172
+ :key="preset.id"
173
+ color="neutral"
174
+ variant="subtle"
175
+ size="xs"
176
+ icon="i-lucide-wand-2"
177
+ :title="t(`settings.modelPolicy.presets.${preset.id}.description`)"
178
+ @click="applyPreset(preset.policy)"
179
+ >
180
+ {{ t(`settings.modelPolicy.presets.${preset.id}.label`) }}
181
+ </UButton>
182
+ </div>
183
+ </div>
184
+
185
+ <!-- Mode -->
186
+ <div class="space-y-2">
187
+ <label class="text-[11px] font-medium text-slate-300">
188
+ {{ t('settings.modelPolicy.modeLabel') }}
189
+ </label>
190
+ <div class="grid grid-cols-1 gap-2 sm:grid-cols-2">
191
+ <USelect v-model="mode" :items="modeItems" value-key="value" size="sm" />
192
+ </div>
193
+ </div>
194
+
195
+ <!-- Families -->
196
+ <div v-if="mode !== 'off'" class="space-y-2">
197
+ <label class="text-[11px] font-medium text-slate-300">
198
+ {{
199
+ mode === 'blocklist'
200
+ ? t('settings.modelPolicy.familiesBlockLabel')
201
+ : t('settings.modelPolicy.familiesAllowLabel')
202
+ }}
203
+ </label>
204
+ <div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
205
+ <UCheckbox
206
+ v-for="fam in FAMILIES"
207
+ :key="fam"
208
+ :model-value="families.includes(fam)"
209
+ :label="familyLabels[fam]"
210
+ size="sm"
211
+ @update:model-value="toggleFamily(fam)"
212
+ />
213
+ </div>
214
+ </div>
215
+
216
+ <!-- Trusted (residency-guaranteed) routes -->
217
+ <div v-if="mode !== 'off'" class="space-y-2">
218
+ <label class="text-[11px] font-medium text-slate-300">
219
+ {{ t('settings.modelPolicy.trustedLabel') }}
220
+ </label>
221
+ <p class="text-[11px] text-slate-400">{{ t('settings.modelPolicy.trustedHint') }}</p>
222
+ <div class="grid grid-cols-2 gap-1 sm:grid-cols-4">
223
+ <UCheckbox
224
+ v-for="provider in TRUSTED_PROVIDERS"
225
+ :key="provider"
226
+ :model-value="trusted.includes(provider)"
227
+ :label="providerLabels[provider]"
228
+ size="sm"
229
+ @update:model-value="toggleTrusted(provider)"
230
+ />
231
+ </div>
232
+ </div>
233
+
234
+ <div class="flex gap-2">
235
+ <UButton
236
+ color="primary"
237
+ size="xs"
238
+ icon="i-lucide-save"
239
+ :loading="saving"
240
+ data-testid="account-model-policy-save"
241
+ @click="save"
242
+ >
243
+ {{ t('common.save') }}
244
+ </UButton>
245
+ </div>
246
+ </section>
247
+ </template>
@@ -4,6 +4,7 @@ import { apiErrorEnvelope } from '~/composables/api/errors'
4
4
  import type { AccountRole } from '~/types/domain'
5
5
  import type { InvitationStatus } from '@cat-factory/contracts'
6
6
  import AccountDeploymentSettings from '~/components/layout/AccountDeploymentSettings.vue'
7
+ import AccountModelPolicySettings from '~/components/layout/AccountModelPolicySettings.vue'
7
8
  import SecretInput from '~/components/common/SecretInput.vue'
8
9
 
9
10
  // Team settings for an org account: the member roster (with combinable admin /
@@ -13,11 +14,16 @@ import SecretInput from '~/components/common/SecretInput.vue'
13
14
  const props = defineProps<{ accountId: string }>()
14
15
 
15
16
  const accounts = useAccountsStore()
17
+ const auth = useAuthStore()
16
18
  const toast = useToast()
17
19
  const { t, te } = useI18n()
18
20
  const { confirmAction, toastDone } = useConfirmAction()
19
21
  const busy = ref(false)
20
22
 
23
+ // The account-wide model-family policy is a hosted / mothership-only control (the backend
24
+ // reports `false` in plain local mode, where there is no account admin to govern).
25
+ const modelPolicySupported = computed(() => auth.infrastructure?.modelPolicy?.supported ?? false)
26
+
21
27
  const ROLE_ITEMS = computed<{ label: string; value: AccountRole }[]>(() => [
22
28
  { label: t('layout.accountTeam.roles.admin'), value: 'admin' },
23
29
  { label: t('layout.accountTeam.roles.developer'), value: 'developer' },
@@ -326,5 +332,10 @@ async function disconnectEmail() {
326
332
  <section v-if="isAdmin">
327
333
  <AccountDeploymentSettings :account-id="accountId" />
328
334
  </section>
335
+
336
+ <!-- account-wide model-family allow/block policy (admin-only; hosted/mothership only) -->
337
+ <section v-if="isAdmin && modelPolicySupported">
338
+ <AccountModelPolicySettings :account-id="accountId" />
339
+ </section>
329
340
  </div>
330
341
  </template>
@@ -6,7 +6,11 @@
6
6
  // they succeed only on the local facade (elsewhere the backend returns a clear error surfaced as
7
7
  // a toast). Renders inline inside the Infrastructure window's "Shared stacks" tab.
8
8
  import { computed, reactive, ref } from 'vue'
9
- import type { SharedStack, SharedStackStatus } from '~/types/sharedStacks'
9
+ import type {
10
+ SharedStack,
11
+ SharedStackRecommendation,
12
+ SharedStackStatus,
13
+ } from '~/types/sharedStacks'
10
14
 
11
15
  const { t } = useI18n()
12
16
  const store = useSharedStacksStore()
@@ -16,6 +20,7 @@ const { confirmAction, toastDone } = useConfirmAction()
16
20
  const stacks = computed(() => store.stacks)
17
21
  const busyId = ref<string | null>(null)
18
22
  const saving = ref(false)
23
+ const detecting = ref(false)
19
24
  // null ⇒ the form is in "add" mode; a stack id ⇒ editing that stack's definition in place.
20
25
  const editingId = ref<string | null>(null)
21
26
 
@@ -23,12 +28,20 @@ const form = reactive({
23
28
  name: '',
24
29
  cloneUrl: '',
25
30
  gitRef: '',
31
+ // Subdirectory the compose stack lives in (monorepo) — a detect-time hint only, NOT persisted:
32
+ // the resolved `composeFiles` already carry the prefix. Absent ⇒ the repo root is scanned.
33
+ directory: '',
26
34
  composeFiles: '',
27
35
  composeProfiles: '',
28
36
  managedNetworks: '',
29
37
  allowHostCommands: false,
30
38
  })
31
39
 
40
+ // Env/config templates (`*-dist` → gitignored target) the autodetect scan surfaced. The form has no
41
+ // editor for them, so we carry the detected (or, on edit, the stack's existing) set through to the
42
+ // save payload rather than silently dropping them — they're materialized before `up`.
43
+ const detectedEnvFiles = ref<SharedStack['envFiles']>([])
44
+
32
45
  /** Status → badge colour. */
33
46
  const STATUS_COLOR: Record<SharedStackStatus, 'neutral' | 'warning' | 'success' | 'error'> = {
34
47
  stopped: 'neutral',
@@ -72,10 +85,12 @@ function resetForm() {
72
85
  form.name = ''
73
86
  form.cloneUrl = ''
74
87
  form.gitRef = ''
88
+ form.directory = ''
75
89
  form.composeFiles = ''
76
90
  form.composeProfiles = ''
77
91
  form.managedNetworks = ''
78
92
  form.allowHostCommands = false
93
+ detectedEnvFiles.value = []
79
94
  }
80
95
 
81
96
  /** Load a stack's definition into the form for in-place editing. */
@@ -84,10 +99,59 @@ function startEdit(stack: SharedStack) {
84
99
  form.name = stack.name
85
100
  form.cloneUrl = stack.cloneUrl
86
101
  form.gitRef = stack.gitRef ?? ''
102
+ form.directory = ''
87
103
  form.composeFiles = stack.composeFiles.join(', ')
88
104
  form.composeProfiles = stack.composeProfiles.join(', ')
89
105
  form.managedNetworks = stack.managedNetworks.join(', ')
90
106
  form.allowHostCommands = stack.allowHostCommands
107
+ // Preserve the stack's existing env templates so a save (or a later re-detect) doesn't drop them.
108
+ detectedEnvFiles.value = stack.envFiles
109
+ }
110
+
111
+ const canDetect = computed(() => Boolean(form.cloneUrl.trim()) && !detecting.value)
112
+
113
+ /**
114
+ * Read the repo at the entered clone URL (checkout-free, via the workspace's VCS connection) and
115
+ * PREFILL the compose-shaped fields from the recommendation. Non-binding: the user reviews + edits
116
+ * before saving. A SUCCESSFUL detection is authoritative for the compose-shaped fields — it
117
+ * overwrites them wholesale, including clearing a field the scan found empty (so re-detecting a
118
+ * different repo can't leave a stale managed network / profile behind). Manual entries survive only
119
+ * a `detected:false` result, which returns early and touches nothing. The name is suggested only
120
+ * when still blank (it's a user label, not a repo-derived fact).
121
+ */
122
+ async function autodetect() {
123
+ detecting.value = true
124
+ try {
125
+ const rec = await store.detect({
126
+ cloneUrl: form.cloneUrl.trim(),
127
+ ...(form.gitRef.trim() ? { gitRef: form.gitRef.trim() } : {}),
128
+ ...(form.directory.trim() ? { directory: form.directory.trim() } : {}),
129
+ })
130
+ if (!rec.detected) {
131
+ toast.add({
132
+ title: t('settings.sharedStacks.detect.nothing'),
133
+ description: rec.notes[0]?.message ?? '',
134
+ icon: 'i-lucide-info',
135
+ color: 'warning',
136
+ })
137
+ return
138
+ }
139
+ if (rec.name && !form.name.trim()) form.name = rec.name
140
+ form.composeFiles = rec.composeFiles.join(', ')
141
+ form.composeProfiles = rec.composeProfiles.join(', ')
142
+ form.managedNetworks = rec.managedNetworks.join(', ')
143
+ detectedEnvFiles.value = rec.envFiles
144
+ toast.add({
145
+ title: t('settings.sharedStacks.detect.detected'),
146
+ description: t('settings.sharedStacks.detect.detectedBody'),
147
+ icon: 'i-lucide-wand-sparkles',
148
+ color: 'success',
149
+ })
150
+ } catch (e) {
151
+ notifyError(t('settings.sharedStacks.detect.failed'), e)
152
+ } finally {
153
+ detecting.value = false
154
+ }
91
155
  }
92
156
 
93
157
  function notifyError(title: string, e: unknown) {
@@ -110,6 +174,7 @@ async function saveStack() {
110
174
  composeFiles: tokens(form.composeFiles),
111
175
  composeProfiles: tokens(form.composeProfiles),
112
176
  managedNetworks: tokens(form.managedNetworks),
177
+ envFiles: detectedEnvFiles.value,
113
178
  allowHostCommands: form.allowHostCommands,
114
179
  }
115
180
  try {
@@ -300,6 +365,42 @@ async function remove(stack: SharedStack) {
300
365
  />
301
366
  </UFormField>
302
367
 
368
+ <UFormField
369
+ :label="t('settings.sharedStacks.add.directory')"
370
+ :help="t('settings.sharedStacks.add.directoryHelp')"
371
+ >
372
+ <UInput
373
+ v-model="form.directory"
374
+ placeholder="shared"
375
+ class="w-full"
376
+ data-testid="shared-stack-directory"
377
+ />
378
+ </UFormField>
379
+
380
+ <div class="flex items-center gap-2">
381
+ <UButton
382
+ icon="i-lucide-wand-sparkles"
383
+ size="sm"
384
+ variant="soft"
385
+ :loading="detecting"
386
+ :disabled="!canDetect"
387
+ data-testid="shared-stack-autodetect"
388
+ @click="autodetect"
389
+ >
390
+ {{ t('settings.sharedStacks.detect.button') }}
391
+ </UButton>
392
+ <span class="text-[11px] text-slate-500">{{ t('settings.sharedStacks.detect.hint') }}</span>
393
+ </div>
394
+
395
+ <p
396
+ v-if="detectedEnvFiles.length"
397
+ class="text-[11px] text-slate-500"
398
+ data-testid="shared-stack-env-files"
399
+ >
400
+ {{ t('settings.sharedStacks.detect.envFiles') }}
401
+ {{ detectedEnvFiles.map((f) => `${f.template} → ${f.target}`).join(', ') }}
402
+ </p>
403
+
303
404
  <UFormField
304
405
  :label="t('settings.sharedStacks.add.composeFiles')"
305
406
  :help="t('settings.sharedStacks.add.composeFilesHelp')"
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  createSharedStackContract,
3
3
  deleteSharedStackContract,
4
+ detectSharedStackContract,
4
5
  ensureSharedStackUpContract,
5
6
  listSharedStacksContract,
6
7
  teardownSharedStackContract,
7
8
  updateSharedStackContract,
8
9
  } from '@cat-factory/contracts'
9
- import type { UpdateSharedStackInput } from '~/types/sharedStacks'
10
+ import type { DetectSharedStackInput, UpdateSharedStackInput } from '~/types/sharedStacks'
10
11
  import type { SendParams } from './client'
11
12
  import type { ApiContext } from './context'
12
13
 
@@ -23,6 +24,9 @@ export function sharedStacksApi({ send, ws }: ApiContext) {
23
24
  createSharedStack: (workspaceId: string, body: CreateSharedStackBody) =>
24
25
  send(createSharedStackContract, { pathPrefix: ws(workspaceId), body }),
25
26
 
27
+ detectSharedStack: (workspaceId: string, body: DetectSharedStackInput) =>
28
+ send(detectSharedStackContract, { pathPrefix: ws(workspaceId), body }),
29
+
26
30
  updateSharedStack: (workspaceId: string, stackId: string, body: UpdateSharedStackInput) =>
27
31
  send(updateSharedStackContract, {
28
32
  pathPrefix: ws(workspaceId),
@@ -48,6 +48,8 @@ const CONFLICT_TITLE_KEYS: Record<
48
48
  provision_type_unhandled: 'errors.conflict.title.provision_type_unhandled',
49
49
  preset_unsatisfiable: 'errors.conflict.title.preset_unsatisfiable',
50
50
  visual_pipeline_no_frontend: 'errors.conflict.title.visual_pipeline_no_frontend',
51
+ model_policy_blocked: 'errors.conflict.title.model_policy_blocked',
52
+ model_policy_unsupported: 'errors.conflict.title.model_policy_unsupported',
51
53
  deployer_required_before_tester: 'errors.conflict.title.deployer_required_before_tester',
52
54
  }
53
55
 
@@ -1,6 +1,10 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { ref } from 'vue'
3
- import type { SharedStack, UpdateSharedStackInput } from '~/types/sharedStacks'
3
+ import type {
4
+ DetectSharedStackInput,
5
+ SharedStack,
6
+ UpdateSharedStackInput,
7
+ } from '~/types/sharedStacks'
4
8
  import { useWorkspaceStore } from '~/stores/workspace'
5
9
 
6
10
  /**
@@ -34,6 +38,11 @@ export const useSharedStacksStore = defineStore('sharedStacks', () => {
34
38
  return created
35
39
  }
36
40
 
41
+ async function detect(input: DetectSharedStackInput) {
42
+ const ws = useWorkspaceStore()
43
+ return api.detectSharedStack(ws.requireId(), input)
44
+ }
45
+
37
46
  async function update(stackId: string, patchInput: UpdateSharedStackInput) {
38
47
  const ws = useWorkspaceStore()
39
48
  const updated = await api.updateSharedStack(ws.requireId(), stackId, patchInput)
@@ -61,5 +70,5 @@ export const useSharedStacksStore = defineStore('sharedStacks', () => {
61
70
  return updated
62
71
  }
63
72
 
64
- return { stacks, hydrate, create, update, remove, ensureUp, teardown }
73
+ return { stacks, hydrate, create, detect, update, remove, ensureUp, teardown }
65
74
  })
@@ -15,4 +15,9 @@ export type {
15
15
  ContentStorageConfig,
16
16
  ContentStorageSummary,
17
17
  ContentStorageCapability,
18
+ // Account-wide model-family allow/block policy.
19
+ ModelFamily,
20
+ ModelFamilyPolicy,
21
+ ModelPolicyMode,
22
+ AccountRegion,
18
23
  } from '@cat-factory/contracts'
@@ -5,4 +5,6 @@ export type {
5
5
  SharedStackStatus,
6
6
  CreateSharedStackInput,
7
7
  UpdateSharedStackInput,
8
+ DetectSharedStackInput,
9
+ SharedStackRecommendation,
8
10
  } from '@cat-factory/contracts'
@@ -530,6 +530,8 @@
530
530
  "cloneUrl": "Clone-URL des Repositorys",
531
531
  "cloneUrlHelp": "Das Git-Repository, in dem die Compose-Dateien des Stacks liegen.",
532
532
  "gitRef": "Branch oder Tag (optional)",
533
+ "directory": "Unterverzeichnis (optional)",
534
+ "directoryHelp": "Wird nur von der automatischen Erkennung verwendet: das Monorepo-Unterverzeichnis, in dem der Compose-Stack liegt.",
533
535
  "composeFiles": "Compose-Dateien",
534
536
  "composeFilesHelp": "Kommagetrennt, repo-relativ, in Override-Reihenfolge.",
535
537
  "composeProfiles": "Compose-Profile (optional)",
@@ -538,6 +540,15 @@
538
540
  "allowHostCommands": "Host-Command-Setup-Schritte erlauben",
539
541
  "save": "Stack hinzufügen"
540
542
  },
543
+ "detect": {
544
+ "button": "Automatisch erkennen",
545
+ "hint": "Das Repository lesen und Compose-Dateien, Profile und Netzwerke vorausfüllen.",
546
+ "detected": "Stack-Konfiguration erkannt",
547
+ "detectedBody": "Aus dem Repository vorausgefüllt. Überprüfen Sie die Felder vor dem Speichern.",
548
+ "nothing": "Nichts automatisch zu erkennen",
549
+ "failed": "Automatische Erkennung fehlgeschlagen",
550
+ "envFiles": "Env-Vorlagen, die vor dem Start materialisiert werden:"
551
+ },
541
552
  "edit": {
542
553
  "heading": "Gemeinsamen Stack bearbeiten",
543
554
  "save": "Änderungen speichern",
@@ -734,6 +745,65 @@
734
745
  "title": "Diesen Runner entfernen?",
735
746
  "body": "\"{name}\" wird entfernt. Dies kann nicht rückgängig gemacht werden."
736
747
  }
748
+ },
749
+ "modelPolicy": {
750
+ "title": "Modellzugriffsrichtlinie",
751
+ "description": "Beschränkt, welche Modellfamilien die Teams dieses Kontos ausführen dürfen. Das Datenresidenz-Risiko hängt von der Bereitstellungsroute ab, daher kann eine vertrauenswürdige (residenzgarantierte) Route eine andernfalls blockierte Familie freigeben.",
752
+ "regionLabel": "Betriebsregion",
753
+ "applyPreset": "Vorlage anwenden:",
754
+ "modeLabel": "Richtlinienmodus",
755
+ "modes": {
756
+ "off": "Keine Einschränkung",
757
+ "blocklist": "Aufgeführte Familien blockieren",
758
+ "allowlist": "Nur aufgeführte Familien zulassen"
759
+ },
760
+ "familiesBlockLabel": "Blockierte Familien",
761
+ "familiesAllowLabel": "Zugelassene Familien",
762
+ "trustedLabel": "Vertrauenswürdige (residenzgarantierte) Routen",
763
+ "trustedHint": "Eine blockierte Familie bleibt verfügbar, wenn sie über eine dieser Routen bereitgestellt wird.",
764
+ "families": {
765
+ "claude": "Claude (Anthropic)",
766
+ "openai": "OpenAI / ChatGPT",
767
+ "gemini": "Gemini (Google)",
768
+ "llama": "Llama (Meta)",
769
+ "qwen": "Qwen (Alibaba)",
770
+ "kimi": "Kimi (Moonshot)",
771
+ "deepseek": "DeepSeek",
772
+ "glm": "GLM (Z.ai)"
773
+ },
774
+ "regions": {
775
+ "usa": "Vereinigte Staaten",
776
+ "europe": "Europa",
777
+ "china": "China",
778
+ "other": "Andere"
779
+ },
780
+ "providers": {
781
+ "bedrock": "AWS Bedrock"
782
+ },
783
+ "presets": {
784
+ "us-block-cn": {
785
+ "label": "In China gehostete Familien blockieren",
786
+ "description": "Blockiert DeepSeek, Qwen, Kimi und GLM; erlaubt sie nur über eine residenzgarantierte Route (z. B. Bedrock)."
787
+ },
788
+ "eu-block-cn": {
789
+ "label": "EU: in China gehostete Familien blockieren",
790
+ "description": "Blockiert DeepSeek, Qwen, Kimi und GLM; eine residenzgarantierte EU-Route (z. B. Bedrock) bleibt zulässig."
791
+ },
792
+ "eu-guaranteed-only": {
793
+ "label": "EU: nur residenzgarantierte Routen",
794
+ "description": "Erlaubt ein Modell nur, wenn es über eine residenzgarantierte Route bereitgestellt wird."
795
+ },
796
+ "cn-prefer-domestic": {
797
+ "label": "In China gehostete Familien bevorzugen",
798
+ "description": "Erlaubt in China gehostete Familien (plus Llama) und jede residenzgarantierte Route."
799
+ },
800
+ "other-block-cn": {
801
+ "label": "In China gehostete Familien blockieren",
802
+ "description": "Blockiert DeepSeek, Qwen, Kimi und GLM; erlaubt sie nur über eine residenzgarantierte Route."
803
+ }
804
+ },
805
+ "saved": "Modellzugriffsrichtlinie gespeichert",
806
+ "saveFailed": "Modellzugriffsrichtlinie konnte nicht gespeichert werden"
737
807
  }
738
808
  },
739
809
  "inspector": {
@@ -3580,6 +3650,8 @@
3580
3650
  "provision_type_unhandled": "Kein Handler für diesen Bereitstellungstyp",
3581
3651
  "preset_unsatisfiable": "Modell-Preset kann diese Pipeline nicht ausführen",
3582
3652
  "visual_pipeline_no_frontend": "Kein Frontend zum Testen",
3653
+ "model_policy_blocked": "Modell durch Kontorichtlinie blockiert",
3654
+ "model_policy_unsupported": "Modellrichtlinie hier nicht verfügbar",
3583
3655
  "deployer_required_before_tester": "Füge einen Deployer vor dem Tester hinzu"
3584
3656
  },
3585
3657
  "fallbackMessage": "Diese Aktion steht im Konflikt mit dem aktuellen Zustand.",