@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,430 @@
1
+ import { SvelteMap } from 'svelte/reactivity'
2
+ import type { KitNotify } from '../context'
3
+ import type {
4
+ Conversation,
5
+ ConversationClient,
6
+ ConversationSummary,
7
+ ModelOption,
8
+ ModelRef,
9
+ } from '../ports'
10
+ import type { ChatTurn, RunSession } from './run-session.svelte'
11
+
12
+ type Translate = (key: string, values?: Record<string, string | number>) => string
13
+
14
+ type HistoryDependencies = {
15
+ history: ConversationClient | undefined
16
+ getSession: () => RunSession | null
17
+ getModelOptions: () => ModelOption[] | null
18
+ getModelError: () => string | null
19
+ setDraft: (value: string) => void
20
+ bumpDraftRevision: () => void
21
+ setFullAccess: (value: boolean) => void
22
+ getScope: () => string | undefined
23
+ getLocale: () => string
24
+ onConversationChange?: ((id: string | null) => void) | undefined
25
+ bumpScrollRevision: () => void
26
+ notify: KitNotify
27
+ t: Translate
28
+ errorText: (error: unknown) => string
29
+ consumeBase: () => Parameters<RunSession['translate']>[0]
30
+ discardRecovery: () => void
31
+ }
32
+
33
+ export class ChatPanelHistoryState {
34
+ readonly history: ConversationClient | undefined
35
+ conversations = $state<ConversationSummary[]>([])
36
+ shownSummary = $state<ConversationSummary | null>(null)
37
+ historyOpen = $state(false)
38
+ historyBusy = $state(false)
39
+ historyLoading = $state(false)
40
+ historyError = $state<string | null>(null)
41
+ retryHistory = $state<() => Promise<void>>(async () => {
42
+ await this.refresh()
43
+ })
44
+ renameTitle = $state('')
45
+ renaming = $state(false)
46
+ restored = $state(false)
47
+ restoreBlocked = $state(false)
48
+ replyDialog = $state<'edit' | 'translate' | null>(null)
49
+ replyOpen = $state(false)
50
+ sourceReply = $state<ChatTurn | null>(null)
51
+ editDraft = $state('')
52
+ expectedText = ''
53
+ targetLanguage = $state('')
54
+ replyModel = $state<ModelRef | null>(null)
55
+ replyError = $state<string | null>(null)
56
+ viewGeneration = $state(0)
57
+ disposed = $state(false)
58
+ #listGeneration = 0
59
+ #reported: string | null
60
+ readonly #deps: HistoryDependencies
61
+
62
+ constructor(deps: HistoryDependencies, initialConversationId: string | null) {
63
+ this.#deps = deps
64
+ this.history = deps.history
65
+ this.#reported = initialConversationId
66
+ this.retryHistory = this.refresh
67
+ }
68
+
69
+ get title() {
70
+ const session = this.#deps.getSession()
71
+ return (
72
+ (this.shownSummary?.id === session?.conversationId ? this.shownSummary?.title : null) ??
73
+ this.conversations.find((conversation) => conversation.id === session?.conversationId)
74
+ ?.title ??
75
+ this.#deps.t('chat.newConversation')
76
+ )
77
+ }
78
+
79
+ get groups() {
80
+ const grouped = new SvelteMap<string, ChatTurn[]>()
81
+ for (const turn of this.#deps.getSession()?.turns ?? []) {
82
+ const replies = grouped.get(turn.questionId)
83
+ if (replies) replies.push(turn)
84
+ else grouped.set(turn.questionId, [turn])
85
+ }
86
+ return [...grouped.entries()]
87
+ }
88
+
89
+ get actionBusy() {
90
+ return this.historyBusy || this.#deps.getSession()?.running === true || this.restoreBlocked
91
+ }
92
+
93
+ get validReplyModel() {
94
+ const model = this.replyModel
95
+ return (
96
+ this.#deps
97
+ .getModelOptions()
98
+ ?.some(
99
+ (option) => option.endpointId === model?.endpointId && option.modelId === model?.modelId,
100
+ ) === true
101
+ )
102
+ }
103
+
104
+ get isRestored() {
105
+ return this.restored
106
+ }
107
+
108
+ dispose = () => {
109
+ this.disposed = true
110
+ this.viewGeneration++
111
+ this.#listGeneration++
112
+ this.#deps.setFullAccess(false)
113
+ this.#deps.getSession()?.dispose()
114
+ }
115
+
116
+ syncConversation = () => {
117
+ const session = this.#deps.getSession()
118
+ const id = session?.conversationId ?? null
119
+ if (!this.restored || this.restoreBlocked || id === this.#reported) return
120
+ this.#reported = id
121
+ this.#deps.onConversationChange?.(id)
122
+ }
123
+
124
+ refresh = async () => {
125
+ if (!this.history) return
126
+ const generation = ++this.#listGeneration
127
+ this.historyLoading = true
128
+ try {
129
+ const next = await this.history.list(this.#deps.getScope())
130
+ if (this.disposed || generation !== this.#listGeneration) return
131
+ this.conversations = next
132
+ if (this.retryHistory === this.refresh) this.historyError = null
133
+ } catch (error) {
134
+ if (!this.disposed && generation === this.#listGeneration && !this.restoreBlocked) {
135
+ this.historyError = this.#deps.errorText(error)
136
+ this.retryHistory = this.refresh
137
+ }
138
+ } finally {
139
+ if (!this.disposed && generation === this.#listGeneration) this.historyLoading = false
140
+ }
141
+ }
142
+
143
+ restore = async (id: string | null) => {
144
+ if (id !== null && this.history && this.#deps.getSession()) {
145
+ this.restoreBlocked = true
146
+ await this.open(id)
147
+ }
148
+ this.restored = true
149
+ }
150
+
151
+ open = async (id: string) => {
152
+ const session = this.#deps.getSession()
153
+ if (!this.history || !session || session.running || this.historyBusy) return
154
+ const generation = ++this.viewGeneration
155
+ this.#deps.setFullAccess(false)
156
+ this.historyBusy = true
157
+ try {
158
+ const conversation = await this.history.get(id)
159
+ if (this.disposed || generation !== this.viewGeneration) return
160
+ session.show(id, conversation.turns, conversation.summary.selectedTurnId)
161
+ this.shownSummary = conversation.summary
162
+ this.restoreBlocked = false
163
+ this.restored = true
164
+ this.historyError = null
165
+ this.retryHistory = this.refresh
166
+ this.historyOpen = false
167
+ this.renaming = false
168
+ this.#deps.bumpScrollRevision()
169
+ } catch (error) {
170
+ if (this.disposed || generation !== this.viewGeneration) return
171
+ this.historyError = this.#deps.errorText(error)
172
+ this.retryHistory = () => this.open(id)
173
+ } finally {
174
+ if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
175
+ }
176
+ }
177
+
178
+ newConversation = () => {
179
+ const session = this.#deps.getSession()
180
+ if (!session || session.running || this.historyBusy) return
181
+ this.viewGeneration++
182
+ this.#deps.setFullAccess(false)
183
+ session.show(null)
184
+ this.shownSummary = null
185
+ this.restoreBlocked = false
186
+ this.restored = true
187
+ this.historyError = null
188
+ this.historyOpen = false
189
+ this.renaming = false
190
+ this.#deps.bumpScrollRevision()
191
+ }
192
+
193
+ rename = async () => {
194
+ const session = this.#deps.getSession()
195
+ const id = session?.conversationId
196
+ const next = this.renameTitle.trim()
197
+ if (!this.history || !id || !next || session?.running || this.historyBusy) return
198
+ const generation = this.viewGeneration
199
+ this.historyBusy = true
200
+ try {
201
+ const summary = await this.history.rename(id, next)
202
+ if (this.disposed || generation !== this.viewGeneration) return
203
+ this.shownSummary = summary
204
+ this.conversations = this.conversations.map((item) => (item.id === id ? summary : item))
205
+ this.renaming = false
206
+ this.historyError = null
207
+ } catch (error) {
208
+ if (this.disposed || generation !== this.viewGeneration) return
209
+ this.historyError = this.#deps.errorText(error)
210
+ this.retryHistory = this.rename
211
+ } finally {
212
+ if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
213
+ }
214
+ }
215
+
216
+ remove = async () => {
217
+ const session = this.#deps.getSession()
218
+ const id = session?.conversationId
219
+ if (!this.history || !session || !id || session.running || this.historyBusy) return
220
+ const generation = this.viewGeneration
221
+ this.historyBusy = true
222
+ try {
223
+ const ok = await this.#deps.notify.confirm({
224
+ title: this.#deps.t('chat.deleteTitle'),
225
+ body: this.#deps.t('chat.deleteBody'),
226
+ danger: true,
227
+ })
228
+ if (!ok || this.disposed || generation !== this.viewGeneration) return
229
+ await this.history.remove(id)
230
+ if (this.disposed || generation !== this.viewGeneration) return
231
+ if (session.conversationId === id) {
232
+ session.show(null)
233
+ this.shownSummary = null
234
+ this.historyOpen = false
235
+ this.renaming = false
236
+ this.#deps.bumpScrollRevision()
237
+ }
238
+ this.historyError = null
239
+ void this.refresh()
240
+ } catch (error) {
241
+ if (this.disposed || generation !== this.viewGeneration) return
242
+ this.historyError = this.#deps.errorText(error)
243
+ this.retryHistory = this.remove
244
+ } finally {
245
+ if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
246
+ }
247
+ }
248
+
249
+ sourceModel = (turn: ChatTurn): ModelRef | null =>
250
+ turn.endpointId && turn.modelId ? { endpointId: turn.endpointId, modelId: turn.modelId } : null
251
+
252
+ sourceLabel = (turn: ChatTurn) => {
253
+ const option = this.#deps
254
+ .getModelOptions()
255
+ ?.find((item) => item.endpointId === turn.endpointId && item.modelId === turn.modelId)
256
+ return option
257
+ ? (option.name ?? option.modelId)
258
+ : turn.modelId
259
+ ? `${turn.endpointId ?? this.#deps.t('chat.unknown')} · ${turn.modelId}`
260
+ : this.#deps.t('chat.noSourceModel')
261
+ }
262
+
263
+ #hasChildren(turn: ChatTurn) {
264
+ return (
265
+ this.#deps
266
+ .getSession()
267
+ ?.turns.some((child) => child.parentId === turn.id || child.supersedesId === turn.id) ===
268
+ true
269
+ )
270
+ }
271
+
272
+ canEdit = (turn: ChatTurn) => turn.status !== 'running' && !this.#hasChildren(turn)
273
+
274
+ editReply = (turn: ChatTurn) => {
275
+ this.sourceReply = turn
276
+ this.editDraft = turn.text
277
+ this.expectedText = turn.text
278
+ this.replyDialog = 'edit'
279
+ this.replyOpen = true
280
+ }
281
+
282
+ translateDialog = (turn: ChatTurn) => {
283
+ this.sourceReply = turn
284
+ this.replyModel = this.sourceModel(turn)
285
+ this.targetLanguage = this.#deps.getLocale()
286
+ this.replyError = null
287
+ this.replyDialog = 'translate'
288
+ this.replyOpen = true
289
+ }
290
+
291
+ #showConversation(conversation: Conversation) {
292
+ this.viewGeneration++
293
+ this.#deps.setFullAccess(false)
294
+ this.#deps
295
+ .getSession()
296
+ ?.show(conversation.summary.id, conversation.turns, conversation.summary.selectedTurnId)
297
+ this.shownSummary = conversation.summary
298
+ this.#deps.bumpScrollRevision()
299
+ }
300
+
301
+ mutateReply = async (turn: ChatTurn, action: 'select' | 'remove' | 'branch' | 'copy') => {
302
+ const session = this.#deps.getSession()
303
+ const id = session?.conversationId
304
+ if (!this.history || !session || !id || this.actionBusy) return
305
+ let generation = this.viewGeneration
306
+ this.historyBusy = true
307
+ try {
308
+ if (action === 'remove') {
309
+ if (turn.status === 'running') return
310
+ const reparent = session.turns.some((child) => child.parentId === turn.id)
311
+ if (
312
+ !(await this.#deps.notify.confirm({
313
+ title: this.#deps.t('chat.deleteReplyTitle'),
314
+ body: reparent ? this.#deps.t('chat.deleteReparentBody') : '',
315
+ danger: true,
316
+ }))
317
+ )
318
+ return
319
+ }
320
+ if (this.disposed || generation !== this.viewGeneration) return
321
+ const conversation =
322
+ action === 'select' || action === 'branch'
323
+ ? await this.history.selectTurn(id, turn.id)
324
+ : action === 'remove'
325
+ ? await this.history.removeTurn(id, turn.id)
326
+ : await this.history.fork(id, turn.id)
327
+ if (this.disposed || generation !== this.viewGeneration) return
328
+ this.#showConversation(conversation)
329
+ generation = this.viewGeneration
330
+ if (action === 'branch') {
331
+ this.#deps.setDraft('')
332
+ this.#deps.bumpDraftRevision()
333
+ this.#deps.discardRecovery()
334
+ }
335
+ this.historyError = null
336
+ void this.refresh()
337
+ } catch (error) {
338
+ if (this.disposed || generation !== this.viewGeneration) return
339
+ this.#deps.notify.toast('error', this.#deps.errorText(error))
340
+ } finally {
341
+ if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
342
+ }
343
+ }
344
+
345
+ retryReply = async (turn: ChatTurn) => {
346
+ const session = this.#deps.getSession()
347
+ if (!session || this.actionBusy) return
348
+ const source = this.sourceModel(turn)
349
+ const options = this.#deps.getModelOptions()
350
+ if (this.history && turn.persisted) {
351
+ if (options === null || this.#deps.getModelError()) {
352
+ this.#deps.notify.toast(
353
+ 'error',
354
+ this.#deps.getModelError() ?? this.#deps.t('chat.modelsLoading'),
355
+ )
356
+ return
357
+ }
358
+ if (
359
+ !source ||
360
+ !options.some(
361
+ (option) => option.endpointId === source.endpointId && option.modelId === source.modelId,
362
+ )
363
+ ) {
364
+ this.#deps.notify.toast('error', this.#deps.t('chat.sourceModelUnavailable'))
365
+ return
366
+ }
367
+ }
368
+ this.#deps.bumpScrollRevision()
369
+ await session.retry(this.#deps.consumeBase(), turn.id)
370
+ }
371
+
372
+ switchModel = async (turn: ChatTurn, model: ModelRef) => {
373
+ const session = this.#deps.getSession()
374
+ if (!session || this.actionBusy) return
375
+ this.#deps.bumpScrollRevision()
376
+ if (!(await session.retry(this.#deps.consumeBase(), turn.id, model)))
377
+ this.#deps.notify.toast('error', this.#deps.t(`errors.${session.actionError ?? 'internal'}`))
378
+ }
379
+
380
+ submitReply = async () => {
381
+ const session = this.#deps.getSession()
382
+ const turn = this.sourceReply
383
+ if (this.disposed || !session || !turn || this.actionBusy) return
384
+ let generation = this.viewGeneration
385
+ this.replyError = null
386
+ if (this.replyDialog === 'edit') {
387
+ const id = session.conversationId
388
+ if (
389
+ !this.history ||
390
+ !id ||
391
+ !this.editDraft.trim() ||
392
+ this.editDraft.length > 24_000 ||
393
+ !this.canEdit(turn)
394
+ )
395
+ return
396
+ this.historyBusy = true
397
+ try {
398
+ const conversation = await this.history.editTurn(
399
+ id,
400
+ turn.id,
401
+ this.editDraft,
402
+ this.expectedText,
403
+ )
404
+ if (this.disposed || generation !== this.viewGeneration) return
405
+ this.#showConversation(conversation)
406
+ generation = this.viewGeneration
407
+ this.replyOpen = false
408
+ } catch (error) {
409
+ if (this.disposed || generation !== this.viewGeneration) return
410
+ this.replyError = this.#deps.errorText(error)
411
+ } finally {
412
+ if (!this.disposed && generation === this.viewGeneration) this.historyBusy = false
413
+ }
414
+ } else {
415
+ if (!this.validReplyModel || !this.replyModel) return
416
+ if (!this.targetLanguage.trim() || this.targetLanguage.trim().length > 80) return
417
+ const accepted = await session.translate(
418
+ { ...this.#deps.consumeBase(), model: this.replyModel },
419
+ turn.id,
420
+ this.targetLanguage.trim(),
421
+ )
422
+ if (this.disposed || generation !== this.viewGeneration) return
423
+ if (accepted) this.replyOpen = false
424
+ else
425
+ this.replyError = session.actionError
426
+ ? this.#deps.t(`errors.${session.actionError}`)
427
+ : this.#deps.t('errors.internal')
428
+ }
429
+ }
430
+ }
@@ -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
+ }
@@ -0,0 +1,38 @@
1
+ import type { ImageRef, ModelRef, PermissionMode, ReasoningEffort, SkillRef } from '../ports'
2
+
3
+ /** Host inputs for the chat container, independent of its private view composition. */
4
+ export interface ChatPanelProps {
5
+ purpose?: string
6
+ model?: ModelRef | null
7
+ scope?: string
8
+ /** Host context can be removed for the next message without changing conversation ownership. */
9
+ context?: { kind: string; id: string; label: string } | null
10
+ skills?: SkillRef[]
11
+ onSkillsChange?: (refs: SkillRef[]) => void
12
+ skillsBudget?: number
13
+ onInsert?: (markdown: string) => void
14
+ onSaveNote?: (markdown: string, images: ImageRef[]) => Promise<void> | void
15
+ onExportMarkdown?: (markdown: string) => Promise<void> | void
16
+ onOpenSettings?: () => void
17
+ /** A failed restore stays visible until retried or replaced with a new conversation. */
18
+ conversationId?: string | null
19
+ onConversationChange?: (id: string | null) => void
20
+ onClose?: () => void
21
+ /** A host painting model gates image generation. */
22
+ paintingModel?: boolean
23
+ /** Ordered pinned tool ids; omitted means the default toolbar. */
24
+ toolbar?: string[]
25
+ onToolbarChange?: (ids: string[]) => void
26
+ /** Saved preference mapped to the selected model's nearest supported effort. */
27
+ reasoningEffort?: ReasoningEffort
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
35
+ permissionModes?: PermissionMode[]
36
+ /** Full access stays private and is consumed once, never saved as a preference. */
37
+ permissionMode?: PermissionMode
38
+ }
@@ -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
+ }