@ouraihub/aether-ui 0.0.0-stage → 0.1.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 (179) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +23 -0
  28. package/src/chat/ChatPanel.svelte +2097 -0
  29. package/src/chat/MessageItem.svelte +583 -0
  30. package/src/chat/SkillPicker.svelte +126 -0
  31. package/src/chat/ToolCallItem.svelte +23 -0
  32. package/src/chat/UsageDetails.svelte +138 -0
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/run-session.svelte.ts +556 -0
  35. package/src/components/ui/alert/alert-action.svelte +20 -0
  36. package/src/components/ui/alert/alert-description.svelte +23 -0
  37. package/src/components/ui/alert/alert-title.svelte +23 -0
  38. package/src/components/ui/alert/alert.svelte +43 -0
  39. package/src/components/ui/alert/index.ts +17 -0
  40. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  41. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  42. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  43. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  44. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  45. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  46. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  47. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  48. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  49. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  50. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  51. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  52. package/src/components/ui/alert-dialog/index.ts +40 -0
  53. package/src/components/ui/button/button.svelte +81 -0
  54. package/src/components/ui/button/index.ts +17 -0
  55. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  56. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  57. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  58. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  59. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  62. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  63. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  64. package/src/components/ui/dialog/dialog.svelte +7 -0
  65. package/src/components/ui/dialog/index.ts +34 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  69. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  70. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  71. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  72. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  73. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  74. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  75. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  76. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  77. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  78. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  79. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  80. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  81. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  82. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  83. package/src/components/ui/dropdown-menu/index.ts +54 -0
  84. package/src/components/ui/empty/empty-content.svelte +23 -0
  85. package/src/components/ui/empty/empty-description.svelte +23 -0
  86. package/src/components/ui/empty/empty-header.svelte +20 -0
  87. package/src/components/ui/empty/empty-media.svelte +41 -0
  88. package/src/components/ui/empty/empty-title.svelte +20 -0
  89. package/src/components/ui/empty/empty.svelte +23 -0
  90. package/src/components/ui/empty/index.ts +22 -0
  91. package/src/components/ui/field/field-content.svelte +20 -0
  92. package/src/components/ui/field/field-description.svelte +25 -0
  93. package/src/components/ui/field/field-error.svelte +58 -0
  94. package/src/components/ui/field/field-group.svelte +23 -0
  95. package/src/components/ui/field/field-label.svelte +25 -0
  96. package/src/components/ui/field/field-legend.svelte +24 -0
  97. package/src/components/ui/field/field-separator.svelte +35 -0
  98. package/src/components/ui/field/field-set.svelte +20 -0
  99. package/src/components/ui/field/field-title.svelte +20 -0
  100. package/src/components/ui/field/field.svelte +47 -0
  101. package/src/components/ui/field/index.ts +33 -0
  102. package/src/components/ui/input/index.ts +7 -0
  103. package/src/components/ui/input/input.svelte +48 -0
  104. package/src/components/ui/label/index.ts +7 -0
  105. package/src/components/ui/label/label.svelte +20 -0
  106. package/src/components/ui/select/index.ts +40 -0
  107. package/src/components/ui/select/select-content.svelte +45 -0
  108. package/src/components/ui/select/select-group-heading.svelte +21 -0
  109. package/src/components/ui/select/select-group.svelte +17 -0
  110. package/src/components/ui/select/select-item.svelte +43 -0
  111. package/src/components/ui/select/select-label.svelte +20 -0
  112. package/src/components/ui/select/select-portal.svelte +7 -0
  113. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  114. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  115. package/src/components/ui/select/select-separator.svelte +18 -0
  116. package/src/components/ui/select/select-trigger.svelte +29 -0
  117. package/src/components/ui/select/select-value.svelte +7 -0
  118. package/src/components/ui/select/select.svelte +11 -0
  119. package/src/components/ui/separator/index.ts +7 -0
  120. package/src/components/ui/separator/separator.svelte +21 -0
  121. package/src/components/ui/sonner/index.ts +1 -0
  122. package/src/components/ui/sonner/sonner.svelte +40 -0
  123. package/src/components/ui/switch/index.ts +7 -0
  124. package/src/components/ui/switch/switch.svelte +34 -0
  125. package/src/components/ui/textarea/index.ts +7 -0
  126. package/src/components/ui/textarea/textarea.svelte +22 -0
  127. package/src/context.ts +34 -0
  128. package/src/dev/Demo.svelte +45 -0
  129. package/src/dev/main.ts +7 -0
  130. package/src/i18n/en.ts +617 -0
  131. package/src/i18n/index.ts +23 -0
  132. package/src/i18n/zh-CN.ts +599 -0
  133. package/src/index.ts +10 -0
  134. package/src/logs/LogPanel.svelte +97 -0
  135. package/src/logs/LogSettings.svelte +56 -0
  136. package/src/logs/index.ts +5 -0
  137. package/src/logs/logger.ts +96 -0
  138. package/src/markdown.ts +8 -0
  139. package/src/mcp/McpClientSettings.svelte +491 -0
  140. package/src/mcp/McpServerSettings.svelte +327 -0
  141. package/src/mcp/config.ts +26 -0
  142. package/src/mcp/index.ts +3 -0
  143. package/src/mcp/input.ts +73 -0
  144. package/src/notify.svelte.ts +43 -0
  145. package/src/ports/contract.ts +165 -0
  146. package/src/ports/fake.ts +484 -0
  147. package/src/ports/index.ts +7 -0
  148. package/src/ports/search.ts +47 -0
  149. package/src/ports/skill-sources.ts +45 -0
  150. package/src/providers/EndpointKeysDialog.svelte +343 -0
  151. package/src/providers/EndpointPanel.svelte +491 -0
  152. package/src/providers/ModelPickerDialog.svelte +187 -0
  153. package/src/providers/ModelSelect.svelte +434 -0
  154. package/src/providers/ModelSettingsDialog.svelte +453 -0
  155. package/src/providers/ProvidersSettings.svelte +631 -0
  156. package/src/providers/RequestConfigDialog.svelte +250 -0
  157. package/src/providers/SecretInput.svelte +276 -0
  158. package/src/providers/draft.ts +329 -0
  159. package/src/providers/edit-session.ts +146 -0
  160. package/src/providers/index.ts +5 -0
  161. package/src/providers/reasoning.ts +41 -0
  162. package/src/providers/revision.svelte.ts +4 -0
  163. package/src/search/SearchSettings.svelte +703 -0
  164. package/src/search/index.ts +13 -0
  165. package/src/search/settings-flow.ts +62 -0
  166. package/src/session.ts +20 -0
  167. package/src/skills/SkillAdd.svelte +347 -0
  168. package/src/skills/SkillAuthoring.svelte +381 -0
  169. package/src/skills/SkillEditor.svelte +94 -0
  170. package/src/skills/SkillPreviewDialog.svelte +122 -0
  171. package/src/skills/SkillsSettings.svelte +140 -0
  172. package/src/skills/error.ts +32 -0
  173. package/src/skills/index.ts +4 -0
  174. package/src/styles/kit.css +137 -0
  175. package/src/styles/tokens.css +22 -0
  176. package/src/tauri/index.ts +282 -0
  177. package/src/utils.ts +22 -0
  178. package/src/web/index.ts +376 -0
  179. package/README.md +0 -3
