@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
@@ -0,0 +1,174 @@
1
+ <script lang="ts">
2
+ // The one discrete effort rail: a tone gradient fill and a pill thumb carrying the level's
3
+ // mark and name over a native range input (docs/16, 31).
4
+ import type { ReasoningEffort } from '../ports'
5
+ import { effortTone } from './model-effort'
6
+ import PathIcon from './icons/PathIcon.svelte'
7
+ import { effortPath } from './icons/effort-icons'
8
+
9
+ let {
10
+ efforts,
11
+ value,
12
+ defaultIndex = 0,
13
+ disabled = false,
14
+ label,
15
+ valueText,
16
+ onchange,
17
+ }: {
18
+ /** Declared levels in display order; needs at least two. */
19
+ efforts: readonly ReasoningEffort[]
20
+ value: ReasoningEffort
21
+ /** Visual position for `default`; never written back. */
22
+ defaultIndex?: number
23
+ disabled?: boolean
24
+ label: string
25
+ valueText: string
26
+ onchange: (effort: ReasoningEffort) => void
27
+ } = $props()
28
+ const found = $derived(efforts.indexOf(value))
29
+ const index = $derived(found >= 0 ? found : defaultIndex)
30
+ const last = $derived(Math.max(1, efforts.length - 1))
31
+ const tone = $derived(effortTone(found >= 0 ? value : 'default'))
32
+ const TONE_COLOR = {
33
+ fast: 'var(--color-info)',
34
+ regular: 'var(--color-primary)',
35
+ deep: 'var(--color-warning)',
36
+ } as const
37
+ const fill = $derived(
38
+ `linear-gradient(to right, ${efforts
39
+ .map((effort, at) => `${TONE_COLOR[effortTone(effort)]} ${(at / last) * 100}%`)
40
+ .join(', ')})`,
41
+ )
42
+ function commit(event: Event & { currentTarget: HTMLInputElement }) {
43
+ const effort = efforts[Number(event.currentTarget.value)]
44
+ if (effort && !disabled) onchange(effort)
45
+ }
46
+ </script>
47
+
48
+ <div
49
+ class="reasoning-slider"
50
+ data-tone={tone}
51
+ style:--reasoning-fraction={index / last}
52
+ style:--reasoning-fill={fill}
53
+ >
54
+ <div class="reasoning-track" aria-hidden="true"><span class="reasoning-fill"></span></div>
55
+ <input
56
+ type="range"
57
+ min="0"
58
+ max={efforts.length - 1}
59
+ step="1"
60
+ value={index}
61
+ aria-label={label}
62
+ aria-valuetext={valueText}
63
+ {disabled}
64
+ oninput={commit}
65
+ onpointerup={(event) => {
66
+ if (event.button === 0) commit(event)
67
+ }}
68
+ onkeyup={(event) => {
69
+ if (
70
+ !event.isComposing &&
71
+ ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End'].includes(event.key)
72
+ )
73
+ commit(event)
74
+ }}
75
+ />
76
+ <span class="reasoning-thumb" aria-hidden="true"
77
+ ><PathIcon d={effortPath(found >= 0 ? value : 'default')} class="reasoning-icon" /><span
78
+ >{valueText}</span
79
+ ></span
80
+ >
81
+ </div>
82
+
83
+ <style>
84
+ /* The native thumb is as wide as the pill, so the range maps 1:1 onto the inset track. */
85
+ .reasoning-slider {
86
+ --reasoning-color: var(--color-primary);
87
+ --reasoning-pill: 4.25rem;
88
+ position: relative;
89
+ height: 2rem;
90
+ }
91
+ .reasoning-slider[data-tone='fast'] {
92
+ --reasoning-color: var(--color-info);
93
+ }
94
+ .reasoning-slider[data-tone='deep'] {
95
+ --reasoning-color: var(--color-warning);
96
+ }
97
+ .reasoning-track {
98
+ position: absolute;
99
+ top: 50%;
100
+ inset-inline: calc(var(--reasoning-pill) / 2);
101
+ height: 0.75rem;
102
+ overflow: hidden;
103
+ border: 1px solid var(--color-border);
104
+ border-radius: 999px;
105
+ background: var(--color-secondary, var(--color-muted));
106
+ transform: translateY(-50%);
107
+ }
108
+ .reasoning-fill {
109
+ position: absolute;
110
+ inset: 0;
111
+ background: var(--reasoning-fill);
112
+ clip-path: inset(0 calc((1 - var(--reasoning-fraction)) * 100%) 0 0);
113
+ }
114
+ .reasoning-thumb {
115
+ position: absolute;
116
+ top: 50%;
117
+ left: calc(
118
+ var(--reasoning-pill) / 2 + (100% - var(--reasoning-pill)) * var(--reasoning-fraction)
119
+ );
120
+ display: flex;
121
+ align-items: center;
122
+ justify-content: center;
123
+ gap: 0.25rem;
124
+ box-sizing: border-box;
125
+ width: var(--reasoning-pill);
126
+ height: 1.5rem;
127
+ border: 1px solid var(--color-border);
128
+ border-radius: 999px;
129
+ background: var(--color-popover);
130
+ box-shadow: 0 1px 2px rgb(0 0 0 / 0.08);
131
+ color: var(--color-foreground);
132
+ font-size: var(--kit-font-size-xs, 0.75rem);
133
+ white-space: nowrap;
134
+ pointer-events: none;
135
+ transform: translate(-50%, -50%);
136
+ }
137
+ .reasoning-thumb :global(.reasoning-icon) {
138
+ width: 0.875rem;
139
+ height: 0.875rem;
140
+ flex-shrink: 0;
141
+ color: var(--reasoning-color);
142
+ }
143
+ input[type='range'] {
144
+ position: absolute;
145
+ inset: 0;
146
+ width: 100%;
147
+ height: 100%;
148
+ margin: 0;
149
+ appearance: none;
150
+ background: transparent;
151
+ opacity: 0;
152
+ cursor: pointer;
153
+ }
154
+ input::-webkit-slider-thumb {
155
+ appearance: none;
156
+ width: var(--reasoning-pill);
157
+ height: 1.5rem;
158
+ }
159
+ input::-moz-range-thumb {
160
+ width: var(--reasoning-pill);
161
+ height: 1.5rem;
162
+ border: 0;
163
+ }
164
+ input:disabled {
165
+ cursor: not-allowed;
166
+ }
167
+ .reasoning-slider:has(input:disabled) {
168
+ opacity: 0.5;
169
+ }
170
+ .reasoning-slider:has(input:focus-visible) .reasoning-thumb {
171
+ outline: 2px solid var(--color-ring);
172
+ outline-offset: 2px;
173
+ }
174
+ </style>
@@ -33,7 +33,9 @@
33
33
  let reconfirmOpen = $state(false)
