@ouraihub/aether-ui 0.1.1 → 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 (34) hide show
  1. package/package.json +2 -2
  2. package/src/chat/ChatComposer.svelte +47 -5
  3. package/src/chat/ChatPanel.css +380 -380
  4. package/src/chat/ChatPanel.svelte +48 -51
  5. package/src/chat/ChatPanelComposer.svelte +44 -25
  6. package/src/chat/ChatPanelHeader.svelte +1 -25
  7. package/src/chat/ChatPanelMode.svelte +3 -3
  8. package/src/chat/ChatPanelToolbar.svelte +14 -3
  9. package/src/chat/ContextUsageStatus.svelte +97 -0
  10. package/src/chat/ModelEffortPicker.svelte +326 -0
  11. package/src/chat/PickerEffort.svelte +71 -0
  12. package/src/chat/ReasoningControl.svelte +10 -112
  13. package/src/chat/ReasoningSlider.svelte +174 -0
  14. package/src/chat/SkillPicker.svelte +1 -0
  15. package/src/chat/UsageControl.svelte +12 -5
  16. package/src/chat/UsageDetails.svelte +19 -3
  17. package/src/chat/chat-attachments.svelte.ts +9 -5
  18. package/src/chat/chat-panel-models.svelte.ts +97 -0
  19. package/src/chat/chat-panel-props.ts +6 -0
  20. package/src/chat/context-status.ts +101 -0
  21. package/src/chat/icons/LICENSE-REMIX +186 -0
  22. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  23. package/src/chat/icons/PathIcon.svelte +8 -0
  24. package/src/chat/icons/effort-icons.ts +65 -0
  25. package/src/chat/icons/status-icons.ts +105 -0
  26. package/src/chat/index.ts +20 -0
  27. package/src/chat/model-effort.ts +177 -0
  28. package/src/chat/test-support/chat-fixture.ts +16 -0
  29. package/src/i18n/en-chat.d.ts +1 -0
  30. package/src/i18n/en-chat.js +201 -0
  31. package/src/i18n/en.ts +24 -174
  32. package/src/i18n/zh-CN-chat.d.ts +1 -0
  33. package/src/i18n/zh-CN-chat.js +190 -0
  34. package/src/i18n/zh-CN.ts +2 -158
@@ -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>
@@ -119,6 +119,7 @@
119
119
  </Select.Content>
120
120
  </Select.Root>
121
121
  <span
122
+ data-composer-capacity
122
123
  class={cn('max-w-full shrink-0 text-muted-foreground', chars > budget && 'text-destructive')}
123
124
  >
124
125
  {t('skills.budget', { chars, budget })}
@@ -10,6 +10,8 @@
10
10
  i18n,
11
11
  settings,
12
12
  details,
13
+ summary,
14
+ summaryLabel,
13
15
  open = $bindable(false),