@@ -0,0 +1,631 @@
1
+ <script lang="ts">
2
+ // Provider settings (docs/33): list on the left, detail on the right, every field saves itself.
3
+ import { Button } from '#lib/components/ui/button/index.js'
4
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
5
+ import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js'
6
+ import * as Empty from '#lib/components/ui/empty/index.js'
7
+ import * as Field from '#lib/components/ui/field/index.js'
8
+ import { Input } from '#lib/components/ui/input/index.js'
9
+ import * as Select from '#lib/components/ui/select/index.js'
10
+ import { Switch } from '#lib/components/ui/switch/index.js'
11
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
12
+ import EllipsisIcon from '@lucide/svelte/icons/ellipsis-vertical'
13
+ import SquarePenIcon from '@lucide/svelte/icons/square-pen'
14
+ import Trash2Icon from '@lucide/svelte/icons/trash-2'
15
+ import UserPenIcon from '@lucide/svelte/icons/user-pen'
16
+ import PlusIcon from '@lucide/svelte/icons/plus'
17
+ import SearchIcon from '@lucide/svelte/icons/search'
18
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
19
+ import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal'
20
+ import { getKitContext } from '../context'
21
+ import { isKitClientError, type ProbeResult, type Provider, type ProviderPreset } from '../ports'
22
+ import { cn } from '../utils'
23
+ import { newEndpoint, savable, toDraft, validateModel, type ProviderDraft } from './draft'
24
+ import { createProviderEditSession, type ProviderEditSession } from './edit-session'
25
+ import EndpointPanel, { type CheckOutcome, type CheckState } from './EndpointPanel.svelte'
26
+ import ModelPickerDialog from './ModelPickerDialog.svelte'
27
+ import { providersRevision, settingsTarget } from './revision.svelte'
28
+
29
+ let {
30
+ onchanged,
31
+ presets = [],
32
+ usedBy,
33
+ }: {
34
+ onchanged?: () => void
35
+ presets?: ProviderPreset[]
36
+ /** Localized role names bound to a model, shown when its type changes (contract §6). */
37
+ usedBy?: (endpointId: string, modelId: string) => string[]
38
+ } = $props()
39
+ const { ports, i18n, notify } = getKitContext()
40
+ const { t } = i18n
41
+ const port = ports.providers
42
+ const FILTERS = ['all', 'enabled', 'disabled'] as const
43
+
44
+ let providers = $state<Provider[]>([])
45
+ let draft = $state<ProviderDraft | null>(null)
46
+ /** Remounts the detail when another provider is loaded, resetting its local input state. */
47
+ let token = $state(0)
48
+ let active = $state(0)
49
+ let check = $state<{ slug: string; value: CheckState } | null>(null)
50
+ let probe = $state<{ slug: string; result: ProbeResult } | null>(null)
51
+ let pickerOpen = $state(false)
52
+ let failed = $state(false)
53
+ /** Last rejected model setting, named in the banner (docs/41 §4.3). */
54
+ let invalid = $state<{ model: string | null; message: string } | null>(null)
55
+ let enabledSaving = $state(false)
56
+ let query = $state('')
57
+ let filter = $state<(typeof FILTERS)[number]>('all')
58
+ let addOpen = $state(false)
59
+ let newName = $state('')
60
+ let infoOpen = $state(false)
61
+ let editName = $state('')
62
+ let notesOpen = $state(false)
63
+ let editNotes = $state('')
64
+ /** Provider whose row menu is open (hover ⋯ or right-click). */
65
+ let menuFor = $state<string | null>(null)
66
+ let editSession: ProviderEditSession | null = null
67
+ let probeGeneration = 0
68
+
69
+ const visible = $derived(
70
+ providers.filter(
71
+ (p) =>
72
+ p.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) &&
73
+ (filter === 'all' || (filter === 'enabled') === p.enabled),
74
+ ),
75
+ )
76
+ const endpoint = $derived(draft?.endpoints[active] ?? null)
77
+
78
+ function errorText(error: unknown): string {
79
+ return isKitClientError(error) ? t(`errors.${error.code}`) : String(error)
80
+ }
81
+ const MODEL_FIELDS: Record<string, true> = {
82
+ name: true,
83
+ groupName: true,
84
+ contextWindow: true,
85
+ maxOutput: true,
86
+ reasoning: true,
87
+ pricing: true,
88
+ }
89
+ /** A rejected model setting: the backend names field + reason; the model is `entity` or found by the local rules. */
90
+ function modelError(error: unknown, sent: ProviderDraft): { model: string | null; message: string } | null {
91
+ if (!isKitClientError(error) || error.code !== 'bad_request') return null
92
+ const { field, reason, entity } = error.details
93
+ if (!field || !reason || !Object.hasOwn(MODEL_FIELDS, field)) return null
94
+ const models = sent.endpoints.flatMap((e) => e.models.map((model) => ({ model, protocol: e.protocol })))
95
+ const found =
96
+ models.find(({ model }) => model.id === entity) ??
97
+ models.find(({ model, protocol }) =>
98
+ validateModel(model, protocol).some((item) => item.field === field && item.reason === reason),
99
+ )
100
+ const model = found ? (found.model.name ?? found.model.id) : null
101
+ return { model, message: t(`providers.error.${field}.${reason}`) }
102
+ }
103
+ function status(p: Provider): string | null {
104
+ if (!p.enabled) return null
105
+ return p.endpoints.length === 0 || p.endpoints.some((e) => !e.keys.some((k) => k.enabled && k.readable))
106
+ ? 'bg-warning'
107
+ : 'bg-success'
108
+ }
109
+ function notifyChanged() {
110
+ providersRevision.value++
111
+ onchanged?.()
112
+ }
113
+ /** Key operations return the whole list; only the matching endpoint's stored record changes in the draft. */
114
+ function keysChanged(next: Provider[]) {
115
+ providers = next
116
+ const stored = next.find((p) => p.id === draft?.id)
117
+ for (const e of draft?.endpoints ?? []) {
118
+ const record = stored?.endpoints.find((s) => s.id === e.saved?.id)
119
+ if (record) e.saved = record
120
+ }
121
+ notifyChanged()
122
+ }
123
+
124
+ function load(next: ProviderDraft | null) {
125
+ editSession?.invalidate()
126
+ editSession = null
127
+ probeGeneration++
128
+ pickerOpen = false
129
+ check = null
130
+ probe = null
131
+ failed = false
132
+ invalid = null
133
+ draft = next
134
+ active = 0
135
+ token++
136
+ if (next && port)
137
+ editSession = createProviderEditSession(port, {
138
+ readDraft: () => {
139
+ if (!draft) throw new Error('Provider edit session has no draft')
140
+ return draft
141
+ },
142
+ writeDraft: (next) => {
143
+ draft = next
144
+ },
145
+ readProviders: () => providers,
146
+ onSaved: (next) => {
147
+ providers = next
148
+ notifyChanged()
149
+ },
150
+ })
151
+ }
152
+ async function select(p: Provider) {
153
+ if (p.id === draft?.id) return
154
+ if (failed && !(await notify.confirm({ title: t('providers.discard_title'), body: t('providers.discard_body') })))
155
+ return
156
+ load(toDraft(p))
157
+ }
158
+ async function refresh() {
159
+ if (!port) return
160
+ try {
161
+ providers = await port.list()
162
+ // Backend no-ops a preset already present and lets a same-named provider adopt it (docs/39 §3).
163
+ for (const preset of presets) {
164
+ if (providers.some((p) => p.presetId === preset.id)) continue
165
+ providers = await port.save({
166
+ name: preset.name,
167
+ presetId: preset.id,
168
+ enabled: false,
169
+ endpoints: [{ slug: 'ep-1', baseUrl: preset.baseUrl, protocol: preset.protocol, models: [] }],
170
+ })
171
+ }
172
+ const target = settingsTarget.value
173
+ const owner = target && providers.find((p) => p.endpoints.some((e) => e.id === target.endpointId))
174
+ if (!owner) settingsTarget.value = null
175
+ load(owner ? toDraft(owner) : providers[0] ? toDraft(providers[0]) : null)
176
+ // EndpointPanel opens the target model's dialog when it mounts (and clears the target).
177
+ if (owner && target && draft)
178
+ active = Math.max(
179
+ 0,
180
+ draft.endpoints.findIndex((e) => e.saved?.id === target.endpointId),
181
+ )
182
+ } catch (error) {
183
+ notify.toast('error', errorText(error))
184
+ }
185
+ }
186
+ const keyUrl = $derived(
187
+ presets.find((preset) => preset.id === providers.find((p) => p.id === draft?.id)?.presetId)?.apiKeyUrl,
188
+ )
189
+ $effect(() => {
190
+ if (port) void refresh()
191
+ return () => {
192
+ editSession?.invalidate()
193
+ probeGeneration++
194
+ }
195
+ })
196
+
197
+ /** Presentation only; the ordinary TS session owns the queue and its save barrier. */
198
+ async function commit() {
199
+ const session = editSession
200
+ if (!session || !draft) return
201
+ const sent = $state.snapshot(savable(draft)) as ProviderDraft
202
+ try {
203
+ await session.commit()
204
+ if (editSession !== session) return
205
+ failed = false
206
+ invalid = null
207
+ } catch (error) {
208
+ if (editSession !== session || (isKitClientError(error) && error.code === 'cancelled')) return
209
+ failed = true
210
+ invalid = modelError(error, sent)
211
+ if (!invalid) notify.toast('error', errorText(error))
212
+ }
213
+ }
214
+
215
+ async function setEnabled(enabled: boolean) {
216
+ const id = draft?.id
217
+ if (!draft || !id || !port || enabledSaving) return
218
+ const previous = draft.enabled
219
+ draft.enabled = enabled
220
+ enabledSaving = true
221
+ try {
222
+ providers = await port.setEnabled(id, enabled)
223
+ notifyChanged()
224
+ } catch (error) {
225
+ if (draft?.id === id) draft.enabled = previous
226
+ notify.toast('error', errorText(error))
227
+ } finally {
228
+ enabledSaving = false
229
+ }
230
+ }
231
+
232
+ async function create() {
233
+ const name = newName.trim()
234
+ if (!name) return
235
+ addOpen = false
236
+ newName = ''
237
+ load({ id: null, name, enabled: true, notes: '', endpoints: [newEndpoint([])] })
238
+ await commit()
239
+ }
240
+ function rename() {
241
+ const name = editName.trim()
242
+ infoOpen = false
243
+ if (!draft || !name || name === draft.name) return
244
+ draft.name = name
245
+ void commit()
246
+ }
247
+ /** Row menu actions act on that row, so select it first (honours the unsaved-changes prompt). */
248
+ async function rowAction(p: Provider, action: 'edit' | 'notes' | 'delete') {
249
+ menuFor = null
250
+ await select(p)
251
+ if (draft?.id !== p.id) return
252
+ if (action === 'delete') return remove()
253
+ editName = draft.name
254
+ editNotes = draft.notes
255
+ if (action === 'edit') infoOpen = true
256
+ else notesOpen = true
257
+ }
258
+ function saveNotes() {
259
+ notesOpen = false
260
+ if (!draft || editNotes === draft.notes) return
261
+ draft.notes = editNotes
262
+ void commit()
263
+ }
264
+ async function remove() {
265
+ const current = draft
266
+ const session = editSession
267
+ if (!current || !port) return
268
+ infoOpen = false
269
+ if (current.id === null) return load(providers[0] ? toDraft(providers[0]) : null)
270
+ const body = t('providers.delete_confirm', { name: current.name })
271
+ if (!(await notify.confirm({ title: t('providers.delete'), body, danger: true }))) return
272
+ if (editSession !== session) return
273
+ session?.invalidate()
274
+ probeGeneration++
275
+ pickerOpen = false
276
+ try {
277
+ const next = await port.remove(current.id)
278
+ if (editSession !== session) return
279
+ providers = next
280
+ load(providers[0] ? toDraft(providers[0]) : null)
281
+ notifyChanged()
282
+ } catch (error) {
283
+ if (editSession !== session) return
284
+ load(current)
285
+ notify.toast('error', errorText(error))
286
+ }
287
+ }
288
+ function addEndpoint() {
289
+ if (!draft) return
290
+ draft.endpoints.push(newEndpoint(draft.endpoints))
291
+ active = draft.endpoints.length - 1
292
+ // Stored right away so its keys can be added before it has an address (docs/34 §2).
293
+ void commit()
294
+ }
295
+ async function removeEndpoint() {
296
+ if (!draft || !endpoint) return
297
+ const index = active
298
+ if (
299
+ endpoint.saved &&
300
+ !(await notify.confirm({
301
+ title: t('providers.remove_endpoint'),
302
+ body: t('providers.remove_endpoint_confirm'),
303
+ danger: true,
304
+ }))
305
+ )
306
+ return
307
+ const wasSaved = draft.endpoints[index]?.saved !== null
308
+ draft.endpoints.splice(index, 1)
309
+ active = 0
310
+ if (wasSaved) void commit()
311
+ }
312
+
313
+ function outcome(result: ProbeResult): CheckOutcome {
314
+ const e = result.error
315
+ if (!e) return { state: 'ok', count: result.models.length, suggested: result.suggestedProtocol }
316
+ const message = [
317
+ t(`providers.probe.${e.kind}`, { status: e.status ?? '' }),
318
+ e.hint ? t(e.hint) : '',
319
+ e.message ?? '',
320
+ ]
321
+ .filter(Boolean)
322
+ .join('\n')
323
+ return { state: 'error', message }
324
+ }
325
+ async function runProbe(index: number, openPicker: boolean) {
326
+ const target = draft?.endpoints[index]
327
+ const session = editSession
328
+ if (!target || !session) return
329
+ const generation = ++probeGeneration
330
+ const slug = target.slug
331
+ check = { slug, value: { state: 'running' } }
332
+ try {
333
+ const batch = await session.probe(slug)
334
+ if (session !== editSession || generation !== probeGeneration) return
335
+ failed = false
336
+ invalid = null
337
+ const first = batch.results[0]
338
+ check = {
339
+ slug,
340
+ value:
341
+ first?.keyId === null
342
+ ? outcome(first.result)
343
+ : {
344
+ state: 'keys',
345
+ results: batch.results.flatMap(({ keyId, result }) =>
346
+ keyId === null ? [] : [{ keyId, value: outcome(result) }],
347
+ ),
348
+ },
349
+ }
350
+ if (openPicker && batch.usable) {
351
+ probe = { slug, result: batch.usable }
352
+ pickerOpen = true
353
+ }
354
+ } catch (error) {
355
+ if (session !== editSession || generation !== probeGeneration) return
356
+ check =
357
+ isKitClientError(error) && error.code === 'cancelled'
358
+ ? null
359
+ : { slug, value: { state: 'error', message: errorText(error) } }
360
+ }
361
+ }
362
+ const probed = $derived(probe ? (draft?.endpoints.find((e) => e.slug === probe?.slug) ?? null) : null)
363
+ </script>
364
+
365
+ {#if port}
366
+ <div class="flex min-h-0 min-w-0 flex-col gap-4 sm:flex-row">
367
+ <nav class="flex w-full shrink-0 flex-col gap-1 sm:w-60" aria-label={t('providers.title')}>
368
+ <div class="mb-1 flex items-center gap-1.5">
369
+ <div class="relative min-w-0 flex-1">
370
+ <SearchIcon
371
+ class="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2"
372
+ />
373
+ <Input
374
+ class="pl-8"
375
+ placeholder={t('providers.search')}
376
+ aria-label={t('providers.search')}
377
+ bind:value={query}
378
+ autocomplete="off"
379
+ />
380
+ </div>
381
+ <Select.Root type="single" value={filter} onValueChange={(value) => (filter = value as typeof filter)}>
382
+ <Select.Trigger
383
+ class={cn('shrink-0 px-2 [&>svg:last-child]:hidden', filter !== 'all' && 'bg-accent')}
384
+ aria-label={t('providers.filter')}
385
+ title={t(`providers.filter_${filter}`)}
386
+ >
387
+ <SlidersHorizontalIcon />
388
+ </Select.Trigger>
389
+ <Select.Content>
390
+ <Select.Group>
391
+ {#each FILTERS as f (f)}<Select.Item value={f} label={t(`providers.filter_${f}`)} />{/each}
392
+ </Select.Group>
393
+ </Select.Content>
394
+ </Select.Root>
395
+ </div>
396
+ <ul class="flex min-w-0 flex-col gap-0.5">
397
+ {#each visible as p (p.id)}
398
+ {@const dot = status(p)}
399
+ <li
400
+ class="group/row relative"
401
+ oncontextmenu={(event) => {
402
+ event.preventDefault()
403
+ menuFor = p.id
404
+ }}
405
+ >
406
+ <button
407
+ type="button"
408
+ class={cn(
409
+ 'hover:bg-muted flex h-9 w-full min-w-0 items-center gap-2.5 rounded-kit px-2 pr-8 text-left text-kit-base',
410
+ draft?.id === p.id && 'bg-muted font-medium',
411
+ )}
412
+ aria-current={draft?.id === p.id ? 'true' : undefined}
413
+ onclick={() => void select(p)}
414
+ >
415
+ <span
416
+ aria-hidden="true"
417
+ class="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-md text-kit-xs font-semibold"
418
+ >{p.name.slice(0, 1).toUpperCase()}</span
419
+ >
420
+ <span class={cn('min-w-0 flex-1 truncate', !p.enabled && 'text-muted-foreground')}>{p.name}</span>
421
+ {#if dot}<span class={cn('size-1.5 shrink-0 rounded-full', dot)}></span><span class="sr-only"
422
+ >{t(dot === 'bg-success' ? 'providers.filter_enabled' : 'providers.needs_setup')}</span
423
+ >{/if}
424
+ </button>
425
+ <DropdownMenu.Root open={menuFor === p.id} onOpenChange={(open) => (menuFor = open ? p.id : null)}>
426
+ <DropdownMenu.Trigger>
427
+ {#snippet child({ props })}<button
428
+ {...props}
429
+ type="button"
430
+ class={cn(
431
+ 'text-muted-foreground hover:text-foreground hover:bg-accent absolute top-1/2 right-1 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100',
432
+ menuFor === p.id && 'opacity-100',
433
+ )}
434
+ aria-label={t('providers.row_menu', { name: p.name })}
435
+ title={t('providers.row_menu', { name: p.name })}><EllipsisIcon class="size-4" /></button
436
+ >{/snippet}
437
+ </DropdownMenu.Trigger>
438
+ <DropdownMenu.Content align="start" class="min-w-36">
439
+ <DropdownMenu.Item onSelect={() => void rowAction(p, 'edit')}
440
+ ><SquarePenIcon />{t('providers.edit')}</DropdownMenu.Item
441
+ >
442
+ <DropdownMenu.Item onSelect={() => void rowAction(p, 'notes')}
443
+ ><UserPenIcon />{t('providers.notes')}</DropdownMenu.Item
444
+ >
445
+ {#if !p.presetId}<DropdownMenu.Item variant="destructive" onSelect={() => void rowAction(p, 'delete')}
446
+ ><Trash2Icon />{t('providers.remove')}</DropdownMenu.Item
447
+ >{/if}
448
+ </DropdownMenu.Content>
449
+ </DropdownMenu.Root>
450
+ </li>
451
+ {:else}
452
+ <li class="text-muted-foreground px-2 py-4 text-center text-kit-sm">{t('providers.no_matches')}</li>
453
+ {/each}
454
+ </ul>
455
+ <Button variant="outline" class="mt-2" onclick={() => (addOpen = true)}><PlusIcon />{t('providers.add')}</Button>
456
+ </nav>
457
+
458
+ <section class="flex min-w-0 flex-1 flex-col gap-5">
459
+ {#if draft}
460
+ <header class="flex min-h-8 min-w-0 items-center gap-1.5">
461
+ <h2 class="min-w-0 truncate text-[15px] font-semibold">{draft.name}</h2>
462
+ <Button
463
+ variant="ghost"
464
+ size="icon-xs"
465
+ class="text-muted-foreground"
466
+ title={t('providers.settings')}
467
+ aria-label={t('providers.settings')}
468
+ onclick={() => {
469
+ editName = draft?.name ?? ''
470
+ infoOpen = true
471
+ }}><Settings2Icon /></Button
472
+ >
473
+ <Switch
474
+ id="kit-provider-enabled"
475
+ class="ml-auto"
476
+ aria-label={t('providers.enabled')}
477
+ checked={draft.enabled}
478
+ disabled={enabledSaving || draft.id === null}
479
+ onCheckedChange={(enabled) => void setEnabled(enabled)}
480
+ />
481
+ </header>
482
+ {#if failed}
483
+ <p role="alert" class="text-destructive flex items-center gap-2 text-kit-sm">
484
+ {invalid
485
+ ? invalid.model
486
+ ? t('providers.save_model_invalid', { model: invalid.model, error: invalid.message })
487
+ : invalid.message
488
+ : t('providers.save_failed')}
489
+ <Button variant="link" size="sm" class="h-auto p-0" onclick={() => void commit()}
490
+ >{t('providers.retry')}</Button
491
+ >
492
+ </p>
493
+ {/if}
494
+ {#if draft.endpoints.length > 1}
495
+ <Field.Field orientation="horizontal" class="gap-2">
496
+ <Field.Label for="kit-provider-endpoint" class="shrink-0">{t('providers.endpoints')}</Field.Label>
497
+ <Select.Root type="single" value={String(active)} onValueChange={(value) => (active = Number(value))}>
498
+ <Select.Trigger id="kit-provider-endpoint" class="min-w-0 flex-1">
499
+ <span class="truncate">{endpoint?.baseUrl || t('providers.endpoint_new')}</span>
500
+ </Select.Trigger>
501
+ <Select.Content>
502
+ <Select.Group>
503
+ {#each draft.endpoints as e, index (e.slug)}
504
+ <Select.Item value={String(index)} label={e.baseUrl || t('providers.endpoint_new')} />
505
+ {/each}
506
+ </Select.Group>
507
+ </Select.Content>
508
+ </Select.Root>
509
+ </Field.Field>
510
+ {/if}
511
+ {#if endpoint}
512
+ {#key `${token}:${endpoint.slug}`}
513
+ <EndpointPanel
514
+ bind:endpoint={
515
+ () => endpoint,
516
+ (next) => {
517
+ if (draft) draft.endpoints[active] = next
518
+ }
519
+ }
520
+ check={check?.slug === endpoint.slug ? check.value : null}
521
+ onaddendpoint={draft.endpoints.length < 20 ? addEndpoint : null}
522
+ onremove={() => void removeEndpoint()}
523
+ oncommit={() => void commit()}
524
+ onkeys={keysChanged}
525
+ oncheck={() => void runProbe(active, false)}
526
+ onsync={() => void runProbe(active, true)}
527
+ {keyUrl}
528
+ {usedBy}
529
+ />
530
+ {/key}
531
+ {:else}
532
+ <Button variant="outline" class="self-start" onclick={addEndpoint}>
533
+ <PlusIcon />{t('providers.add_endpoint')}
534
+ </Button>
535
+ {/if}
536
+ {:else}
537
+ <Empty.Root>
538
+ <Empty.Header>
539
+ <Empty.Title>{t('providers.empty')}</Empty.Title>
540
+ <Empty.Description>{t('providers.empty_hint')}</Empty.Description>
541
+ </Empty.Header>
542
+ </Empty.Root>
543
+ {/if}
544
+ </section>
545
+ </div>
546
+
547
+ <ModelPickerDialog
548
+ bind:open={pickerOpen}
549
+ result={probe?.result ?? null}
550
+ selected={probed?.models ?? []}
551
+ onchange={(models) => {
552
+ if (!probed) return
553
+ probed.models = models
554
+ void commit()
555
+ }}
556
+ />
557
+
558
+ <Dialog.Root bind:open={addOpen}>
559
+ <Dialog.Content class="sm:max-w-md">
560
+ <Dialog.Header><Dialog.Title>{t('providers.add')}</Dialog.Title></Dialog.Header>
561
+ <form
562
+ class="contents"
563
+ onsubmit={(event) => {
564
+ event.preventDefault()
565
+ void create()
566
+ }}
567
+ >
568
+ <Field.Field>
569
+ <Field.Label for="kit-provider-new">{t('providers.name')}</Field.Label>
570
+ <Input id="kit-provider-new" maxlength={80} bind:value={newName} />
571
+ </Field.Field>
572
+ <Dialog.Footer>
573
+ <Button type="button" variant="outline" onclick={() => (addOpen = false)}>{t('common.cancel')}</Button>
574
+ <Button type="submit" disabled={!newName.trim()}>{t('common.confirm')}</Button>
575
+ </Dialog.Footer>
576
+ </form>
577
+ </Dialog.Content>
578
+ </Dialog.Root>
579
+
580
+ <Dialog.Root bind:open={infoOpen}>
581
+ <Dialog.Content class="sm:max-w-md">
582
+ <Dialog.Header><Dialog.Title>{t('providers.settings')}</Dialog.Title></Dialog.Header>
583
+ <form
584
+ class="contents"
585
+ onsubmit={(event) => {
586
+ event.preventDefault()
587
+ rename()
588
+ }}
589
+ >
590
+ <Field.Field>
591
+ <Field.Label for="kit-provider-name">{t('providers.name')}</Field.Label>
592
+ <Input id="kit-provider-name" maxlength={80} bind:value={editName} />
593
+ </Field.Field>
594
+ <Dialog.Footer class="sm:justify-between">
595
+ {#if !providers.find((p) => p.id === draft?.id)?.presetId}<Button
596
+ type="button"
597
+ variant="ghost"
598
+ class="text-destructive"
599
+ onclick={() => void remove()}>{t('providers.delete')}</Button
600
+ >{:else}<span></span>{/if}
601
+ <Button type="submit" disabled={!editName.trim()}>{t('providers.save')}</Button>
602
+ </Dialog.Footer>
603
+ </form>
604
+ </Dialog.Content>
605
+ </Dialog.Root>
606
+
607
+ <Dialog.Root bind:open={notesOpen}>
608
+ <Dialog.Content class="sm:max-w-lg">
609
+ <Dialog.Header><Dialog.Title>{t('providers.notes')}</Dialog.Title></Dialog.Header>
610
+ <form
611
+ class="contents"
612
+ onsubmit={(event) => {
613
+ event.preventDefault()
614
+ saveNotes()
615
+ }}
616
+ >
617
+ <Textarea
618
+ class="min-h-64 font-mono text-kit-sm"
619
+ aria-label={t('providers.notes')}
620
+ placeholder={t('providers.notes_placeholder')}
621
+ maxlength={10000}
622
+ bind:value={editNotes}
623
+ />
624
+ <Dialog.Footer>
625
+ <Button type="button" variant="outline" onclick={() => (notesOpen = false)}>{t('common.cancel')}</Button>
626
+ <Button type="submit">{t('providers.save')}</Button>
627
+ </Dialog.Footer>
628
+ </form>
629
+ </Dialog.Content>
630
+ </Dialog.Root>
631
+ {/if}