@ouraihub/aether-ui 0.1.0 → 0.1.2

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 (90) hide show
  1. package/package.json +3 -3
  2. package/src/chat/ApprovalCard.svelte +8 -2
  3. package/src/chat/ChatComposer.svelte +319 -0
  4. package/src/chat/ChatMessage.svelte +262 -0
  5. package/src/chat/ChatPanel.css +386 -0
  6. package/src/chat/ChatPanel.svelte +276 -1880
  7. package/src/chat/ChatPanelApprovals.svelte +28 -0
  8. package/src/chat/ChatPanelComposer.svelte +336 -0
  9. package/src/chat/ChatPanelDialogs.svelte +242 -0
  10. package/src/chat/ChatPanelHeader.svelte +108 -0
  11. package/src/chat/ChatPanelMessages.svelte +166 -0
  12. package/src/chat/ChatPanelMode.svelte +190 -0
  13. package/src/chat/ChatPanelState.svelte.ts +1 -0
  14. package/src/chat/ChatPanelToolbar.svelte +436 -0
  15. package/src/chat/ChatSurface.svelte +168 -0
  16. package/src/chat/ContextUsageStatus.svelte +97 -0
  17. package/src/chat/MessageItem.svelte +111 -214
  18. package/src/chat/ModelEffortPicker.svelte +326 -0
  19. package/src/chat/PickerEffort.svelte +71 -0
  20. package/src/chat/ReasoningControl.svelte +184 -0
  21. package/src/chat/ReasoningSlider.svelte +174 -0
  22. package/src/chat/SkillPicker.svelte +14 -6
  23. package/src/chat/UsageControl.svelte +223 -0
  24. package/src/chat/UsageDetails.svelte +44 -90
  25. package/src/chat/chat-attachments.svelte.ts +78 -0
  26. package/src/chat/chat-panel-history.svelte.ts +430 -0
  27. package/src/chat/chat-panel-models.svelte.ts +97 -0
  28. package/src/chat/chat-panel-props.ts +38 -0
  29. package/src/chat/context-status.ts +101 -0
  30. package/src/chat/icons/LICENSE-REMIX +186 -0
  31. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  32. package/src/chat/icons/PathIcon.svelte +8 -0
  33. package/src/chat/icons/effort-icons.ts +65 -0
  34. package/src/chat/icons/status-icons.ts +105 -0
  35. package/src/chat/index.ts +25 -1
  36. package/src/chat/model-effort.ts +177 -0
  37. package/src/chat/run-session-history.ts +56 -0
  38. package/src/chat/run-session-types.ts +69 -0
  39. package/src/chat/run-session.svelte.ts +73 -135
  40. package/src/chat/test-support/chat-fixture.ts +194 -0
  41. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  42. package/src/chat/test-support/run-session-fixture.ts +112 -0
  43. package/src/dev/Demo.svelte +3 -1
  44. package/src/i18n/en-chat.d.ts +1 -0
  45. package/src/i18n/en-chat.js +201 -0
  46. package/src/i18n/en.ts +58 -180
  47. package/src/i18n/index.ts +7 -2
  48. package/src/i18n/zh-CN-chat.d.ts +1 -0
  49. package/src/i18n/zh-CN-chat.js +190 -0
  50. package/src/i18n/zh-CN.ts +8 -153
  51. package/src/logs/LogPanel.svelte +9 -3
  52. package/src/logs/LogSettings.svelte +3 -1
  53. package/src/mcp/McpClientEditor.svelte +204 -0
  54. package/src/mcp/McpClientSettings.svelte +57 -130
  55. package/src/mcp/McpServerSettings.svelte +47 -11
  56. package/src/mcp/input.ts +2 -1
  57. package/src/ports/contract.ts +11 -2
  58. package/src/ports/fake-optional.ts +81 -0
  59. package/src/ports/fake.ts +90 -86
  60. package/src/ports/search.ts +11 -2
  61. package/src/ports/test-support/fake-fixture.ts +63 -0
  62. package/src/providers/EndpointKeysDialog.svelte +27 -13
  63. package/src/providers/EndpointModels.svelte +311 -0
  64. package/src/providers/EndpointPanel.svelte +33 -258
  65. package/src/providers/ModelPickerDialog.svelte +59 -16
  66. package/src/providers/ModelPricing.svelte +125 -0
  67. package/src/providers/ModelSelect.svelte +22 -8
  68. package/src/providers/ModelSettingsDialog.svelte +61 -126
  69. package/src/providers/ProviderDialogs.svelte +120 -0
  70. package/src/providers/ProviderList.svelte +159 -0
  71. package/src/providers/ProvidersSettings.svelte +67 -207
  72. package/src/providers/RequestConfigDialog.svelte +40 -12
  73. package/src/providers/SecretInput.svelte +11 -3
  74. package/src/providers/draft.ts +39 -13
  75. package/src/providers/edit-session.ts +31 -7
  76. package/src/providers/reasoning.ts +18 -4
  77. package/src/providers/revision.svelte.ts +3 -1
  78. package/src/providers/test-support/providers-fixture.ts +120 -0
  79. package/src/search/SearchAdvancedTools.svelte +182 -0
  80. package/src/search/SearchProviderCard.svelte +195 -0
  81. package/src/search/SearchSettings.svelte +62 -265
  82. package/src/search/settings-flow.ts +8 -3
  83. package/src/skills/SkillAdd.svelte +51 -18
  84. package/src/skills/SkillAuthoring.svelte +69 -20
  85. package/src/skills/SkillEditor.svelte +13 -3
  86. package/src/skills/SkillPreviewDialog.svelte +19 -5
  87. package/src/skills/SkillsSettings.svelte +11 -4
  88. package/src/skills/test-support/skills-fixture.ts +139 -0
  89. package/src/tauri/index.ts +23 -7
  90. package/src/web/index.ts +90 -25
