@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.
- package/package.json +3 -3
- package/src/chat/ApprovalCard.svelte +8 -2
- package/src/chat/ChatComposer.svelte +319 -0
- package/src/chat/ChatMessage.svelte +262 -0
- package/src/chat/ChatPanel.css +386 -0
- package/src/chat/ChatPanel.svelte +276 -1880
- package/src/chat/ChatPanelApprovals.svelte +28 -0
- package/src/chat/ChatPanelComposer.svelte +336 -0
- package/src/chat/ChatPanelDialogs.svelte +242 -0
- package/src/chat/ChatPanelHeader.svelte +108 -0
- package/src/chat/ChatPanelMessages.svelte +166 -0
- package/src/chat/ChatPanelMode.svelte +190 -0
- package/src/chat/ChatPanelState.svelte.ts +1 -0
- package/src/chat/ChatPanelToolbar.svelte +436 -0
- package/src/chat/ChatSurface.svelte +168 -0
- package/src/chat/ContextUsageStatus.svelte +97 -0
- package/src/chat/MessageItem.svelte +111 -214
- package/src/chat/ModelEffortPicker.svelte +326 -0
- package/src/chat/PickerEffort.svelte +71 -0
- package/src/chat/ReasoningControl.svelte +184 -0
- package/src/chat/ReasoningSlider.svelte +174 -0
- package/src/chat/SkillPicker.svelte +14 -6
- package/src/chat/UsageControl.svelte +223 -0
- package/src/chat/UsageDetails.svelte +44 -90
- package/src/chat/chat-attachments.svelte.ts +78 -0
- package/src/chat/chat-panel-history.svelte.ts +430 -0
- package/src/chat/chat-panel-models.svelte.ts +97 -0
- package/src/chat/chat-panel-props.ts +38 -0
- package/src/chat/context-status.ts +101 -0
- package/src/chat/icons/LICENSE-REMIX +186 -0
- package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
- package/src/chat/icons/PathIcon.svelte +8 -0
- package/src/chat/icons/effort-icons.ts +65 -0
- package/src/chat/icons/status-icons.ts +105 -0
- package/src/chat/index.ts +25 -1
- package/src/chat/model-effort.ts +177 -0
- package/src/chat/run-session-history.ts +56 -0
- package/src/chat/run-session-types.ts +69 -0
- package/src/chat/run-session.svelte.ts +73 -135
- package/src/chat/test-support/chat-fixture.ts +194 -0
- package/src/chat/test-support/conversation-fixtures.ts +32 -0
- package/src/chat/test-support/run-session-fixture.ts +112 -0
- package/src/dev/Demo.svelte +3 -1
- package/src/i18n/en-chat.d.ts +1 -0
- package/src/i18n/en-chat.js +201 -0
- package/src/i18n/en.ts +58 -180
- package/src/i18n/index.ts +7 -2
- package/src/i18n/zh-CN-chat.d.ts +1 -0
- package/src/i18n/zh-CN-chat.js +190 -0
- package/src/i18n/zh-CN.ts +8 -153
- package/src/logs/LogPanel.svelte +9 -3
- package/src/logs/LogSettings.svelte +3 -1
- package/src/mcp/McpClientEditor.svelte +204 -0
- package/src/mcp/McpClientSettings.svelte +57 -130
- package/src/mcp/McpServerSettings.svelte +47 -11
- package/src/mcp/input.ts +2 -1
- package/src/ports/contract.ts +11 -2
- package/src/ports/fake-optional.ts +81 -0
- package/src/ports/fake.ts +90 -86
- package/src/ports/search.ts +11 -2
- package/src/ports/test-support/fake-fixture.ts +63 -0
- package/src/providers/EndpointKeysDialog.svelte +27 -13
- package/src/providers/EndpointModels.svelte +311 -0
- package/src/providers/EndpointPanel.svelte +33 -258
- package/src/providers/ModelPickerDialog.svelte +59 -16
- package/src/providers/ModelPricing.svelte +125 -0
- package/src/providers/ModelSelect.svelte +22 -8
- package/src/providers/ModelSettingsDialog.svelte +61 -126
- package/src/providers/ProviderDialogs.svelte +120 -0
- package/src/providers/ProviderList.svelte +159 -0
- package/src/providers/ProvidersSettings.svelte +67 -207
- package/src/providers/RequestConfigDialog.svelte +40 -12
- package/src/providers/SecretInput.svelte +11 -3
- package/src/providers/draft.ts +39 -13
- package/src/providers/edit-session.ts +31 -7
- package/src/providers/reasoning.ts +18 -4
- package/src/providers/revision.svelte.ts +3 -1
- package/src/providers/test-support/providers-fixture.ts +120 -0
- package/src/search/SearchAdvancedTools.svelte +182 -0
- package/src/search/SearchProviderCard.svelte +195 -0
- package/src/search/SearchSettings.svelte +62 -265
- package/src/search/settings-flow.ts +8 -3
- package/src/skills/SkillAdd.svelte +51 -18
- package/src/skills/SkillAuthoring.svelte +69 -20
- package/src/skills/SkillEditor.svelte +13 -3
- package/src/skills/SkillPreviewDialog.svelte +19 -5
- package/src/skills/SkillsSettings.svelte +11 -4
- package/src/skills/test-support/skills-fixture.ts +139 -0
- package/src/tauri/index.ts +23 -7
- package/src/web/index.ts +90 -25
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Composer pill + upward popover (docs/31 §4.2). The host owns data and every event's effect.
|
|
3
|
+
import { Command, Popover } from 'bits-ui'
|
|
4
|
+
import CheckIcon from '@lucide/svelte/icons/check'
|
|
5
|
+
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
6
|
+
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
7
|
+
import KeyIcon from '@lucide/svelte/icons/key-round'
|
|
8
|
+
import PinIcon from '@lucide/svelte/icons/pin'
|
|
9
|
+
import RefreshIcon from '@lucide/svelte/icons/refresh-cw'
|
|
10
|
+
import SearchIcon from '@lucide/svelte/icons/search'
|
|
11
|
+
import { SvelteSet } from 'svelte/reactivity'
|
|
12
|
+
import { Button } from '#lib/components/ui/button/index.js'
|
|
13
|
+
import { Separator } from '#lib/components/ui/separator/index.js'
|
|
14
|
+
import { cn } from '#lib/utils.js'
|
|
15
|
+
import type { KitI18n } from '../i18n'
|
|
16
|
+
import type { ReasoningEffort } from '../ports'
|
|
17
|
+
import { REASONING_EFFORTS } from '../providers/reasoning'
|
|
18
|
+
import PickerEffort from './PickerEffort.svelte'
|
|
19
|
+
import {
|
|
20
|
+
effortBars,
|
|
21
|
+
effortTone,
|
|
22
|
+
groupModels,
|
|
23
|
+
groupSummary,
|
|
24
|
+
nextValue,
|
|
25
|
+
type PickerModel,
|
|
26
|
+
type PickerValue,
|
|
27
|
+
} from './model-effort'
|
|
28
|
+
|
|
29
|
+
let {
|
|
30
|
+
models,
|
|
31
|
+
value = null,
|
|
32
|
+
defaultValue = null,
|
|
33
|
+
disabled = false,
|
|
34
|
+
refreshing = false,
|
|
35
|
+
notice = '',
|
|
36
|
+
i18n,
|
|
37
|
+
open = $bindable(false),
|
|
38
|
+
onselect,
|
|
39
|
+
onsetdefault,
|
|
40
|
+
onrefresh,
|
|
41
|
+
onopensettings,
|
|
42
|
+
}: {
|
|
43
|
+
models: readonly PickerModel[]
|
|
44
|
+
value?: PickerValue | null
|
|
45
|
+
defaultValue?: PickerValue | null
|
|
46
|
+
disabled?: boolean
|
|
47
|
+
refreshing?: boolean
|
|
48
|
+
notice?: string
|
|
49
|
+
i18n: KitI18n
|
|
50
|
+
open?: boolean
|
|
51
|
+
onselect: (value: PickerValue) => void
|
|
52
|
+
onsetdefault?: ((value: PickerValue) => void) | undefined
|
|
53
|
+
onrefresh?: ((force: boolean) => void) | undefined
|
|
54
|
+
/** Opens host settings for the current model when it declares no reasoning levels. */
|
|
55
|
+
onopensettings?: (() => void) | undefined
|
|
56
|
+
} = $props()
|
|
57
|
+
const t = (key: string, params?: Record<string, string | number>) => i18n.t(key, params)
|
|
58
|
+
let query = $state('')
|
|
59
|
+
// Command tracks the highlighted item; arrows/Enter come from the primitive.
|
|
60
|
+
let highlighted = $state('')
|
|
61
|
+
let trigger = $state<HTMLButtonElement | null>(null)
|
|
62
|
+
// Groups opened while browsing, and groups folded while searching (searching opens all).
|
|
63
|
+
const expanded = new SvelteSet<string>()
|
|
64
|
+
const searchFolded = new SvelteSet<string>()
|
|
65
|
+
const searching = $derived(query.trim() !== '')
|
|
66
|
+
// Hosts may declare levels in any order; the rail always reads 关闭 → 最大.
|
|
67
|
+
const current = $derived.by(() => {
|
|
68
|
+
const found = models.find((model) => model.key === value?.key)
|
|
69
|
+
return found
|
|
70
|
+
? {
|
|
71
|
+
...found,
|
|
72
|
+
efforts: REASONING_EFFORTS.filter((level) => found.efforts.includes(level)),
|
|
73
|
+
}
|
|
74
|
+
: null
|
|
75
|
+
})
|
|
76
|
+
const effort: ReasoningEffort = $derived(
|
|
77
|
+
current && value && current.efforts.includes(value.effort) ? value.effort : 'default',
|
|
78
|
+
)
|
|
79
|
+
const effortLabel = $derived(t(`chat.effort.${effort}`))
|
|
80
|
+
const name = $derived(current?.name ?? t('chat.picker.choose'))
|
|
81
|
+
const bars = $derived(current ? effortBars(current, effort) : 0)
|
|
82
|
+
const tone = $derived(effortTone(effort))
|
|
83
|
+
const groups = $derived(groupModels(models, query))
|
|
84
|
+
const isOpen = (id: string) => (searching ? !searchFolded.has(id) : expanded.has(id))
|
|
85
|
+
const sameAsDefault = $derived(
|
|
86
|
+
value !== null && value.key === defaultValue?.key && value.effort === defaultValue.effort,
|
|
87
|
+
)
|
|
88
|
+
$effect(() => {
|
|
89
|
+
// A new current model reveals its group; other groups keep the user's folding.
|
|
90
|
+
if (current) expanded.add(current.providerId)
|
|
91
|
+
})
|
|
92
|
+
function openChanged(next: boolean) {
|
|
93
|
+
if (!next) return
|
|
94
|
+
query = ''
|
|
95
|
+
highlighted = value?.key ?? ''
|
|
96
|
+
searchFolded.clear()
|
|
97
|
+
onrefresh?.(false)
|
|
98
|
+
}
|
|
99
|
+
function toggle(id: string) {
|
|
100
|
+
const set = searching ? searchFolded : expanded
|
|
101
|
+
if (!set.delete(id)) set.add(id)
|
|
102
|
+
}
|
|
103
|
+
</script>
|
|
104
|
+
|
|
105
|
+
<Popover.Root bind:open onOpenChange={openChanged}>
|
|
106
|
+
<Popover.Trigger {disabled}>
|
|
107
|
+
{#snippet child({ props })}
|
|
108
|
+
<Button
|
|
109
|
+
{...props}
|
|
110
|
+
bind:ref={trigger}
|
|
111
|
+
variant="ghost"
|
|
112
|
+
size="sm"
|
|
113
|
+
class="min-w-0 max-w-70 shrink"
|
|
114
|
+
data-composer-model
|
|
115
|
+
aria-label={t('chat.picker.trigger', { name, effort: effortLabel })}
|
|
116
|
+
>
|
|
117
|
+
<span
|
|
118
|
+
data-composer-model-detail
|
|
119
|
+
class="text-kit-xs text-muted-foreground"
|
|
120
|
+
aria-hidden="true">{current?.provider.charAt(0).toUpperCase() ?? '·'}</span
|
|
121
|
+
>
|
|
122
|
+
<span class="min-w-0 truncate">{name}</span>
|
|
123
|
+
{#if current && effort !== 'default'}
|
|
124
|
+
<span class="inline-flex h-3 shrink-0 items-end gap-0.5" aria-hidden="true">
|
|
125
|
+
{#each ['h-1', 'h-1.5', 'h-2', 'h-3'] as height, bar (height)}
|
|
126
|
+
<span
|
|
127
|
+
class={cn(
|
|
128
|
+
'w-0.5 rounded-full bg-border',
|
|
129
|
+
height,
|
|
130
|
+
bar < bars && tone === 'fast' && 'bg-info',
|
|
131
|
+
bar < bars && tone === 'regular' && 'bg-primary',
|
|
132
|
+
bar < bars && tone === 'deep' && 'bg-warning',
|
|
133
|
+
)}
|
|
134
|
+
></span>
|
|
135
|
+
{/each}
|
|
136
|
+
</span>
|
|
137
|
+
<span data-composer-model-detail class="text-kit-xs text-muted-foreground"
|
|
138
|
+
>{effortLabel}</span
|
|
139
|
+
>
|
|
140
|
+
{/if}
|
|
141
|
+
{#if current?.hasKey}<KeyIcon
|
|
142
|
+
data-composer-model-detail
|
|
143
|
+
data-icon="inline-end"
|
|
144
|
+
aria-hidden="true"
|
|
145
|
+
/>{/if}
|
|
146
|
+
<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />
|
|
147
|
+
</Button>
|
|
148
|
+
{/snippet}
|
|
149
|
+
</Popover.Trigger>
|
|
150
|
+
<Popover.Portal>
|
|
151
|
+
<Popover.Content
|
|
152
|
+
class={cn(
|
|
153
|
+
'z-[210] flex w-[min(360px,calc(100vw-16px))] flex-col overflow-hidden rounded-kit',
|
|
154
|
+
'max-h-[min(calc(100dvh-64px),var(--bits-popover-content-available-height))]',
|
|
155
|
+
'border bg-popover text-xs text-popover-foreground shadow-lg',
|
|
156
|
+
)}
|
|
157
|
+
side="top"
|
|
158
|
+
align="start"
|
|
159
|
+
sideOffset={4}
|
|
160
|
+
collisionPadding={8}
|
|
161
|
+
trapFocus={false}
|
|
162
|
+
preventScroll={false}
|
|
163
|
+
onEscapeKeydown={(event) => {
|
|
164
|
+
if (event.isComposing) event.preventDefault()
|
|
165
|
+
event.stopPropagation()
|
|
166
|
+
}}
|
|
167
|
+
onCloseAutoFocus={(event) => {
|
|
168
|
+
event.preventDefault()
|
|
169
|
+
trigger?.focus({ preventScroll: true })
|
|
170
|
+
}}
|
|
171
|
+
>
|
|
172
|
+
<Command.Root
|
|
173
|
+
bind:value={highlighted}
|
|
174
|
+
shouldFilter={false}
|
|
175
|
+
loop
|
|
176
|
+
label={t('chat.picker.choose')}
|
|
177
|
+
class="flex min-h-0 flex-col"
|
|
178
|
+
>
|
|
179
|
+
<div class="flex items-center gap-1.5 px-2 text-muted-foreground">
|
|
180
|
+
<SearchIcon class="size-3.5 shrink-0" aria-hidden="true" />
|
|
181
|
+
<Command.Input
|
|
182
|
+
bind:value={query}
|
|
183
|
+
placeholder={t('chat.picker.search')}
|
|
184
|
+
aria-label={t('chat.picker.searchLabel')}
|
|
185
|
+
class="h-8 min-w-0 flex-1 bg-transparent text-foreground outline-none"
|
|
186
|
+
/>
|
|
187
|
+
</div>
|
|
188
|
+
<Separator />
|
|
189
|
+
<Command.List class="max-h-[clamp(48px,calc(100dvh-250px),248px)] overflow-y-auto p-1">
|
|
190
|
+
{#if models.length === 0 || (searching && groups.length === 0)}
|
|
191
|
+
<Command.Empty forceMount class="p-3 text-kit-xs text-muted-foreground">
|
|
192
|
+
{t(models.length === 0 ? 'chat.picker.empty' : 'chat.picker.noMatch')}
|
|
193
|
+
</Command.Empty>
|
|
194
|
+
{/if}
|
|
195
|
+
{#each groups as group (group.id)}
|
|
196
|
+
{@const shown = isOpen(group.id)}
|
|
197
|
+
<Command.Group value={`group\n${group.id}`}>
|
|
198
|
+
<Command.GroupHeading>
|
|
199
|
+
<Button
|
|
200
|
+
variant="ghost"
|
|
201
|
+
size="xs"
|
|
202
|
+
class="w-full justify-start font-normal"
|
|
203
|
+
aria-expanded={shown}
|
|
204
|
+
aria-label={t('chat.picker.group', {
|
|
205
|
+
provider: group.provider,
|
|
206
|
+
state: t(shown ? 'chat.picker.expanded' : 'chat.picker.collapsed'),
|
|
207
|
+
})}
|
|
208
|
+
onclick={() => toggle(group.id)}
|
|
209
|
+
>
|
|
210
|
+
<span class="text-muted-foreground">{group.provider.charAt(0).toUpperCase()}</span
|
|
211
|
+
>
|
|
212
|
+
<span class="min-w-0 truncate">{group.provider}</span>
|
|
213
|
+
<span class="ml-auto max-w-32 truncate text-muted-foreground"
|
|
214
|
+
>{groupSummary(group, i18n)}</span
|
|
215
|
+
>
|
|
216
|
+
{#if shown}<ChevronDownIcon data-icon="inline-end" aria-hidden="true" />{:else}
|
|
217
|
+
<ChevronRightIcon data-icon="inline-end" aria-hidden="true" />{/if}
|
|
218
|
+
</Button>
|
|
219
|
+
</Command.GroupHeading>
|
|
220
|
+
{#if shown}
|
|
221
|
+
<Command.GroupItems>
|
|
222
|
+
{#each group.models as model (model.key)}
|
|
223
|
+
{@const selected = model.key === value?.key}
|
|
224
|
+
<Command.Item
|
|
225
|
+
value={model.key}
|
|
226
|
+
{disabled}
|
|
227
|
+
aria-current={selected ? 'true' : undefined}
|
|
228
|
+
aria-label={t('chat.picker.select', {
|
|
229
|
+
name: model.name,
|
|
230
|
+
provider: model.provider,
|
|
231
|
+
})}
|
|
232
|
+
onSelect={() => !disabled && onselect(nextValue(model, value, query))}
|
|
233
|
+
class={cn(
|
|
234
|
+
'flex cursor-pointer items-center gap-2 rounded-kit-sm py-1 pr-2 pl-6',
|
|
235
|
+
'data-[selected]:bg-accent data-[selected]:text-accent-foreground',
|
|
236
|
+
'data-[disabled]:cursor-not-allowed data-[disabled]:opacity-50',
|
|
237
|
+
selected && 'bg-accent/60',
|
|
238
|
+
)}
|
|
239
|
+
>
|
|
240
|
+
<span class="flex min-w-0 flex-1 flex-col">
|
|
241
|
+
<span class="truncate">{model.name}</span>
|
|
242
|
+
<span
|
|
243
|
+
class="flex min-w-0 items-center gap-1 text-kit-xs text-muted-foreground"
|
|
244
|
+
>
|
|
245
|
+
{#if model.hasKey}<KeyIcon
|
|
246
|
+
class="size-3 shrink-0"
|
|
247
|
+
aria-hidden="true"
|
|
248
|
+
/>{/if}
|
|
249
|
+
<span class="truncate">{model.detail}</span>
|
|
250
|
+
</span>
|
|
251
|
+
</span>
|
|
252
|
+
{#if selected}<CheckIcon class="size-3 shrink-0" aria-hidden="true" />{/if}
|
|
253
|
+
</Command.Item>
|
|
254
|
+
{/each}
|
|
255
|
+
</Command.GroupItems>
|
|
256
|
+
{/if}
|
|
257
|
+
</Command.Group>
|
|
258
|
+
{/each}
|
|
259
|
+
</Command.List>
|
|
260
|
+
</Command.Root>
|
|
261
|
+
{#if current && current.efforts.length > 0}
|
|
262
|
+
<Separator />
|
|
263
|
+
<PickerEffort
|
|
264
|
+
model={current}
|
|
265
|
+
{effort}
|
|
266
|
+
{disabled}
|
|
267
|
+
{i18n}
|
|
268
|
+
onselect={(next) => onselect({ key: current.key, effort: next })}
|
|
269
|
+
/>
|
|
270
|
+
{:else if current}
|
|
271
|
+
<Separator />
|
|
272
|
+
<div class="flex items-center justify-between gap-2 px-2 py-1.5">
|
|
273
|
+
<p class="text-kit-xs text-muted-foreground">{t('chat.reasoningUnconfigured')}</p>
|
|
274
|
+
{#if onopensettings}
|
|
275
|
+
<Button
|
|
276
|
+
variant="outline"
|
|
277
|
+
size="xs"
|
|
278
|
+
onclick={() => {
|
|
279
|
+
open = false
|
|
280
|
+
onopensettings()
|
|
281
|
+
}}>{t('chat.openSettings')}</Button
|
|
282
|
+
>
|
|
283
|
+
{/if}
|
|
284
|
+
</div>
|
|
285
|
+
{/if}
|
|
286
|
+
{#if notice}
|
|
287
|
+
<p class="truncate px-2 pb-1 text-kit-xs text-muted-foreground" role="status">
|
|
288
|
+
{notice}
|
|
289
|
+
</p>
|
|
290
|
+
{/if}
|
|
291
|
+
<Separator />
|
|
292
|
+
<div class="flex items-center gap-0.5 p-1">
|
|
293
|
+
{#if onsetdefault}
|
|
294
|
+
<Button
|
|
295
|
+
variant="ghost"
|
|
296
|
+
size="xs"
|
|
297
|
+
disabled={disabled || value === null || sameAsDefault}
|
|
298
|
+
onclick={() => value && onsetdefault(value)}
|
|
299
|
+
>
|
|
300
|
+
<PinIcon data-icon="inline-start" aria-hidden="true" />{t('chat.picker.setDefault')}
|
|
301
|
+
</Button>
|
|
302
|
+
{/if}
|
|
303
|
+
<Button
|
|
304
|
+
variant="ghost"
|
|
305
|
+
size="xs"
|
|
306
|
+
disabled={disabled || !current?.efforts.includes('none')}
|
|
307
|
+
onclick={() => current && onselect({ key: current.key, effort: 'none' })}
|
|
308
|
+
>
|
|
309
|
+
<RefreshIcon data-icon="inline-start" aria-hidden="true" />{t('chat.picker.resetOff')}
|
|
310
|
+
</Button>
|
|
311
|
+
{#if onrefresh}
|
|
312
|
+
<Button
|
|
313
|
+
variant="outline"
|
|
314
|
+
size="xs"
|
|
315
|
+
class="ml-auto"
|
|
316
|
+
disabled={refreshing}
|
|
317
|
+
onclick={() => onrefresh(true)}
|
|
318
|
+
>
|
|
319
|
+
<RefreshIcon data-icon="inline-start" aria-hidden="true" />
|
|
320
|
+
{t(refreshing ? 'chat.picker.refreshing' : 'chat.picker.refresh')}
|
|
321
|
+
</Button>
|
|
322
|
+
{/if}
|
|
323
|
+
</div>
|
|
324
|
+
</Popover.Content>
|
|
325
|
+
</Popover.Portal>
|
|
326
|
+
</Popover.Root>
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
// Effort section of the model picker: rail, level toggles, reset and hint (docs/31 §4.2).
|
|
3
|
+
import { ToggleGroup } from 'bits-ui'
|
|
4
|
+
import { Button } from '#lib/components/ui/button/index.js'
|
|
5
|
+
import type { KitI18n } from '../i18n'
|
|
6
|
+
import type { ReasoningEffort } from '../ports'
|
|
7
|
+
import ReasoningSlider from './ReasoningSlider.svelte'
|
|
8
|
+
import type { PickerModel } from './model-effort'
|
|
9
|
+
|
|
10
|
+
let {
|
|
11
|
+
model,
|
|
12
|
+
effort,
|
|
13
|
+
disabled = false,
|
|
14
|
+
i18n,
|
|
15
|
+
onselect,
|
|
16
|
+
}: {
|
|
17
|
+
model: PickerModel
|
|
18
|
+
effort: ReasoningEffort
|
|
19
|
+
disabled?: boolean
|
|
20
|
+
i18n: KitI18n
|
|
21
|
+
onselect: (effort: ReasoningEffort) => void
|
|
22
|
+
} = $props()
|
|
23
|
+
const t = (key: string, params?: Record<string, string | number>) => i18n.t(key, params)
|
|
24
|
+
const label = $derived(t(`chat.effort.${effort}`))
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<section class="flex flex-col gap-0.5 px-2 pt-2 pb-1" aria-label={t('chat.reasoningEffort')}>
|
|
28
|
+
<div class="flex items-center justify-between gap-2">
|
|
29
|
+
<p class="text-kit-xs text-muted-foreground">
|
|
30
|
+
{t('chat.picker.effortFor', { name: model.name })}
|
|
31
|
+
</p>
|
|
32
|
+
{#if effort !== 'default'}
|
|
33
|
+
<Button variant="link" size="xs" {disabled} onclick={() => onselect('default')}>
|
|
34
|
+
{t('chat.resetReasoning')}
|
|
35
|
+
</Button>
|
|
36
|
+
{/if}
|
|
37
|
+
</div>
|
|
38
|
+
{#if model.efforts.length > 1}
|
|
39
|
+
<ReasoningSlider
|
|
40
|
+
efforts={model.efforts}
|
|
41
|
+
value={effort}
|
|
42
|
+
defaultIndex={Math.floor(model.efforts.length / 2)}
|
|
43
|
+
{disabled}
|
|
44
|
+
label={t('chat.reasoningEffort')}
|
|
45
|
+
valueText={label}
|
|
46
|
+
onchange={onselect}
|
|
47
|
+
/>
|
|
48
|
+
{/if}
|
|
49
|
+
<ToggleGroup.Root
|
|
50
|
+
type="single"
|
|
51
|
+
value={effort === 'default' ? '' : effort}
|
|
52
|
+
onValueChange={(next) => next && onselect(next as ReasoningEffort)}
|
|
53
|
+
{disabled}
|
|
54
|
+
aria-label={t('chat.reasoningEffort')}
|
|
55
|
+
class="flex"
|
|
56
|
+
>
|
|
57
|
+
{#each model.efforts as level (level)}
|
|
58
|
+
<ToggleGroup.Item
|
|
59
|
+
value={level}
|
|
60
|
+
aria-label={`${t('chat.reasoningEffort')} ${t(`chat.effort.${level}`)}`}
|
|
61
|
+
class="min-w-0 flex-1 rounded-kit-sm py-0.5 text-kit-xs whitespace-nowrap
|
|
62
|
+
text-muted-foreground hover:text-foreground focus-visible:outline-2
|
|
63
|
+
focus-visible:outline-ring disabled:cursor-not-allowed disabled:opacity-50
|
|
64
|
+
data-[state=on]:font-semibold data-[state=on]:text-foreground"
|
|
65
|
+
>
|
|
66
|
+
{t(`chat.effort.${level}`)}
|
|
67
|
+
</ToggleGroup.Item>
|
|
68
|
+
{/each}
|
|
69
|
+
</ToggleGroup.Root>
|
|
70
|
+
<p class="text-kit-xs text-muted-foreground">{t(`chat.effortHint.${effort}`)}</p>
|
|
71
|
+
</section>
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import GaugeIcon from '@lucide/svelte/icons/gauge'
|
|
3
|
+
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
4
|
+
import { Popover } from 'bits-ui'
|
|
5
|
+
import { Button } from '#lib/components/ui/button/index.js'
|
|
6
|
+
import * as Field from '#lib/components/ui/field/index.js'
|
|
7
|
+
import type { KitI18n } from '../i18n'
|
|
8
|
+
import type { ReasoningEffort } from '../ports'
|
|
9
|
+
import { REASONING_EFFORTS } from '../providers/reasoning'
|
|
10
|
+
import ReasoningSlider from './ReasoningSlider.svelte'
|
|
11
|
+
|
|
12
|
+
let {
|
|
13
|
+
value = 'default',
|
|
14
|
+
efforts,
|
|
15
|
+
disabled = false,
|
|
16
|
+
open = $bindable(false),
|
|
17
|
+
i18n,
|
|
18
|
+
onchange,
|
|
19
|
+
onOpenSettings,
|
|
20
|
+
}: {
|
|
21
|
+
value?: ReasoningEffort
|
|
22
|
+
efforts: readonly ReasoningEffort[]
|
|
23
|
+
disabled?: boolean
|
|
24
|
+
open?: boolean
|
|
25
|
+
i18n: KitI18n
|
|
26
|
+
onchange: (effort: ReasoningEffort) => void
|
|
27
|
+
onOpenSettings?: (() => void) | undefined
|
|
28
|
+
} = $props()
|
|
29
|
+
const id = $props.id()
|
|
30
|
+
let trigger = $state<HTMLButtonElement | null>(null)
|
|
31
|
+
const t = (key: string) => i18n.t(key)
|
|
32
|
+
const allowedEfforts = $derived(REASONING_EFFORTS.filter((effort) => efforts.includes(effort)))
|
|
33
|
+
const effectiveEffort = $derived(
|
|
34
|
+
value === 'default' || allowedEfforts.includes(value) ? value : 'default',
|
|
35
|
+
)
|
|
36
|
+
const effortLabel = $derived(t(`chat.effort.${effectiveEffort}`))
|
|
37
|
+
const effortIndex = $derived(
|
|
38
|
+
effectiveEffort === 'default'
|
|
39
|
+
? Math.floor(allowedEfforts.length / 2)
|
|
40
|
+
: allowedEfforts.indexOf(effectiveEffort),
|
|
41
|
+
)
|
|
42
|
+
function choose(effort: ReasoningEffort) {
|
|
43
|
+
if (!disabled && (effort === 'default' || allowedEfforts.includes(effort))) onchange(effort)
|
|
44
|
+
}
|
|
45
|
+
</script>
|
|
46
|
+
|
|
47
|
+
<Popover.Root bind:open>
|
|
48
|
+
<Popover.Trigger {disabled}>
|
|
49
|
+
{#snippet child({ props })}
|
|
50
|
+
<Button
|
|
51
|
+
{...props}
|
|
52
|
+
bind:ref={trigger}
|
|
53
|
+
variant="ghost"
|
|
54
|
+
size="sm"
|
|
55
|
+
aria-label={`${t('chat.reasoningEffort')}: ${effortLabel}`}
|
|
56
|
+
>
|
|
57
|
+
<GaugeIcon data-icon="inline-start" />
|
|
58
|
+
{effortLabel}
|
|
59
|
+
<ChevronDownIcon data-icon="inline-end" />
|
|
60
|
+
</Button>
|
|
61
|
+
{/snippet}
|
|
62
|
+
</Popover.Trigger>
|
|
63
|
+
<Popover.Portal>
|
|
64
|
+
<Popover.Content
|
|
65
|
+
class="reasoning-popover"
|
|
66
|
+
side="top"
|
|
67
|
+
align="end"
|
|
68
|
+
sideOffset={8}
|
|
69
|
+
collisionPadding={8}
|
|
70
|
+
trapFocus={false}
|
|
71
|
+
preventScroll={false}
|
|
72
|
+
aria-label={t('chat.reasoningEffort')}
|
|
73
|
+
onEscapeKeydown={(event) => {
|
|
74
|
+
if (event.isComposing) event.preventDefault()
|
|
75
|
+
event.stopPropagation()
|
|
76
|
+
}}
|
|
77
|
+
onCloseAutoFocus={(event) => {
|
|
78
|
+
event.preventDefault()
|
|
79
|
+
trigger?.focus({ preventScroll: true })
|
|
80
|
+
}}
|
|
81
|
+
>
|
|
82
|
+
<div class="reasoning-heading">
|
|
83
|
+
<p>{t('chat.reasoningStrength')}: <strong>{effortLabel}</strong></p>
|
|
84
|
+
<Button
|
|
85
|
+
variant="secondary"
|
|
86
|
+
size="xs"
|
|
87
|
+
disabled={disabled || value === 'default'}
|
|
88
|
+
onclick={() => choose('default')}>{t('chat.resetReasoning')}</Button
|
|
89
|
+
>
|
|
90
|
+
</div>
|
|
91
|
+
{#if allowedEfforts.length === 0}
|
|
92
|
+
<p class="reasoning-hint">{t('chat.reasoningUnconfigured')}</p>
|
|
93
|
+
{#if onOpenSettings}<Button
|
|
94
|
+
variant="outline"
|
|
95
|
+
size="sm"
|
|
96
|
+
onclick={() => {
|
|
97
|
+
open = false
|
|
98
|
+
onOpenSettings()
|
|
99
|
+
}}>{t('chat.openSettings')}</Button
|
|
100
|
+
>{/if}
|
|
101
|
+
{:else if allowedEfforts.length > 1}
|
|
102
|
+
<div class="reasoning-scale" aria-hidden="true">
|
|
103
|
+
<span>{t('chat.reasoningFaster')}</span><span>{t('chat.reasoningSmarter')}</span>
|
|
104
|
+
</div>
|
|
105
|
+
<ReasoningSlider
|
|
106
|
+
efforts={allowedEfforts}
|
|
107
|
+
value={effectiveEffort}
|
|
108
|
+
defaultIndex={effortIndex}
|
|
109
|
+
{disabled}
|
|
110
|
+
label={t('chat.reasoningEffort')}
|
|
111
|
+
valueText={effortLabel}
|
|
112
|
+
onchange={choose}
|
|
113
|
+
/>
|
|
114
|
+
{:else}
|
|
115
|
+
<Field.FieldSet {disabled}>
|
|
116
|
+
<Field.FieldLegend class="sr-only">{t('chat.reasoningEffort')}</Field.FieldLegend>
|
|
117
|
+
{#each ['default' as const, ...allowedEfforts] as effort (effort)}
|
|
118
|
+
<label class="reasoning-option">
|
|
119
|
+
<input
|
|
120
|
+
type="radio"
|
|
121
|
+
name={id}
|
|
122
|
+
value={effort}
|
|
123
|
+
checked={effectiveEffort === effort}
|
|
124
|
+
onchange={() => choose(effort)}
|
|
125
|
+
/>
|
|
126
|
+
{t(`chat.effort.${effort}`)}
|
|
127
|
+
</label>
|
|
128
|
+
{/each}
|
|
129
|
+
</Field.FieldSet>
|
|
130
|
+
{/if}
|
|
131
|
+
{#if effectiveEffort === 'default'}<p class="reasoning-hint">
|
|
132
|
+
{t('chat.reasoningDefaultHint')}
|
|
133
|
+
</p>{/if}
|
|
134
|
+
</Popover.Content>
|
|
135
|
+
</Popover.Portal>
|
|
136
|
+
</Popover.Root>
|
|
137
|
+
|
|
138
|
+
<style>
|
|
139
|
+
:global(.reasoning-popover) {
|
|
140
|
+
box-sizing: border-box;
|
|
141
|
+
z-index: 210;
|
|
142
|
+
width: min(18rem, calc(100vw - 1rem));
|
|
143
|
+
max-height: calc(100dvh - 1rem);
|
|
144
|
+
overflow-y: auto;
|
|
145
|
+
display: flex;
|
|
146
|
+
flex-direction: column;
|
|
147
|
+
gap: 0.75rem;
|
|
148
|
+
padding: 0.75rem;
|
|
149
|
+
border: 1px solid var(--color-border);
|
|
150
|
+
border-radius: var(--kit-radius, var(--radius));
|
|
151
|
+
background: var(--color-popover);
|
|
152
|
+
color: var(--color-popover-foreground);
|
|
153
|
+
box-shadow: 0 4px 12px color-mix(in oklch, var(--color-foreground) 12%, transparent);
|
|
154
|
+
font-size: var(--kit-font-size-sm, 0.8125rem);
|
|
155
|
+
}
|
|
156
|
+
.reasoning-heading,
|
|
157
|
+
.reasoning-scale {
|
|
158
|
+
display: flex;
|
|
159
|
+
justify-content: space-between;
|
|
160
|
+
align-items: center;
|
|
161
|
+
gap: 0.5rem;
|
|
162
|
+
}
|
|
163
|
+
.reasoning-heading p,
|
|
164
|
+
.reasoning-hint {
|
|
165
|
+
margin: 0;
|
|
166
|
+
overflow-wrap: anywhere;
|
|
167
|
+
}
|
|
168
|
+
.reasoning-scale,
|
|
169
|
+
.reasoning-hint {
|
|
170
|
+
color: var(--color-muted-foreground);
|
|
171
|
+
}
|
|
172
|
+
:global(.reasoning-popover input) {
|
|
173
|
+
accent-color: var(--color-primary);
|
|
174
|
+
}
|
|
175
|
+
:global(.reasoning-popover input:focus-visible) {
|
|
176
|
+
outline: 2px solid var(--color-ring);
|
|
177
|
+
outline-offset: 2px;
|
|
178
|
+
}
|
|
179
|
+
.reasoning-option {
|
|
180
|
+
display: flex;
|
|
181
|
+
align-items: center;
|
|
182
|
+
gap: 0.5rem;
|
|
183
|
+
}
|
|
184
|
+
</style>
|