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