@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,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(
|
|
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)
|
|
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-
|
|
104
|
-
{value.length === 0
|
|
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
|
|
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
|
|
5
|
-
|
|
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 {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
66
|
-
>{
|
|
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
|
-
|
|
77
|
-
</
|
|
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
|
+
}
|