14
16
  }: {
15
17
  context?: {
@@ -21,6 +23,9 @@
21
23
  i18n: KitI18n
22
24
  settings?: Snippet | undefined
23
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
24
29
  open?: boolean
25
30
  } = $props()
26
31
  const t = (key: string) => i18n.t(key)
@@ -41,11 +46,12 @@
41
46
  {...props}
42
47
  bind:ref={trigger}
43
48
  variant="ghost"
44
- size="icon-sm"
45
- aria-label={t('chat.usageSettings')}
46
- title={t('chat.usageSettings')}
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')}
47
53
  >
48
- <Settings2Icon aria-hidden="true" />
54
+ {#if summary}{@render summary()}{:else}<Settings2Icon aria-hidden="true" />{/if}
49
55
  </Button>
50
56
  {/snippet}
51
57
  </Popover.Trigger>
@@ -106,7 +112,8 @@
106
112
 
107
113
  <style>
108
114
  /* Hallmark · component: usage-settings · theme: inherited Aether
109
- * Pre-emit critique: Philosophy 5 · Hierarchy 5 · Execution 4 · Specificity 5 · Restraint 5 · Variety 4 */
115
+ * Pre-emit critique: Philosophy 5 · Hierarchy 5 · Execution 4 · Specificity 5
116
+ * · Restraint 5 · Variety 4 */
110
117
  :global(.usage-popover) {
111
118
  box-sizing: border-box;
112
119
  z-index: 210;
@@ -1,22 +1,31 @@
1
1
  <script lang="ts">
2
2
  import type { ContextUsage, Cost, TokenUsage } from '../ports'
3
3
  import { getKitContext } from '../context'
4
- import UsageControl from './UsageControl.svelte'
4
+ import ContextUsageStatus from './ContextUsageStatus.svelte'
5
+ import type { SpendingTurn } from './context-status'
5
6
  interface Props {
6
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[]
7
12
  usage: TokenUsage | null
8
13
  total: TokenUsage
9
14
  /** Last turn's cost; null when its model has no pricing or a round lacked token counts. */
10
15
  cost: Cost | null
11
16
  /** Session total per currency. */
12
17
  totalCost: Cost[]
18
+ open?: boolean
13
19
  }
14
20
  let {
15
21
  context,
22
+ contextWindow = null,
23
+ turns,
16
24
  usage,
17
25
  total,
18
26
  cost,
19
27
  totalCost,
28
+ open = $bindable(false),
20
29
  modelName = '',
21
30
  }: Props & { modelName?: string } = $props()
22
31
  const { i18n } = getKitContext()
@@ -32,7 +41,14 @@
32
41
  `${currency === 'CNY' ? '¥' : '$'}${amount.toFixed(4)}`
33
42
  </script>
34
43
 
35
- <UsageControl context={context ? { ...context, modelName } : null} {i18n}>
44
+ <ContextUsageStatus
45
+ bind:open
46
+ {context}
47
+ contextWindow={contextWindow ?? null}
48
+ {turns}
49
+ {modelName}
50
+ {i18n}
51
+ >
36
52
  {#snippet details()}
37
53
  <table>
38
54
  <thead
@@ -56,7 +72,7 @@
56
72
  </table>
57
73
  <p>{t('chat.usageHint')}</p>
58
74
  {/snippet}
59
- </UsageControl>
75
+ </ContextUsageStatus>
60
76
 
61
77
  <style>
62
78
  p {
@@ -22,11 +22,15 @@ export class ChatAttachments {
22
22
  images = $state<AttachedImage[]>([])
23
23
  reading = $state(0)
24
24
 
25
- constructor(
26
- private readonly canVision: () => boolean,
27
- private readonly notify: KitNotify,
28
- private readonly i18n: KitI18n,
29
- ) {}
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
+ }
30
34
 
31
35
  remove = (index: number): void => {
32
36
  const [image] = this.images.splice(index, 1)
@@ -0,0 +1,97 @@
1
+ // Chat model catalog behind the composer picker (docs/31 §5.1). Provider metadata only enriches
2
+ // rows; a failed `list()` never blocks model selection.
3
+ import type { KitI18n } from '../i18n'
4
+ import type { ModelOption, ModelRef, Provider, ProvidersPort, ReasoningEffort } from '../ports'
5
+ import { nearestEffort, REASONING_EFFORTS } from '../providers/reasoning'
6
+ import { modelKey, pickerModels, type PickerModel, type PickerValue } from './model-effort'
7
+
8
+ export class ChatPanelModels {
9
+ options = $state<ModelOption[] | null>(null)
10
+ providers = $state<Provider[] | null>(null)
11
+ error = $state<string | null>(null)
12
+ refreshing = $state(false)
13
+ notice = $state('')
14
+ readonly #port: ProvidersPort | undefined
15
+ readonly #i18n: KitI18n
16
+ readonly #errorText: (error: unknown) => string
17
+ #generation = 0
18
+
19
+ constructor(
20
+ port: ProvidersPort | undefined,
21
+ i18n: KitI18n,
22
+ errorText: (error: unknown) => string,
23
+ ) {
24
+ this.#port = port
25
+ this.#i18n = i18n
26
+ this.#errorText = errorText
27
+ }
28
+
29
+ get picker(): PickerModel[] {
30
+ return pickerModels(this.options ?? [], this.providers, this.#i18n)
31
+ }
32
+
33
+ /** Rereads in place: nulling options would reset the chosen effort (docs/41 §3.5). */
34
+ async load(announce = false): Promise<void> {
35
+ const generation = ++this.#generation
36
+ this.error = null
37
+ if (!this.#port) {
38
+ this.options = []
39
+ return
40
+ }
41
+ this.refreshing = announce
42
+ const [options, providers] = await Promise.allSettled([
43
+ this.#port.modelOptions({ modelType: 'chat' }),
44
+ this.#port.list(),
45
+ ])
46
+ if (generation !== this.#generation) return
47
+ this.refreshing = false
48
+ if (options.status === 'rejected') {
49
+ this.error = this.#errorText(options.reason)
50
+ if (announce) this.notice = this.error
51
+ return
52
+ }
53
+ this.options = options.value
54
+ this.providers = providers.status === 'fulfilled' ? providers.value : null
55
+ if (announce)
56
+ this.notice = this.#i18n.t('chat.picker.reloaded', { count: options.value.length })
57
+ }
58
+
59
+ #find(key: string): ModelOption | undefined {
60
+ return this.options?.find((item) => modelKey(item.endpointId, item.modelId) === key)
61
+ }
62
+
63
+ /** Hands the host the model behind the key; a failed save is shown in the picker notice. */
64
+ async saveDefault(
65
+ value: PickerValue,
66
+ save: (model: ModelRef, effort: ReasoningEffort) => Promise<void> | void,
67
+ ): Promise<void> {
68
+ const option = this.#find(value.key)
69
+ if (!option) return
70
+ try {
71
+ await save({ endpointId: option.endpointId, modelId: option.modelId }, value.effort)
72
+ } catch (error) {
73
+ this.notice = this.#errorText(error)
74
+ }
75
+ }
76
+
77
+ /**
78
+ * A model switch keeps the saved effort policy (nearest allowed level); only a level the user
79
+ * picked on the same model, or typed as a search suffix, is written back.
80
+ */
81
+ select(
82
+ value: PickerValue,
83
+ picked: ModelRef | null,
84
+ chosen: ReasoningEffort,
85
+ pick: (ref: ModelRef) => void,
86
+ choose: (effort: ReasoningEffort) => void,
87
+ ): void {
88
+ const option = this.#find(value.key)
89
+ if (!option) return
90
+ const same = picked !== null && modelKey(picked.endpointId, picked.modelId) === value.key
91
+ if (!same) pick({ endpointId: option.endpointId, modelId: option.modelId })
92
+ const declared = option.reasoning
93
+ const allowed = REASONING_EFFORTS.filter((effort) => declared?.efforts.includes(effort))
94
+ const implied = nearestEffort(chosen, allowed)
95
+ if (value.effort !== implied && (same || value.effort !== 'default')) choose(value.effort)
96
+ }
97
+ }
@@ -26,6 +26,12 @@ export interface ChatPanelProps {
26
26
  /** Saved preference mapped to the selected model's nearest supported effort. */
27
27
  reasoningEffort?: ReasoningEffort
28
28
  onReasoningEffortChange?: (effort: ReasoningEffort) => void
29
+ /** Host default for new conversations; enables「设为默认」when `onDefaultModelChange` is set. */
30
+ defaultModel?: ModelRef | null
31
+ /** Saved default effort, independent of the current session choice. */
32
+ defaultReasoningEffort?: ReasoningEffort
33
+ /** The host persists the default model and effort; the picker never writes storage itself. */
34
+ onDefaultModelChange?: (model: ModelRef, effort: ReasoningEffort) => Promise<void> | void
29
35
  permissionModes?: PermissionMode[]
30
36
  /** Full access stays private and is consumed once, never saved as a preference. */
31
37
  permissionMode?: PermissionMode
@@ -0,0 +1,101 @@
1
+ // Pure rules for the inline context status beside Send (docs/31 §4.3). No I/O.
2
+ import type { ContextUsage, Cost, TokenUsage } from '../ports'
3
+
4
+ export type Pressure = 'normal' | 'warning' | 'high' | 'critical'
5
+ export type Platform = 'windows' | 'macos' | 'linux' | 'other'
6
+
7
+ /** Occupancy in percent, clamped to 0–100; `null` when tokens or window are unknown. */
8
+ export function contextPercent(context: ContextUsage | null, window: number | null): number | null {
9
+ const tokens = context?.tokens
10
+ if (tokens == null || window == null || window <= 0) return null
11
+ return Math.min(100, Math.max(0, (tokens / window) * 100))
12
+ }
13
+
14
+ /** Highest of percentage and absolute pressure; absolute counts need a known window. */
15
+ export function pressure(context: ContextUsage | null, window: number | null): Pressure {
16
+ const percent = contextPercent(context, window) ?? 0
17
+ const tokens = window != null && window > 0 ? (context?.tokens ?? 0) : 0
18
+ if (percent >= 90 || tokens >= 500_000) return 'critical'
19
+ if (percent >= 70 || tokens >= 270_000) return 'high'
20
+ if (percent >= 50 || tokens >= 150_000) return 'warning'
21
+ return 'normal'
22
+ }
23
+
24
+ /** Truncates rather than rounds, so 999 999 never reads as 1000k. */
25
+ export function compact(value: number): string {
26
+ const n = Math.max(0, Math.floor(value))
27
+ const [divisor, suffix] =
28
+ n >= 1e9 ? [1e9, 'B'] : n >= 1e6 ? [1e6, 'M'] : n >= 1e3 ? [1e3, 'k'] : [1, '']
29
+ if (divisor === 1) return String(n)
30
+ const tenth = Math.floor((n * 10) / divisor)
31
+ const whole = Math.floor(tenth / 10)
32
+ return tenth % 10 === 0 ? `${whole}${suffix}` : `${whole}.${tenth % 10}${suffix}`
33
+ }
34
+
35
+ /** `~12.3%/1.1M`; unknown parts render as `?`. */
36
+ export function summary(context: ContextUsage | null, window: number | null): string {
37
+ const percent = contextPercent(context, window)
38
+ const size = window != null && window > 0 ? compact(window) : '?'
39
+ const estimated = context?.source === 'estimated' ? '~' : ''
40
+ return `${estimated}${percent === null ? '?' : percent.toFixed(1)}%/${size}`
41
+ }
42
+
43
+ /** Estimated money from configured prices; invalid amounts are unknown. */
44
+ export function money({ amount, currency }: Cost): string | null {
45
+ if (!Number.isFinite(amount) || amount < 0) return null
46
+ const text =
47
+ amount > 0 && amount < 0.0001
48
+ ? '<0.0001'
49
+ : amount > 0 && amount < 0.01
50
+ ? amount.toFixed(4)
51
+ : amount.toFixed(2)
52
+ return `≈${currency === 'CNY' ? '¥' : '$'}${text}`
53
+ }
54
+
55
+ export interface SpendingTurn {
56
+ usage: TokenUsage | null
57
+ cost: Cost | null
58
+ }
59
+
60
+ /**
61
+ * Money only when every turn with usage has a priced cost in one currency; otherwise tokens.
62
+ * Input already contains cache, output contains reasoning, so only the two totals are added.
63
+ */
64
+ export function spending(turns: readonly SpendingTurn[]): string {
65
+ const used = turns.filter(
66
+ ({ usage }) => usage != null && (usage.inputTokens != null || usage.outputTokens != null),
67
+ )
68
+ if (used.length === 0) return '? tokens'
69
+ const priced = used.flatMap(({ usage, cost }) =>
70
+ cost &&
71
+ Number.isFinite(cost.amount) &&
72
+ cost.amount >= 0 &&
73
+ usage?.inputTokens != null &&
74
+ usage.outputTokens != null
75
+ ? [cost]
76
+ : [],
77
+ )
78
+ const first = priced[0]
79
+ const oneCurrency = priced.every((cost) => cost.currency === first?.currency)
80
+ if (first && priced.length === used.length && oneCurrency) {
81
+ const amount = priced.reduce((sum, cost) => sum + cost.amount, 0)
82
+ const text = money({ amount, currency: first.currency })
83
+ if (text) return text
84
+ }
85
+ let tokens = 0
86
+ for (const { usage } of used) tokens += (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0)
87
+ return `${compact(tokens)} tokens`
88
+ }
89
+
90
+ type PlatformNavigator = Navigator & { userAgentData?: { platform?: string } }
91
+
92
+ /** Browser-reported OS; on the web this is the visitor's system, not the server's. */
93
+ export function detectPlatform(
94
+ nav: PlatformNavigator | undefined = globalThis.navigator,
95
+ ): Platform {
96
+ const name = (nav?.userAgentData?.platform || nav?.platform || '').toLowerCase()
97
+ if (name.startsWith('win')) return 'windows'
98
+ if (name.startsWith('mac') || name.includes('darwin')) return 'macos'
99
+ if (name.includes('linux') && !name.includes('android')) return 'linux'
100
+ return 'other'
101
+ }