34
34
 
35
35
  const ids = $derived(value.map((r) => r.id))
36
- const chars = $derived(skills.filter((s) => ids.includes(s.id)).reduce((sum, s) => sum + s.bodyChars, 0))
36
+ const chars = $derived(
37
+ skills.filter((s) => ids.includes(s.id)).reduce((sum, s) => sum + s.bodyChars, 0),
38
+ )
37
39
 
38
40
  async function refresh() {
39
41
  try {
@@ -74,7 +76,8 @@
74
76
 
75
77
  function confirmChanged() {
76
78
  const skill = reconfirm
77
- if (skill) onchange(value.map((r) => (r.id === skill.id ? { id: r.id, revision: skill.revision } : r)))
79
+ if (skill)
80
+ onchange(value.map((r) => (r.id === skill.id ? { id: r.id, revision: skill.revision } : r)))
78
81
  reconfirm = null
79
82
  reconfirmOpen = false
80
83
  onresolved()
@@ -91,7 +94,7 @@
91
94
  })
92
95
  </script>
93
96
 
94
- <div class="flex items-center gap-2 text-kit-sm">
97
+ <div class="flex min-w-0 max-w-full flex-wrap items-center gap-2 text-kit-sm">
95
98
  <Select.Root
96
99
  type="multiple"
97
100
  value={ids}
@@ -100,8 +103,10 @@
100
103
  if (open) void refresh()
101
104
  }}
