@cat-factory/app 0.6.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.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +88 -0
  3. package/app/app.config.ts +8 -0
  4. package/app/app.vue +11 -0
  5. package/app/assets/css/main.css +100 -0
  6. package/app/components/auth/AuthGate.vue +24 -0
  7. package/app/components/auth/LoginScreen.vue +143 -0
  8. package/app/components/auth/UserMenu.vue +39 -0
  9. package/app/components/board/AddTaskModal.vue +444 -0
  10. package/app/components/board/AgentFailureCard.vue +97 -0
  11. package/app/components/board/AgentStopButton.vue +61 -0
  12. package/app/components/board/BoardCanvas.vue +183 -0
  13. package/app/components/board/ContextPicker.vue +367 -0
  14. package/app/components/board/RecurringPipelineModal.vue +219 -0
  15. package/app/components/board/TaskDependencyEdges.vue +132 -0
  16. package/app/components/board/nodes/AgentChip.vue +59 -0
  17. package/app/components/board/nodes/BlockNode.vue +433 -0
  18. package/app/components/board/nodes/DecisionBadge.vue +27 -0
  19. package/app/components/board/nodes/DraggableTask.vue +48 -0
  20. package/app/components/board/nodes/ModuleFrame.vue +97 -0
  21. package/app/components/board/nodes/TaskCard.vue +359 -0
  22. package/app/components/board/nodes/TaskPipelineMini.vue +159 -0
  23. package/app/components/bootstrap/BootstrapModal.vue +665 -0
  24. package/app/components/clarity/ClarityReviewWindow.vue +611 -0
  25. package/app/components/consensus/ConsensusSessionWindow.vue +210 -0
  26. package/app/components/documents/DocumentImportModal.vue +161 -0
  27. package/app/components/documents/DocumentSourceConnectModal.vue +127 -0
  28. package/app/components/documents/SpawnPreviewModal.vue +161 -0
  29. package/app/components/documents/TaskContextDocs.vue +83 -0
  30. package/app/components/focus/BlockFocusView.vue +171 -0
  31. package/app/components/fragments/FragmentLibraryPanel.vue +340 -0
  32. package/app/components/gates/GateResultView.vue +282 -0
  33. package/app/components/github/AddServiceFromRepoModal.vue +354 -0
  34. package/app/components/github/GitHubConnect.vue +183 -0
  35. package/app/components/github/GitHubOnboarding.vue +45 -0
  36. package/app/components/github/GitHubPanel.vue +584 -0
  37. package/app/components/github/RepoTreeBrowser.vue +171 -0
  38. package/app/components/layout/AccountTeamSettings.vue +237 -0
  39. package/app/components/layout/BoardSwitcher.vue +280 -0
  40. package/app/components/layout/BoardToolbar.vue +156 -0
  41. package/app/components/layout/CommandBar.vue +336 -0
  42. package/app/components/layout/GitHubPatBanner.vue +73 -0
  43. package/app/components/layout/NotificationsInbox.vue +175 -0
  44. package/app/components/layout/SideBar.vue +314 -0
  45. package/app/components/layout/SpendWarningBanner.vue +107 -0
  46. package/app/components/observability/StepMetricsBar.vue +102 -0
  47. package/app/components/palettes/AgentPalette.vue +86 -0
  48. package/app/components/panels/AgentStepDetail.vue +737 -0
  49. package/app/components/panels/DecisionModal.vue +71 -0
  50. package/app/components/panels/InspectorPanel.vue +465 -0
  51. package/app/components/panels/ObservabilityPanel.vue +351 -0
  52. package/app/components/panels/StepMetadataCard.vue +253 -0
  53. package/app/components/panels/StepRestartControl.vue +90 -0
  54. package/app/components/panels/StepResultViewHost.vue +40 -0
  55. package/app/components/panels/StepTestReport.vue +84 -0
  56. package/app/components/panels/inspector/ContainerSummary.vue +74 -0
  57. package/app/components/panels/inspector/RecurringScheduleSettings.vue +178 -0
  58. package/app/components/panels/inspector/ServiceFragments.vue +82 -0
  59. package/app/components/panels/inspector/ServiceTestConfig.vue +198 -0
  60. package/app/components/panels/inspector/TaskAgentConfig.vue +81 -0
  61. package/app/components/panels/inspector/TaskDependencies.vue +70 -0
  62. package/app/components/panels/inspector/TaskEstimateBadge.vue +56 -0
  63. package/app/components/panels/inspector/TaskExecution.vue +364 -0
  64. package/app/components/panels/inspector/TaskRunSettings.vue +187 -0
  65. package/app/components/panels/inspector/TaskStructure.vue +96 -0
  66. package/app/components/pipeline/AgentKindIcon.vue +30 -0
  67. package/app/components/pipeline/IterationCapPrompt.vue +70 -0
  68. package/app/components/pipeline/PipelineBuilder.vue +817 -0
  69. package/app/components/pipeline/PipelineProgress.vue +484 -0
  70. package/app/components/providers/ApiKeysSection.vue +273 -0
  71. package/app/components/providers/PersonalCredentialModal.vue +128 -0
  72. package/app/components/providers/PersonalSubscriptionSection.vue +225 -0
  73. package/app/components/providers/VendorCredentialsModal.vue +197 -0
  74. package/app/components/recurring/RecurrenceEditor.vue +124 -0
  75. package/app/components/requirements/RequirementsReviewWindow.vue +620 -0
  76. package/app/components/settings/DatadogPanel.vue +213 -0
  77. package/app/components/settings/LocalModelEndpointsPanel.vue +286 -0
  78. package/app/components/settings/MergeThresholdsPanel.vue +378 -0
  79. package/app/components/settings/ModelDefaultsPanel.vue +250 -0
  80. package/app/components/settings/ServiceFragmentDefaultsPanel.vue +124 -0
  81. package/app/components/settings/WorkspaceSettingsPanel.vue +142 -0
  82. package/app/components/slack/SlackPanel.vue +299 -0
  83. package/app/components/tasks/TaskContextIssues.vue +88 -0
  84. package/app/components/tasks/TaskImportModal.vue +207 -0
  85. package/app/components/tasks/TaskSourceConnectModal.vue +133 -0
  86. package/app/components/testing/TestReportWindow.vue +404 -0
  87. package/app/composables/api/accounts.ts +81 -0
  88. package/app/composables/api/auth.ts +45 -0
  89. package/app/composables/api/board.ts +101 -0
  90. package/app/composables/api/bootstrap.ts +62 -0
  91. package/app/composables/api/context.ts +25 -0
  92. package/app/composables/api/documents.ts +74 -0
  93. package/app/composables/api/execution.ts +127 -0
  94. package/app/composables/api/fragments.ts +71 -0
  95. package/app/composables/api/github.ts +131 -0
  96. package/app/composables/api/models.ts +127 -0
  97. package/app/composables/api/notifications.ts +23 -0
  98. package/app/composables/api/presets.ts +29 -0
  99. package/app/composables/api/recurring.ts +68 -0
  100. package/app/composables/api/releaseHealth.ts +43 -0
  101. package/app/composables/api/reviews.ts +146 -0
  102. package/app/composables/api/slack.ts +54 -0
  103. package/app/composables/api/tasks.ts +72 -0
  104. package/app/composables/api/workspaces.ts +36 -0
  105. package/app/composables/useApi.ts +89 -0
  106. package/app/composables/useBlockDrag.ts +90 -0
  107. package/app/composables/useBlockQueries.ts +154 -0
  108. package/app/composables/useBoardFlow.ts +11 -0
  109. package/app/composables/useContextLinking.ts +65 -0
  110. package/app/composables/useDepLabels.ts +26 -0
  111. package/app/composables/useFrameResize.ts +54 -0
  112. package/app/composables/useResultView.ts +48 -0
  113. package/app/composables/useReviewStage.ts +40 -0
  114. package/app/composables/useSemanticZoom.ts +31 -0
  115. package/app/composables/useStepApproval.ts +233 -0
  116. package/app/composables/useStepProse.ts +78 -0
  117. package/app/composables/useStepTimer.ts +63 -0
  118. package/app/composables/useTaskExpansion.ts +92 -0
  119. package/app/composables/useWorkspaceStream.ts +155 -0
  120. package/app/docs/architecture.md +31 -0
  121. package/app/pages/index.vue +141 -0
  122. package/app/stores/accounts.ts +152 -0
  123. package/app/stores/agentConfig.ts +35 -0
  124. package/app/stores/agentRuns.ts +122 -0
  125. package/app/stores/agents.ts +40 -0
  126. package/app/stores/apiKeys.ts +108 -0
  127. package/app/stores/auth.ts +166 -0
  128. package/app/stores/board.spec.ts +205 -0
  129. package/app/stores/board.ts +286 -0
  130. package/app/stores/bootstrap.ts +97 -0
  131. package/app/stores/clarity.ts +196 -0
  132. package/app/stores/consensus.ts +60 -0
  133. package/app/stores/documents.ts +176 -0
  134. package/app/stores/execution.ts +273 -0
  135. package/app/stores/fragmentLibrary.ts +147 -0
  136. package/app/stores/fragments.ts +40 -0
  137. package/app/stores/github.ts +305 -0
  138. package/app/stores/localModels.ts +51 -0
  139. package/app/stores/mergePresets.ts +58 -0
  140. package/app/stores/modelDefaults.ts +76 -0
  141. package/app/stores/models.ts +134 -0
  142. package/app/stores/notifications.ts +70 -0
  143. package/app/stores/observability.ts +144 -0
  144. package/app/stores/personalSubscriptions.ts +215 -0
  145. package/app/stores/pipelines.ts +327 -0
  146. package/app/stores/recurringPipelines.ts +112 -0
  147. package/app/stores/releaseHealth.ts +75 -0
  148. package/app/stores/requirements.spec.ts +94 -0
  149. package/app/stores/requirements.ts +208 -0
  150. package/app/stores/serviceFragmentDefaults.ts +29 -0
  151. package/app/stores/services.ts +87 -0
  152. package/app/stores/slack.ts +142 -0
  153. package/app/stores/taskExpansion.ts +36 -0
  154. package/app/stores/tasks.spec.ts +71 -0
  155. package/app/stores/tasks.ts +176 -0
  156. package/app/stores/tracker.ts +27 -0
  157. package/app/stores/ui.ts +434 -0
  158. package/app/stores/vendorCredentials.ts +54 -0
  159. package/app/stores/workspace.ts +215 -0
  160. package/app/stores/workspaceSettings.ts +36 -0
  161. package/app/types/accounts.ts +77 -0
  162. package/app/types/bootstrap.ts +83 -0
  163. package/app/types/clarity.ts +59 -0
  164. package/app/types/consensus.ts +91 -0
  165. package/app/types/documents.ts +104 -0
  166. package/app/types/domain.ts +495 -0
  167. package/app/types/execution.ts +383 -0
  168. package/app/types/fragments.ts +72 -0
  169. package/app/types/github.ts +173 -0
  170. package/app/types/localModels.ts +73 -0
  171. package/app/types/merge.ts +71 -0
  172. package/app/types/models.ts +157 -0
  173. package/app/types/notifications.ts +74 -0
  174. package/app/types/recurring.ts +69 -0
  175. package/app/types/releaseHealth.ts +31 -0
  176. package/app/types/requirements.ts +61 -0
  177. package/app/types/services.ts +27 -0
  178. package/app/types/slack.ts +57 -0
  179. package/app/types/tasks.ts +82 -0
  180. package/app/types/tracker.ts +18 -0
  181. package/app/utils/agentOutput.spec.ts +128 -0
  182. package/app/utils/agentOutput.ts +173 -0
  183. package/app/utils/catalog.spec.ts +112 -0
  184. package/app/utils/catalog.ts +455 -0
  185. package/app/utils/dnd.ts +29 -0
  186. package/app/utils/observability.ts +52 -0
  187. package/app/utils/pipelineRender.ts +151 -0
  188. package/nuxt.config.ts +55 -0
  189. package/package.json +45 -0
