@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,69 @@
1
+ import type {
2
+ ContextRef,
3
+ ContextUsage,
4
+ Cost,
5
+ ImageRef,
6
+ ModelRef,
7
+ PermissionMode,
8
+ ReasoningEffort,
9
+ SearchHit,
10
+ SkillRef,
11
+ TokenUsage,
12
+ ToolOutcome,
13
+ TurnStatus,
14
+ } from '../ports'
15
+
16
+ /** One tool call of a turn, as shown by ToolCallItem / ApprovalCard. */
17
+ export interface ToolItem {
18
+ callId: string
19
+ name: string
20
+ server: string | null
21
+ pending: string | null
22
+ outcome: ToolOutcome | null
23
+ summary: string
24
+ }
25
+
26
+ export interface ChatTurn {
27
+ id: string
28
+ persisted: boolean
29
+ parentId: string | null
30
+ questionId: string
31
+ supersedesId: string | null
32
+ kind: 'reply' | 'translation'
33
+ endpointId: string | null
34
+ modelId: string | null
35
+ input: string
36
+ images: ImageRef[]
37
+ generated: ImageRef[]
38
+ text: string
39
+ reasoningText: string
40
+ reasoningEffort: ReasoningEffort
41
+ usage: TokenUsage | null
42
+ cost: Cost | null
43
+ contextUsage: ContextUsage | null
44
+ status: TurnStatus
45
+ error: string | null
46
+ tools: ToolItem[]
47
+ exhausted: boolean
48
+ search: SearchState
49
+ }
50
+
51
+ export interface SearchState {
52
+ status: 'idle' | 'searching' | 'complete' | 'failed'
53
+ query: string | null
54
+ provider: string | null
55
+ hits: SearchHit[]
56
+ error: string | null
57
+ }
58
+
59
+ export interface RunBase {
60
+ purpose: string
61
+ scope: string | null
62
+ language: string
63
+ model: ModelRef | null
64
+ skills: SkillRef[]
65
+ context: ContextRef | null
66
+ reasoningEffort?: ReasoningEffort
67
+ generateImage?: boolean
68
+ permissionMode?: PermissionMode
69
+ }
@@ -1,86 +1,24 @@
1
1
  // One conversation in the panel: turns, the active run and its abort (docs/16 §4.2, §7 trap 4).
2
2
  import { SvelteMap } from 'svelte/reactivity'
3
- import { isKitClientError } from '../ports'
3
+ import type { ChatTurn, RunBase, SearchState, ToolItem } from './run-session-types'
4
+ import { cumulativeCost, cumulativeUsage, fromHistory } from './run-session-history'
4
5
  import type {
5
- RunClient,
6
6
  ConversationClient,
7
- ContextRef,
7
+ ContextUsage,
8
+ Cost,
8
9
  ImagePart,
9
10
  ImageRef,
10
11
  KitError,
11
12
  ModelRef,
13
+ RunClient,
12
14
  RunEvent,
13
15
  RunRequest,
14
- SearchHit,
15
- SkillRef,
16
- ToolOutcome,
16
+ TokenUsage,
17
17
  Turn,
18
18
  TurnStatus,
19
19
  } from '../ports'