@@ -50,7 +50,8 @@
50
50
 
51
51
  const key = (r: ModelRef) => `${r.endpointId}\n${r.modelId}`
52
52
  // label is `{provider} · {slug} · {modelId}`; a model's display name never enters it.
53
- const group = (o: ModelOption) => o.label.slice(0, Math.max(0, o.label.lastIndexOf(' · '))) || o.label
53
+ const group = (o: ModelOption) =>
54
+ o.label.slice(0, Math.max(0, o.label.lastIndexOf(' · '))) || o.label
54
55
  type Cap = 'vision' | 'reasoning' | 'tools' | 'web'
55
56
  const CAPS = [
56
57
  { cap: 'vision', icon: EyeIcon, has: (o: ModelOption) => o.canVision },
@@ -105,7 +106,9 @@
105
106
  {#if failed}
106
107
  <div class="model-error" role="alert">
107
108
  <span>{t('chat.modelsFailed')}</span>
108
- <Button variant="outline" size="sm" {disabled} onclick={() => reload++}>{t('chat.retry')}</Button>
109
+ <Button variant="outline" size="sm" {disabled} onclick={() => reload++}
110
+ >{t('chat.retry')}</Button
111
+ >
109
112
  </div>
110
113
  {:else}
111
114
  <Popover.Root
@@ -132,7 +135,9 @@
132
135
  <span class="model-group">| {group(current)}</span>
133
136
  {:else}
134
137
  <span class="model-name muted"
135
- >{invalid ? t('providers.model_invalid') : (placeholder ?? t('providers.model_placeholder'))}</span
138
+ >{invalid
139
+ ? t('providers.model_invalid')
140
+ : (placeholder ?? t('providers.model_placeholder'))}</span
136
141
  >
137
142
  {/if}
138
143
  <ChevronDownIcon class="model-chevron" />
@@ -150,7 +155,11 @@
150
155
  if (event.isComposing) event.preventDefault()
151
156
  }}
152
157
  >
153
- <Command.Root bind:value={highlighted} filter={match} label={t('providers.model_placeholder')}>
158
+ <Command.Root
159
+ bind:value={highlighted}
160
+ filter={match}
161
+ label={t('providers.model_placeholder')}
162
+ >
154
163
  <div class="model-search">
155
164
  <SearchIcon class="model-search-icon" />
156
165
  <Command.Input class="model-search-input" placeholder={t('providers.model_search')} />
@@ -163,8 +172,9 @@
163
172
  class="cap-chip cap-{c.cap}"
164
173
  aria-pressed={active.includes(c.cap)}
165
174
  onclick={() =>
166
- (active = active.includes(c.cap) ? active.filter((x) => x !== c.cap) : [...active, c.cap])}
167
- ><c.icon />{t(`providers.cap_${c.cap}`)}</button
175
+ (active = active.includes(c.cap)
176
+ ? active.filter((x) => x !== c.cap)
177
+ : [...active, c.cap])}><c.icon />{t(`providers.cap_${c.cap}`)}</button
168
178
  >
169
179
  {/each}
170
180
  </div>
@@ -180,13 +190,17 @@
180
190
  class="model-item"
181
191
  value={key(option)}
182
192
  keywords={[option.modelId, option.name ?? '', name]}
