@ouraihub/aether-ui 0.0.0-stage → 0.1.1

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 (212) 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 +29 -0
  28. package/src/chat/ChatComposer.svelte +277 -0
  29. package/src/chat/ChatMessage.svelte +262 -0
  30. package/src/chat/ChatPanel.css +386 -0
  31. package/src/chat/ChatPanel.svelte +496 -0
  32. package/src/chat/ChatPanelApprovals.svelte +28 -0
  33. package/src/chat/ChatPanelComposer.svelte +317 -0
  34. package/src/chat/ChatPanelDialogs.svelte +242 -0
  35. package/src/chat/ChatPanelHeader.svelte +132 -0
  36. package/src/chat/ChatPanelMessages.svelte +166 -0
  37. package/src/chat/ChatPanelMode.svelte +190 -0
  38. package/src/chat/ChatPanelState.svelte.ts +1 -0
  39. package/src/chat/ChatPanelToolbar.svelte +425 -0
  40. package/src/chat/ChatSurface.svelte +168 -0
  41. package/src/chat/MessageItem.svelte +480 -0
  42. package/src/chat/ReasoningControl.svelte +286 -0
  43. package/src/chat/SkillPicker.svelte +133 -0
  44. package/src/chat/ToolCallItem.svelte +23 -0
  45. package/src/chat/UsageControl.svelte +216 -0
  46. package/src/chat/UsageDetails.svelte +76 -0
  47. package/src/chat/chat-attachments.svelte.ts +74 -0
  48. package/src/chat/chat-panel-history.svelte.ts +430 -0
  49. package/src/chat/chat-panel-props.ts +32 -0
  50. package/src/chat/index.ts +6 -0
  51. package/src/chat/run-session-history.ts +56 -0
  52. package/src/chat/run-session-types.ts +69 -0
  53. package/src/chat/run-session.svelte.ts +494 -0
  54. package/src/chat/test-support/chat-fixture.ts +178 -0
  55. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  56. package/src/chat/test-support/run-session-fixture.ts +112 -0
  57. package/src/components/ui/alert/alert-action.svelte +20 -0
  58. package/src/components/ui/alert/alert-description.svelte +23 -0
  59. package/src/components/ui/alert/alert-title.svelte +23 -0
  60. package/src/components/ui/alert/alert.svelte +43 -0
  61. package/src/components/ui/alert/index.ts +17 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  65. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  66. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  67. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  68. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  69. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  70. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  71. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  72. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  73. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  74. package/src/components/ui/alert-dialog/index.ts +40 -0
  75. package/src/components/ui/button/button.svelte +81 -0
  76. package/src/components/ui/button/index.ts +17 -0
  77. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  78. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  79. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  80. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  81. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  82. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  83. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  84. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  85. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  86. package/src/components/ui/dialog/dialog.svelte +7 -0
  87. package/src/components/ui/dialog/index.ts +34 -0
  88. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  89. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  90. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  91. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  92. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  93. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  94. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  95. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  96. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  97. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  98. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  99. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  100. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  101. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  102. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  103. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  104. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  105. package/src/components/ui/dropdown-menu/index.ts +54 -0
  106. package/src/components/ui/empty/empty-content.svelte +23 -0
  107. package/src/components/ui/empty/empty-description.svelte +23 -0
  108. package/src/components/ui/empty/empty-header.svelte +20 -0
  109. package/src/components/ui/empty/empty-media.svelte +41 -0
  110. package/src/components/ui/empty/empty-title.svelte +20 -0
  111. package/src/components/ui/empty/empty.svelte +23 -0
  112. package/src/components/ui/empty/index.ts +22 -0
  113. package/src/components/ui/field/field-content.svelte +20 -0
  114. package/src/components/ui/field/field-description.svelte +25 -0
  115. package/src/components/ui/field/field-error.svelte +58 -0
  116. package/src/components/ui/field/field-group.svelte +23 -0
  117. package/src/components/ui/field/field-label.svelte +25 -0
  118. package/src/components/ui/field/field-legend.svelte +24 -0
  119. package/src/components/ui/field/field-separator.svelte +35 -0
  120. package/src/components/ui/field/field-set.svelte +20 -0
  121. package/src/components/ui/field/field-title.svelte +20 -0
  122. package/src/components/ui/field/field.svelte +47 -0
  123. package/src/components/ui/field/index.ts +33 -0
  124. package/src/components/ui/input/index.ts +7 -0
  125. package/src/components/ui/input/input.svelte +48 -0
  126. package/src/components/ui/label/index.ts +7 -0
  127. package/src/components/ui/label/label.svelte +20 -0
  128. package/src/components/ui/select/index.ts +40 -0
  129. package/src/components/ui/select/select-content.svelte +45 -0
  130. package/src/components/ui/select/select-group-heading.svelte +21 -0
  131. package/src/components/ui/select/select-group.svelte +17 -0
  132. package/src/components/ui/select/select-item.svelte +43 -0
  133. package/src/components/ui/select/select-label.svelte +20 -0
  134. package/src/components/ui/select/select-portal.svelte +7 -0
  135. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  136. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  137. package/src/components/ui/select/select-separator.svelte +18 -0
  138. package/src/components/ui/select/select-trigger.svelte +29 -0
  139. package/src/components/ui/select/select-value.svelte +7 -0
  140. package/src/components/ui/select/select.svelte +11 -0
  141. package/src/components/ui/separator/index.ts +7 -0
  142. package/src/components/ui/separator/separator.svelte +21 -0
  143. package/src/components/ui/sonner/index.ts +1 -0
  144. package/src/components/ui/sonner/sonner.svelte +40 -0
  145. package/src/components/ui/switch/index.ts +7 -0
  146. package/src/components/ui/switch/switch.svelte +34 -0
  147. package/src/components/ui/textarea/index.ts +7 -0
  148. package/src/components/ui/textarea/textarea.svelte +22 -0
  149. package/src/context.ts +34 -0
  150. package/src/dev/Demo.svelte +47 -0
  151. package/src/dev/main.ts +7 -0
  152. package/src/i18n/en.ts +645 -0
  153. package/src/i18n/index.ts +28 -0
  154. package/src/i18n/zh-CN.ts +610 -0
  155. package/src/index.ts +10 -0
  156. package/src/logs/LogPanel.svelte +103 -0
  157. package/src/logs/LogSettings.svelte +58 -0
  158. package/src/logs/index.ts +5 -0
  159. package/src/logs/logger.ts +96 -0
  160. package/src/markdown.ts +8 -0
  161. package/src/mcp/McpClientEditor.svelte +204 -0
  162. package/src/mcp/McpClientSettings.svelte +418 -0
  163. package/src/mcp/McpServerSettings.svelte +363 -0
  164. package/src/mcp/config.ts +26 -0
  165. package/src/mcp/index.ts +3 -0
  166. package/src/mcp/input.ts +74 -0
  167. package/src/notify.svelte.ts +43 -0
  168. package/src/ports/contract.ts +174 -0
  169. package/src/ports/fake-optional.ts +81 -0
  170. package/src/ports/fake.ts +488 -0
  171. package/src/ports/index.ts +7 -0
  172. package/src/ports/search.ts +56 -0
  173. package/src/ports/skill-sources.ts +45 -0
  174. package/src/ports/test-support/fake-fixture.ts +63 -0
  175. package/src/providers/EndpointKeysDialog.svelte +357 -0
  176. package/src/providers/EndpointModels.svelte +311 -0
  177. package/src/providers/EndpointPanel.svelte +266 -0
  178. package/src/providers/ModelPickerDialog.svelte +230 -0
  179. package/src/providers/ModelPricing.svelte +125 -0
  180. package/src/providers/ModelSelect.svelte +448 -0
  181. package/src/providers/ModelSettingsDialog.svelte +388 -0
  182. package/src/providers/ProviderDialogs.svelte +120 -0
  183. package/src/providers/ProviderList.svelte +159 -0
  184. package/src/providers/ProvidersSettings.svelte +491 -0
  185. package/src/providers/RequestConfigDialog.svelte +278 -0
  186. package/src/providers/SecretInput.svelte +284 -0
  187. package/src/providers/draft.ts +355 -0
  188. package/src/providers/edit-session.ts +170 -0
  189. package/src/providers/index.ts +5 -0
  190. package/src/providers/reasoning.ts +55 -0
  191. package/src/providers/revision.svelte.ts +6 -0
  192. package/src/providers/test-support/providers-fixture.ts +120 -0
  193. package/src/search/SearchAdvancedTools.svelte +182 -0
  194. package/src/search/SearchProviderCard.svelte +195 -0
  195. package/src/search/SearchSettings.svelte +500 -0
  196. package/src/search/index.ts +13 -0
  197. package/src/search/settings-flow.ts +67 -0
  198. package/src/session.ts +20 -0
  199. package/src/skills/SkillAdd.svelte +380 -0
  200. package/src/skills/SkillAuthoring.svelte +430 -0
  201. package/src/skills/SkillEditor.svelte +104 -0
  202. package/src/skills/SkillPreviewDialog.svelte +136 -0
  203. package/src/skills/SkillsSettings.svelte +147 -0
  204. package/src/skills/error.ts +32 -0
  205. package/src/skills/index.ts +4 -0
  206. package/src/skills/test-support/skills-fixture.ts +139 -0
  207. package/src/styles/kit.css +137 -0
  208. package/src/styles/tokens.css +22 -0
  209. package/src/tauri/index.ts +298 -0
  210. package/src/utils.ts +22 -0
  211. package/src/web/index.ts +441 -0
  212. package/README.md +0 -3
