@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.
- package/package.json +2 -2
- package/src/chat/ChatComposer.svelte +47 -5
- package/src/chat/ChatPanel.css +380 -380
- package/src/chat/ChatPanel.svelte +48 -51
- package/src/chat/ChatPanelComposer.svelte +44 -25
- package/src/chat/ChatPanelHeader.svelte +1 -25
- package/src/chat/ChatPanelMode.svelte +3 -3
- package/src/chat/ChatPanelToolbar.svelte +14 -3
- package/src/chat/ContextUsageStatus.svelte +97 -0
- package/src/chat/ModelEffortPicker.svelte +326 -0
- package/src/chat/PickerEffort.svelte +71 -0
- package/src/chat/ReasoningControl.svelte +10 -112
- package/src/chat/ReasoningSlider.svelte +174 -0
- package/src/chat/SkillPicker.svelte +1 -0
- package/src/chat/UsageControl.svelte +12 -5
- package/src/chat/UsageDetails.svelte +19 -3
- package/src/chat/chat-attachments.svelte.ts +9 -5
- package/src/chat/chat-panel-models.svelte.ts +97 -0
- package/src/chat/chat-panel-props.ts +6 -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 +20 -0
- package/src/chat/model-effort.ts +177 -0
- package/src/chat/test-support/chat-fixture.ts +16 -0
- package/src/i18n/en-chat.d.ts +1 -0
- package/src/i18n/en-chat.js +201 -0
- package/src/i18n/en.ts +24 -174
- 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 +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>
|
|
@@ -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=
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
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
|
|
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
|
-
<
|
|
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
|
-
</
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
+
}
|