183
- aria-current={value !== null && key(option) === key(value) ? 'true' : undefined}
193
+ aria-current={value !== null && key(option) === key(value)
194
+ ? 'true'
195
+ : undefined}
184
196
  onSelect={() => pick(option)}
185
197
  >
186
198
  <span class="model-avatar" aria-hidden="true"
187
199
  >{(option.name ?? option.modelId).charAt(0).toUpperCase()}</span
188
200
  >
189
- <span class="model-name" title={option.label}>{option.name ?? option.modelId}</span>
201
+ <span class="model-name" title={option.label}
202
+ >{option.name ?? option.modelId}</span
203
+ >
190
204
  <span class="model-tags">
191
205
  {#each CAPS.filter((c) => c.has(option)) as c (c.cap)}
192
206
  <span class="cap-tag cap-{c.cap}" title={t(`providers.cap_${c.cap}`)}
@@ -2,8 +2,7 @@
2
2
  // Per-model settings (docs/41 §3–§4). Edits a copy; nothing changes until Save.
3
3
  import { untrack } from 'svelte'
4
4
  import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
5
- import MinusIcon from '@lucide/svelte/icons/minus'
6
- import PlusIcon from '@lucide/svelte/icons/plus'
5
+ import ModelPricing from './ModelPricing.svelte'
7
6
  import { Button } from '#lib/components/ui/button/index.js'
8
7
  import * as Dialog from '#lib/components/ui/dialog/index.js'
9
8
  import * as Field from '#lib/components/ui/field/index.js'
@@ -11,10 +10,24 @@
11
10
  import * as Select from '#lib/components/ui/select/index.js'
12
11
  import CopyInput from '../CopyInput.svelte'
13
12
  import { getKitContext } from '../context'
14
- import type { Model, ModelType, PricingTier, Protocol, ReasoningDialect, ReasoningEffort } from '../ports'
13
+ import type { Model, ModelType, Protocol, ReasoningDialect, ReasoningEffort } from '../ports'
15
14
  import { cn } from '../utils'
16
- import { idPrefix, purposeOf, validateModel, withPurpose, withType, type ModelField, type Purpose } from './draft'
17
- import { REASONING_DIALECTS, REASONING_EFFORTS, allowsAuto, dialectFits, emptyReasoning } from './reasoning'
15
+ import {
16
+ idPrefix,
17
+ purposeOf,
18
+ validateModel,
19
+ withPurpose,
20
+ withType,
21
+ type ModelField,
22
+ type Purpose,
23
+ } from './draft'
24
+ import {
25
+ REASONING_DIALECTS,
26
+ REASONING_EFFORTS,
27
+ allowsAuto,
28
+ dialectFits,
29
+ emptyReasoning,
30
+ } from './reasoning'
18
31
 
19
32
  let {
20
33
  open = $bindable(false),
@@ -48,12 +61,7 @@
48
61
  ['64K', 65536],
49
62
  ['128K', 131072],
50
63
  ]
51
- const PRICES = ['input', 'output', 'cacheRead', 'cacheWrite'] as const
52
64
  const PURPOSES: Purpose[] = ['chat', 'image', 'edit']
53
- const CURRENCIES = [
54
- ['USD', '$'],
55
- ['CNY', '¥'],
56
- ] as const
57
65
  const CHIP = 'aria-pressed:border-primary aria-pressed:bg-primary/10 aria-pressed:text-primary'
58
66
 
59
67
  const { t } = getKitContext().i18n
@@ -90,28 +98,8 @@
90
98
  }
91
99
  /** Tri-state: not probed → supported → unsupported → not probed. */
92
100
  function cycleTools() {
93
- if (edit) edit.supportsTools = edit.supportsTools === null ? true : edit.supportsTools ? false : null
94
- }
95
- const tier = (minInputTokens: number): PricingTier => ({
96
- minInputTokens,
97
- input: 0,
98
- output: 0,
99
- cacheRead: null,
100
- cacheWrite: null,
101
- })
102
- function setPrice(index: number, key: keyof PricingTier, value: number) {
103
- const target = edit?.pricing?.tiers[index]
104
- if (!target) return
105
- // Required prices stay NaN when cleared so validation flags them; cache prices fall back to the input price.
106
- if (key === 'cacheRead' || key === 'cacheWrite') target[key] = count(value)
107
- else target[key] = value
108
- }
109
- function removeTier(index: number) {
110
- const pricing = edit?.pricing
111
- if (!edit || !pricing) return
112
- pricing.tiers.splice(index, 1)
113
- if (pricing.tiers[0]) pricing.tiers[0].minInputTokens = 0
114
- else edit.pricing = null
101
+ if (edit)
102
+ edit.supportsTools = edit.supportsTools === null ? true : edit.supportsTools ? false : null
115
103
  }