20
-
21
- /** One tool call of a turn, as shown by ToolCallItem / ApprovalCard. */
22
- export interface ToolItem {
23
- callId: string
24
- name: string
25
- /** MCP server name; absent for host tools. */
26
- server: string | null
27
- /** Arguments preview while the call waits for the user. */
28
- pending: string | null
29
- outcome: ToolOutcome | null
30
- summary: string
31
- }
32
-
33
- import type { ContextUsage, Cost, TokenUsage, ReasoningEffort, PermissionMode } from '../ports'
34
-
35
- export interface ChatTurn {
36
- id: string
37
- persisted: boolean
38
- parentId: string | null
39
- questionId: string
40
- supersedesId: string | null
41
- kind: 'reply' | 'translation'
42
- endpointId: string | null
43
- modelId: string | null
44
- input: string
45
- images: ImageRef[]
46
- /** Images `generate_image` produced in this reply (docs/20). */
47
- generated: ImageRef[]
48
- text: string
49
- reasoningText: string
50
- reasoningEffort: ReasoningEffort
51
- usage: TokenUsage | null
52
- /** Priced at run time; later price changes never recompute it (docs/41 §3.7). */
53
- cost: Cost | null
54
- contextUsage: ContextUsage | null
55
- status: TurnStatus
56
- error: string | null
57
- tools: ToolItem[]
58
- exhausted: boolean
59
- search: SearchState
60
- }
61
-
62
- export interface SearchState {
63
- status: 'idle' | 'searching' | 'complete' | 'failed'
64
- query: string | null
65
- provider: string | null
66
- hits: SearchHit[]
67
- error: string | null
68
- }
69
-
70
- export interface RunBase {
71
- purpose: string
72
- scope: string | null
73
- language: string
74
- model: ModelRef | null
75
- skills: SkillRef[]
76
- context: ContextRef | null
77
- reasoningEffort?: ReasoningEffort
78
- /** Offer the `generate_image` tool (docs/20). */
79
- generateImage?: boolean
80
- /** Permission mode of the run (docs/16 §4.2); omitted is `default`. */
81
- permissionMode?: PermissionMode
82
- }
83
-
20
+ import { isKitClientError } from '../ports'
21
+ export type { ChatTurn, RunBase, SearchState, ToolItem } from './run-session-types'
84
22
  export class RunSession {
85
23
  turns = $state<ChatTurn[]>([])
86
24
  conversationId = $state<string | null>(null)
@@ -89,7 +27,13 @@ export class RunSession {
89
27
  historyRefreshError = $state<string | null>(null)
90
28
  running = $state(false)
91
29
  contextUsage = $state<ContextUsage | null>(null)
92
- search = $state<SearchState>({ status: 'idle', query: null, provider: null, hits: [], error: null })
30
+ search = $state<SearchState>({
31
+ status: 'idle',
32
+ query: null,
33
+ provider: null,
34
+ hits: [],
35
+ error: null,
36
+ })
93
37
  /** Id of a selected skill the backend rejected as `skill_changed`; the user has to confirm it again. */
94
38
  skillChanged = $state<string | null>(null)
95
39
  #abort: AbortController | null = null
@@ -108,36 +52,30 @@ export class RunSession {
108
52
  }
109
53
 
110
54
  get cumulativeUsage(): TokenUsage {
111
- const total: TokenUsage = {
112
- inputTokens: null,
113
- outputTokens: null,
114
- cacheReadTokens: null,
115
- cacheWriteTokens: null,
116
- reasoningTokens: null,
117
- }
118
- for (const turn of this.turns)
119
- for (const key of Object.keys(total) as (keyof TokenUsage)[]) {
120
- const value = turn.usage?.[key]
121
- if (value != null) total[key] = (total[key] ?? 0) + value
122
- }
123
- return total
55
+ return cumulativeUsage(this.turns)
124
56
  }
125
57
 
126
58
  /** Known turn costs summed per currency, same scope as `cumulativeUsage` (no exchange rates). */
127
59
  get cumulativeCost(): Cost[] {
128
- const totals: Cost[] = []
129
- for (const { cost } of this.turns) {
130
- if (!cost) continue
131
- const found = totals.find((item) => item.currency === cost.currency)
132
- if (found) found.amount += cost.amount
133
- else totals.push({ ...cost })
134
- }
135
- return totals
60
+ return cumulativeCost(this.turns)
136
61
  }
137
62
 
138
- send(base: RunBase, input: string, webSearch = false, images: ImagePart[] = []): Promise<boolean> {
63
+ send(
64
+ base: RunBase,
65
+ input: string,
66
+ webSearch = false,
67
+ images: ImagePart[] = [],
68
+ ): Promise<boolean> {
139
69
  if (this.running || input.trim() === '') return Promise.resolve(false)
140
- return this.#append(base, input, this.selectedTurnId, null, 'reply', images.length ? { images } : {}, webSearch)
70
+ return this.#append(
71
+ base,
72
+ input,
73
+ this.selectedTurnId,
74
+ null,
75
+ 'reply',
76
+ images.length ? { images } : {},
77
+ webSearch,
78
+ )
141
79
  }
142
80
 
143
81
  retry(base: RunBase, turnId: string, model?: ModelRef | null): Promise<boolean> {
@@ -161,7 +99,9 @@ export class RunSession {
161
99
  const storedSource = this.#stored && source.persisted
162
100
  // Without stored history the host cannot read the source images back, so resend the local data URLs.
163
101
  const images = source.images.flatMap((image) =>
164
- image.url?.startsWith('data:') ? [{ mime: image.mime, data: image.url.slice(image.url.indexOf(',') + 1) }] : [],
102
+ image.url?.startsWith('data:')
103
+ ? [{ mime: image.mime, data: image.url.slice(image.url.indexOf(',') + 1) }]
104
+ : [],
165
105
  )
166
106
  return this.#append(
167
107
  snapshot,
@@ -190,7 +130,9 @@ export class RunSession {
190
130
  reasoningEffort: source.reasoningEffort,
191
131
  model:
192
132
  base.model ??
193
- (source.endpointId && source.modelId ? { endpointId: source.endpointId, modelId: source.modelId } : null),
133
+ (source.endpointId && source.modelId
134
+ ? { endpointId: source.endpointId, modelId: source.modelId }
135
+ : null),
194
136
  }
195
137
  return this.#append(
196
138
  snapshot,
@@ -198,7 +140,9 @@ export class RunSession {
198
140
  source.id,
199
141
  null,
200
142
  'translation',
201
- this.#stored && source.persisted ? { translateTurnId: source.id, targetLanguage: language.trim() } : {},
143
+ this.#stored && source.persisted
144
+ ? { translateTurnId: source.id, targetLanguage: language.trim() }
145
+ : {},
202
146
  )
203
147
  }
204
148
 
@@ -236,7 +180,10 @@ export class RunSession {
236
180
  modelId: base.model?.modelId ?? null,
237
181
  input,
238
182
  images:
239
- action.images?.map((image) => ({ mime: image.mime, url: `data:${image.mime};base64,${image.data}` })) ??
183
+ action.images?.map((image) => ({
184
+ mime: image.mime,
185
+ url: `data:${image.mime};base64,${image.data}`,
186
+ })) ??
240
187
  source?.images ??
241
188
  [],
242
189
  generated: [],
@@ -262,7 +209,8 @@ export class RunSession {
262
209
  if (this.#runId && !this.#ended) {
263
210
  const abort = this.#abort
264
211
  void this.#port.cancel(this.#runId).catch((error: unknown) => {
265
- if (this.#abort === abort && this.running) this.actionError = isKitClientError(error) ? error.code : 'internal'
212
+ if (this.#abort === abort && this.running)
213
+ this.actionError = isKitClientError(error) ? error.code : 'internal'
266
214
  })
267
215
  }
268
216
  }
@@ -291,7 +239,9 @@ export class RunSession {
291
239
 
292
240
  show(conversationId: string | null, turns: readonly Turn[] = [], selectedTurnId?: string | null) {
293
241
  const previous =
294
- conversationId === this.conversationId ? new SvelteMap(this.turns.map((turn) => [turn.id, turn])) : null
242
+ conversationId === this.conversationId
243
+ ? new SvelteMap(this.turns.map((turn) => [turn.id, turn]))
244
+ : null
295
245
  this.stop()
296
246
  this.#abort = null
297
247
  this.#runId = null
@@ -306,7 +256,7 @@ export class RunSession {
306
256
  this.contextUsage = turns.find((turn) => turn.id === this.selectedTurnId)?.contextUsage ?? null
307
257
  this.search = { status: 'idle', query: null, provider: null, hits: [], error: null }
308
258
  this.turns = turns.map((turn) => {
309
- const restored = this.#fromHistory(turn)
259
+ const restored = fromHistory(turn)
310
260
  const current = previous?.get(turn.id)
311
261
  if (
312
262
  current &&
@@ -322,34 +272,12 @@ export class RunSession {
322
272
  })
323
273
  }
324
274
 
325
- #fromHistory(turn: Turn): ChatTurn {
326
- return {
327
- id: turn.id,
328
- persisted: true,
329
- parentId: turn.parentId,
330
- questionId: turn.questionId,
331
- supersedesId: turn.supersedesId,
332
- kind: turn.kind,
333
- endpointId: turn.endpointId,
334
- modelId: turn.modelId,
335
- input: turn.input,
336
- images: turn.images,
337
- generated: turn.generated,
338
- text: turn.text,
339
- reasoningText: turn.reasoningText ?? '',
340
- reasoningEffort: turn.reasoningEffort ?? 'default',
341
- usage: turn.usage ?? null,
342
- cost: turn.cost ?? null,
343
- contextUsage: turn.contextUsage ?? null,
344
- status: turn.status,
345
- error: turn.errorCode,
346
- tools: [],
347
- exhausted: false,
348
- search: { status: 'idle', query: null, provider: null, hits: [], error: null },
349
- }
350
- }
351
-
352
- async #start(base: RunBase, input: string, action: Partial<RunRequest>, webSearch: boolean): Promise<boolean> {
275
+ async #start(
276
+ base: RunBase,
277
+ input: string,
278
+ action: Partial<RunRequest>,
279
+ webSearch: boolean,
280
+ ): Promise<boolean> {
353
281
  // The proxied element, so streamed writes are reactive.
354
282
  const turn = this.turns[this.turns.length - 1]
355
283
  if (!turn) return Promise.resolve(false)
@@ -381,15 +309,22 @@ export class RunSession {
381
309
  const current = live.get(stored.id)
382
310
  live.delete(stored.id)
383
311
  return {
384
- ...this.#fromHistory(stored),
312
+ ...fromHistory(stored),
385
313
  tools: current?.tools ?? [],
386
- search: current?.search ?? { status: 'idle', query: null, provider: null, hits: [], error: null },
314
+ search: current?.search ?? {
315
+ status: 'idle',
316
+ query: null,
317
+ provider: null,
318
+ hits: [],
319
+ error: null,
320
+ },
387
321
  exhausted: current?.exhausted ?? false,
388
322
  }
389
323
  })
390
324
  this.turns.push(...live.values())
391
325
  this.selectedTurnId = conversation.summary.selectedTurnId
392
- this.contextUsage = this.turns.find((item) => item.id === this.selectedTurnId)?.contextUsage ?? null
326
+ this.contextUsage =
327
+ this.turns.find((item) => item.id === this.selectedTurnId)?.contextUsage ?? null
393
328
  }
394
329
  } catch (error: unknown) {
395
330
  if (this.#abort !== abort) return
@@ -476,7 +411,8 @@ export class RunSession {
476
411
  }
477
412
  case 'tool_result': {
478
413
  const item = tool(event.callId)
479
- if (item) Object.assign(item, { pending: null, outcome: event.outcome, summary: event.summary })
414
+ if (item)
415
+ Object.assign(item, { pending: null, outcome: event.outcome, summary: event.summary })
480
416
  const painted = (event.payload as { images?: ImageRef[] } | undefined)?.images
481
417
  if (event.outcome === 'ok' && Array.isArray(painted)) turn.generated.push(...painted)
482
418
  break
@@ -520,7 +456,8 @@ export class RunSession {
520
456
  if (!ended) void this.#port.cancel(result.runId).catch(() => undefined)
521
457
  return true
522
458
  }
523
- if (abort.signal.aborted && !ended) void this.#port.cancel(result.runId).catch(() => undefined)
459
+ if (abort.signal.aborted && !ended)
460
+ void this.#port.cancel(result.runId).catch(() => undefined)
524
461
  accepted = true
525
462
  this.#runId = result.runId
526
463
  if (result.conversationId) this.conversationId = result.conversationId
@@ -538,7 +475,8 @@ export class RunSession {
538
475
  const code = isKitClientError(error) ? error.code : 'internal'
539
476
  if (isKitClientError(error)) this.#checkSkill(error)
540
477
  finish(code === 'cancelled' ? 'cancelled' : 'failed', code === 'cancelled' ? null : code)
541
- if ((turn.supersedesId !== null || turn.kind === 'translation') && code !== 'cancelled') this.actionError = code
478
+ if ((turn.supersedesId !== null || turn.kind === 'translation') && code !== 'cancelled')
479
+ this.actionError = code
542
480
  if (turn.supersedesId !== null || turn.kind === 'translation')
543
481
  this.turns = this.turns.filter((item) => item !== turn)
544
482
  this.#abort = null
@@ -0,0 +1,194 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/svelte'
2
+ import type { ComponentProps } from 'svelte'
3
+ import { expect, vi } from 'vitest'
4
+ import { createI18n } from '../../i18n'
5
+ import {
6
+ createFakePorts,
7
+ KitClientError,
8
+ type ConversationClient,
9
+ type Conversation,
10
+ type RunClient,
11
+ type RunEvent,
12
+ type RunRequest,
13
+ type ProvidersPort,
14
+ type SearchPort,
15
+ type SkillsPort,
16
+ } from '../../ports'
17
+ import type ChatPanel from '../ChatPanel.svelte'
18
+ import Harness from '../Harness.test.svelte'
19
+
20
+ export const model = { endpointId: 'e1', modelId: 'm1' }
21
+
22
+ export function required<T>(value: T | null | undefined): T {
23
+ if (value == null) throw new Error('Expected chat fixture or control is missing')
24
+ return value
25
+ }
26
+
27
+ export interface Run {
28
+ request: RunRequest
29
+ turnId: string
30
+ emit: (event: RunEvent) => void
31
+ signal: AbortSignal | undefined
32
+ }
33
+
34
+ export interface Extra {
35
+ props?: Partial<ComponentProps<typeof ChatPanel>>
36
+ history?: Partial<ConversationClient>
37
+ providers?: Partial<ProvidersPort>
38
+ search?: Partial<SearchPort>
39
+ skills?: Partial<SkillsPort>
40
+ chat?: Partial<RunClient>
41
+ }
42
+
43
+ export function setup(
44
+ withHistory: boolean,
45
+ context: { kind: string; id: string; label: string } | null = null,
46
+ extra: Extra = {},
47
+ ) {
48
+ const runs: Run[] = []
49
+ const stored: Conversation = {
50
+ summary: {
51
+ id: 'c1',
52
+ title: 'Test',
53
+ scope: null,
54
+ selectedTurnId: null,
55
+ createdAt: 0,
56
+ updatedAt: 0,
57
+ },
58
+ turns: [],
59
+ }
60
+ const approve = vi.fn<(runId: string, callId: string, allow: boolean) => Promise<void>>(() =>
61
+ Promise.resolve(),
62
+ )
63
+ const ports = createFakePorts({
64
+ providers: {
65
+ modelOptions: () =>
66
+ Promise.resolve([
67
+ {
68
+ ...model,
69
+ label: 'Local · main · m1',
70
+ modelType: 'chat',
71
+ name: null,
72
+ supportsTools: null,
73
+ reasoning: null,
74
+ nativeWebSearch: false,
75
+ canVision: false,
76
+ },
77
+ ]),
78
+ ...extra.providers,
79
+ },
80
+ ...(extra.search ? { search: extra.search } : {}),
81
+ ...(extra.skills ? { skills: extra.skills } : {}),
82
+ chat: {
83
+ start: (request, emit, options) => {
84
+ const id = crypto.randomUUID()
85
+ const source = stored.turns.find(
86
+ (turn) => turn.id === (request.retryTurnId ?? request.translateTurnId),
87
+ )
88
+ const turn: Conversation['turns'][number] = {
89
+ id,
90
+ parentId: request.retryTurnId
91
+ ? (source?.parentId ?? null)
92
+ : (request.translateTurnId ?? request.replyToId ?? stored.summary.selectedTurnId),
93
+ questionId: request.retryTurnId ? (source?.questionId ?? id) : id,
94
+ supersedesId: request.retryTurnId ?? null,
95
+ kind: request.translateTurnId ? 'translation' : 'reply',
96
+ input: source?.input ?? request.input,
97
+ images: source
98
+ ? source.images
99
+ : (request.images ?? []).map((image) => ({
100
+ mime: image.mime,
101
+ url: `data:${image.mime};base64,${image.data}`,
102
+ })),
103
+ generated: [],
104
+ text: '',
105
+ status: 'running',
106
+ errorCode: null,
107
+ endpointId: request.model?.endpointId ?? null,
108
+ modelId: request.model?.modelId ?? null,
109
+ reasoningEffort: request.reasoningEffort ?? 'default',
110
+ reasoningText: null,
111
+ }
112
+ stored.turns.push(turn)
113
+ const wrapped = (event: RunEvent) => {
114
+ if (event.kind === 'text_delta') turn.text += event.text
115
+ if (event.kind === 'reasoning_delta')
116
+ turn.reasoningText = (turn.reasoningText ?? '') + event.text
117
+ if (event.kind === 'usage') {
118
+ turn.usage = {
119
+ inputTokens: event.inputTokens,
120
+ outputTokens: event.outputTokens,
121
+ cacheReadTokens: event.cacheReadTokens,
122
+ cacheWriteTokens: event.cacheWriteTokens,
123
+ reasoningTokens: event.reasoningTokens,
124
+ }
125
+ turn.cost = event.cost
126
+ }
127
+ if (event.kind === 'context') turn.contextUsage = event.usage
128
+ if (event.kind === 'end') {
129
+ turn.status = event.status
130
+ turn.errorCode = event.error?.code ?? null
131
+ if (event.status === 'completed' && !request.retryTurnId)
132
+ stored.summary.selectedTurnId = id
133
+ }
134
+ emit(event)
135
+ }
136
+ runs.push({ request, turnId: id, emit: wrapped, signal: options?.signal })
137
+ options?.signal?.addEventListener('abort', () =>
138
+ wrapped({ kind: 'end', status: 'cancelled' }),
139
+ )
140
+ return Promise.resolve({
141
+ runId: `r${runs.length}`,
142
+ conversationId: withHistory ? (request.conversationId ?? 'c1') : null,
143
+ turnId: withHistory ? id : null,
144
+ })
145
+ },
146
+ approve,
147
+ ...extra.chat,
148
+ },
149
+ })
150
+ if (ports.conversations)
151
+ Object.assign(
152
+ ports.conversations,
153
+ {
154
+ get: (id: string) =>
155
+ id === stored.summary.id
156
+ ? Promise.resolve(structuredClone(stored))
157
+ : Promise.reject(
158
+ new KitClientError({ code: 'not_found', message: 'conversation not found' }),
159
+ ),
160
+ },
161
+ extra.history,
162
+ )
163
+ if (!withHistory) delete ports.conversations
164
+ const notify = { toast: vi.fn(), confirm: vi.fn(() => Promise.resolve(true)) }
165
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
166
+ const kit = { ports, i18n: createI18n('en'), notify, logger }
167
+ const view = render(Harness, { props: { kit, model, context, ...extra.props } })
168
+ return { runs, view, approve, notify }
169
+ }
170
+
171
+ export async function send(text: string) {
172
+ await fireEvent.input(screen.getByRole('textbox', { name: 'Message' }), {
173
+ target: { value: text },
174
+ })
175
+ const button = screen.getByRole('button', { name: 'Send' })
176
+ await waitFor(() => expect(button.hasAttribute('disabled')).toBe(false))
177
+ await fireEvent.click(button)
178
+ }
179
+
180
+ const SEARCH = 'Search models, optionally with a level'
181
+
182
+ /** The composer model/effort pill; its name carries the current model and effort. */
183
+ export const pickerTrigger = () => screen.findByRole('button', { name: /^Model and reasoning:/ })
184
+
185
+ /** Opens the picker, searches so every group unfolds, picks a row, then closes it like a user. */
186
+ export async function pickModel(search: string, name: RegExp) {
187
+ const trigger = await pickerTrigger()
188
+ if (trigger.getAttribute('aria-expanded') !== 'true') await fireEvent.click(trigger)
189
+ const input = await screen.findByLabelText(SEARCH)
190
+ await fireEvent.input(input, { target: { value: search } })
191
+ await fireEvent.click(await screen.findByRole('option', { name }))
192
+ await fireEvent.keyDown(input, { key: 'Escape' })
193
+ await waitFor(() => expect(screen.queryByLabelText(SEARCH)).toBeNull())
194
+ }
@@ -0,0 +1,32 @@
1
+ import type { Conversation } from '../../ports'
2
+ import { model } from './chat-fixture'
3
+
4
+ export function candidates(): Conversation {
5
+ const reply = (id: string, text: string): Conversation['turns'][number] => ({
6
+ id,
7
+ parentId: null,
8
+ questionId: 'q',
9
+ supersedesId: null,
10
+ kind: 'reply',
11
+ ...model,
12
+ input: 'Shared question',
13
+ text,
14
+ status: 'completed',
15
+ errorCode: null,
16
+ reasoningEffort: 'default',
17
+ reasoningText: null,
18
+ images: [],
19
+ generated: [],
20
+ })
21
+ return {
22
+ summary: {
23
+ id: 'saved',
24
+ title: 'Candidates',
25
+ scope: null,
26
+ selectedTurnId: 'a',
27
+ createdAt: 0,
28
+ updatedAt: 0,
29
+ },
30
+ turns: [reply('a', 'Original **reply**'), reply('b', 'Alternative reply')],
31
+ }
32
+ }