@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,491 @@
1
+ <script lang="ts">
2
+ // One endpoint's keys, address and models.
3
+ // Fields edit the draft in place; `oncommit` asks the parent to save (docs/33 §3); keys save on their own (docs/34).
4
+ import BrainIcon from '@lucide/svelte/icons/brain'
5
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
6
+ import ChevronsDownUpIcon from '@lucide/svelte/icons/chevrons-down-up'
7
+ import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'
8
+ import EyeIcon from '@lucide/svelte/icons/eye'
9
+ import Globe2Icon from '@lucide/svelte/icons/globe-2'
10
+ import LoaderIcon from '@lucide/svelte/icons/loader-circle'
11
+ import MinusIcon from '@lucide/svelte/icons/minus'
12
+ import PlusIcon from '@lucide/svelte/icons/plus'
13
+ import RefreshIcon from '@lucide/svelte/icons/refresh-cw'
14
+ import SearchIcon from '@lucide/svelte/icons/search'
15
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
16
+ import WrenchIcon from '@lucide/svelte/icons/wrench'
17
+ import { Button } from '#lib/components/ui/button/index.js'
18
+ import { Switch } from '#lib/components/ui/switch/index.js'
19
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
20
+ import * as Field from '#lib/components/ui/field/index.js'
21
+ import { Input } from '#lib/components/ui/input/index.js'
22
+ import CopyInput from '../CopyInput.svelte'
23
+ import * as Select from '#lib/components/ui/select/index.js'
24
+ import { getKitContext } from '../context'
25
+ import type { KeyStrategy, Model, Protocol, Provider } from '../ports'
26
+ import { cn } from '../utils'
27
+ import { groupKey, keyError, maskedText, probeUrl, validUrl, type EndpointDraft } from './draft'
28
+ import ModelSettingsDialog from './ModelSettingsDialog.svelte'
29
+ import RequestConfigDialog from './RequestConfigDialog.svelte'
30
+ import { settingsTarget } from './revision.svelte'
31
+ import SecretInput from './SecretInput.svelte'
32
+
33
+ export type CheckOutcome =
34
+ { state: 'ok'; count: number; suggested: Protocol | null } | { state: 'error'; message: string }
35
+ export type CheckState =
36
+ | { state: 'running' }
37
+ | CheckOutcome
38
+ /** One probe per enabled key when there are at least two (docs/34 §5.3). */
39
+ | { state: 'keys'; results: { keyId: string; value: CheckOutcome }[] }
40
+
41
+ let {
42
+ endpoint = $bindable(),
43
+ check,
44
+ onaddendpoint,
45
+ onremove,
46
+ oncommit,
47
+ onkeys,
48
+ oncheck,
49
+ onsync,
50
+ keyUrl,
51
+ usedBy,
52
+ }: {
53
+ endpoint: EndpointDraft
54
+ check: CheckState | null
55
+ onaddendpoint: (() => void) | null
56
+ onremove: () => void
57
+ oncommit: () => void
58
+ /** Providers returned by a key operation. */
59
+ onkeys: (providers: Provider[]) => void
60
+ oncheck: () => void
61
+ onsync: () => void
62
+ /** Preset "get a key" link shown beside the label (docs/39). */
63
+ keyUrl?: string | undefined
64
+ /** Localized roles bound to a stored model (contract §6). */
65
+ usedBy?: ((endpointId: string, modelId: string) => string[]) | undefined
66
+ } = $props()
67
+
68
+ const CAPS = ['vision', 'reasoning', 'tools', 'web'] as const
69
+ type Cap = (typeof CAPS)[number]
70
+ const CAP_STYLE: Record<Cap, string> = {
71
+ vision: 'bg-success/15 text-success',
72
+ reasoning: 'bg-info/15 text-info',
73
+ tools: 'bg-warning/15 text-warning',
74
+ web: 'bg-primary/10 text-primary',
75
+ }
76
+ const { ports, i18n, notify } = getKitContext()
77
+ const { t } = i18n
78
+ let keySaving = $state(false)
79
+ async function setKeyEnabled(keyId: string, enabled: boolean) {
80
+ const endpointId = endpoint.saved?.id
81
+ if (!ports.providers || !endpointId || keySaving) return
82
+ keySaving = true
83
+ try {
84
+ onkeys(await ports.providers.saveKey({ endpointId, id: keyId, enabled }))
85
+ } catch (error) {
86
+ notify.toast('error', keyError(error, t))
87
+ } finally {
88
+ keySaving = false
89
+ }
90
+ }
91
+
92
+ let url = $state(endpoint.baseUrl)
93
+ let urlError = $state(false)
94
+ // A save replaces the draft object; only a changed committed address may overwrite what the user is typing.
95
+ let seenUrl = endpoint.baseUrl
96
+ $effect.pre(() => {
97
+ const next = endpoint.baseUrl
98
+ if (next === seenUrl) return
99
+ seenUrl = next
100
+ url = next
101
+ urlError = false
102
+ })
103
+ let settingsOpen = $state(false)
104
+ // A chat "open settings" action names this endpoint's model: open its dialog once.
105
+ const targeted = settingsTarget.value?.endpointId === endpoint.saved?.id
106
+ const targetModel = targeted
107
+ ? (endpoint.models.find((model) => model.id === settingsTarget.value?.modelId) ?? null)
108
+ : null
109
+ if (targeted) settingsTarget.value = null
110
+ let modelOpen = $state(targetModel !== null)
111
+ let editing = $state<Model | null>(targetModel)
112
+ let addOpen = $state(false)
113
+ let newModelId = $state('')
114
+ let searching = $state(false)
115
+ let query = $state('')
116
+ let capFilter = $state<Cap | 'all'>('all')
117
+ let folded = $state<Record<string, boolean>>({})
118
+
119
+ const has = (model: Model, cap: Cap) =>
120
+ cap === 'vision'
121
+ ? model.canVision
122
+ : cap === 'reasoning'
123
+ ? model.reasoning !== null
124
+ : cap === 'tools'
125
+ ? model.supportsTools === true
126
+ : model.nativeWebSearch
127
+ const shown = $derived.by(() => {
128
+ const search = query.trim().toLowerCase()
129
+ return endpoint.models.filter(
130
+ (model) =>
131
+ (model.id.toLowerCase().includes(search) || !!model.name?.toLowerCase().includes(search)) &&
132
+ (capFilter === 'all' || has(model, capFilter)),
133
+ )
134
+ })
135
+ const groups = $derived(Object.entries(Object.groupBy(shown, groupKey)))
136
+ const allFolded = $derived(groups.length > 0 && groups.every(([group]) => folded[group]))
137
+
138
+ function commitUrl() {
139
+ const next = url.trim()
140
+ if (next === endpoint.baseUrl) return
141
+ if (next && !validUrl(next)) {
142
+ urlError = true
143
+ return
144
+ }
145
+ urlError = false
146
+ endpoint.baseUrl = next
147
+ oncommit()
148
+ }
149
+ function setModels(models: Model[]) {
150
+ endpoint.models = models
151
+ oncommit()
152
+ }
153
+ function addModel() {
154
+ const id = newModelId.trim()
155
+ if (id && !endpoint.models.some((model) => model.id === id)) {
156
+ setModels([
157
+ ...endpoint.models,
158
+ {
159
+ id,
160
+ name: null,
161
+ groupName: null,
162
+ modelType: 'chat',
163
+ typeSource: 'user',
164
+ contextWindow: null,
165
+ maxOutput: null,
166
+ canVision: false,
167
+ supportsTools: null,
168
+ reasoning: null,
169
+ nativeWebSearch: false,
170
+ pricing: null,
171
+ },
172
+ ])
173
+ }
174
+ newModelId = ''
175
+ addOpen = false
176
+ }
177
+ /** First probe suggestion: the whole check or any key's (all keys share one relay). */
178
+ const suggested = $derived.by((): Protocol | null => {
179
+ if (check?.state === 'ok') return check.suggested
180
+ if (check?.state !== 'keys') return null
181
+ for (const { value } of check.results) if (value.state === 'ok' && value.suggested) return value.suggested
182
+ return null
183
+ })
184
+ </script>
185
+
186
+ <div class="flex min-w-0 flex-col gap-5">
187
+ <Field.Field class="gap-2">
188
+ <div class="flex items-center gap-2">
189
+ <Field.Label for="kit-endpoint-key">{t('providers.api_key')}</Field.Label>
190
+ {#if keyUrl && /^https:\/\//.test(keyUrl)}<a
191
+ class="text-primary text-kit-xs hover:underline"
192
+ href={keyUrl}
193
+ target="_blank"
194
+ rel="noopener noreferrer">{t('providers.get_key')}</a
195
+ >{/if}
196
+ </div>
197
+ <div class="flex min-w-0 items-start gap-2">
198
+ <SecretInput id="kit-endpoint-key" endpoint={endpoint.saved} onchanged={onkeys} />
199
+ <Button variant="outline" class="shrink-0" disabled={!validUrl(endpoint.baseUrl)} onclick={oncheck}>
200
+ {#if check?.state === 'running'}<LoaderIcon class="animate-spin" />{/if}
201
+ {t('providers.check')}
202
+ </Button>
203
+ </div>
204
+ {#if check?.state === 'keys'}
205
+ <ul role="status" class="flex min-w-0 flex-col gap-1">
206
+ {#each check.results as { keyId, value } (keyId)}
207
+ {@const key = endpoint.saved?.keys.find((k) => k.id === keyId)}
208
+ {#if key}
209
+ <li class="flex min-w-0 items-start gap-2 text-kit-xs">
210
+ <span
211
+ class={cn(
212
+ 'min-w-0 flex-1 whitespace-pre-line',
213
+ value.state === 'ok' ? 'text-success' : 'text-destructive',
214
+ )}
215
+ >{value.state === 'ok' ? '✓' : '✕'}
216
+ {key.label || maskedText(key, t)} — {value.state === 'ok'
217
+ ? t('providers.check_ok', { count: value.count })
218
+ : value.message}</span
219
+ >
220
+ <Switch
221
+ size="sm"
222
+ aria-label={t('providers.keys_enable_for', { name: key.label || maskedText(key, t) })}
223
+ checked={key.enabled}
224
+ disabled={keySaving}
225
+ onCheckedChange={(enabled) => void setKeyEnabled(key.id, enabled)}
226
+ />
227
+ </li>
228
+ {/if}
229
+ {/each}
230
+ </ul>
231
+ {:else if check && check.state !== 'running'}
232
+ <p
233
+ role="status"
234
+ class={cn('text-kit-xs whitespace-pre-line', check.state === 'ok' ? 'text-success' : 'text-destructive')}
235
+ >
236
+ {check.state === 'ok' ? t('providers.check_ok', { count: check.count }) : check.message}
237
+ </p>
238
+ {/if}
239
+ {#if suggested && suggested !== endpoint.protocol}
240
+ {@const next = suggested}
241
+ <div role="status" class="bg-info/10 flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-kit-xs">
242
+ <span class="min-w-0 flex-1">{t('providers.suggest_protocol')}</span>
243
+ <Button
244
+ variant="outline"
245
+ size="sm"
246
+ class="shrink-0"
247
+ onclick={() => {
248
+ endpoint.protocol = next
249
+ oncommit()
250
+ }}>{t('providers.suggest_protocol_apply')}</Button
251
+ >
252
+ </div>
253
+ {/if}
254
+ </Field.Field>
255
+
256
+ {#if ports.providers?.keyStrategies}
257
+ <Field.Field class="gap-2">
258
+ <Field.Label for="kit-endpoint-key-strategy">{t('providers.key_strategy')}</Field.Label>
259
+ <Select.Root
260
+ type="single"
261
+ value={endpoint.keyStrategy}
262
+ onValueChange={(value) => {
263
+ if (value === endpoint.keyStrategy) return
264
+ endpoint.keyStrategy = value as KeyStrategy
265
+ oncommit()
266
+ }}
267
+ >
268
+ <Select.Trigger id="kit-endpoint-key-strategy" class="w-full sm:w-64"
269
+ >{t(`providers.key_strategy_${endpoint.keyStrategy}`)}</Select.Trigger
270
+ >
271
+ <Select.Content>
272
+ <Select.Group>
273
+ {#each ['round_robin', 'fill_first'] as const as strategy (strategy)}<Select.Item
274
+ value={strategy}
275
+ label={t(`providers.key_strategy_${strategy}`)}
276
+ />{/each}
277
+ </Select.Group>
278
+ </Select.Content>
279
+ </Select.Root>
280
+ <Field.Description class="text-kit-xs"
281
+ >{t(`providers.key_strategy_${endpoint.keyStrategy}_desc`)}</Field.Description
282
+ >
283
+ </Field.Field>
284
+ {/if}
285
+
286
+ <Field.Field class="gap-2">
287
+ <div class="flex items-center gap-2">
288
+ <Field.Label for="kit-endpoint-url">{t('providers.base_url')}</Field.Label>
289
+ {#if onaddendpoint}
290
+ <button type="button" class="text-primary text-kit-xs hover:underline" onclick={onaddendpoint}>
291
+ {t('providers.add_endpoint')}
292
+ </button>
293
+ {/if}
294
+ </div>
295
+ <div class="flex min-w-0 items-center gap-2">
296
+ <CopyInput
297
+ id="kit-endpoint-url"
298
+ class="min-w-0 flex-1 font-mono"
299
+ placeholder="https://api.example.com/v1"
300
+ aria-invalid={urlError}
301
+ bind:value={url}
302
+ onblur={commitUrl}
303
+ onkeydown={(event) => event.key === 'Enter' && event.currentTarget.blur()}
304
+ />
305
+ <Button
306
+ variant="outline"
307
+ size="icon"
308
+ class="shrink-0"
309
+ title={t('providers.request_config_tip')}
310
+ aria-label={t('providers.request_config_tip')}
311
+ onclick={() => (settingsOpen = true)}><Settings2Icon /></Button
312
+ >
313
+ </div>
314
+ {#if urlError}
315
+ <p role="alert" class="text-destructive text-kit-xs">{t('providers.url_invalid')}</p>
316
+ {:else if validUrl(endpoint.baseUrl)}
317
+ <p class="text-muted-foreground truncate font-mono text-kit-xs">GET {probeUrl(endpoint.baseUrl)}</p>
318
+ {/if}
319
+ </Field.Field>
320
+
321
+ <section class="flex min-w-0 flex-col gap-2" aria-labelledby="kit-endpoint-models">
322
+ <div class="flex min-w-0 flex-wrap items-center gap-1">
323
+ <h3 id="kit-endpoint-models" class="mr-1 text-kit-base font-semibold">{t('providers.models')}</h3>
324
+ <Button
325
+ variant="ghost"
326
+ size="icon-xs"
327
+ title={t(allFolded ? 'providers.expand_all' : 'providers.collapse_all')}
328
+ aria-label={t(allFolded ? 'providers.expand_all' : 'providers.collapse_all')}
329
+ disabled={groups.length === 0}
330
+ onclick={() => (folded = allFolded ? {} : Object.fromEntries(groups.map(([group]) => [group, true])))}
331
+ >{#if allFolded}<ChevronsUpDownIcon />{:else}<ChevronsDownUpIcon />{/if}</Button
332
+ >
333
+ {#if searching || query}
334
+ <Input
335
+ class="h-7 w-36 text-kit-sm"
336
+ autofocus
337
+ placeholder={t('providers.model_search')}
338
+ aria-label={t('providers.model_search')}
339
+ bind:value={query}
340
+ onblur={() => (searching = false)}
341
+ onkeydown={(event) => event.key === 'Escape' && ((query = ''), event.currentTarget.blur())}
342
+ />
343
+ {:else}
344
+ <Button
345
+ variant="ghost"
346
+ size="icon-xs"
347
+ title={t('providers.model_search')}
348
+ aria-label={t('providers.model_search')}
349
+ onclick={() => (searching = true)}><SearchIcon /></Button
350
+ >
351
+ {/if}
352
+ <Select.Root type="single" value={capFilter} onValueChange={(value) => (capFilter = value as Cap | 'all')}>
353
+ <Select.Trigger
354
+ size="sm"
355
+ class={cn('border-0 shadow-none', capFilter !== 'all' && 'bg-accent')}
356
+ aria-label={t('providers.cap_filter')}
357
+ >
358
+ {capFilter === 'all' ? t('providers.all_types') : t(`providers.cap_${capFilter}`)}
359
+ </Select.Trigger>
360
+ <Select.Content>
361
+ <Select.Group>
362
+ <Select.Item value="all" label={t('providers.all_types')} />
363
+ {#each CAPS as cap (cap)}<Select.Item value={cap} label={t(`providers.cap_${cap}`)} />{/each}
364
+ </Select.Group>
365
+ </Select.Content>
366
+ </Select.Root>
367
+ <div class="ml-auto flex items-center gap-1">
368
+ <Button variant="outline" size="sm" disabled={!validUrl(endpoint.baseUrl)} onclick={onsync}>
369
+ <RefreshIcon />{t('providers.sync_models')}
370
+ </Button>
371
+ <Button
372
+ variant="outline"
373
+ size="icon-sm"
374
+ title={t('providers.add_model_manual')}
375
+ aria-label={t('providers.add_model_manual')}
376
+ onclick={() => (addOpen = true)}><PlusIcon /></Button
377
+ >
378
+ </div>
379
+ </div>
380
+
381
+ {#each groups as [group, models = []] (group)}
382
+ <section class="group/model-group overflow-hidden rounded-kit border">
383
+ <div class="bg-muted/30 flex items-center gap-1 pr-2">
384
+ <button
385
+ type="button"
386
+ class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-kit-sm"
387
+ aria-expanded={!folded[group]}
388
+ onclick={() => (folded[group] = !folded[group])}
389
+ >
390
+ <ChevronRightIcon class={cn('size-4 shrink-0 transition-transform', !folded[group] && 'rotate-90')} />
391
+ <span class="truncate">{group || t('providers.no_group')}</span>
392
+ <span class="text-muted-foreground text-kit-xs tabular-nums">{models.length}</span>
393
+ </button>
394
+ <Button
395
+ variant="ghost"
396
+ size="icon-xs"
397
+ class="opacity-0 group-hover/model-group:opacity-100 focus-visible:opacity-100"
398
+ title={t('providers.remove_group')}
399
+ aria-label={t('providers.remove_group')}
400
+ onclick={() => setModels(endpoint.models.filter((model) => !models.includes(model)))}><MinusIcon /></Button
401
+ >
402
+ </div>
403
+ {#if !folded[group]}
404
+ <ul class="divide-y border-t">
405
+ {#each models as model (model.id)}
406
+ <li class="hover:bg-accent/40 flex min-w-0 items-center gap-2 px-3 py-1.5">
407
+ <span
408
+ aria-hidden="true"
409
+ class="bg-muted text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full text-kit-xs uppercase"
410
+ >{(model.name ?? model.id).slice(0, 1)}</span
411
+ >
412
+ <span class="min-w-0 flex-1 truncate text-kit-sm" title={model.id}>{model.name ?? model.id}</span>
413
+ <span class="flex shrink-0 items-center gap-0.5">
414
+ {#each CAPS.filter((cap) => has(model, cap)) as cap (cap)}
415
+ <span
416
+ class={cn('flex h-5 w-6 items-center justify-center rounded-full', CAP_STYLE[cap])}
417
+ title={t(`providers.cap_${cap}`)}
418
+ >
419
+ {#if cap === 'vision'}<EyeIcon class="size-3" />{:else if cap === 'reasoning'}<BrainIcon
420
+ class="size-3"
421
+ />{:else if cap === 'tools'}<WrenchIcon class="size-3" />{:else}<Globe2Icon
422
+ class="size-3"
423
+ />{/if}
424
+ <span class="sr-only">{t(`providers.cap_${cap}`)}</span>
425
+ </span>
426
+ {/each}
427
+ </span>
428
+ <Button
429
+ variant="ghost"
430
+ size="icon-xs"
431
+ title={t('providers.model_settings')}
432
+ aria-label={t('providers.model_settings_for', { id: model.id })}
433
+ onclick={() => {
434
+ editing = model
435
+ modelOpen = true
436
+ }}><Settings2Icon /></Button
437
+ >
438
+ <Button
439
+ variant="ghost"
440
+ size="icon-xs"
441
+ title={t('providers.remove_model', { id: model.id })}
442
+ aria-label={t('providers.remove_model', { id: model.id })}
443
+ onclick={() => setModels(endpoint.models.filter((item) => item.id !== model.id))}
444
+ ><MinusIcon /></Button
445
+ >
446
+ </li>
447
+ {/each}
448
+ </ul>
449
+ {/if}
450
+ </section>
451
+ {:else}
452
+ <p class="text-muted-foreground rounded-kit border border-dashed px-3 py-6 text-center text-kit-sm">
453
+ {endpoint.models.length === 0 ? t('providers.no_models') : t('providers.no_model_matches')}
454
+ </p>
455
+ {/each}
456
+ </section>
457
+ </div>
458
+
459
+ <ModelSettingsDialog
460
+ bind:open={modelOpen}
461
+ model={editing}
462
+ protocol={endpoint.protocol}
463
+ usedBy={editing && endpoint.saved && usedBy ? usedBy(endpoint.saved.id, editing.id) : []}
464
+ onsave={(saved) => setModels(endpoint.models.map((model) => (model.id === saved.id ? saved : model)))}
465
+ />
466
+
467
+ <RequestConfigDialog bind:open={settingsOpen} bind:endpoint {onaddendpoint} {onremove} {oncommit} />
468
+
469
+ <Dialog.Root bind:open={addOpen}>
470
+ <Dialog.Content class="sm:max-w-md">
471
+ <Dialog.Header>
472
+ <Dialog.Title>{t('providers.add_model_manual')}</Dialog.Title>
473
+ </Dialog.Header>
474
+ <form
475
+ class="contents"
476
+ onsubmit={(event) => {
477
+ event.preventDefault()
478
+ addModel()
479
+ }}
480
+ >
481
+ <Field.Field>
482
+ <Field.Label for="kit-model-new">{t('providers.model_id')}</Field.Label>
483
+ <Input id="kit-model-new" class="font-mono" autocomplete="off" spellcheck={false} bind:value={newModelId} />
484
+ </Field.Field>
485
+ <Dialog.Footer>
486
+ <Button type="button" variant="outline" onclick={() => (addOpen = false)}>{t('common.cancel')}</Button>
487
+ <Button type="submit" disabled={!newModelId.trim()}>{t('common.confirm')}</Button>
488
+ </Dialog.Footer>
489
+ </form>
490
+ </Dialog.Content>
491
+ </Dialog.Root>
@@ -0,0 +1,187 @@
1
+ <script lang="ts">
2
+ // Search and filter fetched models; every +/- applies at once.
3
+ import { untrack } from 'svelte'
4
+ import ChevronRight from '@lucide/svelte/icons/chevron-right'
5
+ import Plus from '@lucide/svelte/icons/plus'
6
+ import Minus from '@lucide/svelte/icons/minus'
7
+ import { Button } from '#lib/components/ui/button/index.js'
8
+ import { Input } from '#lib/components/ui/input/index.js'
9
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
10
+ import * as Select from '#lib/components/ui/select/index.js'
11
+ import { getKitContext } from '../context'
12
+ import type { Model, ModelType, ProbeResult } from '../ports'
13
+ import { mergeModels } from './draft'
14
+
15
+ let {
16
+ open = $bindable(false),
17
+ result,
18
+ selected,
19
+ onchange,
20
+ }: {
21
+ open: boolean
22
+ result: ProbeResult | null
23
+ /** Currently selected models; preselected and their user-set types kept. */
24
+ selected: Model[]
25
+ /** Called after every change with the endpoint's full model list. */
26
+ onchange: (models: Model[]) => void
27
+ } = $props()
28
+
29
+ const TYPES: ModelType[] = ['chat', 'image', 'video', 'tts', 'stt', 'embedding', 'rerank', 'unknown']
30
+ const { t } = getKitContext().i18n
31
+
32
+ let checked = $state<Record<string, boolean>>({})
33
+ let types = $state<Record<string, ModelType>>({})
34
+ let query = $state('')
35
+ let filter = $state<ModelType | 'all'>('all')
36
+ let collapsed = $state<Record<string, boolean>>({})
37
+
38
+ $effect.pre(() => {
39
+ if (!open || !result) return
40
+ const current = Object.fromEntries(untrack(() => selected).map((m) => [m.id, m]))
41
+ checked = Object.fromEntries(result.models.map((m) => [m.id, m.id in current]))
42
+ types = Object.fromEntries(result.models.map((m) => [m.id, current[m.id]?.modelType ?? m.modelType]))
43
+ query = ''
44
+ filter = 'all'
45
+ collapsed = {}
46
+ })
47
+
48
+ const models = $derived(result?.models ?? [])
49
+ const searched = $derived(models.filter((m) => m.id.toLowerCase().includes(query.trim().toLowerCase())))
50
+ const shown = $derived(searched.filter((m) => filter === 'all' || (types[m.id] ?? m.modelType) === filter))
51
+ const groups = $derived(Object.entries(Object.groupBy(shown, (m) => m.group ?? '')))
52
+ const count = $derived(models.filter((m) => checked[m.id]).length)
53
+ const bulkRemoves = $derived(shown.length > 0 && shown.every((m) => checked[m.id]))
54
+
55
+ function bulk() {
56
+ const on = !bulkRemoves
57
+ for (const model of shown) checked[model.id] = on
58
+ emit()
59
+ }
60
+ function toggle(id: string) {
61
+ checked[id] = !checked[id]
62
+ emit()
63
+ }
64
+ function setType(id: string, type: ModelType) {
65
+ types[id] = type
66
+ if (checked[id]) emit()
67
+ }
68
+
69
+ /** Models outside this probe (added by hand, or no longer listed) are kept untouched. */
70
+ function emit() {
71
+ onchange(mergeModels(selected, models, checked, types))
72
+ }
73
+ </script>
74
+
75
+ <Dialog.Root bind:open>
76
+ <Dialog.Content class="flex max-h-[85dvh] min-w-0 flex-col gap-3 sm:max-w-2xl">
77
+ <Dialog.Header class="shrink-0 text-left">
78
+ <Dialog.Title class="flex items-center gap-2 pr-6">
79
+ {t('providers.picker_title')}
80
+ <span aria-hidden="true" class="bg-muted rounded px-1.5 text-kit-xs tabular-nums">{models.length}</span>
81
+ </Dialog.Title>
82
+ <Dialog.Description>{t('providers.picker_hint')}</Dialog.Description>
83
+ </Dialog.Header>
84
+ <div class="flex shrink-0 items-center gap-2">
85
+ <Input
86
+ class="min-w-0 flex-1"
87
+ bind:value={query}
88
+ placeholder={t('providers.search_models')}
89
+ aria-label={t('providers.search_models')}
90
+ autocomplete="off"
91
+ spellcheck={false}
92
+ />
93
+ <Button variant="outline" size="sm" class="shrink-0" disabled={shown.length === 0} onclick={bulk}>
94
+ {t(bulkRemoves ? 'providers.remove_all' : 'providers.add_all')}
95
+ </Button>
96
+ </div>
97
+ <div class="flex shrink-0 gap-1 overflow-x-auto pb-1" role="group" aria-label={t('providers.model_type')}>
98
+ {#each ['all', ...TYPES] as type (type)}
99
+ {@const hits =
100
+ type === 'all' ? searched.length : searched.filter((m) => (types[m.id] ?? m.modelType) === type).length}
101
+ <Button
102
+ variant={filter === type ? 'secondary' : 'ghost'}
103
+ size="sm"
104
+ class="shrink-0"
105
+ aria-pressed={filter === type}
106
+ disabled={hits === 0 && type !== 'all'}
107
+ onclick={() => (filter = type as ModelType | 'all')}
108
+ >
109
+ {type === 'all' ? t('providers.all_types') : t(`providers.type.${type as ModelType}`)}
110
+ <span class="text-muted-foreground tabular-nums">{hits}</span>
111
+ </Button>
112
+ {/each}
113
+ </div>
114
+ <div class="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto" role="region" aria-label={t('providers.models')}>
115
+ {#if shown.length === 0}
116
+ <p class="text-muted-foreground py-8 text-center text-kit-sm">{t('providers.no_model_matches')}</p>
117
+ {/if}
118
+ {#each groups as [group, items = []] (group)}
119
+ {@const folded = !query.trim() && collapsed[group]}
120
+ <section class="flex min-w-0 flex-col">
121
+ <Button
122
+ variant="ghost"
123
+ size="sm"
124
+ class="justify-start"
125
+ aria-expanded={!folded}
126
+ onclick={() => (collapsed[group] = !collapsed[group])}
127
+ >
128
+ <ChevronRight class={folded ? '' : 'rotate-90'} />
129
+ {group || t('providers.no_group')}
130
+ <span class="bg-muted rounded px-1.5 tabular-nums">{items.length}</span>
131
+ </Button>
132
+ {#if !folded}
133
+ <ul>
134
+ {#each items as model (model.id)}
135
+ <li class="hover:bg-accent/50 flex min-w-0 items-center gap-1 rounded-md py-0.5 pl-4">
136
+ <span class="min-w-0 flex-1 truncate font-mono text-kit-sm" title={model.id}>{model.id}</span>
137
+ <Select.Root
138
+ type="single"
139
+ value={types[model.id] ?? model.modelType}
140
+ onValueChange={(value) => setType(model.id, value as ModelType)}
141
+ >
142
+ <Select.Trigger
143
+ size="sm"
144
+ class="shrink-0 border-0 bg-transparent shadow-none"
145
+ aria-label={t('providers.type_for', { id: model.id })}
146
+ >
147
+ {t(`providers.type.${types[model.id] ?? model.modelType}`)}
148
+ </Select.Trigger>
149
+ <Select.Content
150
+ ><Select.Group>
151
+ {#each TYPES as type (type)}
152
+ <Select.Item value={type} label={t(`providers.type.${type}`)} />
153
+ {/each}
154
+ </Select.Group></Select.Content
155
+ >
156
+ </Select.Root>
157
+ {#if model.canVision}<span class="text-muted-foreground hidden shrink-0 text-kit-xs sm:inline"
158
+ >{t('providers.vision')}</span
159
+ >{/if}
160
+ <span class="text-success w-8 shrink-0 text-right text-kit-xs"
161
+ >{checked[model.id] ? t('providers.picked') : ''}</span
162
+ >
163
+ <Button
164
+ variant="ghost"
165
+ size="icon-sm"
166
+ class="shrink-0"
167
+ aria-label={t(checked[model.id] ? 'providers.remove_model' : 'providers.add_model', {
168
+ id: model.id,
169
+ })}
170
+ onclick={() => toggle(model.id)}
171
+ >
172
+ {#if checked[model.id]}<Minus />{:else}<Plus />{/if}
173
+ </Button>
174
+ </li>
175
+ {/each}
176
+ </ul>
177
+ {/if}
178
+ </section>
179
+ {/each}
180
+ </div>
181
+ <Dialog.Footer class="shrink-0">
182
+ <p class="text-muted-foreground mr-auto text-kit-xs" aria-live="polite">
183
+ {t('providers.picked_count', { count })}
184
+ </p>
185
+ </Dialog.Footer>
186
+ </Dialog.Content>
187
+ </Dialog.Root>