116
104
  function save() {
117
105
  if (!edit || errors.length > 0) return
@@ -192,17 +180,22 @@
192
180
  size="sm"
193
181
  class={CHIP}
194
182
  aria-pressed={edit.modelType === type}
195
- onclick={() => edit && (edit = withType(edit, type))}>{t(`providers.type.${type}`)}</Button
183
+ onclick={() => edit && (edit = withType(edit, type))}
184
+ >{t(`providers.type.${type}`)}</Button
196
185
  >
197
186
  {/each}
198
187
  <Select.Root
199
188
  type="single"
200
189
  value={OTHER_TYPES.includes(edit.modelType) ? edit.modelType : ''}
201
- onValueChange={(value) => edit && value && (edit = withType(edit, value as ModelType))}
190
+ onValueChange={(value) =>
191
+ edit && value && (edit = withType(edit, value as ModelType))}
202
192
  >
203
193
  <Select.Trigger
204
194
  size="sm"
205
- class={cn(OTHER_TYPES.includes(edit.modelType) && 'border-primary bg-primary/10 text-primary')}
195
+ class={cn(
196
+ OTHER_TYPES.includes(edit.modelType) &&
197
+ 'border-primary bg-primary/10 text-primary',
198
+ )}
206
199
  aria-label={t('providers.type_other')}
207
200
  >{OTHER_TYPES.includes(edit.modelType)
208
201
  ? t(`providers.type.${edit.modelType}`)
@@ -220,7 +213,9 @@
220
213
  </div>
221
214
  {#if usedBy.length > 0 && edit.modelType !== model?.modelType}
222
215
  <Field.Description class="text-warning text-kit-xs" role="status"
223
- >{t('providers.type_in_use', { roles: usedBy.join(t('providers.list_separator')) })}</Field.Description
216
+ >{t('providers.type_in_use', {
217
+ roles: usedBy.join(t('providers.list_separator')),
218
+ })}</Field.Description
224
219
  >
225
220
  {/if}
226
221
  </Field.Set>
@@ -234,18 +229,25 @@
234
229
  class={CHIP}
235
230
  aria-pressed={edit.reasoning !== null}
236
231
  onclick={() =>
237
- edit && (edit.reasoning = edit.reasoning ? null : emptyReasoning(dialects[0] ?? 'openai'))}
232
+ edit &&
233
+ (edit.reasoning = edit.reasoning
234
+ ? null
235
+ : emptyReasoning(dialects[0] ?? 'openai'))}
238
236
  >{t('providers.cap_reasoning')}</Button
239
237
  >
240
238
  <Button
241
239
  variant="outline"
242
240
  size="sm"
243
- class={cn(CHIP, edit.supportsTools === false && 'text-muted-foreground line-through')}
241
+ class={cn(
242
+ CHIP,
243
+ edit.supportsTools === false && 'text-muted-foreground line-through',
244
+ )}
244
245
  aria-pressed={edit.supportsTools === true}
245
246
  title={t(`providers.tools_state.${edit.supportsTools ?? 'unknown'}`)}
246
247
  onclick={cycleTools}
247
248
  >{t('providers.cap_tools')}{#if edit.supportsTools === null}<span
248
- class="text-muted-foreground text-kit-xs">{t('providers.tools_state.unknown')}</span
249
+ class="text-muted-foreground text-kit-xs"
250
+ >{t('providers.tools_state.unknown')}</span
249
251
  >{/if}</Button
250
252
  >
251
253
  {#if protocol !== 'openai-chat'}
@@ -272,7 +274,8 @@
272
274
  size="sm"
273
275
  class={CHIP}
274
276
  aria-pressed={edit.canVision}
275
- onclick={() => edit && (edit.canVision = !edit.canVision)}>{t('providers.cap_vision')}</Button
277
+ onclick={() => edit && (edit.canVision = !edit.canVision)}
278
+ >{t('providers.cap_vision')}</Button
276
279
  >
277
280
  </div>
278
281
  </Field.Set>
@@ -280,7 +283,7 @@
280
283
  {@render tokens('contextWindow', CONTEXT_PRESETS, 32000)}
281
284
  {@render tokens('maxOutput', OUTPUT_PRESETS, 4096)}
282
285
  {#if edit.modelType === 'chat'}
283
- {@render pricing()}
286
+ <ModelPricing {edit} {errorFor} />
284
287
  {/if}
285
288
  {/if}
286
289
  </Field.Group>
@@ -299,12 +302,18 @@
299
302
  <Select.Root
300
303
  type="single"
301
304
  value={dialect}
302
- onValueChange={(value) => edit && (edit.reasoning = emptyReasoning(value as ReasoningDialect))}
305
+ onValueChange={(value) =>
306
+ edit && (edit.reasoning = emptyReasoning(value as ReasoningDialect))}
303
307
  >
304
- <Select.Trigger id="kit-model-dialect" class="w-full">{t(`providers.dialect.${dialect}`)}</Select.Trigger>
308
+ <Select.Trigger id="kit-model-dialect" class="w-full"
309
+ >{t(`providers.dialect.${dialect}`)}</Select.Trigger
310
+ >
305
311
  <Select.Content>
306
312
  <Select.Group>
307
- {#each dialects as item (item)}<Select.Item value={item} label={t(`providers.dialect.${item}`)} />{/each}
313
+ {#each dialects as item (item)}<Select.Item
314
+ value={item}
315
+ label={t(`providers.dialect.${item}`)}
316
+ />{/each}
308
317
  </Select.Group>
309
318
  </Select.Content>
310
319
  </Select.Root>
@@ -322,7 +331,9 @@
322
331
  >
323
332
  {/each}
324
333
  </div>
325
- {#if dialect !== 'anthropic-budget' && dialect !== 'qwen'}<Field.Error errors={errorFor('reasoning')} />{/if}
334
+ {#if dialect !== 'anthropic-budget' && dialect !== 'qwen'}<Field.Error
335
+ errors={errorFor('reasoning')}
336
+ />{/if}
326
337
  </Field.Set>
327
338
  {#if dialect === 'anthropic-budget' || dialect === 'qwen'}
328
339
  <Field.Field>
@@ -341,7 +352,11 @@
341
352
  {/if}
342
353
  {/snippet}
343
354
 
344
- {#snippet tokens(field: 'contextWindow' | 'maxOutput', presets: [string, number][], fallback: number)}
355
+ {#snippet tokens(
356
+ field: 'contextWindow' | 'maxOutput',
357
+ presets: [string, number][],
358
+ fallback: number,
359
+ )}
345
360
  {@const value = edit?.[field] ?? null}
346
361
  <Field.Field>
347
362
  <Field.Label for="kit-model-{field}"
@@ -371,83 +386,3 @@
371
386
  <Field.Error errors={errorFor(field)} />
372
387
  </Field.Field>
373
388
  {/snippet}
374
-
375
- {#snippet pricing()}
376
- <Field.Set>
377
- <Field.Legend variant="label">{t('providers.pricing')}</Field.Legend>
378
- {#if edit?.pricing}
379
- {@const current = edit.pricing}
380
- <div class="flex flex-wrap gap-1.5" role="group" aria-label={t('providers.currency')}>
381
- {#each CURRENCIES as [currency, symbol] (currency)}
382
- <Button
383
- variant="outline"
384
- size="sm"
385
- class={CHIP}
386
- aria-pressed={current.currency === currency}
387
- aria-label={t(`providers.currency.${currency}`)}
388
- onclick={() => (current.currency = currency)}>{symbol}</Button
389
- >
390
- {/each}
391
- </div>
392
- <p class="text-muted-foreground text-kit-xs">{t('providers.pricing_unit')}</p>
393
- {#each current.tiers as item, index (index)}
394
- <fieldset class="flex flex-col gap-2 rounded-kit border p-2">
395
- <legend class="flex w-full items-center gap-2 px-1 text-kit-sm"
396
- >{t('providers.tier', { n: index + 1 })}{#if current.tiers.length > 1}<Button
397
- variant="ghost"
398
- size="icon-xs"
399
- class="ml-auto"
400
- aria-label={t('providers.tier_remove', { n: index + 1 })}
401
- title={t('providers.tier_remove', { n: index + 1 })}
402
- onclick={() => removeTier(index)}><MinusIcon /></Button
403
- >{/if}</legend
404
- >
405
- <div class="grid grid-cols-2 gap-2 sm:grid-cols-3">
406
- <label class="flex flex-col gap-1 text-kit-xs"
407
- >{t('providers.tier_start')}<Input
408
- type="number"
409
- min="0"
410
- step="1"
411
- disabled={index === 0}
412
- value={item.minInputTokens}
413
- oninput={(event) => setPrice(index, 'minInputTokens', event.currentTarget.valueAsNumber)}
414
- /></label
415
- >
416
- {#each PRICES as key (key)}
417
- <label class="flex flex-col gap-1 text-kit-xs"
418
- >{t(`providers.price.${key}`)}<Input
419
- type="number"
420
- min="0"
421
- step="any"
422
- placeholder={key === 'cacheRead' || key === 'cacheWrite' ? t('providers.price_same_as_input') : ''}
423
- value={item[key] ?? ''}
424
- oninput={(event) => setPrice(index, key, event.currentTarget.valueAsNumber)}
425
- /></label
426
- >
427
- {/each}
428
- </div>
429
- </fieldset>
430
- {/each}
431
- <Field.Error errors={errorFor('pricing')} />
432
- <div class="flex flex-wrap gap-1.5">
433
- {#if current.tiers.length < 8}<Button
434
- variant="outline"
435
- size="sm"
436
- onclick={() => current.tiers.push(tier((current.tiers.at(-1)?.minInputTokens ?? 0) + 200000))}
437
- ><PlusIcon />{t('providers.tier_add')}</Button
438
- >{/if}
439
- <Button variant="ghost" size="sm" onclick={() => edit && (edit.pricing = null)}
440
- >{t('providers.pricing_clear')}</Button
441
- >
442
- </div>
443
- {:else}
444
- <Button
445
- variant="outline"
446
- size="sm"
447
- class="self-start"
448
- onclick={() => edit && (edit.pricing = { currency: 'USD', tiers: [tier(0)] })}
449
- ><PlusIcon />{t('providers.pricing_add')}</Button
450
- >
451
- {/if}
452
- </Field.Set>
453
- {/snippet}
@@ -0,0 +1,120 @@
1
+ <script lang="ts">
2
+ import * as Dialog from '#lib/components/ui/dialog/index.js'
3
+ import * as Field from '#lib/components/ui/field/index.js'
4
+ import { Input } from '#lib/components/ui/input/index.js'
5
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
6
+ import { Button } from '#lib/components/ui/button/index.js'
7
+ import type { Provider } from '../ports'
8
+ import type { ProviderDraft } from './draft'
9
+ import { getKitContext } from '../context'
10
+
11
+ let {
12
+ addOpen = $bindable(false),
13
+ infoOpen = $bindable(false),
14
+ notesOpen = $bindable(false),
15
+ newName = $bindable(''),
16
+ editName = $bindable(''),
17
+ editNotes = $bindable(''),
18
+ draft,
19
+ providers,
20
+ create,
21
+ rename,
22
+ saveNotes,
23
+ remove,
24
+ }: {
25
+ addOpen?: boolean
26
+ infoOpen?: boolean
27
+ notesOpen?: boolean
28
+ newName?: string
29
+ editName?: string
30
+ editNotes?: string
31
+ draft: ProviderDraft | null
32
+ providers: Provider[]
33
+ create: () => void | Promise<void>
34
+ rename: () => void
35
+ saveNotes: () => void
36
+ remove: () => void | Promise<void>
37
+ } = $props()
38
+ const { i18n } = getKitContext()
39
+ const { t } = i18n
40
+ </script>
41
+
42
+ <Dialog.Root bind:open={addOpen}>
43
+ <Dialog.Content class="sm:max-w-md">
44
+ <Dialog.Header><Dialog.Title>{t('providers.add')}</Dialog.Title></Dialog.Header>
45
+ <form
46
+ class="contents"
47
+ onsubmit={(event) => {
48
+ event.preventDefault()
49
+ void create()
50
+ }}
51
+ >
52
+ <Field.Field>
53
+ <Field.Label for="kit-provider-new">{t('providers.name')}</Field.Label>
54
+ <Input id="kit-provider-new" maxlength={80} bind:value={newName} />
55
+ </Field.Field>
56
+ <Dialog.Footer>
57
+ <Button type="button" variant="outline" onclick={() => (addOpen = false)}
58
+ >{t('common.cancel')}</Button
59
+ >
60
+ <Button type="submit" disabled={!newName.trim()}>{t('common.confirm')}</Button>
61
+ </Dialog.Footer>
62
+ </form>
63
+ </Dialog.Content>
64
+ </Dialog.Root>
65
+
66
+ <Dialog.Root bind:open={infoOpen}>
67
+ <Dialog.Content class="sm:max-w-md">
68
+ <Dialog.Header><Dialog.Title>{t('providers.settings')}</Dialog.Title></Dialog.Header>
69
+ <form
70
+ class="contents"
71
+ onsubmit={(event) => {
72
+ event.preventDefault()
73
+ rename()
74
+ }}
75
+ >
76
+ <Field.Field>
77
+ <Field.Label for="kit-provider-name">{t('providers.name')}</Field.Label>
78
+ <Input id="kit-provider-name" maxlength={80} bind:value={editName} />
79
+ </Field.Field>
80
+ <Dialog.Footer class="sm:justify-between">
81
+ {#if !providers.find((p) => p.id === draft?.id)?.presetId}
82
+ <Button
83
+ type="button"
84
+ variant="ghost"
85
+ class="text-destructive"
86
+ onclick={() => void remove()}>{t('providers.delete')}</Button
87
+ >
88
+ {:else}<span></span>{/if}
89
+ <Button type="submit" disabled={!editName.trim()}>{t('providers.save')}</Button>
90
+ </Dialog.Footer>
91
+ </form>
92
+ </Dialog.Content>
93
+ </Dialog.Root>
94
+
95
+ <Dialog.Root bind:open={notesOpen}>
96
+ <Dialog.Content class="sm:max-w-lg">
97
+ <Dialog.Header><Dialog.Title>{t('providers.notes')}</Dialog.Title></Dialog.Header>
98
+ <form
99
+ class="contents"
100
+ onsubmit={(event) => {
101
+ event.preventDefault()
102
+ saveNotes()
103
+ }}
104
+ >
105
+ <Textarea
106
+ class="min-h-64 font-mono text-kit-sm"
107
+ aria-label={t('providers.notes')}
108
+ placeholder={t('providers.notes_placeholder')}
109
+ maxlength={10000}
110
+ bind:value={editNotes}
111
+ />
112
+ <Dialog.Footer>
113
+ <Button type="button" variant="outline" onclick={() => (notesOpen = false)}
114
+ >{t('common.cancel')}</Button
115
+ >
116
+ <Button type="submit">{t('providers.save')}</Button>
117
+ </Dialog.Footer>
118
+ </form>
119
+ </Dialog.Content>
120
+ </Dialog.Root>
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import EllipsisIcon from '@lucide/svelte/icons/ellipsis-vertical'
3
+ import PlusIcon from '@lucide/svelte/icons/plus'
4
+ import SearchIcon from '@lucide/svelte/icons/search'
5
+ import SquarePenIcon from '@lucide/svelte/icons/square-pen'
6
+ import SlidersHorizontalIcon from '@lucide/svelte/icons/sliders-horizontal'
7
+ import Trash2Icon from '@lucide/svelte/icons/trash-2'
8
+ import UserPenIcon from '@lucide/svelte/icons/user-pen'
9
+ import { Button } from '#lib/components/ui/button/index.js'
10
+ import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js'
11
+ import { Input } from '#lib/components/ui/input/index.js'
12
+ import * as Select from '#lib/components/ui/select/index.js'
13
+ import { getKitContext } from '../context'
14
+ import type { Provider } from '../ports'
15
+ import { cn } from '../utils'
16
+
17
+ let {
18
+ providers,
19
+ selectedId,
20
+ onselect,
21
+ onaction,
22
+ onadd,
23
+ }: {
24
+ providers: Provider[]
25
+ selectedId: string | null
26
+ onselect: (provider: Provider) => void
27
+ onaction: (provider: Provider, action: 'edit' | 'notes' | 'delete') => void
28
+ onadd: () => void
29
+ } = $props()
30
+ const { i18n } = getKitContext()
31
+ const { t } = i18n
32
+ const FILTERS = ['all', 'enabled', 'disabled'] as const
33
+ let query = $state('')
34
+ let filter = $state<(typeof FILTERS)[number]>('all')
35
+ let menuFor = $state<string | null>(null)
36
+ const visible = $derived(
37
+ providers.filter(
38
+ (p) =>
39
+ p.name.toLocaleLowerCase().includes(query.trim().toLocaleLowerCase()) &&
40
+ (filter === 'all' || (filter === 'enabled') === p.enabled),
41
+ ),
42
+ )
43
+ function status(p: Provider): string | null {
44
+ if (!p.enabled) return null
45
+ return p.endpoints.length === 0 ||
46
+ p.endpoints.some((e) => !e.keys.some((k) => k.enabled && k.readable))
47
+ ? 'bg-warning'
48
+ : 'bg-success'
49
+ }
50
+ </script>
51
+
52
+ <nav class="flex w-full shrink-0 flex-col gap-1 sm:w-60" aria-label={t('providers.title')}>
53
+ <div class="mb-1 flex items-center gap-1.5">
54
+ <div class="relative min-w-0 flex-1">
55
+ <SearchIcon
56
+ class="text-muted-foreground pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2"
57
+ />
58
+ <Input
59
+ class="pl-8"
60
+ placeholder={t('providers.search')}
61
+ aria-label={t('providers.search')}
62
+ bind:value={query}
63
+ autocomplete="off"
64
+ />
65
+ </div>
66
+ <Select.Root
67
+ type="single"
68
+ value={filter}
69
+ onValueChange={(value) => (filter = value as typeof filter)}
70
+ >
71
+ <Select.Trigger
72
+ class={cn('shrink-0 px-2 [&>svg:last-child]:hidden', filter !== 'all' && 'bg-accent')}
73
+ aria-label={t('providers.filter')}
74
+ title={t(`providers.filter_${filter}`)}
75
+ >
76
+ <SlidersHorizontalIcon />
77
+ </Select.Trigger>
78
+ <Select.Content
79
+ ><Select.Group
80
+ >{#each FILTERS as f (f)}<Select.Item
81
+ value={f}
82
+ label={t(`providers.filter_${f}`)}
83
+ />{/each}</Select.Group
84
+ ></Select.Content
85
+ >
86
+ </Select.Root>
87
+ </div>
88
+ <ul class="flex min-w-0 flex-col gap-0.5">
89
+ {#each visible as p (p.id)}
90
+ {@const dot = status(p)}
91
+ <li
92
+ class="group/row relative"
93
+ oncontextmenu={(event) => {
94
+ event.preventDefault()
95
+ menuFor = p.id
96
+ }}
97
+ >
98
+ <button
99
+ type="button"
100
+ class={cn(
101
+ 'hover:bg-muted flex h-9 w-full min-w-0 items-center gap-2.5 rounded-kit px-2 pr-8 text-left text-kit-base',
102
+ selectedId === p.id && 'bg-muted font-medium',
103
+ )}
104
+ aria-current={selectedId === p.id ? 'true' : undefined}
105
+ onclick={() => onselect(p)}
106
+ >
107
+ <span
108
+ aria-hidden="true"
109
+ class="bg-primary/10 text-primary flex size-6 shrink-0 items-center justify-center rounded-md text-kit-xs font-semibold"
110
+ >{p.name.slice(0, 1).toUpperCase()}</span
111
+ >
112
+ <span class={cn('min-w-0 flex-1 truncate', !p.enabled && 'text-muted-foreground')}
113
+ >{p.name}</span
114
+ >
115
+ {#if dot}<span class={cn('size-1.5 shrink-0 rounded-full', dot)}></span><span
116
+ class="sr-only"
117
+ >{t(
118
+ dot === 'bg-success' ? 'providers.filter_enabled' : 'providers.needs_setup',
119
+ )}</span
120
+ >{/if}
121
+ </button>
122
+ <DropdownMenu.Root
123
+ open={menuFor === p.id}
124
+ onOpenChange={(open) => (menuFor = open ? p.id : null)}
125
+ >
126
+ <DropdownMenu.Trigger
127
+ >{#snippet child({ props })}<button
128
+ {...props}
129
+ type="button"
130
+ class={cn(
131
+ 'text-muted-foreground hover:text-foreground hover:bg-accent absolute top-1/2 right-1 inline-flex size-6 -translate-y-1/2 items-center justify-center rounded-md opacity-0 group-hover/row:opacity-100 focus-visible:opacity-100',
132
+ menuFor === p.id && 'opacity-100',
133
+ )}
134
+ aria-label={t('providers.row_menu', { name: p.name })}
135
+ title={t('providers.row_menu', { name: p.name })}
136
+ ><EllipsisIcon class="size-4" /></button
137
+ >{/snippet}</DropdownMenu.Trigger
138
+ >
139
+ <DropdownMenu.Content align="start" class="min-w-36">
140
+ <DropdownMenu.Item onSelect={() => onaction(p, 'edit')}
141
+ ><SquarePenIcon />{t('providers.edit')}</DropdownMenu.Item
142
+ >
143
+ <DropdownMenu.Item onSelect={() => onaction(p, 'notes')}
144
+ ><UserPenIcon />{t('providers.notes')}</DropdownMenu.Item
145
+ >
146
+ {#if !p.presetId}<DropdownMenu.Item
147
+ variant="destructive"
148
+ onSelect={() => onaction(p, 'delete')}
149
+ ><Trash2Icon />{t('providers.remove')}</DropdownMenu.Item
150
+ >{/if}
151
+ </DropdownMenu.Content>
152
+ </DropdownMenu.Root>
153
+ </li>
154
+ {:else}<li class="text-muted-foreground px-2 py-4 text-center text-kit-sm">
155
+ {t('providers.no_matches')}
156
+ </li>{/each}
157
+ </ul>
158
+ <Button variant="outline" class="mt-2" onclick={onadd}><PlusIcon />{t('providers.add')}</Button>
159
+ </nav>