@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,278 @@
1
+ <script lang="ts">
2
+ // Request config (custom headers) for one endpoint: edits a local copy, Save writes it back (docs/36 §4).
3
+ import BracesIcon from '@lucide/svelte/icons/braces'
4
+ import ListIcon from '@lucide/svelte/icons/list'
5
+ import PlusIcon from '@lucide/svelte/icons/plus'
6
+ import TrashIcon from '@lucide/svelte/icons/trash-2'
7
+ import { untrack } from 'svelte'
8
+ import { Button } from '#lib/components/ui/button/index.js'
9
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
10
+ import * as Field from '#lib/components/ui/field/index.js'
11
+ import { Input } from '#lib/components/ui/input/index.js'
12
+ import CopyInput from '../CopyInput.svelte'
13
+ import * as Select from '#lib/components/ui/select/index.js'
14
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
15
+ import { getKitContext } from '../context'
16
+ import type { Protocol } from '../ports'
17
+ import { chatUrl, checkHeaders, validUrl, type EndpointDraft } from './draft'
18
+
19
+ let {
20
+ open = $bindable(false),
21
+ endpoint = $bindable(),
22
+ onaddendpoint,
23
+ onremove,
24
+ oncommit,
25
+ }: {
26
+ open: boolean
27
+ endpoint: EndpointDraft
28
+ onaddendpoint: (() => void) | null
29
+ onremove: () => void
30
+ oncommit: () => void
31
+ } = $props()
32
+
33
+ const PROTOCOLS: Protocol[] = ['openai-chat', 'openai-responses', 'anthropic-messages']
34
+ const { i18n } = getKitContext()
35
+ const { t } = i18n
36
+
37
+ type Row = { name: string; value: string }
38
+ let protocol = $state<Protocol>('openai-chat')
39
+ let baseUrl = $state('')
40
+ let imageBaseUrl = $state('')
41
+ let imageEditBaseUrl = $state('')
42
+ let rows = $state<Row[]>([])
43
+ let json = $state<string | null>(null)
44
+ let error = $state<string | null>(null)
45
+
46
+ // Copy the endpoint into the local draft each time the dialog opens.
47
+ $effect.pre(() => {
48
+ if (!open) return
49
+ untrack(() => {
50
+ protocol = endpoint.protocol
51
+ baseUrl = endpoint.baseUrl
52
+ imageBaseUrl = endpoint.imageBaseUrl ?? ''
53
+ imageEditBaseUrl = endpoint.imageEditBaseUrl ?? ''
54
+ rows = Object.entries(endpoint.headers).map(([name, value = '']) => ({ name, value }))
55
+ json = null
56
+ error = null
57
+ })
58
+ })
59
+
60
+ /** JSON text → rows; `null` when it is not an object of strings. */
61
+ function parse(text: string): Row[] | null {
62
+ try {
63
+ const value: unknown = JSON.parse(text.trim() || '{}')
64
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return null
65
+ const entries = Object.entries(value)
66
+ return entries.every(([, v]) => typeof v === 'string')
67
+ ? entries.map(([name, v]) => ({ name, value: v as string }))
68
+ : null
69
+ } catch {
70
+ return null
71
+ }
72
+ }
73
+ function toggle() {
74
+ error = null
75
+ if (json === null) {
76
+ json = JSON.stringify(Object.fromEntries(rows.map((r) => [r.name, r.value])), null, 2)
77
+ return
78
+ }
79
+ const parsed = parse(json)
80
+ if (!parsed) error = t('providers.headers_invalid_json')
81
+ else {
82
+ rows = parsed
83
+ json = null
84
+ }
85
+ }
86
+ /** Same rule as the backend `image_base`: optional, http(s), ≤2048 bytes. */
87
+ const badImage = (url: string) =>
88
+ !!url && (!validUrl(url) || new TextEncoder().encode(url).length > 2048)
89
+ function save() {
90
+ error = null
91
+ const url = baseUrl.trim()
92
+ const image = imageBaseUrl.trim()
93
+ const imageEdit = imageEditBaseUrl.trim()
94
+ if ((url && !validUrl(url)) || badImage(image) || badImage(imageEdit)) {
95
+ error = t('providers.url_invalid')
96
+ return
97
+ }
98
+ const list = json === null ? rows : parse(json)
99
+ if (!list) {
100
+ error = t('providers.headers_invalid_json')
101
+ return
102
+ }
103
+ const checked = checkHeaders(list)
104
+ if ('error' in checked) {
105
+ error =
106
+ checked.error.key === 'headers_reserved'
107
+ ? t('providers.headers_reserved', { name: checked.error.name })
108
+ : t(`providers.${checked.error.key}`)
109
+ return
110
+ }
111
+ endpoint.protocol = protocol
112
+ endpoint.baseUrl = url
113
+ endpoint.imageBaseUrl = image || null
114
+ endpoint.imageEditBaseUrl = imageEdit || null
115
+ endpoint.headers = checked.headers
116
+ open = false
117
+ oncommit()
118
+ }
119
+ </script>
120
+
121
+ <Dialog.Root bind:open>
122
+ <Dialog.Content class="sm:max-w-lg">
123
+ <Dialog.Header>
124
+ <Dialog.Title>{t('providers.request_config')}</Dialog.Title>
125
+ </Dialog.Header>
126
+ <Field.Group>
127
+ <Field.Field>
128
+ <Field.Label for="kit-endpoint-protocol">{t('providers.protocol')}</Field.Label>
129
+ <Select.Root
130
+ type="single"
131
+ value={protocol}
132
+ onValueChange={(value) => (protocol = value as Protocol)}
133
+ >
134
+ <Select.Trigger id="kit-endpoint-protocol" class="w-full"
135
+ >{t(`providers.protocol.${protocol}`)}</Select.Trigger
136
+ >
137
+ <Select.Content>
138
+ <Select.Group>
139
+ {#each PROTOCOLS as p (p)}<Select.Item
140
+ value={p}
141
+ label={t(`providers.protocol.${p}`)}
142
+ />{/each}
143
+ </Select.Group>
144
+ </Select.Content>
145
+ </Select.Root>
146
+ </Field.Field>
147
+ <Field.Field>
148
+ <div class="flex items-center gap-2">
149
+ <Field.Label for="kit-request-url">{t('providers.base_url')}</Field.Label>
150
+ <span class="bg-primary/10 text-primary rounded-kit px-1.5 text-kit-xs"
151
+ >{t('providers.default')}</span
152
+ >
153
+ </div>
154
+ <CopyInput
155
+ id="kit-request-url"
156
+ class="font-mono"
157
+ placeholder="https://api.example.com/v1"
158
+ bind:value={baseUrl}
159
+ />
160
+ {#if validUrl(baseUrl)}
161
+ <Field.Description class="truncate font-mono text-kit-xs"
162
+ >{t('providers.base_url_hint', {
163
+ url: `POST ${chatUrl(baseUrl, protocol)}`,
164
+ })}</Field.Description
165
+ >
166
+ {/if}
167
+ </Field.Field>
168
+ {#if onaddendpoint}
169
+ <Button
170
+ variant="outline"
171
+ class="w-full border-dashed"
172
+ onclick={() => {
173
+ open = false
174
+ onaddendpoint()
175
+ }}><PlusIcon />{t('providers.add_endpoint')}</Button
176
+ >
177
+ {/if}
178
+ <Field.Field>
179
+ <Field.Label for="kit-request-image-url">{t('providers.image_base_url')}</Field.Label>
180
+ <CopyInput
181
+ id="kit-request-image-url"
182
+ class="font-mono"
183
+ placeholder="https://example.com"
184
+ bind:value={imageBaseUrl}
185
+ />
186
+ <Field.Description class="text-kit-xs"
187
+ >{t('providers.image_base_url_hint')}</Field.Description
188
+ >
189
+ </Field.Field>
190
+ <Field.Field>
191
+ <Field.Label for="kit-request-image-edit-url"
192
+ >{t('providers.image_edit_base_url')}</Field.Label
193
+ >
194
+ <CopyInput
195
+ id="kit-request-image-edit-url"
196
+ class="font-mono"
197
+ placeholder="https://example.com"
198
+ bind:value={imageEditBaseUrl}
199
+ />
200
+ <Field.Description class="text-kit-xs"
201
+ >{t('providers.image_edit_base_url_hint')}</Field.Description
202
+ >
203
+ </Field.Field>
204
+ <Field.Field>
205
+ <div class="flex items-center justify-between gap-2">
206
+ <Field.Label for={json === null ? undefined : 'kit-request-headers'}
207
+ >{t('providers.headers')}</Field.Label
208
+ >
209
+ <Button
210
+ variant="ghost"
211
+ size="icon"
212
+ title={json === null ? t('providers.headers_to_json') : t('providers.headers_to_list')}
213
+ aria-label={json === null
214
+ ? t('providers.headers_to_json')
215
+ : t('providers.headers_to_list')}
216
+ onclick={toggle}
217
+ >{#if json === null}<BracesIcon />{:else}<ListIcon />{/if}</Button
218
+ >
219
+ </div>
220
+ {#if json === null}
221
+ {#each rows as row, index (index)}
222
+ <div class="flex min-w-0 items-center gap-2">
223
+ <Input
224
+ class="min-w-0 flex-1 font-mono"
225
+ aria-label={t('providers.header_name')}
226
+ placeholder={t('providers.header_name')}
227
+ bind:value={row.name}
228
+ />
229
+ <Input
230
+ class="min-w-0 flex-1 font-mono"
231
+ aria-label={t('providers.header_value')}
232
+ placeholder={t('providers.header_value')}
233
+ bind:value={row.value}
234
+ />
235
+ <Button
236
+ variant="ghost"
237
+ size="icon"
238
+ aria-label={t('providers.header_remove', { name: row.name })}
239
+ onclick={() => rows.splice(index, 1)}><TrashIcon /></Button
240
+ >
241
+ </div>
242
+ {/each}
243
+ <Button
244
+ variant="ghost"
245
+ class="self-start"
246
+ disabled={rows.length >= 20}
247
+ onclick={() => rows.push({ name: '', value: '' })}
248
+ ><PlusIcon />{t('providers.headers_add')}</Button
249
+ >
250
+ {:else}
251
+ <Textarea
252
+ id="kit-request-headers"
253
+ class="min-h-32 font-mono text-xs"
254
+ spellcheck={false}
255
+ bind:value={json}
256
+ />
257
+ {/if}
258
+ </Field.Field>
259
+ </Field.Group>
260
+ {#if error}
261
+ <p role="alert" class="text-destructive text-kit-xs">{error}</p>
262
+ {/if}
263
+ <Dialog.Footer class="sm:justify-between">
264
+ <Button
265
+ variant="ghost"
266
+ class="text-destructive"
267
+ onclick={() => {
268
+ open = false
269
+ onremove()
270
+ }}>{t('providers.remove_endpoint')}</Button
271
+ >
272
+ <div class="flex gap-2">
273
+ <Button variant="outline" onclick={() => (open = false)}>{t('common.cancel')}</Button>
274
+ <Button onclick={save}>{t('providers.save')}</Button>
275
+ </div>
276
+ </Dialog.Footer>
277
+ </Dialog.Content>
278
+ </Dialog.Root>
@@ -0,0 +1,284 @@
1
+ <script lang="ts">
2
+ // API key row: primary key masked with `+N`, show/copy/edit/delete; 🔑 opens key management.
3
+ import { onDestroy, untrack } from 'svelte'
4
+ import { createClientSession } from '../session'
5
+ import CopyIcon from '@lucide/svelte/icons/copy'
6
+ import EditIcon from '@lucide/svelte/icons/pencil'
7
+ import EyeIcon from '@lucide/svelte/icons/eye'
8
+ import EyeOffIcon from '@lucide/svelte/icons/eye-off'
9
+ import KeyRoundIcon from '@lucide/svelte/icons/key-round'
10
+ import TrashIcon from '@lucide/svelte/icons/trash-2'
11
+ import { Button } from '#lib/components/ui/button/index.js'
12
+ import { Input } from '#lib/components/ui/input/index.js'
13
+ import { getKitContext } from '../context'
14
+ import type { Endpoint, Provider, ProvidersPort } from '../ports'
15
+ import { keyError, maskedText, primaryKey } from './draft'
16
+ import EndpointKeysDialog from './EndpointKeysDialog.svelte'
17
+
18
+ let {
19
+ id,
20
+ endpoint,
21
+ onchanged,
22
+ }: {
23
+ id: string
24
+ /** `null` while a new endpoint is still being stored. */
25
+ endpoint: Endpoint | null
26
+ onchanged: (providers: Provider[]) => void
27
+ } = $props()
28
+ const { ports, i18n, notify } = getKitContext()
29
+ const { t } = i18n
30
+ let askingPassword = $state(false),
31
+ password = $state(''),
32
+ revealed = $state<{ id: string; text: string } | null>(null),
33
+ copyAfterReveal = false,
34
+ text = $state(''),
35
+ busy = $state(false),
36
+ dialogOpen = $state(false),
37
+ editKeyId = $state<string | null>(null)
38
+ const keys = $derived(endpoint?.keys ?? [])
39
+ const primary = $derived(primaryKey(keys))
40
+ const plain = $derived(revealed && revealed.id === primary?.id ? revealed.text : null)
41
+ const session = createClientSession()
42
+ let passwordRevision = 0
43
+ let textRevision = 0
44
+ function clear() {
45
+ session.invalidate()
46
+ password = ''
47
+ passwordRevision++
48
+ text = ''
49
+ textRevision++
50
+ revealed = null
51
+ askingPassword = false
52
+ copyAfterReveal = false
53
+ busy = false
54
+ }
55
+ $effect.pre(() => {
56
+ const target = `${endpoint?.id ?? ''}:${JSON.stringify(keys)}`
57
+ const managing = dialogOpen
58
+ untrack(() => {
59
+ void target
60
+ void managing
61
+ clear()
62
+ })
63
+ })
64
+ onDestroy(clear)
65
+
66
+ async function reveal() {
67
+ const port = ports.providers
68
+ const key = primary
69
+ if (!port || !key?.readable || busy) return
70
+ if (port.revealNeedsPassword && !password) {
71
+ askingPassword = true
72
+ return
73
+ }
74
+ const token = session.capture()
75
+ const revision = passwordRevision
76
+ const copying = copyAfterReveal
77
+ busy = true
78
+ try {
79
+ const value = await port.revealKey(key.id, password)
80
+ if (!session.isCurrent(token)) return
81
+ revealed = { id: key.id, text: value }
82
+ askingPassword = false
83
+ if (copying) await copy()
84
+ } catch (error) {
85
+ if (session.isCurrent(token))
86
+ notify.toast(
87
+ 'error',
88
+ error instanceof Error ? error.message : t('providers.key_reveal_failed'),
89
+ )
90
+ } finally {
91
+ if (session.isCurrent(token)) {
92
+ busy = false
93
+ if (passwordRevision === revision) password = ''
94
+ copyAfterReveal = false
95
+ }
96
+ }
97
+ }
98
+ /** Check the generation again immediately before the irreversible clipboard write. */
99
+ async function copy() {
100
+ if (plain === null) {
101
+ copyAfterReveal = true
102
+ return reveal()
103
+ }
104
+ const token = session.capture()
105
+ const value = plain
106
+ try {
107
+ if (!session.isCurrent(token)) return
108
+ await navigator.clipboard.writeText(value)
109
+ if (session.isCurrent(token)) notify.toast('success', t('providers.key_copied'))
110
+ } catch {
111
+ if (session.isCurrent(token)) notify.toast('error', t('providers.key_copy_failed'))
112
+ }
113
+ }
114
+ async function run(action: (port: ProvidersPort) => Promise<Provider[]>) {
115
+ if (!ports.providers || busy) return false
116
+ const token = session.capture()
117
+ busy = true
118
+ try {
119
+ const next = await action(ports.providers)
120
+ if (!session.isCurrent(token)) return false
121
+ onchanged(next)
122
+ return true
123
+ } catch (error) {
124
+ if (session.isCurrent(token)) notify.toast('error', keyError(error, t))
125
+ return false
126
+ } finally {
127
+ if (session.isCurrent(token)) busy = false
128
+ }
129
+ }
130
+ async function commit() {
131
+ const key = text.trim()
132
+ const endpointId = endpoint?.id
133
+ if (!key || !endpointId || busy) return
134
+ const revision = textRevision
135
+ session.invalidate()
136
+ revealed = null
137
+ password = ''
138
+ askingPassword = false
139
+ if (await run((port) => port.saveKey({ endpointId, key }))) {
140
+ if (textRevision === revision) text = ''
141
+ }
142
+ }
143
+ async function remove() {
144
+ const endpointId = endpoint?.id
145
+ const keyId = primary?.id
146
+ if (!endpointId || !keyId) return
147
+ const token = session.capture()
148
+ if (
149
+ !(await notify.confirm({
150
+ title: t('providers.key_delete'),
151
+ body: t('providers.key_delete_confirm'),
152
+ })) ||
153
+ !session.isCurrent(token)
154
+ )
155
+ return
156
+ clear()
157
+ await run((port) => port.removeKey(endpointId, keyId))
158
+ }
159
+ function manage(keyId: string | null) {
160
+ clear()
161
+ editKeyId = keyId
162
+ dialogOpen = true
163
+ }
164
+ </script>
165
+
166
+ <div class="flex min-w-0 flex-1 flex-col">
167
+ <div class="flex min-w-0 items-start gap-2">
168
+ {#if !primary}
169
+ <!-- An API key is not a login password: type="password" lets browsers autofill (and auto-save) the login one. -->
170
+ <Input
171
+ {id}
172
+ type="text"
173
+ autocomplete="off"
174
+ name="provider-api-key"
175
+ spellcheck={false}
176
+ class="min-w-0 flex-1 font-mono [-webkit-text-security:disc]"
177
+ placeholder={t('providers.key_placeholder')}
178
+ disabled={!endpoint || busy}
179
+ bind:value={text}
180
+ oninput={() => textRevision++}
181
+ onblur={commit}
182
+ onkeydown={(event) => {
183
+ if (event.key === 'Enter') event.currentTarget.blur()
184
+ if (event.key === 'Escape') text = ''
185
+ }}
186
+ />
187
+ {:else}
188
+ <div
189
+ class="group/key flex h-kit-control min-w-0 flex-1 items-center gap-1 rounded-kit border bg-muted/30 pr-1.5 font-mono text-kit-sm transition-colors hover:border-ring/60 focus-within:border-ring"
190
+ >
191
+ <button
192
+ {id}
193
+ type="button"
194
+ aria-haspopup="dialog"
195
+ class="flex h-full min-w-0 flex-1 items-center gap-1.5 pl-2.5 text-left outline-none"
196
+ onclick={() => manage(null)}
197
+ >
198
+ {#if primary.label}<span class="shrink-0 font-sans text-muted-foreground"
199
+ >{primary.label}</span
200
+ >{/if}
201
+ <span class="min-w-0 truncate">{plain ?? maskedText(primary, t)}</span>
202
+ {#if keys.length > 1}<span class="shrink-0 font-sans text-kit-xs text-muted-foreground"
203
+ >+{keys.length - 1}</span
204
+ >{/if}
205
+ </button>
206
+ <div
207
+ class="pointer-events-none flex shrink-0 items-center gap-0.5 opacity-0 transition-opacity group-hover/key:pointer-events-auto group-hover/key:opacity-100 group-focus-within/key:pointer-events-auto group-focus-within/key:opacity-100"
208
+ >
209
+ {#if primary.readable}<Button
210
+ variant="ghost"
211
+ size="icon-xs"
212
+ title={t('providers.key_copy')}
213
+ aria-label={t('providers.key_copy')}
214
+ disabled={busy}
215
+ onclick={() => void copy()}><CopyIcon /></Button
216
+ >{/if}
217
+ <Button
218
+ variant="ghost"
219
+ size="icon-xs"
220
+ title={t('providers.key_edit')}
221
+ aria-label={t('providers.key_edit')}
222
+ onclick={() => manage(primary?.id ?? null)}><EditIcon /></Button
223
+ >
224
+ {#if keys.length === 1}<Button
225
+ variant="ghost"
226
+ size="icon-xs"
227
+ class="hover:bg-destructive/10 hover:text-destructive"
228
+ title={t('providers.key_delete')}
229
+ aria-label={t('providers.key_delete')}
230
+ disabled={busy}
231
+ onclick={() => void remove()}><TrashIcon /></Button
232
+ >{/if}
233
+ </div>
234
+ {#if primary.readable}<Button
235
+ variant="ghost"
236
+ size="icon-xs"
237
+ class="text-muted-foreground"
238
+ title={plain === null ? t('providers.key_show') : t('providers.key_hide')}
239
+ aria-label={plain === null ? t('providers.key_show') : t('providers.key_hide')}
240
+ disabled={busy}
241
+ onclick={() => {
242
+ copyAfterReveal = false
243
+ if (plain === null) void reveal()
244
+ else clear()
245
+ }}
246
+ >{#if plain === null}<EyeIcon />{:else}<EyeOffIcon />{/if}</Button
247
+ >{/if}
248
+ </div>
249
+ {/if}
250
+ <Button
251
+ variant="outline"
252
+ size="icon"
253
+ class="shrink-0"
254
+ title={t('providers.keys_manage')}
255
+ aria-label={t('providers.keys_manage')}
256
+ disabled={!endpoint}
257
+ onclick={() => manage(null)}><KeyRoundIcon /></Button
258
+ >
259
+ </div>
260
+ {#if askingPassword}
261
+ <Input
262
+ type="password"
263
+ autocomplete="new-password"
264
+ class="mt-2"
265
+ placeholder={t('providers.key_current_password')}
266
+ aria-label={t('providers.key_current_password')}
267
+ bind:value={password}
268
+ oninput={() => passwordRevision++}
269
+ onkeydown={(event) => event.key === 'Enter' && void reveal()}
270
+ />
271
+ {/if}
272
+ </div>
273
+
274
+ {#if endpoint}
275
+ <EndpointKeysDialog
276
+ bind:open={dialogOpen}
277
+ {endpoint}
278
+ {editKeyId}
279
+ onchanged={(next) => {
280
+ clear()
281
+ onchanged(next)
282
+ }}
283
+ />
284
+ {/if}