@@ -0,0 +1,311 @@
1
+ <script lang="ts">
2
+ import BrainIcon from '@lucide/svelte/icons/brain'
3
+ import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
4
+ import ChevronsDownUpIcon from '@lucide/svelte/icons/chevrons-down-up'
5
+ import ChevronsUpDownIcon from '@lucide/svelte/icons/chevrons-up-down'
6
+ import EyeIcon from '@lucide/svelte/icons/eye'
7
+ import Globe2Icon from '@lucide/svelte/icons/globe-2'
8
+ import MinusIcon from '@lucide/svelte/icons/minus'
9
+ import PlusIcon from '@lucide/svelte/icons/plus'
10
+ import RefreshIcon from '@lucide/svelte/icons/refresh-cw'
11
+ import SearchIcon from '@lucide/svelte/icons/search'
12
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
13
+ import WrenchIcon from '@lucide/svelte/icons/wrench'
14
+ import { Button } from '#lib/components/ui/button/index.js'
15
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
16
+ import * as Field from '#lib/components/ui/field/index.js'
17
+ import { Input } from '#lib/components/ui/input/index.js'
18
+ import * as Select from '#lib/components/ui/select/index.js'
19
+ import { getKitContext } from '../context'
20
+ import type { Model } from '../ports'
21
+ import { cn } from '../utils'
22
+ import { groupKey, type EndpointDraft } from './draft'
23
+ import ModelSettingsDialog from './ModelSettingsDialog.svelte'
24
+
25
+ let {
26
+ endpoint,
27
+ onsync,
28
+ oncommit,
29
+ usedBy,
30
+ targetModel,
31
+ }: {
32
+ endpoint: EndpointDraft
33
+ onsync: () => void
34
+ oncommit: () => void
35
+ usedBy?: ((endpointId: string, modelId: string) => string[]) | undefined
36
+ targetModel?: Model | null
37
+ } = $props()
38
+
39
+ const { i18n } = getKitContext()
40
+ const { t } = i18n
41
+ const CAPS = ['vision', 'reasoning', 'tools', 'web'] as const
42
+ type Cap = (typeof CAPS)[number]
43
+ const CAP_STYLE: Record<Cap, string> = {
44
+ vision: 'bg-success/15 text-success',
45
+ reasoning: 'bg-info/15 text-info',
46
+ tools: 'bg-warning/15 text-warning',
47
+ web: 'bg-primary/10 text-primary',
48
+ }
49
+ let modelOpen = $state(false)
50
+ let editing = $state<Model | null>(null)
51
+ let openedTarget = $state<string | null>(null)
52
+ $effect.pre(() => {
53
+ if (targetModel && openedTarget !== targetModel.id) {
54
+ openedTarget = targetModel.id
55
+ editing = targetModel
56
+ modelOpen = true
57
+ }
58
+ })
59
+ let addOpen = $state(false)
60
+ let newModelId = $state('')
61
+ let searching = $state(false)
62
+ let query = $state('')
63
+ let capFilter = $state<Cap | 'all'>('all')
64
+ let folded = $state<Record<string, boolean>>({})
65
+
66
+ const has = (model: Model, cap: Cap) =>
67
+ cap === 'vision'
68
+ ? model.canVision
69
+ : cap === 'reasoning'
70
+ ? model.reasoning !== null
71
+ : cap === 'tools'
72
+ ? model.supportsTools === true
73
+ : model.nativeWebSearch
74
+ const shown = $derived.by(() => {
75
+ const search = query.trim().toLowerCase()
76
+ return endpoint.models.filter(
77
+ (model) =>
78
+ (model.id.toLowerCase().includes(search) || !!model.name?.toLowerCase().includes(search)) &&
79
+ (capFilter === 'all' || has(model, capFilter)),
80
+ )
81
+ })
82
+ const groups = $derived(Object.entries(Object.groupBy(shown, groupKey)))
83
+ const allFolded = $derived(groups.length > 0 && groups.every(([group]) => folded[group]))
84
+
85
+ function setModels(models: Model[]) {
86
+ endpoint.models = models
87
+ oncommit()
88
+ }
89
+ function addModel() {
90
+ const id = newModelId.trim()
91
+ if (id && !endpoint.models.some((model) => model.id === id)) {
92
+ setModels([
93
+ ...endpoint.models,
94
+ {
95
+ id,
96
+ name: null,
97
+ groupName: null,
98
+ modelType: 'chat',
99
+ typeSource: 'user',
100
+ contextWindow: null,
101
+ maxOutput: null,
102
+ canVision: false,
103
+ supportsTools: null,
104
+ reasoning: null,
105
+ nativeWebSearch: false,
106
+ pricing: null,
107
+ },
108
+ ])
109
+ }
110
+ newModelId = ''
111
+ addOpen = false
112
+ }
113
+ </script>
114
+
115
+ <section class="flex min-w-0 flex-col gap-2" aria-labelledby="kit-endpoint-models">
116
+ <div class="flex min-w-0 flex-wrap items-center gap-1">
117
+ <h3 id="kit-endpoint-models" class="mr-1 text-kit-base font-semibold">
118
+ {t('providers.models')}
119
+ </h3>
120
+ <Button
121
+ variant="ghost"
122
+ size="icon-xs"
123
+ title={t(allFolded ? 'providers.expand_all' : 'providers.collapse_all')}
124
+ aria-label={t(allFolded ? 'providers.expand_all' : 'providers.collapse_all')}
125
+ disabled={groups.length === 0}
126
+ onclick={() =>
127
+ (folded = allFolded ? {} : Object.fromEntries(groups.map(([group]) => [group, true])))}
128
+ >{#if allFolded}<ChevronsUpDownIcon />{:else}<ChevronsDownUpIcon />{/if}</Button
129
+ >
130
+ {#if searching || query}
131
+ <Input
132
+ class="h-7 w-36 text-kit-sm"
133
+ autofocus
134
+ placeholder={t('providers.model_search')}
135
+ aria-label={t('providers.model_search')}
136
+ bind:value={query}
137
+ onblur={() => (searching = false)}
138
+ onkeydown={(event) => event.key === 'Escape' && ((query = ''), event.currentTarget.blur())}
139
+ />
140
+ {:else}
141
+ <Button
142
+ variant="ghost"
143
+ size="icon-xs"
144
+ title={t('providers.model_search')}
145
+ aria-label={t('providers.model_search')}
146
+ onclick={() => (searching = true)}><SearchIcon /></Button
147
+ >
148
+ {/if}
149
+ <Select.Root
150
+ type="single"
151
+ value={capFilter}
152
+ onValueChange={(value) => (capFilter = value as Cap | 'all')}
153
+ >
154
+ <Select.Trigger
155
+ size="sm"
156
+ class={cn('border-0 shadow-none', capFilter !== 'all' && 'bg-accent')}
157
+ aria-label={t('providers.cap_filter')}
158
+ >
159
+ {capFilter === 'all' ? t('providers.all_types') : t(`providers.cap_${capFilter}`)}
160
+ </Select.Trigger>
161
+ <Select.Content>
162
+ <Select.Group>
163
+ <Select.Item value="all" label={t('providers.all_types')} />
164
+ {#each CAPS as cap (cap)}<Select.Item
165
+ value={cap}
166
+ label={t(`providers.cap_${cap}`)}
167
+ />{/each}
168
+ </Select.Group>
169
+ </Select.Content>
170
+ </Select.Root>
171
+ <div class="ml-auto flex items-center gap-1">
172
+ <Button variant="outline" size="sm" disabled={!endpoint.baseUrl} onclick={onsync}>
173
+ <RefreshIcon />{t('providers.sync_models')}
174
+ </Button>
175
+ <Button
176
+ variant="outline"
177
+ size="icon-sm"
178
+ title={t('providers.add_model_manual')}
179
+ aria-label={t('providers.add_model_manual')}
180
+ onclick={() => (addOpen = true)}><PlusIcon /></Button
181
+ >
182
+ </div>
183
+ </div>
184
+
185
+ {#each groups as [group, models = []] (group)}
186
+ <section class="group/model-group overflow-hidden rounded-kit border">
187
+ <div class="bg-muted/30 flex items-center gap-1 pr-2">
188
+ <button
189
+ type="button"
190
+ class="flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left text-kit-sm"
191
+ aria-expanded={!folded[group]}
192
+ onclick={() => (folded[group] = !folded[group])}
193
+ >
194
+ <ChevronRightIcon
195
+ class={cn('size-4 shrink-0 transition-transform', !folded[group] && 'rotate-90')}
196
+ />
197
+ <span class="truncate">{group || t('providers.no_group')}</span>
198
+ <span class="text-muted-foreground text-kit-xs tabular-nums">{models.length}</span>
199
+ </button>
200
+ <Button
201
+ variant="ghost"
202
+ size="icon-xs"
203
+ class="opacity-0 group-hover/model-group:opacity-100 focus-visible:opacity-100"
204
+ title={t('providers.remove_group')}
205
+ aria-label={t('providers.remove_group')}
206
+ onclick={() => setModels(endpoint.models.filter((model) => !models.includes(model)))}
207
+ ><MinusIcon /></Button
208
+ >
209
+ </div>
210
+ {#if !folded[group]}
211
+ <ul class="divide-y border-t">
212
+ {#each models as model (model.id)}
213
+ <li class="hover:bg-accent/40 flex min-w-0 items-center gap-2 px-3 py-1.5">
214
+ <span
215
+ aria-hidden="true"
216
+ class="bg-muted text-muted-foreground flex size-6 shrink-0 items-center justify-center rounded-full text-kit-xs uppercase"
217
+ >{(model.name ?? model.id).slice(0, 1)}</span
218
+ >
219
+ <span class="min-w-0 flex-1 truncate text-kit-sm" title={model.id}
220
+ >{model.name ?? model.id}</span
221
+ >
222
+ <span class="flex shrink-0 items-center gap-0.5">
223
+ {#each CAPS.filter((cap) => has(model, cap)) as cap (cap)}
224
+ <span
225
+ class={cn(
226
+ 'flex h-5 w-6 items-center justify-center rounded-full',
227
+ CAP_STYLE[cap],
228
+ )}
229
+ title={t(`providers.cap_${cap}`)}
230
+ >
231
+ {#if cap === 'vision'}<EyeIcon
232
+ class="size-3"
233
+ />{:else if cap === 'reasoning'}<BrainIcon
234
+ class="size-3"
235
+ />{:else if cap === 'tools'}<WrenchIcon class="size-3" />{:else}<Globe2Icon
236
+ class="size-3"
237
+ />{/if}
238
+ <span class="sr-only">{t(`providers.cap_${cap}`)}</span>
239
+ </span>
240
+ {/each}
241
+ </span>
242
+ <Button
243
+ variant="ghost"
244
+ size="icon-xs"
245
+ title={t('providers.model_settings')}
246
+ aria-label={t('providers.model_settings_for', { id: model.id })}
247
+ onclick={() => {
248
+ editing = model
249
+ modelOpen = true
250
+ }}><Settings2Icon /></Button
251
+ >
252
+ <Button
253
+ variant="ghost"
254
+ size="icon-xs"
255
+ title={t('providers.remove_model', { id: model.id })}
256
+ aria-label={t('providers.remove_model', { id: model.id })}
257
+ onclick={() => setModels(endpoint.models.filter((item) => item.id !== model.id))}
258
+ ><MinusIcon /></Button
259
+ >
260
+ </li>
261
+ {/each}
262
+ </ul>
263
+ {/if}
264
+ </section>
265
+ {:else}
266
+ <p
267
+ class="text-muted-foreground rounded-kit border border-dashed px-3 py-6 text-center text-kit-sm"
268
+ >
269
+ {endpoint.models.length === 0 ? t('providers.no_models') : t('providers.no_model_matches')}
270
+ </p>
271
+ {/each}
272
+ </section>
273
+
274
+ <ModelSettingsDialog
275
+ bind:open={modelOpen}
276
+ model={editing}
277
+ protocol={endpoint.protocol}
278
+ usedBy={editing && endpoint.saved && usedBy ? usedBy(endpoint.saved.id, editing.id) : []}
279
+ onsave={(saved) =>
280
+ setModels(endpoint.models.map((model) => (model.id === saved.id ? saved : model)))}
281
+ />
282
+
283
+ <Dialog.Root bind:open={addOpen}>
284
+ <Dialog.Content class="sm:max-w-md">
285
+ <Dialog.Header><Dialog.Title>{t('providers.add_model_manual')}</Dialog.Title></Dialog.Header>
286
+ <form
287
+ class="contents"
288
+ onsubmit={(event) => {
289
+ event.preventDefault()
290
+ addModel()
291
+ }}
292
+ >
293
+ <Field.Field>
294
+ <Field.Label for="kit-model-new">{t('providers.model_id')}</Field.Label>
295
+ <Input
296
+ id="kit-model-new"
297
+ class="font-mono"
298
+ autocomplete="off"
299
+ spellcheck={false}
300
+ bind:value={newModelId}
301
+ />
302
+ </Field.Field>
303
+ <Dialog.Footer>
304
+ <Button type="button" variant="outline" onclick={() => (addOpen = false)}
305
+ >{t('common.cancel')}</Button
306
+ >
307
+ <Button type="submit" disabled={!newModelId.trim()}>{t('common.confirm')}</Button>
308
+ </Dialog.Footer>
309
+ </form>
310
+ </Dialog.Content>
311
+ </Dialog.Root>
@@ -0,0 +1,266 @@
1
+ <script lang="ts">
2
+ // One endpoint's keys and address.
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 LoaderIcon from '@lucide/svelte/icons/loader-circle'
5
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
6
+ import { Button } from '#lib/components/ui/button/index.js'
7
+ import { Switch } from '#lib/components/ui/switch/index.js'
8
+ import * as Field from '#lib/components/ui/field/index.js'
9
+ import CopyInput from '../CopyInput.svelte'
10
+ import * as Select from '#lib/components/ui/select/index.js'
11
+ import { getKitContext } from '../context'
12
+ import type { KeyStrategy, Protocol, Provider } from '../ports'
13
+ import { cn } from '../utils'
14
+ import { keyError, maskedText, probeUrl, validUrl, type EndpointDraft } from './draft'
15
+ import RequestConfigDialog from './RequestConfigDialog.svelte'
16
+ import { settingsTarget } from './revision.svelte'
17
+ import SecretInput from './SecretInput.svelte'
18
+ import EndpointModels from './EndpointModels.svelte'
19
+
20
+ export type CheckOutcome =
21
+ { state: 'ok'; count: number; suggested: Protocol | null } | { state: 'error'; message: string }
22
+ export type CheckState =
23
+ | { state: 'running' }
24
+ | CheckOutcome
25
+ /** One probe per enabled key when there are at least two (docs/34 §5.3). */
26
+ | { state: 'keys'; results: { keyId: string; value: CheckOutcome }[] }
27
+
28
+ let {
29
+ endpoint = $bindable(),
30
+ check,
31
+ onaddendpoint,
32
+ onremove,
33
+ oncommit,
34
+ onkeys,
35
+ oncheck,
36
+ onsync,
37
+ keyUrl,
38
+ usedBy,
39
+ }: {
40
+ endpoint: EndpointDraft
41
+ check: CheckState | null
42
+ onaddendpoint: (() => void) | null
43
+ onremove: () => void
44
+ oncommit: () => void
45
+ /** Providers returned by a key operation. */
46
+ onkeys: (providers: Provider[]) => void
47
+ oncheck: () => void
48
+ onsync: () => void
49
+ /** Preset "get a key" link shown beside the label (docs/39). */
50
+ keyUrl?: string | undefined
51
+ /** Localized roles bound to a stored model (contract §6). */
52
+ usedBy?: ((endpointId: string, modelId: string) => string[]) | undefined
53
+ } = $props()
54
+ const { ports, i18n, notify } = getKitContext()
55
+ const { t } = i18n
56
+ let keySaving = $state(false)
57
+ async function setKeyEnabled(keyId: string, enabled: boolean) {
58
+ const endpointId = endpoint.saved?.id
59
+ if (!ports.providers || !endpointId || keySaving) return
60
+ keySaving = true
61
+ try {
62
+ onkeys(await ports.providers.saveKey({ endpointId, id: keyId, enabled }))
63
+ } catch (error) {
64
+ notify.toast('error', keyError(error, t))
65
+ } finally {
66
+ keySaving = false
67
+ }
68
+ }
69
+
70
+ let url = $state(endpoint.baseUrl)
71
+ let urlError = $state(false)
72
+ // A save replaces the draft object; only a changed committed address may overwrite what the user is typing.
73
+ let seenUrl = endpoint.baseUrl
74
+ $effect.pre(() => {
75
+ const next = endpoint.baseUrl
76
+ if (next === seenUrl) return
77
+ seenUrl = next
78
+ url = next
79
+ urlError = false
80
+ })
81
+ let settingsOpen = $state(false)
82
+ // A chat "open settings" action names this endpoint's model: consume it once.
83
+ const targeted = settingsTarget.value?.endpointId === endpoint.saved?.id
84
+ const targetModel = targeted
85
+ ? (endpoint.models.find((model) => model.id === settingsTarget.value?.modelId) ?? null)
86
+ : null
87
+ if (targeted) settingsTarget.value = null
88
+ function commitUrl() {
89
+ const next = url.trim()
90
+ if (next === endpoint.baseUrl) return
91
+ if (next && !validUrl(next)) {
92
+ urlError = true
93
+ return
94
+ }
95
+ urlError = false
96
+ endpoint.baseUrl = next
97
+ oncommit()
98
+ }
99
+ /** First probe suggestion: the whole check or any key's (all keys share one relay). */
100
+ const suggested = $derived.by((): Protocol | null => {
101
+ if (check?.state === 'ok') return check.suggested
102
+ if (check?.state !== 'keys') return null
103
+ for (const { value } of check.results)
104
+ if (value.state === 'ok' && value.suggested) return value.suggested
105
+ return null
106
+ })
107
+ </script>
108
+
109
+ <div class="flex min-w-0 flex-col gap-5">
110
+ <Field.Field class="gap-2">
111
+ <div class="flex items-center gap-2">
112
+ <Field.Label for="kit-endpoint-key">{t('providers.api_key')}</Field.Label>
113
+ {#if keyUrl && /^https:\/\//.test(keyUrl)}<a
114
+ class="text-primary text-kit-xs hover:underline"
115
+ href={keyUrl}
116
+ target="_blank"
117
+ rel="noopener noreferrer">{t('providers.get_key')}</a
118
+ >{/if}
119
+ </div>
120
+ <div class="flex min-w-0 items-start gap-2">
121
+ <SecretInput id="kit-endpoint-key" endpoint={endpoint.saved} onchanged={onkeys} />
122
+ <Button
123
+ variant="outline"
124
+ class="shrink-0"
125
+ disabled={!validUrl(endpoint.baseUrl)}
126
+ onclick={oncheck}
127
+ >
128
+ {#if check?.state === 'running'}<LoaderIcon class="animate-spin" />{/if}
129
+ {t('providers.check')}
130
+ </Button>
131
+ </div>
132
+ {#if check?.state === 'keys'}
133
+ <ul role="status" class="flex min-w-0 flex-col gap-1">
134
+ {#each check.results as { keyId, value } (keyId)}
135
+ {@const key = endpoint.saved?.keys.find((k) => k.id === keyId)}
136
+ {#if key}
137
+ <li class="flex min-w-0 items-start gap-2 text-kit-xs">
138
+ <span
139
+ class={cn(
140
+ 'min-w-0 flex-1 whitespace-pre-line',
141
+ value.state === 'ok' ? 'text-success' : 'text-destructive',
142
+ )}
143
+ >{value.state === 'ok' ? '✓' : '✕'}
144
+ {key.label || maskedText(key, t)} — {value.state === 'ok'
145
+ ? t('providers.check_ok', { count: value.count })
146
+ : value.message}</span
147
+ >
148
+ <Switch
149
+ size="sm"
150
+ aria-label={t('providers.keys_enable_for', {
151
+ name: key.label || maskedText(key, t),
152
+ })}
153
+ checked={key.enabled}
154
+ disabled={keySaving}
155
+ onCheckedChange={(enabled) => void setKeyEnabled(key.id, enabled)}
156
+ />
157
+ </li>
158
+ {/if}
159
+ {/each}
160
+ </ul>
161
+ {:else if check && check.state !== 'running'}
162
+ <p
163
+ role="status"
164
+ class={cn(
165
+ 'text-kit-xs whitespace-pre-line',
166
+ check.state === 'ok' ? 'text-success' : 'text-destructive',
167
+ )}
168
+ >
169
+ {check.state === 'ok' ? t('providers.check_ok', { count: check.count }) : check.message}
170
+ </p>
171
+ {/if}
172
+ {#if suggested && suggested !== endpoint.protocol}
173
+ {@const next = suggested}
174
+ <div
175
+ role="status"
176
+ class="bg-info/10 flex min-w-0 items-center gap-2 rounded-md px-2.5 py-1.5 text-kit-xs"
177
+ >
178
+ <span class="min-w-0 flex-1">{t('providers.suggest_protocol')}</span>
179
+ <Button
180
+ variant="outline"
181
+ size="sm"
182
+ class="shrink-0"
183
+ onclick={() => {
184
+ endpoint.protocol = next
185
+ oncommit()
186
+ }}>{t('providers.suggest_protocol_apply')}</Button
187
+ >
188
+ </div>
189
+ {/if}
190
+ </Field.Field>
191
+
192
+ {#if ports.providers?.keyStrategies}
193
+ <Field.Field class="gap-2">
194
+ <Field.Label for="kit-endpoint-key-strategy">{t('providers.key_strategy')}</Field.Label>
195
+ <Select.Root
196
+ type="single"
197
+ value={endpoint.keyStrategy}
198
+ onValueChange={(value) => {
199
+ if (value === endpoint.keyStrategy) return
200
+ endpoint.keyStrategy = value as KeyStrategy
201
+ oncommit()
202
+ }}
203
+ >
204
+ <Select.Trigger id="kit-endpoint-key-strategy" class="w-full sm:w-64"
205
+ >{t(`providers.key_strategy_${endpoint.keyStrategy}`)}</Select.Trigger
206
+ >
207
+ <Select.Content>
208
+ <Select.Group>
209
+ {#each ['round_robin', 'fill_first'] as const as strategy (strategy)}<Select.Item
210
+ value={strategy}
211
+ label={t(`providers.key_strategy_${strategy}`)}
212
+ />{/each}
213
+ </Select.Group>
214
+ </Select.Content>
215
+ </Select.Root>
216
+ <Field.Description class="text-kit-xs"
217
+ >{t(`providers.key_strategy_${endpoint.keyStrategy}_desc`)}</Field.Description
218
+ >
219
+ </Field.Field>
220
+ {/if}
221
+
222
+ <Field.Field class="gap-2">
223
+ <div class="flex items-center gap-2">
224
+ <Field.Label for="kit-endpoint-url">{t('providers.base_url')}</Field.Label>
225
+ {#if onaddendpoint}
226
+ <button
227
+ type="button"
228
+ class="text-primary text-kit-xs hover:underline"
229
+ onclick={onaddendpoint}
230
+ >
231
+ {t('providers.add_endpoint')}
232
+ </button>
233
+ {/if}
234
+ </div>
235
+ <div class="flex min-w-0 items-center gap-2">
236
+ <CopyInput
237
+ id="kit-endpoint-url"
238
+ class="min-w-0 flex-1 font-mono"
239
+ placeholder="https://api.example.com/v1"
240
+ aria-invalid={urlError}
241
+ bind:value={url}
242
+ onblur={commitUrl}
243
+ onkeydown={(event) => event.key === 'Enter' && event.currentTarget.blur()}
244
+ />
245
+ <Button
246
+ variant="outline"
247
+ size="icon"
248
+ class="shrink-0"
249
+ title={t('providers.request_config_tip')}
250
+ aria-label={t('providers.request_config_tip')}
251
+ onclick={() => (settingsOpen = true)}><Settings2Icon /></Button
252
+ >
253
+ </div>
254
+ {#if urlError}
255
+ <p role="alert" class="text-destructive text-kit-xs">{t('providers.url_invalid')}</p>
256
+ {:else if validUrl(endpoint.baseUrl)}
257
+ <p class="text-muted-foreground truncate font-mono text-kit-xs">
258
+ GET {probeUrl(endpoint.baseUrl)}
259
+ </p>
260
+ {/if}
261
+ </Field.Field>
262
+
263
+ <EndpointModels {endpoint} {onsync} {oncommit} {usedBy} {targetModel} />
264
+ </div>
265
+
266
+ <RequestConfigDialog bind:open={settingsOpen} bind:endpoint {onaddendpoint} {onremove} {oncommit} />