102
105
  >
103
- <Select.Trigger size="sm" class="w-64" aria-label={t('skills.picker')}>
104
- {value.length === 0 ? t('skills.picker_none') : t('skills.picker_count', { count: value.length })}
106
+ <Select.Trigger size="sm" class="max-w-full shrink-0" aria-label={t('skills.picker')}>
107
+ {value.length === 0
108
+ ? t('skills.picker_none')
109
+ : t('skills.picker_count', { count: value.length })}
105
110
  </Select.Trigger>
106
111
  <Select.Content>
107
112
  <Select.Group>
@@ -113,7 +118,10 @@
113
118
  </Select.Group>
114
119
  </Select.Content>
115
120
  </Select.Root>
116
- <span class={cn('text-muted-foreground', chars > budget && 'text-destructive')}>
121
+ <span
122
+ data-composer-capacity
123
+ class={cn('max-w-full shrink-0 text-muted-foreground', chars > budget && 'text-destructive')}
124
+ >
117
125
  {t('skills.budget', { chars, budget })}
118
126
  </span>
119
127
  </div>
@@ -0,0 +1,223 @@
1
+ <script lang="ts">
2
+ import type { Snippet } from 'svelte'
3
+ import { Popover } from 'bits-ui'
4
+ import Settings2Icon from '@lucide/svelte/icons/settings-2'
5
+ import { Button } from '#lib/components/ui/button/index.js'
6
+ import type { KitI18n } from '../i18n'
7
+
8
+ let {
9
+ context = null,
10
+ i18n,
11
+ settings,
12
+ details,
13
+ summary,
14
+ summaryLabel,
15
+ open = $bindable(false),
16
+ }: {
17
+ context?: {
18
+ tokens: number | null
19
+ contextWindow: number | null
20
+ modelName: string
21
+ source: 'reported' | 'estimated' | 'unknown'
22
+ } | null
23
+ i18n: KitI18n
24
+ settings?: Snippet | undefined
25
+ details?: Snippet | undefined
26
+ /** Replaces the icon with inline content; the popover, focus return and meter stay shared. */
27
+ summary?: Snippet | undefined
28
+ summaryLabel?: string | undefined
29
+ open?: boolean
30
+ } = $props()
31
+ const t = (key: string) => i18n.t(key)
32
+ const percent = $derived(
33
+ context?.tokens != null && context.contextWindow != null && context.contextWindow > 0
34
+ ? Math.min(100, Math.max(0, (context.tokens / context.contextWindow) * 100))
35
+ : null,
36
+ )
37
+ const format = (value: number | null | undefined) =>
38
+ value == null ? t('chat.unknown') : value.toLocaleString(i18n.locale)
39
+ let trigger = $state<HTMLButtonElement | null>(null)
40
+ </script>
41
+
42
+ <Popover.Root bind:open>
43
+ <Popover.Trigger>
44
+ {#snippet child({ props })}
45
+ <Button
46
+ {...props}
47
+ bind:ref={trigger}
48
+ variant="ghost"
49
+ size={summary ? 'sm' : 'icon-sm'}
50
+ class={summary ? 'min-w-0 max-w-full shrink px-1' : undefined}
51
+ aria-label={summary ? (summaryLabel ?? t('chat.contextUsage')) : t('chat.usageSettings')}
52
+ title={summary ? undefined : t('chat.usageSettings')}
53
+ >
54
+ {#if summary}{@render summary()}{:else}<Settings2Icon aria-hidden="true" />{/if}
55
+ </Button>
56
+ {/snippet}
57
+ </Popover.Trigger>
58
+ <Popover.Portal>
59
+ <Popover.Content
60
+ class="usage-popover"
61
+ side="top"
62
+ align="end"
63
+ sideOffset={8}
64
+ collisionPadding={12}
65
+ aria-label={t('chat.usageSettings')}
66
+ trapFocus={false}
67
+ preventScroll={false}
68
+ onEscapeKeydown={(event) => {
69
+ if (event.isComposing) event.preventDefault()
70
+ event.stopPropagation()
71
+ }}
72
+ onCloseAutoFocus={(event) => {
73
+ event.preventDefault()
74
+ trigger?.focus({ preventScroll: true })
75
+ }}
76
+ >
77
+ <section class="context-summary" aria-label={t('chat.contextUsage')}>
78
+ <h3>{t('chat.contextUsage')}</h3>
79
+ {#if percent !== null}
80
+ <meter min="0" max="100" value={percent} aria-label={t('chat.contextUsage')}></meter>
81
+ {/if}
82
+ <div class="context-values">
83
+ <span
84
+ >{format(context?.tokens)} / {format(context?.contextWindow)}{#if percent !== null}
85
+ ({Math.round(percent)}%){/if}</span
86
+ >
87
+ {#if context?.modelName}<span class="context-model" title={context.modelName}
88
+ >{context.modelName}</span
89
+ >{/if}
90
+ </div>
91
+ <small
92
+ >{t(
93
+ context?.source === 'reported'
94
+ ? 'chat.reported'
95
+ : context?.source === 'estimated'
96
+ ? 'chat.estimated'
97
+ : 'chat.unknown',
98
+ )}</small
99
+ >
100
+ </section>
101
+ {#if settings}<section class="usage-settings" aria-label={t('chat.runSettings')}>
102
+ <h3>{t('chat.runSettings')}</h3>
103
+ {@render settings()}
104
+ </section>{/if}
105
+ {#if details}<details class="usage-more">
106
+ <summary>{t('chat.usage')}</summary>
107
+ <div class="usage-more-content">{@render details()}</div>
108
+ </details>{/if}
109
+ </Popover.Content>
110
+ </Popover.Portal>
111
+ </Popover.Root>
112
+
113
+ <style>
114
+ /* Hallmark · component: usage-settings · theme: inherited Aether
115
+ * Pre-emit critique: Philosophy 5 · Hierarchy 5 · Execution 4 · Specificity 5
116
+ * · Restraint 5 · Variety 4 */
117
+ :global(.usage-popover) {
118
+ box-sizing: border-box;
119
+ z-index: 210;
120
+ width: min(360px, calc(100vw - 24px));
121
+ max-height: min(70dvh, var(--bits-popover-content-available-height));
122
+ overflow-y: auto;
123
+ display: flex;
124
+ flex-direction: column;
125
+ gap: 16px;
126
+ padding: 16px;
127
+ border: 1px solid var(--color-border);
128
+ border-radius: var(--kit-radius, var(--radius));
129
+ background: var(--color-popover);
130
+ color: var(--color-popover-foreground);
131
+ font-size: var(--kit-font-size-sm, 0.8125rem);
132
+ }
133
+ h3 {
134
+ margin: 0;
135
+ font: inherit;
136
+ font-weight: 500;
137
+ }
138
+ .context-summary {
139
+ display: flex;
140
+ flex-direction: column;
141
+ gap: 8px;
142
+ min-width: 0;
143
+ }
144
+ .context-values {
145
+ display: flex;
146
+ align-items: center;
147
+ justify-content: space-between;
148
+ gap: 12px;
149
+ color: var(--color-muted-foreground);
150
+ font-variant-numeric: tabular-nums;
151
+ }
152
+ .context-values > span:first-child {
153
+ flex: none;
154
+ }
155
+ .context-model {
156
+ min-width: 0;
157
+ overflow: hidden;
158
+ text-overflow: ellipsis;
159
+ white-space: nowrap;
160
+ }
161
+ small {
162
+ color: var(--color-muted-foreground);
163
+ font-size: 11px;
164
+ }
165
+ meter {
166
+ appearance: none;
167
+ width: 100%;
168
+ height: 6px;
169
+ border: 0;
170
+ border-radius: 999px;
171
+ background: var(--color-muted);
172
+ }
173
+ meter::-webkit-meter-bar {
174
+ border: 0;
175
+ border-radius: 999px;
176
+ background: var(--color-muted);
177
+ }
178
+ meter::-webkit-meter-optimum-value {
179
+ background: var(--color-primary);
180
+ border-radius: 999px;
181
+ }
182
+ meter::-moz-meter-bar {
183
+ background: var(--color-primary);
184
+ border-radius: 999px;
185
+ }
186
+ .usage-settings {
187
+ display: flex;
188
+ flex-direction: column;
189
+ gap: 12px;
190
+ border-top: 1px solid var(--color-border);
191
+ padding-top: 12px;
192
+ }
193
+ .usage-more {
194
+ border-top: 1px solid var(--color-border);
195
+ padding-top: 12px;
196
+ }
197
+ summary {
198
+ cursor: pointer;
199
+ color: var(--color-muted-foreground);
200
+ }
201
+ summary:hover {
202
+ color: var(--color-foreground);
203
+ }
204
+ summary:focus-visible {
205
+ outline: 2px solid var(--color-ring);
206
+ outline-offset: 2px;
207
+ }
208
+ .usage-more-content {
209
+ padding-top: 12px;
210
+ min-width: 0;
211
+ overflow-wrap: anywhere;
212
+ }
213
+ @media (max-width: 375px) {
214
+ .context-values {
215
+ align-items: flex-start;
216
+ flex-direction: column;
217
+ gap: 4px;
218
+ }
219
+ .context-model {
220
+ max-width: 100%;
221
+ }
222
+ }
223
+ </style>
@@ -1,70 +1,68 @@
1
1
  <script lang="ts">
2
2
  import type { ContextUsage, Cost, TokenUsage } from '../ports'
3
3
  import { getKitContext } from '../context'
4
- import ChartNoAxesColumnIcon from '@lucide/svelte/icons/chart-no-axes-column'
5
- let details = $state<HTMLDetailsElement | null>(null)
4
+ import ContextUsageStatus from './ContextUsageStatus.svelte'
5
+ import type { SpendingTurn } from './context-status'
6
6
  interface Props {
7
7
  context: ContextUsage | null
8
+ /** Selected model's window when the usage report has none. */
9
+ contextWindow?: number | null | undefined
10
+ /** Every turn's usage and cost; the inline summary prices only fully priced sessions. */
11
+ turns: readonly SpendingTurn[]
8
12
  usage: TokenUsage | null
9
13
  total: TokenUsage
10
14
  /** Last turn's cost; null when its model has no pricing or a round lacked token counts. */
11
15
  cost: Cost | null
12
16
  /** Session total per currency. */
13
17
  totalCost: Cost[]
18
+ open?: boolean
14
19
  }
15
- let { context, usage, total, cost, totalCost }: Props = $props()
16
- const { t } = getKitContext().i18n
17
- const percent = $derived(
18
- context?.tokens != null && context.contextWindow != null && context.contextWindow > 0
19
- ? Math.min(100, Math.max(0, (context.tokens / context.contextWindow) * 100))
20
- : null,
21
- )
22
- const fields = ['inputTokens', 'outputTokens', 'cacheReadTokens', 'cacheWriteTokens', 'reasoningTokens'] as const
23
- const money = ({ amount, currency }: Cost) => `${currency === 'CNY' ? '¥' : '$'}${amount.toFixed(4)}`
20
+ let {
21
+ context,
22
+ contextWindow = null,
23
+ turns,
24
+ usage,
25
+ total,
26
+ cost,
27
+ totalCost,
28
+ open = $bindable(false),
29
+ modelName = '',
30
+ }: Props & { modelName?: string } = $props()
31
+ const { i18n } = getKitContext()
32
+ const { t } = i18n
33
+ const fields = [
34
+ 'inputTokens',
35
+ 'outputTokens',
36
+ 'cacheReadTokens',
37
+ 'cacheWriteTokens',
38
+ 'reasoningTokens',
39
+ ] as const
40
+ const money = ({ amount, currency }: Cost) =>
41
+ `${currency === 'CNY' ? '¥' : '$'}${amount.toFixed(4)}`
24
42
  </script>
25
43
 
26
- <!-- svelte-ignore a11y_no_noninteractive_element_interactions (Esc closes this disclosure first) -->
27
- <details
28
- class="usage-details"
29
- bind:this={details}
30
- aria-label={t('chat.usage')}
31
- onkeydown={(e) => {
32
- if (e.key === 'Escape' && details?.open) {
33
- details.open = false
34
- e.preventDefault()
35
- e.stopPropagation()
36
- }
37
- }}
44
+ <ContextUsageStatus
45
+ bind:open
46
+ {context}
47
+ contextWindow={contextWindow ?? null}
48
+ {turns}
49
+ {modelName}
50
+ {i18n}
38
51
  >
39
- <summary aria-label={t('chat.usage')} title={t('chat.contextUsage')}>
40
- <ChartNoAxesColumnIcon aria-hidden="true" />
41
- <span>{percent === null ? t('chat.unknown') : `${percent.toFixed(1)}%`}</span>
42
- </summary>
43
- <div class="usage-content">
44
- <p>
45
- {t('chat.contextUsage')}: {context?.tokens ?? t('chat.unknown')}{#if context?.contextWindow != null}
46
- / {context.contextWindow}{/if} · {t(
47
- context?.source === 'reported'
48
- ? 'chat.reported'
49
- : context?.source === 'estimated'
50
- ? 'chat.estimated'
51
- : 'chat.unknown',
52
- )}
53
- </p>
54
- {#if percent !== null}<meter min="0" max="100" value={percent} aria-label={t('chat.contextUsage')}></meter>{/if}
52
+ {#snippet details()}
55
53
  <table>
56
54
  <thead
57
55
  ><tr
58
- ><th scope="col">{t('chat.usage')}</th><th scope="col">{t('chat.currentUsage')}</th><th scope="col"
59
- >{t('chat.totalUsage')}</th
56
+ ><th scope="col">{t('chat.usage')}</th><th scope="col">{t('chat.currentUsage')}</th><th
57
+ scope="col">{t('chat.totalUsage')}</th
60
58
  ></tr
61
59
  ></thead
62
60
  >
63
61
  <tbody
64
62
  >{#each fields as field (field)}<tr
65
- ><th scope="row">{t(`chat.usage.${field}`)}</th><td>{usage?.[field] ?? t('chat.unknown')}</td><td
66
- >{total[field] ?? t('chat.unknown')}</td
67
- ></tr
63
+ ><th scope="row">{t(`chat.usage.${field}`)}</th><td
64
+ >{usage?.[field] ?? t('chat.unknown')}</td
65
+ ><td>{total[field] ?? t('chat.unknown')}</td></tr
68
66
  >{/each}<tr
69
67
  ><th scope="row">{t('chat.cost')}</th><td>{cost ? money(cost) : t('chat.unknown')}</td><td
70
68
  >{totalCost.length ? totalCost.map(money).join(' + ') : t('chat.unknown')}</td
@@ -73,51 +71,10 @@
73
71
  >
74
72
  </table>
75
73
  <p>{t('chat.usageHint')}</p>
76
- </div>
77
- </details>
74
+ {/snippet}
75
+ </ContextUsageStatus>
78
76
 
79
77
  <style>
80
- .usage-details {
81
- position: relative;
82
- min-width: 0;
83
- color: var(--color-muted-foreground);
84
- font-size: var(--kit-font-size-sm, 0.8125rem);
85
- }
86
- summary {
87
- display: flex;
88
- align-items: center;
89
- gap: 0.25rem;
90
- cursor: pointer;
91
- padding: 0.25rem;
92
- border-radius: var(--kit-radius, var(--radius));
93
- }
94
- summary::-webkit-details-marker {
95
- display: none;
96
- }
97
- summary:focus-visible {
98
- outline: 2px solid var(--color-ring);
99
- }
100
- summary :global(svg) {
101
- width: 1rem;
102
- height: 1rem;
103
- flex-shrink: 0;
104
- }
105
- .usage-content {
106
- position: absolute;
107
- bottom: calc(100% + 0.5rem);
108
- right: 0;
109
- width: min(20rem, calc(100cqw - 5rem));
110
- max-height: 45cqh;
111
- overflow: auto;
112
- display: flex;
113
- flex-direction: column;
114
- gap: 0.5rem;
115
- padding: 0.75rem;
116
- border: 1px solid var(--color-border);
117
- border-radius: var(--kit-radius, var(--radius));
118
- background: var(--color-popover);
119
- color: var(--color-popover-foreground);
120
- }
121
78
  p {
122
79
  margin: 0;
123
80
  overflow-wrap: anywhere;
@@ -132,7 +89,4 @@
132
89
  text-align: left;
133
90
  overflow-wrap: anywhere;
134
91
  }
135
- meter {
136
- width: 100%;
137
- }
138
92
  </style>
@@ -0,0 +1,78 @@
1
+ import type { KitI18n } from '../i18n'
2
+ import type { KitNotify } from '../context'
3
+ import type { ImagePart } from '../ports'
4
+
5
+ export type AttachedImage = ImagePart & { preview: string }
6
+
7
+ export function hasImages(files: FileList | null | undefined): boolean {
8
+ return [...(files ?? [])].some((file) => file.type.startsWith('image/'))
9
+ }
10
+
11
+ function readBase64(file: File): Promise<string> {
12
+ const { promise, resolve, reject } = Promise.withResolvers<string>()
13
+ const reader = new FileReader()
14
+ reader.onload = () => resolve(String(reader.result).slice(String(reader.result).indexOf(',') + 1))
15
+ reader.onerror = () => reject(reader.error ?? new Error('read failed'))
16
+ reader.readAsDataURL(file)
17
+ return promise
18
+ }
19
+
20
+ /** Owns attachment reads and previews; the panel transfers images to run/draft recovery. */
21
+ export class ChatAttachments {
22
+ images = $state<AttachedImage[]>([])
23
+ reading = $state(0)
24
+
25
+ private readonly canVision: () => boolean
26
+ private readonly notify: KitNotify
27
+ private readonly i18n: KitI18n
28
+
29
+ constructor(canVision: () => boolean, notify: KitNotify, i18n: KitI18n) {
30
+ this.canVision = canVision
31
+ this.notify = notify
32
+ this.i18n = i18n
33
+ }
34
+
35
+ remove = (index: number): void => {
36
+ const [image] = this.images.splice(index, 1)
37
+ if (image) URL.revokeObjectURL(image.preview)
38
+ }
39
+
40
+ attach = async (files: FileList | File[] | null | undefined): Promise<void> => {
41
+ const picked = [...(files ?? [])].filter((file) => file.type.startsWith('image/'))
42
+ if (picked.length === 0) return
43
+ const { t } = this.i18n
44
+ if (!this.canVision()) this.notify.toast('error', t('chat.visionUnsupported'))
45
+ else if (
46
+ picked.some(
47
+ (file) => !['image/png', 'image/jpeg', 'image/webp', 'image/gif'].includes(file.type),
48
+ )
49
+ )
50
+ this.notify.toast('error', t('chat.imageType'))
51
+ else if (picked.some((file) => file.size > 4 * 1024 * 1024))
52
+ this.notify.toast('error', t('chat.imageSize'))
53
+ else if (this.images.length + picked.length > 4)
54
+ this.notify.toast('error', t('chat.imageLimit', { max: 4 }))
55
+ else {
56
+ this.reading++
57
+ try {
58
+ const read = await Promise.all(
59
+ picked.map(async (file) => ({ mime: file.type, data: await readBase64(file), file })),
60
+ )
61
+ if (this.images.length + read.length > 4)
62
+ this.notify.toast('error', t('chat.imageLimit', { max: 4 }))
63
+ else
64
+ this.images.push(
65
+ ...read.map(({ mime, data, file }) => ({
66
+ mime,
67
+ data,
68
+ preview: URL.createObjectURL(file),
69
+ })),
70
+ )
71
+ } catch {
72
+ this.notify.toast('error', t('errors.internal'))
73
+ } finally {
74
+ this.reading--
75
+ }
76
+ }
77
+ }
78
+ }