@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,250 @@
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) => !!url && (!validUrl(url) || new TextEncoder().encode(url).length > 2048)
88
+ function save() {
89
+ error = null
90
+ const url = baseUrl.trim()
91
+ const image = imageBaseUrl.trim()
92
+ const imageEdit = imageEditBaseUrl.trim()
93
+ if ((url && !validUrl(url)) || badImage(image) || badImage(imageEdit)) {
94
+ error = t('providers.url_invalid')
95
+ return
96
+ }
97
+ const list = json === null ? rows : parse(json)
98
+ if (!list) {
99
+ error = t('providers.headers_invalid_json')
100
+ return
101
+ }
102
+ const checked = checkHeaders(list)
103
+ if ('error' in checked) {
104
+ error =
105
+ checked.error.key === 'headers_reserved'
106
+ ? t('providers.headers_reserved', { name: checked.error.name })
107
+ : t(`providers.${checked.error.key}`)
108
+ return
109
+ }
110
+ endpoint.protocol = protocol
111
+ endpoint.baseUrl = url
112
+ endpoint.imageBaseUrl = image || null
113
+ endpoint.imageEditBaseUrl = imageEdit || null
114
+ endpoint.headers = checked.headers
115
+ open = false
116
+ oncommit()
117
+ }
118
+ </script>
119
+
120
+ <Dialog.Root bind:open>
121
+ <Dialog.Content class="sm:max-w-lg">
122
+ <Dialog.Header>
123
+ <Dialog.Title>{t('providers.request_config')}</Dialog.Title>
124
+ </Dialog.Header>
125
+ <Field.Group>
126
+ <Field.Field>
127
+ <Field.Label for="kit-endpoint-protocol">{t('providers.protocol')}</Field.Label>
128
+ <Select.Root type="single" value={protocol} onValueChange={(value) => (protocol = value as Protocol)}>
129
+ <Select.Trigger id="kit-endpoint-protocol" class="w-full"
130
+ >{t(`providers.protocol.${protocol}`)}</Select.Trigger
131
+ >
132
+ <Select.Content>
133
+ <Select.Group>
134
+ {#each PROTOCOLS as p (p)}<Select.Item value={p} label={t(`providers.protocol.${p}`)} />{/each}
135
+ </Select.Group>
136
+ </Select.Content>
137
+ </Select.Root>
138
+ </Field.Field>
139
+ <Field.Field>
140
+ <div class="flex items-center gap-2">
141
+ <Field.Label for="kit-request-url">{t('providers.base_url')}</Field.Label>
142
+ <span class="bg-primary/10 text-primary rounded-kit px-1.5 text-kit-xs">{t('providers.default')}</span>
143
+ </div>
144
+ <CopyInput
145
+ id="kit-request-url"
146
+ class="font-mono"
147
+ placeholder="https://api.example.com/v1"
148
+ bind:value={baseUrl}
149
+ />
150
+ {#if validUrl(baseUrl)}
151
+ <Field.Description class="truncate font-mono text-kit-xs"
152
+ >{t('providers.base_url_hint', { url: `POST ${chatUrl(baseUrl, protocol)}` })}</Field.Description
153
+ >
154
+ {/if}
155
+ </Field.Field>
156
+ {#if onaddendpoint}
157
+ <Button
158
+ variant="outline"
159
+ class="w-full border-dashed"
160
+ onclick={() => {
161
+ open = false
162
+ onaddendpoint()
163
+ }}><PlusIcon />{t('providers.add_endpoint')}</Button
164
+ >
165
+ {/if}
166
+ <Field.Field>
167
+ <Field.Label for="kit-request-image-url">{t('providers.image_base_url')}</Field.Label>
168
+ <CopyInput
169
+ id="kit-request-image-url"
170
+ class="font-mono"
171
+ placeholder="https://example.com"
172
+ bind:value={imageBaseUrl}
173
+ />
174
+ <Field.Description class="text-kit-xs">{t('providers.image_base_url_hint')}</Field.Description>
175
+ </Field.Field>
176
+ <Field.Field>
177
+ <Field.Label for="kit-request-image-edit-url">{t('providers.image_edit_base_url')}</Field.Label>
178
+ <CopyInput
179
+ id="kit-request-image-edit-url"
180
+ class="font-mono"
181
+ placeholder="https://example.com"
182
+ bind:value={imageEditBaseUrl}
183
+ />
184
+ <Field.Description class="text-kit-xs">{t('providers.image_edit_base_url_hint')}</Field.Description>
185
+ </Field.Field>
186
+ <Field.Field>
187
+ <div class="flex items-center justify-between gap-2">
188
+ <Field.Label for={json === null ? undefined : 'kit-request-headers'}>{t('providers.headers')}</Field.Label>
189
+ <Button
190
+ variant="ghost"
191
+ size="icon"
192
+ title={json === null ? t('providers.headers_to_json') : t('providers.headers_to_list')}
193
+ aria-label={json === null ? t('providers.headers_to_json') : t('providers.headers_to_list')}
194
+ onclick={toggle}
195
+ >{#if json === null}<BracesIcon />{:else}<ListIcon />{/if}</Button
196
+ >
197
+ </div>
198
+ {#if json === null}
199
+ {#each rows as row, index (index)}
200
+ <div class="flex min-w-0 items-center gap-2">
201
+ <Input
202
+ class="min-w-0 flex-1 font-mono"
203
+ aria-label={t('providers.header_name')}
204
+ placeholder={t('providers.header_name')}
205
+ bind:value={row.name}
206
+ />
207
+ <Input
208
+ class="min-w-0 flex-1 font-mono"
209
+ aria-label={t('providers.header_value')}
210
+ placeholder={t('providers.header_value')}
211
+ bind:value={row.value}
212
+ />
213
+ <Button
214
+ variant="ghost"
215
+ size="icon"
216
+ aria-label={t('providers.header_remove', { name: row.name })}
217
+ onclick={() => rows.splice(index, 1)}><TrashIcon /></Button
218
+ >
219
+ </div>
220
+ {/each}
221
+ <Button
222
+ variant="ghost"
223
+ class="self-start"
224
+ disabled={rows.length >= 20}
225
+ onclick={() => rows.push({ name: '', value: '' })}><PlusIcon />{t('providers.headers_add')}</Button
226
+ >
227
+ {:else}
228
+ <Textarea id="kit-request-headers" class="min-h-32 font-mono text-xs" spellcheck={false} bind:value={json} />
229
+ {/if}
230
+ </Field.Field>
231
+ </Field.Group>
232
+ {#if error}
233
+ <p role="alert" class="text-destructive text-kit-xs">{error}</p>
234
+ {/if}
235
+ <Dialog.Footer class="sm:justify-between">
236
+ <Button
237
+ variant="ghost"
238
+ class="text-destructive"
239
+ onclick={() => {
240
+ open = false
241
+ onremove()
242
+ }}>{t('providers.remove_endpoint')}</Button
243
+ >
244
+ <div class="flex gap-2">
245
+ <Button variant="outline" onclick={() => (open = false)}>{t('common.cancel')}</Button>
246
+ <Button onclick={save}>{t('providers.save')}</Button>
247
+ </div>
248
+ </Dialog.Footer>
249
+ </Dialog.Content>
250
+ </Dialog.Root>
@@ -0,0 +1,276 @@
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('error', error instanceof Error ? error.message : t('providers.key_reveal_failed'))
87
+ } finally {
88
+ if (session.isCurrent(token)) {
89
+ busy = false
90
+ if (passwordRevision === revision) password = ''
91
+ copyAfterReveal = false
92
+ }
93
+ }
94
+ }
95
+ /** Check the generation again immediately before the irreversible clipboard write. */
96
+ async function copy() {
97
+ if (plain === null) {
98
+ copyAfterReveal = true
99
+ return reveal()
100
+ }
101
+ const token = session.capture()
102
+ const value = plain
103
+ try {
104
+ if (!session.isCurrent(token)) return
105
+ await navigator.clipboard.writeText(value)
106
+ if (session.isCurrent(token)) notify.toast('success', t('providers.key_copied'))
107
+ } catch {
108
+ if (session.isCurrent(token)) notify.toast('error', t('providers.key_copy_failed'))
109
+ }
110
+ }
111
+ async function run(action: (port: ProvidersPort) => Promise<Provider[]>) {
112
+ if (!ports.providers || busy) return false
113
+ const token = session.capture()
114
+ busy = true
115
+ try {
116
+ const next = await action(ports.providers)
117
+ if (!session.isCurrent(token)) return false
118
+ onchanged(next)
119
+ return true
120
+ } catch (error) {
121
+ if (session.isCurrent(token)) notify.toast('error', keyError(error, t))
122
+ return false
123
+ } finally {
124
+ if (session.isCurrent(token)) busy = false
125
+ }
126
+ }
127
+ async function commit() {
128
+ const key = text.trim()
129
+ const endpointId = endpoint?.id
130
+ if (!key || !endpointId || busy) return
131
+ const revision = textRevision
132
+ session.invalidate()
133
+ revealed = null
134
+ password = ''
135
+ askingPassword = false
136
+ if (await run((port) => port.saveKey({ endpointId, key }))) {
137
+ if (textRevision === revision) text = ''
138
+ }
139
+ }
140
+ async function remove() {
141
+ const endpointId = endpoint?.id
142
+ const keyId = primary?.id
143
+ if (!endpointId || !keyId) return
144
+ const token = session.capture()
145
+ if (
146
+ !(await notify.confirm({ title: t('providers.key_delete'), body: t('providers.key_delete_confirm') })) ||
147
+ !session.isCurrent(token)
148
+ )
149
+ return
150
+ clear()
151
+ await run((port) => port.removeKey(endpointId, keyId))
152
+ }
153
+ function manage(keyId: string | null) {
154
+ clear()
155
+ editKeyId = keyId
156
+ dialogOpen = true
157
+ }
158
+ </script>
159
+
160
+ <div class="flex min-w-0 flex-1 flex-col">
161
+ <div class="flex min-w-0 items-start gap-2">
162
+ {#if !primary}
163
+ <!-- An API key is not a login password: type="password" lets browsers autofill (and auto-save) the login one. -->
164
+ <Input
165
+ {id}
166
+ type="text"
167
+ autocomplete="off"
168
+ name="provider-api-key"
169
+ spellcheck={false}
170
+ class="min-w-0 flex-1 font-mono [-webkit-text-security:disc]"
171
+ placeholder={t('providers.key_placeholder')}
172
+ disabled={!endpoint || busy}
173
+ bind:value={text}
174
+ oninput={() => textRevision++}
175
+ onblur={commit}
176
+ onkeydown={(event) => {
177
+ if (event.key === 'Enter') event.currentTarget.blur()
178
+ if (event.key === 'Escape') text = ''
179
+ }}
180
+ />
181
+ {:else}
182
+ <div
183
+ 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"
184
+ >
185
+ <button
186
+ {id}
187
+ type="button"
188
+ aria-haspopup="dialog"
189
+ class="flex h-full min-w-0 flex-1 items-center gap-1.5 pl-2.5 text-left outline-none"
190
+ onclick={() => manage(null)}
191
+ >
192
+ {#if primary.label}<span class="shrink-0 font-sans text-muted-foreground">{primary.label}</span>{/if}
193
+ <span class="min-w-0 truncate">{plain ?? maskedText(primary, t)}</span>
194
+ {#if keys.length > 1}<span class="shrink-0 font-sans text-kit-xs text-muted-foreground"
195
+ >+{keys.length - 1}</span
196
+ >{/if}
197
+ </button>
198
+ <div
199
+ 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"
200
+ >
201
+ {#if primary.readable}<Button
202
+ variant="ghost"
203
+ size="icon-xs"
204
+ title={t('providers.key_copy')}
205
+ aria-label={t('providers.key_copy')}
206
+ disabled={busy}
207
+ onclick={() => void copy()}><CopyIcon /></Button
208
+ >{/if}
209
+ <Button
210
+ variant="ghost"
211
+ size="icon-xs"
212
+ title={t('providers.key_edit')}
213
+ aria-label={t('providers.key_edit')}
214
+ onclick={() => manage(primary?.id ?? null)}><EditIcon /></Button
215
+ >
216
+ {#if keys.length === 1}<Button
217
+ variant="ghost"
218
+ size="icon-xs"
219
+ class="hover:bg-destructive/10 hover:text-destructive"
220
+ title={t('providers.key_delete')}
221
+ aria-label={t('providers.key_delete')}
222
+ disabled={busy}
223
+ onclick={() => void remove()}><TrashIcon /></Button
224
+ >{/if}
225
+ </div>
226
+ {#if primary.readable}<Button
227
+ variant="ghost"
228
+ size="icon-xs"
229
+ class="text-muted-foreground"
230
+ title={plain === null ? t('providers.key_show') : t('providers.key_hide')}
231
+ aria-label={plain === null ? t('providers.key_show') : t('providers.key_hide')}
232
+ disabled={busy}
233
+ onclick={() => {
234
+ copyAfterReveal = false
235
+ if (plain === null) void reveal()
236
+ else clear()
237
+ }}
238
+ >{#if plain === null}<EyeIcon />{:else}<EyeOffIcon />{/if}</Button
239
+ >{/if}
240
+ </div>
241
+ {/if}
242
+ <Button
243
+ variant="outline"
244
+ size="icon"
245
+ class="shrink-0"
246
+ title={t('providers.keys_manage')}
247
+ aria-label={t('providers.keys_manage')}
248
+ disabled={!endpoint}
249
+ onclick={() => manage(null)}><KeyRoundIcon /></Button
250
+ >
251
+ </div>
252
+ {#if askingPassword}
253
+ <Input
254
+ type="password"
255
+ autocomplete="new-password"
256
+ class="mt-2"
257
+ placeholder={t('providers.key_current_password')}
258
+ aria-label={t('providers.key_current_password')}
259
+ bind:value={password}
260
+ oninput={() => passwordRevision++}
261
+ onkeydown={(event) => event.key === 'Enter' && void reveal()}
262
+ />
263
+ {/if}
264
+ </div>
265
+
266
+ {#if endpoint}
267
+ <EndpointKeysDialog
268
+ bind:open={dialogOpen}
269
+ {endpoint}
270
+ {editKeyId}
271
+ onchanged={(next) => {
272
+ clear()
273
+ onchanged(next)
274
+ }}
275
+ />
276
+ {/if}