@@ -0,0 +1,213 @@
1
+ <script setup lang="ts">
2
+ // Datadog post-release-health settings. Two sections:
3
+ // - Connection (per-workspace): site + API/app keys (write-only, never read back).
4
+ // - Monitor/SLO mappings (per service-frame block): which monitors/SLOs the
5
+ // `post-release-health` gate watches after that service's PRs ship.
6
+ import { computed, reactive, ref, watch } from 'vue'
7
+
8
+ const ui = useUiStore()
9
+ const store = useReleaseHealthStore()
10
+ const toast = useToast()
11
+
12
+ const open = computed({
13
+ get: () => ui.datadogOpen,
14
+ set: (v: boolean) => (v ? ui.openDatadog() : ui.closeDatadog()),
15
+ })
16
+
17
+ const conn = reactive({ site: 'datadoghq.com', apiKey: '', appKey: '' })
18
+ const busy = ref(false)
19
+
20
+ // New-mapping form.
21
+ const draft = reactive({ blockId: '', monitorIds: '', sloIds: '', envTag: '' })
22
+
23
+ function notifyError(title: string, e: unknown) {
24
+ toast.add({
25
+ title,
26
+ description: e instanceof Error ? e.message : String(e),
27
+ icon: 'i-lucide-triangle-alert',
28
+ color: 'error',
29
+ })
30
+ }
31
+
32
+ watch(
33
+ () => open.value,
34
+ async (isOpen) => {
35
+ if (!isOpen) return
36
+ try {
37
+ await store.load()
38
+ if (store.connection.site) conn.site = store.connection.site
39
+ } catch (e) {
40
+ notifyError('Could not load Datadog settings', e)
41
+ }
42
+ },
43
+ )
44
+
45
+ function parseIds(csv: string): string[] {
46
+ return csv
47
+ .split(',')
48
+ .map((s) => s.trim())
49
+ .filter((s) => s.length > 0)
50
+ }
51
+
52
+ async function saveConnection() {
53
+ busy.value = true
54
+ try {
55
+ await store.saveConnection({ site: conn.site, apiKey: conn.apiKey, appKey: conn.appKey })
56
+ conn.apiKey = ''
57
+ conn.appKey = ''
58
+ toast.add({ title: 'Datadog connected', icon: 'i-lucide-check', color: 'success' })
59
+ } catch (e) {
60
+ notifyError('Could not save the Datadog connection', e)
61
+ } finally {
62
+ busy.value = false
63
+ }
64
+ }
65
+
66
+ async function disconnect() {
67
+ busy.value = true
68
+ try {
69
+ await store.removeConnection()
70
+ } catch (e) {
71
+ notifyError('Could not disconnect Datadog', e)
72
+ } finally {
73
+ busy.value = false
74
+ }
75
+ }
76
+
77
+ async function addMapping() {
78
+ if (!draft.blockId.trim()) return
79
+ busy.value = true
80
+ try {
81
+ await store.saveConfig(draft.blockId.trim(), {
82
+ monitorIds: parseIds(draft.monitorIds),
83
+ sloIds: parseIds(draft.sloIds),
84
+ envTag: draft.envTag.trim() || null,
85
+ })
86
+ draft.blockId = ''
87
+ draft.monitorIds = ''
88
+ draft.sloIds = ''
89
+ draft.envTag = ''
90
+ } catch (e) {
91
+ notifyError('Could not save the mapping', e)
92
+ } finally {
93
+ busy.value = false
94
+ }
95
+ }
96
+
97
+ async function removeMapping(blockId: string) {
98
+ busy.value = true
99
+ try {
100
+ await store.removeConfig(blockId)
101
+ } catch (e) {
102
+ notifyError('Could not remove the mapping', e)
103
+ } finally {
104
+ busy.value = false
105
+ }
106
+ }
107
+ </script>
108
+
109
+ <template>
110
+ <UModal v-model:open="open" title="Datadog post-release health" :ui="{ content: 'max-w-2xl' }">
111
+ <template #body>
112
+ <div class="space-y-6">
113
+ <p class="text-sm text-slate-400">
114
+ After a release ships, the <code>post-release-health</code> gate watches the configured
115
+ Datadog monitors/SLOs. On a regression it spawns an on-call agent to investigate (a human
116
+ decides whether to revert).
117
+ </p>
118
+
119
+ <!-- Connection -->
120
+ <section class="space-y-3 rounded-lg border border-slate-700 p-3">
121
+ <div class="flex items-center justify-between">
122
+ <h3 class="text-sm font-semibold">Connection</h3>
123
+ <UBadge :color="store.connection.connected ? 'success' : 'neutral'" variant="soft">
124
+ {{
125
+ store.connection.connected
126
+ ? `Connected (${store.connection.site})`
127
+ : 'Not connected'
128
+ }}
129
+ </UBadge>
130
+ </div>
131
+ <UFormField label="Datadog site">
132
+ <UInput v-model="conn.site" placeholder="datadoghq.com" />
133
+ </UFormField>
134
+ <UFormField label="API key">
135
+ <UInput v-model="conn.apiKey" type="password" placeholder="DD-API-KEY" />
136
+ </UFormField>
137
+ <UFormField label="Application key">
138
+ <UInput v-model="conn.appKey" type="password" placeholder="DD-APPLICATION-KEY" />
139
+ </UFormField>
140
+ <div class="flex gap-2">
141
+ <UButton
142
+ :loading="busy"
143
+ :disabled="!conn.apiKey || !conn.appKey"
144
+ @click="saveConnection"
145
+ >
146
+ Save connection
147
+ </UButton>
148
+ <UButton
149
+ v-if="store.connection.connected"
150
+ color="error"
151
+ variant="soft"
152
+ :loading="busy"
153
+ @click="disconnect"
154
+ >
155
+ Disconnect
156
+ </UButton>
157
+ </div>
158
+ </section>
159
+
160
+ <!-- Monitor/SLO mappings -->
161
+ <section class="space-y-3 rounded-lg border border-slate-700 p-3">
162
+ <h3 class="text-sm font-semibold">Monitor / SLO mappings</h3>
163
+ <p class="text-xs text-slate-400">
164
+ Map a service frame's block id to the Datadog monitor and SLO ids that gate its
165
+ releases. Comma-separate multiple ids.
166
+ </p>
167
+ <div
168
+ v-for="c in store.configs"
169
+ :key="c.blockId"
170
+ class="flex items-start justify-between gap-2 rounded border border-slate-800 p-2 text-xs"
171
+ >
172
+ <div class="space-y-0.5">
173
+ <div class="font-mono text-slate-300">{{ c.blockId }}</div>
174
+ <div class="text-slate-400">
175
+ monitors: {{ c.monitorIds.join(', ') || '—' }} · slos:
176
+ {{ c.sloIds.join(', ') || '—' }}
177
+ <span v-if="c.envTag"> · env: {{ c.envTag }}</span>
178
+ </div>
179
+ </div>
180
+ <UButton
181
+ icon="i-lucide-trash-2"
182
+ color="error"
183
+ variant="ghost"
184
+ size="xs"
185
+ :loading="busy"
186
+ @click="removeMapping(c.blockId)"
187
+ />
188
+ </div>
189
+
190
+ <div class="space-y-2 rounded border border-dashed border-slate-700 p-2">
191
+ <UFormField label="Service frame block id">
192
+ <UInput v-model="draft.blockId" placeholder="blk_…" />
193
+ </UFormField>
194
+ <div class="grid grid-cols-2 gap-2">
195
+ <UFormField label="Monitor ids">
196
+ <UInput v-model="draft.monitorIds" placeholder="123, 456" />
197
+ </UFormField>
198
+ <UFormField label="SLO ids">
199
+ <UInput v-model="draft.sloIds" placeholder="abc, def" />
200
+ </UFormField>
201
+ </div>
202
+ <UFormField label="Env tag (optional)">
203
+ <UInput v-model="draft.envTag" placeholder="prod" />
204
+ </UFormField>
205
+ <UButton :loading="busy" :disabled="!draft.blockId" @click="addMapping">
206
+ Add mapping
207
+ </UButton>
208
+ </div>
209
+ </section>
210
+ </div>
211
+ </template>
212
+ </UModal>
213
+ </template>
@@ -0,0 +1,286 @@
1
+ <script setup lang="ts">
2
+ // Per-user settings: "My local runners" — the signed-in user's own-machine LLM endpoints
3
+ // (Ollama, LM Studio, llama.cpp, vLLM, or any OpenAI-compatible server). A runner lives on
4
+ // a person's box, so these are stored per-user (not pooled). Pick a runner type (prefills the
5
+ // default base URL), optionally a bearer key, then "Test connection" to discover the models it
6
+ // serves and tick which to enable. Save persists the endpoint; the enabled models then surface
7
+ // automatically in the per-workspace model picker. One endpoint per runner type.
8
+ import { computed, ref, watch } from 'vue'
9
+ import { LOCAL_RUNNER_DEFAULTS, LOCAL_RUNNER_LABELS, type LocalRunner } from '~/types/localModels'
10
+
11
+ const ui = useUiStore()
12
+ const store = useLocalModelsStore()
13
+ const toast = useToast()
14
+
15
+ const open = computed({
16
+ get: () => ui.localModelsOpen,
17
+ set: (v: boolean) => (v ? ui.openLocalModels() : ui.closeLocalModels()),
18
+ })
19
+
20
+ // Load the user's endpoints whenever the panel opens (loaded independently of the
21
+ // workspace snapshot, like personal subscriptions).
22
+ watch(open, (isOpen) => {
23
+ if (isOpen) void store.load()
24
+ })
25
+
26
+ const RUNNERS: { value: LocalRunner; label: string }[] = (
27
+ Object.keys(LOCAL_RUNNER_LABELS) as LocalRunner[]
28
+ ).map((value) => ({ value, label: LOCAL_RUNNER_LABELS[value] }))
29
+
30
+ // ---- add / edit draft ------------------------------------------------------
31
+ const provider = ref<LocalRunner>('ollama')
32
+ const label = ref('')
33
+ const baseUrl = ref(LOCAL_RUNNER_DEFAULTS.ollama ?? '')
34
+ const apiKey = ref('')
35
+ // The models discovered by the last "Test connection", plus the user's tick selection.
36
+ const discovered = ref<string[]>([])
37
+ const selected = ref<string[]>([])
38
+ const testError = ref<string | null>(null)
39
+ const tested = ref(false)
40
+ const testing = ref(false)
41
+ const busy = ref(false)
42
+
43
+ const existing = computed(() => store.endpoints.find((e) => e.provider === provider.value))
44
+
45
+ // Switching runner type prefills the default base URL and resets the discovered models —
46
+ // editing an already-connected runner loads its stored config instead.
47
+ watch(provider, (p) => {
48
+ const e = store.endpoints.find((x) => x.provider === p)
49
+ if (e) {
50
+ label.value = e.label
51
+ baseUrl.value = e.baseUrl
52
+ discovered.value = [...e.models]
53
+ selected.value = [...e.models]
54
+ } else {
55
+ label.value = ''
56
+ baseUrl.value = LOCAL_RUNNER_DEFAULTS[p] ?? ''
57
+ discovered.value = []
58
+ selected.value = []
59
+ }
60
+ apiKey.value = ''
61
+ testError.value = null
62
+ tested.value = false
63
+ })
64
+
65
+ function notifyError(title: string, e: unknown) {
66
+ toast.add({
67
+ title,
68
+ description: e instanceof Error ? e.message : String(e),
69
+ icon: 'i-lucide-triangle-alert',
70
+ color: 'error',
71
+ })
72
+ }
73
+
74
+ async function test() {
75
+ if (!baseUrl.value.trim()) return
76
+ testing.value = true
77
+ testError.value = null
78
+ try {
79
+ const result = await store.test({
80
+ provider: provider.value,
81
+ baseUrl: baseUrl.value.trim(),
82
+ apiKey: apiKey.value.trim() || undefined,
83
+ })
84
+ tested.value = true
85
+ discovered.value = result.models
86
+ if (result.reachable) {
87
+ // Keep any previously-enabled models that are still served, else default to all.
88
+ const keep = selected.value.filter((m) => result.models.includes(m))
89
+ selected.value = keep.length ? keep : [...result.models]
90
+ testError.value = null
91
+ } else {
92
+ testError.value = result.error ?? 'Could not reach the runner.'
93
+ }
94
+ } catch (e) {
95
+ testError.value = e instanceof Error ? e.message : String(e)
96
+ } finally {
97
+ testing.value = false
98
+ }
99
+ }
100
+
101
+ function toggleModel(model: string, on: boolean) {
102
+ if (on) {
103
+ if (!selected.value.includes(model)) selected.value = [...selected.value, model]
104
+ } else {
105
+ selected.value = selected.value.filter((m) => m !== model)
106
+ }
107
+ }
108
+
109
+ async function save() {
110
+ if (!baseUrl.value.trim() || !selected.value.length) return
111
+ busy.value = true
112
+ try {
113
+ await store.upsert({
114
+ provider: provider.value,
115
+ label: label.value.trim() || undefined,
116
+ baseUrl: baseUrl.value.trim(),
117
+ apiKey: apiKey.value.trim() || undefined,
118
+ models: selected.value,
119
+ })
120
+ apiKey.value = ''
121
+ toast.add({
122
+ title: `${LOCAL_RUNNER_LABELS[provider.value]} saved`,
123
+ icon: 'i-lucide-check',
124
+ color: 'success',
125
+ })
126
+ } catch (e) {
127
+ notifyError('Could not save runner', e)
128
+ } finally {
129
+ busy.value = false
130
+ }
131
+ }
132
+
133
+ async function remove(p: LocalRunner) {
134
+ busy.value = true
135
+ try {
136
+ await store.remove(p)
137
+ if (provider.value === p) {
138
+ baseUrl.value = LOCAL_RUNNER_DEFAULTS[p] ?? ''
139
+ label.value = ''
140
+ discovered.value = []
141
+ selected.value = []
142
+ tested.value = false
143
+ }
144
+ toast.add({ title: 'Runner removed', icon: 'i-lucide-check' })
145
+ } catch (e) {
146
+ notifyError('Could not remove runner', e)
147
+ } finally {
148
+ busy.value = false
149
+ }
150
+ }
151
+ </script>
152
+
153
+ <template>
154
+ <UModal v-model:open="open" title="My local runners" :ui="{ content: 'max-w-2xl' }">
155
+ <template #body>
156
+ <div class="space-y-4">
157
+ <p class="text-xs text-slate-400">
158
+ Point agents at an LLM running on <strong>your own machine</strong> — Ollama, LM Studio,
159
+ llama.cpp, vLLM, or any OpenAI-compatible server. A runner is stored
160
+ <span class="text-slate-300">just for you</span> (a runner lives on your box), and the
161
+ models you enable appear automatically in the model picker. The API key (most runners
162
+ ignore it) is write-only and never shown again.
163
+ </p>
164
+
165
+ <!-- connected endpoints -->
166
+ <div
167
+ v-for="e in store.endpoints"
168
+ :key="e.provider"
169
+ class="flex items-center justify-between rounded-md border border-slate-700 bg-slate-900/50 px-3 py-2 text-sm"
170
+ >
171
+ <div>
172
+ <span class="font-medium text-slate-200">{{ e.label }}</span>
173
+ <span class="ml-2 text-xs text-slate-500">{{ LOCAL_RUNNER_LABELS[e.provider] }}</span>
174
+ <div class="text-[11px] text-slate-500">
175
+ {{ e.baseUrl }} · {{ e.models.length }} model{{ e.models.length === 1 ? '' : 's' }}
176
+ <template v-if="e.hasApiKey"> · key set</template>
177
+ </div>
178
+ </div>
179
+ <div class="flex items-center gap-1">
180
+ <UButton
181
+ icon="i-lucide-pencil"
182
+ color="neutral"
183
+ variant="ghost"
184
+ size="xs"
185
+ :disabled="busy"
186
+ title="Edit"
187
+ @click="provider = e.provider"
188
+ />
189
+ <UButton
190
+ icon="i-lucide-trash-2"
191
+ color="error"
192
+ variant="ghost"
193
+ size="xs"
194
+ :disabled="busy"
195
+ @click="remove(e.provider)"
196
+ />
197
+ </div>
198
+ </div>
199
+
200
+ <!-- add / edit form -->
201
+ <div class="rounded-lg border border-dashed border-slate-700 p-3 space-y-3">
202
+ <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
203
+ {{ existing ? 'Edit runner' : 'Add a runner' }}
204
+ </p>
205
+
206
+ <div class="flex flex-wrap items-end gap-3">
207
+ <UFormField label="Runner type">
208
+ <USelect v-model="provider" :items="RUNNERS" value-key="value" class="w-48" />
209
+ </UFormField>
210
+ <UFormField label="Label (optional)" class="flex-1 min-w-40">
211
+ <UInput v-model="label" :placeholder="`My ${LOCAL_RUNNER_LABELS[provider]}`" />
212
+ </UFormField>
213
+ </div>
214
+
215
+ <UFormField label="Base URL">
216
+ <UInput v-model="baseUrl" class="font-mono" placeholder="http://localhost:11434/v1" />
217
+ </UFormField>
218
+
219
+ <UFormField label="API key (optional)">
220
+ <UInput
221
+ v-model="apiKey"
222
+ type="password"
223
+ class="font-mono"
224
+ :placeholder="
225
+ existing?.hasApiKey ? 'leave blank to keep stored key' : 'most runners ignore this'
226
+ "
227
+ />
228
+ </UFormField>
229
+
230
+ <div class="flex items-center gap-2">
231
+ <UButton
232
+ color="neutral"
233
+ variant="soft"
234
+ size="sm"
235
+ icon="i-lucide-plug-zap"
236
+ :loading="testing"
237
+ :disabled="!baseUrl.trim()"
238
+ @click="test()"
239
+ >
240
+ Test connection
241
+ </UButton>
242
+ <span v-if="testError" class="text-xs text-rose-400">{{ testError }}</span>
243
+ <span v-else-if="tested && discovered.length" class="text-xs text-emerald-400">
244
+ Reachable · {{ discovered.length }} model{{ discovered.length === 1 ? '' : 's' }}
245
+ </span>
246
+ <span v-else-if="tested" class="text-xs text-slate-500">No models reported.</span>
247
+ </div>
248
+
249
+ <!-- discovered models multi-select -->
250
+ <div v-if="discovered.length" class="space-y-1.5">
251
+ <span class="block text-[10px] uppercase tracking-wide text-slate-500">
252
+ Enable models
253
+ </span>
254
+ <div class="grid grid-cols-1 gap-1.5 sm:grid-cols-2">
255
+ <label
256
+ v-for="m in discovered"
257
+ :key="m"
258
+ class="flex items-center gap-2 text-sm text-slate-300"
259
+ >
260
+ <UCheckbox
261
+ :model-value="selected.includes(m)"
262
+ @update:model-value="(v: boolean | 'indeterminate') => toggleModel(m, v === true)"
263
+ />
264
+ <span class="truncate font-mono text-xs">{{ m }}</span>
265
+ </label>
266
+ </div>
267
+ </div>
268
+
269
+ <div class="flex justify-end">
270
+ <UButton
271
+ color="primary"
272
+ variant="soft"
273
+ size="sm"
274
+ icon="i-lucide-save"
275
+ :loading="busy"
276
+ :disabled="!baseUrl.trim() || !selected.length"
277
+ @click="save()"
278
+ >
279
+ {{ existing ? 'Save' : 'Add runner' }}
280
+ </UButton>
281
+ </div>
282
+ </div>
283
+ </div>
284
+ </template>
285
+ </UModal>
286
+ </template>