@ouraihub/aether-ui 0.0.0-stage → 0.1.0

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 (179) hide show
  1. package/LICENSE +789 -0
  2. package/fixtures/model-classify.json +42 -0
  3. package/fixtures/pricing-cost.json +56 -0
  4. package/fixtures/reasoning-infer.json +44 -0
  5. package/fixtures/sse/anthropic-messages/chinese.sse +24 -0
  6. package/fixtures/sse/anthropic-messages/multi-tool.sse +33 -0
  7. package/fixtures/sse/anthropic-messages/single-tool.sse +39 -0
  8. package/fixtures/sse/anthropic-messages/text.sse +27 -0
  9. package/fixtures/sse/anthropic-messages/truncated.sse +21 -0
  10. package/fixtures/sse/anthropic-messages/upstream-error.sse +12 -0
  11. package/fixtures/sse/expect.json +20 -0
  12. package/fixtures/sse/openai-chat/chinese.sse +14 -0
  13. package/fixtures/sse/openai-chat/multi-tool.sse +18 -0
  14. package/fixtures/sse/openai-chat/single-tool.sse +22 -0
  15. package/fixtures/sse/openai-chat/text.sse +14 -0
  16. package/fixtures/sse/openai-chat/truncated.sse +12 -0
  17. package/fixtures/sse/openai-chat/upstream-error.sse +6 -0
  18. package/fixtures/sse/openai-responses/chinese.sse +33 -0
  19. package/fixtures/sse/openai-responses/multi-tool.sse +36 -0
  20. package/fixtures/sse/openai-responses/single-tool.sse +27 -0
  21. package/fixtures/sse/openai-responses/text.sse +33 -0
  22. package/fixtures/sse/openai-responses/truncated.sse +30 -0
  23. package/fixtures/sse/openai-responses/upstream-error.sse +9 -0
  24. package/package.json +71 -3
  25. package/src/CopyInput.svelte +33 -0
  26. package/src/KitNotifier.svelte +33 -0
  27. package/src/chat/ApprovalCard.svelte +23 -0
  28. package/src/chat/ChatPanel.svelte +2097 -0
  29. package/src/chat/MessageItem.svelte +583 -0
  30. package/src/chat/SkillPicker.svelte +126 -0
  31. package/src/chat/ToolCallItem.svelte +23 -0
  32. package/src/chat/UsageDetails.svelte +138 -0
  33. package/src/chat/index.ts +2 -0
  34. package/src/chat/run-session.svelte.ts +556 -0
  35. package/src/components/ui/alert/alert-action.svelte +20 -0
  36. package/src/components/ui/alert/alert-description.svelte +23 -0
  37. package/src/components/ui/alert/alert-title.svelte +23 -0
  38. package/src/components/ui/alert/alert.svelte +43 -0
  39. package/src/components/ui/alert/index.ts +17 -0
  40. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  41. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  42. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  43. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  44. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  45. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  46. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  47. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  48. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  49. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  50. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  51. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  52. package/src/components/ui/alert-dialog/index.ts +40 -0
  53. package/src/components/ui/button/button.svelte +81 -0
  54. package/src/components/ui/button/index.ts +17 -0
  55. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  56. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  57. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  58. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  59. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  60. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  61. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  62. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  63. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  64. package/src/components/ui/dialog/dialog.svelte +7 -0
  65. package/src/components/ui/dialog/index.ts +34 -0
  66. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  67. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  68. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  69. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  70. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  71. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  72. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  73. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  74. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  75. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  76. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  77. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  78. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  79. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  80. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  81. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  82. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  83. package/src/components/ui/dropdown-menu/index.ts +54 -0
  84. package/src/components/ui/empty/empty-content.svelte +23 -0
  85. package/src/components/ui/empty/empty-description.svelte +23 -0
  86. package/src/components/ui/empty/empty-header.svelte +20 -0
  87. package/src/components/ui/empty/empty-media.svelte +41 -0
  88. package/src/components/ui/empty/empty-title.svelte +20 -0
  89. package/src/components/ui/empty/empty.svelte +23 -0
  90. package/src/components/ui/empty/index.ts +22 -0
  91. package/src/components/ui/field/field-content.svelte +20 -0
  92. package/src/components/ui/field/field-description.svelte +25 -0
  93. package/src/components/ui/field/field-error.svelte +58 -0
  94. package/src/components/ui/field/field-group.svelte +23 -0
  95. package/src/components/ui/field/field-label.svelte +25 -0
  96. package/src/components/ui/field/field-legend.svelte +24 -0
  97. package/src/components/ui/field/field-separator.svelte +35 -0
  98. package/src/components/ui/field/field-set.svelte +20 -0
  99. package/src/components/ui/field/field-title.svelte +20 -0
  100. package/src/components/ui/field/field.svelte +47 -0
  101. package/src/components/ui/field/index.ts +33 -0
  102. package/src/components/ui/input/index.ts +7 -0
  103. package/src/components/ui/input/input.svelte +48 -0
  104. package/src/components/ui/label/index.ts +7 -0
  105. package/src/components/ui/label/label.svelte +20 -0
  106. package/src/components/ui/select/index.ts +40 -0
  107. package/src/components/ui/select/select-content.svelte +45 -0
  108. package/src/components/ui/select/select-group-heading.svelte +21 -0
  109. package/src/components/ui/select/select-group.svelte +17 -0
  110. package/src/components/ui/select/select-item.svelte +43 -0
  111. package/src/components/ui/select/select-label.svelte +20 -0
  112. package/src/components/ui/select/select-portal.svelte +7 -0
  113. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  114. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  115. package/src/components/ui/select/select-separator.svelte +18 -0
  116. package/src/components/ui/select/select-trigger.svelte +29 -0
  117. package/src/components/ui/select/select-value.svelte +7 -0
  118. package/src/components/ui/select/select.svelte +11 -0
  119. package/src/components/ui/separator/index.ts +7 -0
  120. package/src/components/ui/separator/separator.svelte +21 -0
  121. package/src/components/ui/sonner/index.ts +1 -0
  122. package/src/components/ui/sonner/sonner.svelte +40 -0
  123. package/src/components/ui/switch/index.ts +7 -0
  124. package/src/components/ui/switch/switch.svelte +34 -0
  125. package/src/components/ui/textarea/index.ts +7 -0
  126. package/src/components/ui/textarea/textarea.svelte +22 -0
  127. package/src/context.ts +34 -0
  128. package/src/dev/Demo.svelte +45 -0
  129. package/src/dev/main.ts +7 -0
  130. package/src/i18n/en.ts +617 -0
  131. package/src/i18n/index.ts +23 -0
  132. package/src/i18n/zh-CN.ts +599 -0
  133. package/src/index.ts +10 -0
  134. package/src/logs/LogPanel.svelte +97 -0
  135. package/src/logs/LogSettings.svelte +56 -0
  136. package/src/logs/index.ts +5 -0
  137. package/src/logs/logger.ts +96 -0
  138. package/src/markdown.ts +8 -0
  139. package/src/mcp/McpClientSettings.svelte +491 -0
  140. package/src/mcp/McpServerSettings.svelte +327 -0
  141. package/src/mcp/config.ts +26 -0
  142. package/src/mcp/index.ts +3 -0
  143. package/src/mcp/input.ts +73 -0
  144. package/src/notify.svelte.ts +43 -0
  145. package/src/ports/contract.ts +165 -0
  146. package/src/ports/fake.ts +484 -0
  147. package/src/ports/index.ts +7 -0
  148. package/src/ports/search.ts +47 -0
  149. package/src/ports/skill-sources.ts +45 -0
  150. package/src/providers/EndpointKeysDialog.svelte +343 -0
  151. package/src/providers/EndpointPanel.svelte +491 -0
  152. package/src/providers/ModelPickerDialog.svelte +187 -0
  153. package/src/providers/ModelSelect.svelte +434 -0
  154. package/src/providers/ModelSettingsDialog.svelte +453 -0
  155. package/src/providers/ProvidersSettings.svelte +631 -0
  156. package/src/providers/RequestConfigDialog.svelte +250 -0
  157. package/src/providers/SecretInput.svelte +276 -0
  158. package/src/providers/draft.ts +329 -0
  159. package/src/providers/edit-session.ts +146 -0
  160. package/src/providers/index.ts +5 -0
  161. package/src/providers/reasoning.ts +41 -0
  162. package/src/providers/revision.svelte.ts +4 -0
  163. package/src/search/SearchSettings.svelte +703 -0
  164. package/src/search/index.ts +13 -0
  165. package/src/search/settings-flow.ts +62 -0
  166. package/src/session.ts +20 -0
  167. package/src/skills/SkillAdd.svelte +347 -0
  168. package/src/skills/SkillAuthoring.svelte +381 -0
  169. package/src/skills/SkillEditor.svelte +94 -0
  170. package/src/skills/SkillPreviewDialog.svelte +122 -0
  171. package/src/skills/SkillsSettings.svelte +140 -0
  172. package/src/skills/error.ts +32 -0
  173. package/src/skills/index.ts +4 -0
  174. package/src/styles/kit.css +137 -0
  175. package/src/styles/tokens.css +22 -0
  176. package/src/tauri/index.ts +282 -0
  177. package/src/utils.ts +22 -0
  178. package/src/web/index.ts +376 -0
  179. package/README.md +0 -3
@@ -0,0 +1,484 @@
1
+ // In-memory RunClient, ConversationClient and desktop actions for tests and demos (docs/16, docs/22).
2
+ // Seed storage with initialConversations; override clients independently or omit a capability.
3
+ import { KitClientError } from './contract'
4
+ import type {
5
+ ConversationClient,
6
+ DesktopActions,
7
+ RunClient,
8
+ KitPorts,
9
+ LogsPort,
10
+ McpClientPort,
11
+ McpServerPort,
12
+ ProvidersPort,
13
+ SkillsPort,
14
+ } from './contract'
15
+ import type { Conversation, LogLevel, RunEvent, RunSnapshot, Turn } from '@ouraihub/aether-contracts'
16
+ import type { SearchPort } from './search'
17
+ import type { SkillSourcesPort } from './skill-sources'
18
+
19
+ type PortName = keyof KitPorts
20
+
21
+ export interface FakePortsOptions {
22
+ /** Initial stored conversations, copied to isolate the caller from fake storage. */
23
+ initialConversations?: readonly Conversation[]
24
+ conversations?: Partial<ConversationClient>
25
+ desktop?: Partial<DesktopActions>
26
+ logs?: Partial<LogsPort>
27
+ providers?: Partial<ProvidersPort>
28
+ chat?: Partial<RunClient>
29
+ search?: Partial<SearchPort>
30
+ skills?: Partial<SkillsPort>
31
+ skillSources?: Partial<SkillSourcesPort>
32
+ mcpClient?: Partial<McpClientPort>
33
+ mcpServer?: Partial<McpServerPort>
34
+ /** Ports to leave out, as a host without that feature would. */
35
+ omit?: readonly PortName[]
36
+ }
37
+
38
+ const STOPPED = { running: false, port: 0, url: '', toolCount: 0 }
39
+ const clientError = (
40
+ code: ConstructorParameters<typeof KitClientError>[0]['code'],
41
+ message: string,
42
+ details: Record<string, string> = {},
43
+ ) => new KitClientError({ code, message, details })
44
+
45
+ export function createFakePorts(options: FakePortsOptions = {}): KitPorts {
46
+ let level: LogLevel = 'INFO'
47
+ let sequence = 0
48
+ let conversationSequence = 0
49
+ const conversations = new Map<string, Conversation>(
50
+ (options.initialConversations ?? []).map((conversation) => [
51
+ conversation.summary.id,
52
+ structuredClone(conversation),
53
+ ]),
54
+ )
55
+ const nextId = (prefix: string): string => {
56
+ let id: string
57
+ do {
58
+ id = `${prefix}-${++sequence}`
59
+ } while (
60
+ [...conversations.values()].some((conversation) =>
61
+ conversation.turns.some((turn) => turn.id === id || turn.questionId === id),
62
+ )
63
+ )
64
+ return id
65
+ }
66
+ const storesConversations = !options.omit?.includes('conversations')
67
+ const runs = new Map<string, RunSnapshot>()
68
+ const terminals: string[] = []
69
+ const acceptRun = (
70
+ conversation: Conversation | null,
71
+ turn: Turn | null,
72
+ select: boolean,
73
+ onEvent: (event: RunEvent) => void,
74
+ signal?: AbortSignal,
75
+ ) => {
76
+ const run: RunSnapshot = {
77
+ runId: nextId('fake-run'),
78
+ conversationId: conversation?.summary.id ?? null,
79
+ turnId: turn?.id ?? null,
80
+ status: 'running',
81
+ cancelRequested: false,
82
+ }
83
+ runs.set(run.runId, run)
84
+ const cancel = () => {
85
+ run.cancelRequested = true
86
+ }
87
+ signal?.addEventListener('abort', cancel, { once: true })
88
+ // Leave an acceptance turn for callers to request cancellation or inspect the active run.
89
+ queueMicrotask(() =>
90
+ queueMicrotask(() => {
91
+ if (signal?.aborted) cancel()
92
+ if (turn && !run.cancelRequested) onEvent({ kind: 'text_delta', text: turn.text })
93
+ run.status = run.cancelRequested ? 'cancelled' : 'completed'
94
+ if (turn && conversation) {
95
+ turn.status = run.status
96
+ if (run.status === 'cancelled') turn.text = ''
97
+ if (select && run.status === 'completed')
98
+ conversation.summary = { ...conversation.summary, selectedTurnId: turn.id, updatedAt: Date.now() }
99
+ }
100
+ signal?.removeEventListener('abort', cancel)
101
+ terminals.push(run.runId)
102
+ if (terminals.length > 128) {
103
+ const expired = terminals.shift()
104
+ if (expired !== undefined) runs.delete(expired)
105
+ }
106
+ onEvent({ kind: 'end', status: run.status })
107
+ }),
108
+ )
109
+ return { runId: run.runId, conversationId: run.conversationId, turnId: run.turnId }
110
+ }
111
+ const snapshot = (conversation: Conversation): Conversation => structuredClone(conversation)
112
+ const getConversation = (id: string) => {
113
+ const conversation = conversations.get(id)
114
+ if (!conversation) throw clientError('not_found', `conversation ${id}`, { entity: 'conversation' })
115
+ if (conversation.turns.some((turn) => turn.status === 'running'))
116
+ throw clientError('conflict', 'conversation is active', { reason: 'active_run' })
117
+ return conversation
118
+ }
119
+ const getTurn = (conversationId: string, turnId: string) => {
120
+ const turn = getConversation(conversationId).turns.find((candidate) => candidate.id === turnId)
121
+ if (!turn) throw clientError('not_found', `turn ${turnId}`, { entity: 'turn' })
122
+ return turn
123
+ }
124
+ const conversationClient: ConversationClient = {
125
+ list: (scope?: string) =>
126
+ Promise.resolve(
127
+ [...conversations.values()]
128
+ .filter(({ summary }) => !scope || summary.scope === scope)
129
+ .map(({ summary }) => ({ ...summary })),
130
+ ),
131
+ get: async (id: string) => {
132
+ const conversation = conversations.get(id)
133
+ if (!conversation) throw clientError('not_found', `conversation ${id}`, { entity: 'conversation' })
134
+ return snapshot(conversation)
135
+ },
136
+ remove: async (id: string) => {
137
+ getConversation(id)
138
+ conversations.delete(id)
139
+ },
140
+ rename: async (id: string, title: string) => {
141
+ const conversation = getConversation(id)
142
+ conversation.summary = { ...conversation.summary, title, updatedAt: Date.now() }
143
+ return { ...conversation.summary }
144
+ },
145
+ editTurn: async (conversationId: string, turnId: string, text: string, expectedText: string) => {
146
+ const conversation = getConversation(conversationId)
147
+ const turn = getTurn(conversationId, turnId)
148
+ if (turn.text !== expectedText) throw clientError('conflict', 'turn changed', { reason: 'text_changed' })
149
+ if (!text.trim() || Array.from(text).length > 24_000)
150
+ throw clientError('bad_request', 'invalid text', { field: 'text' })
151
+ if (conversation.turns.some((candidate) => candidate.parentId === turnId || candidate.supersedesId === turnId))
152
+ throw clientError('conflict', 'turn has children', { reason: 'has_children' })
153
+ turn.text = text
154
+ turn.reasoningText = null
155
+ turn.contextUsage = null
156
+ conversation.summary.updatedAt = Date.now()
157
+ return snapshot(conversation)
158
+ },
159
+ removeTurn: async (conversationId: string, turnId: string) => {
160
+ // Same rule as both hosts (AutoNote docs/35 §3.3): reparent onto the successor, then delete.
161
+ const conversation = getConversation(conversationId)
162
+ const turn = getTurn(conversationId, turnId)
163
+ if (turn.status === 'running') throw clientError('conflict', 'turn running', { reason: 'turn_running' })
164
+ // Turns are stored in creation order, which stands in for `seq`.
165
+ const at = conversation.turns.indexOf(turn)
166
+ const group = conversation.turns.filter(
167
+ (t) => t.kind === 'reply' && t.questionId === turn.questionId && t !== turn,
168
+ )
169
+ const sibling = group.find((t) => conversation.turns.indexOf(t) > at) ?? group.at(-1)
170
+ const successor = sibling?.id ?? turn.parentId
171
+ const removed = new Set([turnId])
172
+ for (const t of conversation.turns)
173
+ if (t.kind === 'translation' && removed.has(t.parentId ?? '')) removed.add(t.id)
174
+ conversation.turns = conversation.turns
175
+ .filter((t) => !removed.has(t.id))
176
+ .map((t) => ({
177
+ ...t,
178
+ parentId: removed.has(t.parentId ?? '') ? successor : t.parentId,
179
+ supersedesId: removed.has(t.supersedesId ?? '')
180
+ ? sibling && sibling.id !== t.id
181
+ ? sibling.id
182
+ : null
183
+ : t.supersedesId,
184
+ }))
185
+ if (removed.has(conversation.summary.selectedTurnId ?? '')) {
186
+ const rest = conversation.turns.filter((t) => t.kind === 'reply')
187
+ let head = successor ?? rest.filter((t) => t.parentId === null).at(-1)?.id ?? null
188
+ for (;;) {
189
+ const child = head === null ? undefined : rest.filter((t) => t.parentId === head).at(-1)
190
+ if (!child) break
191
+ head = child.id
192
+ }
193
+ conversation.summary.selectedTurnId = head
194
+ }
195
+ conversation.summary.updatedAt = Date.now()
196
+ return snapshot(conversation)
197
+ },
198
+ fork: async (conversationId: string, turnId: string) => {
199
+ const source = getConversation(conversationId)
200
+ const target = getTurn(conversationId, turnId)
201
+ const chain: Turn[] = []
202
+ const seen = new Set<string>()
203
+ let cursor: Turn | undefined = target
204
+ while (cursor) {
205
+ if (seen.has(cursor.id)) throw clientError('conflict', 'history cycle', { reason: 'history_cycle' })
206
+ seen.add(cursor.id)
207
+ chain.unshift(cursor)
208
+ if (!cursor.parentId) break
209
+ cursor = source.turns.find((turn) => turn.id === cursor?.parentId)
210
+ if (!cursor) throw clientError('not_found', 'history parent', { entity: 'turn' })
211
+ }
212
+ const ids = new Map<string, string>()
213
+ const questionIds = new Map<string, string>()
214
+ const turns = chain.map((turn) => {
215
+ const id = nextId('fake-turn')
216
+ ids.set(turn.id, id)
217
+ let questionId = questionIds.get(turn.questionId)
218
+ if (questionId === undefined) {
219
+ questionId = id
220
+ questionIds.set(turn.questionId, questionId)
221
+ }
222
+ return {
223
+ ...turn,
224
+ id,
225
+ questionId,
226
+ parentId: turn.parentId ? (ids.get(turn.parentId) ?? null) : null,
227
+ supersedesId: turn.supersedesId ? (ids.get(turn.supersedesId) ?? null) : null,
228
+ }
229
+ })
230
+ let id: string
231
+ do {
232
+ id = `fake-conversation-${++conversationSequence}`
233
+ } while (conversations.has(id))
234
+ const copy: Conversation = {
235
+ summary: {
236
+ ...source.summary,
237
+ id,
238
+ createdAt: Date.now(),
239
+ updatedAt: Date.now(),
240
+ selectedTurnId: ids.get(turnId) ?? null,
241
+ },
242
+ turns: structuredClone(turns),
243
+ }
244
+ conversations.set(id, copy)
245
+ return snapshot(copy)
246
+ },
247
+ selectTurn: async (conversationId: string, turnId: string) => {
248
+ const conversation = getConversation(conversationId)
249
+ getTurn(conversationId, turnId)
250
+ conversation.summary = { ...conversation.summary, selectedTurnId: turnId, updatedAt: Date.now() }
251
+ return snapshot(conversation)
252
+ },
253
+ }
254
+ const ports: Required<Omit<KitPorts, 'search' | 'skillSources'>> = {
255
+ logs: {
256
+ tail: () => Promise.resolve([]),
257
+ ingest: (batch) => Promise.resolve({ accepted: batch.entries.length, rejected: 0 }),
258
+ getLevel: () => Promise.resolve({ level, available: true }),
259
+ setLevel: (next) => {
260
+ level = next
261
+ return Promise.resolve({ level, available: true })
262
+ },
263
+ ...options.logs,
264
+ },
265
+ desktop: {
266
+ openLogsDirectory: () => Promise.resolve(),
267
+ ...options.desktop,
268
+ },
269
+ conversations: { ...conversationClient, ...options.conversations },
270
+ providers: {
271
+ revealNeedsPassword: false,
272
+ keyStrategies: false,
273
+ revealKey: () => Promise.reject(clientError('internal', 'Configure the fake provider reveal method')),
274
+ list: () => Promise.resolve([]),
275
+ setEnabled: () => Promise.resolve([]),
276
+ save: () => Promise.resolve([]),
277
+ saveKey: () => Promise.resolve([]),
278
+ removeKey: () => Promise.resolve([]),
279
+ remove: () => Promise.resolve([]),
280
+ modelOptions: () => Promise.resolve([]),
281
+ probe: () => Promise.resolve({ probeUrl: '', models: [], error: null, suggestedProtocol: null }),
282
+ ...options.providers,
283
+ },
284
+ chat: {
285
+ start: async (request, onEvent, callOptions) => {
286
+ if (callOptions?.signal?.aborted) throw clientError('cancelled', 'cancelled')
287
+ const retry = request.retryTurnId != null || request.regenerate
288
+ const translation = request.translateTurnId != null
289
+ const action = request.retryTurnId != null || translation
290
+ if (
291
+ (retry && translation) ||
292
+ (action && (request.regenerate || request.webSearch === true)) ||
293
+ (request.replyToId != null && (retry || translation)) ||
294
+ (request.targetLanguage != null && !translation)
295
+ )
296
+ throw clientError('bad_request', 'invalid graph request', { field: 'action' })
297
+ if (request.images?.length && (retry || translation))
298
+ throw clientError('bad_request', 'images on action', { field: 'images', reason: 'action_conflict' })
299
+ if (request.purpose === 'writing' && (request.replyToId != null || action || request.targetLanguage != null))
300
+ throw clientError('bad_request', 'writing graph fields', { field: 'action' })
301
+ if ((retry || translation || request.replyToId != null) && (!request.conversationId || !storesConversations))
302
+ throw clientError('bad_request', 'conversation required', { field: 'conversationId' })
303
+ const language = request.targetLanguage?.trim() ?? ''
304
+ if (translation && (!language || Array.from(language).length > 80))
305
+ throw clientError('bad_request', 'invalid language', { field: 'targetLanguage' })
306
+ if (!retry && !translation && !request.input.trim())
307
+ throw clientError('bad_request', 'empty input', { field: 'input' })
308
+ if (!storesConversations || request.purpose === 'writing')
309
+ return acceptRun(null, null, false, onEvent, callOptions?.signal)
310
+ let conversationId = request.conversationId
311
+ if (!conversationId) {
312
+ do {
313
+ conversationId = `fake-conversation-${++conversationSequence}`
314
+ } while (conversations.has(conversationId))
315
+ }
316
+ const conversation: Conversation = request.conversationId
317
+ ? getConversation(conversationId)
318
+ : {
319
+ summary: {
320
+ id: conversationId,
321
+ title: request.input.slice(0, 80),
322
+ scope: request.scope ?? null,
323
+ createdAt: Date.now(),
324
+ updatedAt: Date.now(),
325
+ selectedTurnId: null,
326
+ },
327
+ turns: [],
328
+ }
329
+ const selectedId =
330
+ conversation.summary.selectedTurnId ??
331
+ [...conversation.turns].reverse().find((turn) => turn.status === 'completed')?.id ??
332
+ null
333
+ const sourceId = request.retryTurnId ?? request.translateTurnId ?? (retry ? selectedId : null)
334
+ const source = sourceId ? getTurn(conversationId, sourceId) : undefined
335
+ if ((retry || translation) && !source) throw clientError('not_found', 'source turn', { entity: 'turn' })
336
+ if (request.replyToId) getTurn(conversationId, request.replyToId)
337
+ if (!retry && !translation && selectedId && !request.replyToId) getTurn(conversationId, selectedId)
338
+ const model =
339
+ request.model ??
340
+ (source?.endpointId && source.modelId ? { endpointId: source.endpointId, modelId: source.modelId } : null)
341
+ if (source && !model) throw clientError('needs_config', 'source model unavailable')
342
+ if (source && model && options.providers?.modelOptions) {
343
+ const models = await ports.providers.modelOptions()
344
+ if (!models.some((option) => option.endpointId === model.endpointId && option.modelId === model.modelId))
345
+ throw clientError('needs_config', 'source model unavailable')
346
+ }
347
+ if (callOptions?.signal?.aborted) throw clientError('cancelled', 'cancelled')
348
+ if (request.conversationId) getConversation(conversationId)
349
+ conversations.set(conversationId, conversation)
350
+ const turnId = nextId('fake-turn')
351
+ const translationSource = translation ? source : undefined
352
+ const retrySource = retry ? source : undefined
353
+ const input = translationSource
354
+ ? `Translate the following reply into ${language}. Return only the translation, preserving Markdown formatting.\n\n${translationSource.text}`
355
+ : retrySource
356
+ ? retrySource.input
357
+ : request.input
358
+ const turn: Turn = {
359
+ id: turnId,
360
+ parentId: translationSource
361
+ ? translationSource.id
362
+ : retrySource
363
+ ? retrySource.parentId
364
+ : (request.replyToId ?? selectedId),
365
+ questionId: retrySource ? retrySource.questionId : turnId,
366
+ supersedesId: retrySource ? retrySource.id : null,
367
+ kind: translationSource ? 'translation' : retrySource ? retrySource.kind : 'reply',
368
+ endpointId: model?.endpointId ?? null,
369
+ modelId: model?.modelId ?? null,
370
+ input,
371
+ images: translationSource
372
+ ? []
373
+ : retrySource
374
+ ? retrySource.images
375
+ : (request.images ?? []).map((image) => ({
376
+ mime: image.mime,
377
+ url: `data:${image.mime};base64,${image.data}`,
378
+ })),
379
+ generated: [],
380
+ text: `Fake reply: ${input}`,
381
+ status: 'running',
382
+ errorCode: null,
383
+ reasoningText: null,
384
+ reasoningEffort: request.reasoningEffort ?? source?.reasoningEffort ?? 'default',
385
+ usage: null,
386
+ contextUsage: {
387
+ tokens: input.length * 2 + 12,
388
+ contextWindow: null,
389
+ source: 'estimated',
390
+ endpointId: model?.endpointId ?? null,
391
+ modelId: model?.modelId ?? null,
392
+ },
393
+ }
394
+ conversation.turns.push(turn)
395
+ return acceptRun(conversation, turn, !retry, onEvent, callOptions?.signal)
396
+ },
397
+ cancel: async (runId) => {
398
+ const run = runs.get(runId)
399
+ if (run?.status === 'running') run.cancelRequested = true
400
+ },
401
+ approve: async (runId) => {
402
+ if (!runs.has(runId)) throw clientError('not_found', `run ${runId}`, { entity: 'run' })
403
+ throw clientError('conflict', 'no pending approval', { reason: 'approval_expired' })
404
+ },
405
+ getSnapshot: async (runId) => {
406
+ const run = runs.get(runId)
407
+ if (!run) throw clientError('not_found', `run ${runId}`, { entity: 'run' })
408
+ return { ...run }
409
+ },
410
+ ...options.chat,
411
+ },
412
+ skills: {
413
+ list: () => Promise.resolve([]),
414
+ get: (id) => Promise.reject(clientError('not_found', `skill ${id}`, { entity: 'skill' })),
415
+ preview: () => Promise.reject(clientError('bad_request', 'no package', { field: 'SKILL.md' })),
416
+ install: () => Promise.resolve([]),
417
+ save: () => Promise.resolve([]),
418
+ duplicate: () => Promise.resolve([]),
419
+ reorder: () => Promise.resolve([]),
420
+ remove: () => Promise.resolve([]),
421
+ ...options.skills,
422
+ },
423
+ mcpClient: {
424
+ list: () => Promise.resolve([]),
425
+ save: () => Promise.resolve([]),
426
+ remove: () => Promise.resolve([]),
427
+ refresh: () => Promise.resolve([]),
428
+ setToolApproval: () => Promise.resolve(),
429
+ ...options.mcpClient,
430
+ },
431
+ mcpServer: {
432
+ status: () => Promise.resolve(STOPPED),
433
+ tokens: () => Promise.resolve([]),
434
+ start: () => Promise.resolve(STOPPED),
435
+ stop: () => Promise.resolve(STOPPED),
436
+ setPort: (port) => Promise.resolve({ ...STOPPED, port }),
437
+ issueToken: (name, scopes) =>
438
+ Promise.resolve({
439
+ token: 'kit_fake',
440
+ id: 'fake-token',
441
+ name,
442
+ scopes: [...scopes],
443
+ createdAt: 0,
444
+ lastUsedAt: null,
445
+ revokedAt: null,
446
+ }),
447
+ revokeToken: () => Promise.resolve(),
448
+ scopes: () => Promise.resolve([]),
449
+ setScopes: () => Promise.resolve([]),
450
+ ...options.mcpServer,
451
+ },
452
+ }
453
+ const result: KitPorts = { ...ports }
454
+ if (options.search) {
455
+ const unavailable = () => Promise.reject(clientError('internal', 'Configure the fake search method'))
456
+ result.search = {
457
+ settings: unavailable,
458
+ save: unavailable,
459
+ setDefault: unavailable,
460
+ savePreferences: unavailable,
461
+ saveKey: unavailable,
462
+ removeKey: unavailable,
463
+ reveal: unavailable,
464
+ check: unavailable,
465
+ search: unavailable,
466
+ fetch: unavailable,
467
+ ...options.search,
468
+ }
469
+ }
470
+ if (options.skillSources) {
471
+ const unavailable = () => Promise.reject(clientError('internal', 'Configure the fake skill source method'))
472
+ result.skillSources = {
473
+ search: unavailable,
474
+ prepare: unavailable,
475
+ discover: unavailable,
476
+ prepareSystem: unavailable,
477
+ pickDirectory: unavailable,
478
+ pickSearchDirectory: unavailable,
479
+ ...options.skillSources,
480
+ }
481
+ }
482
+ for (const name of options.omit ?? []) delete result[name]
483
+ return result
484
+ }
@@ -0,0 +1,7 @@
1
+ // Public contracts, client ports and test fakes (docs/16 §2, §6).
2
+ export type * from '@ouraihub/aether-contracts'
3
+ export * from './contract'
4
+ export type * from './search'
5
+ export type * from './skill-sources'
6
+ export { createFakePorts } from './fake'
7
+ export type { FakePortsOptions } from './fake'
@@ -0,0 +1,47 @@
1
+ import type { CallOptions } from './contract'
2
+
3
+ import type {
4
+ SearchCap,
5
+ SearchPreferences,
6
+ SearchSettingsDto,
7
+ SearchSave,
8
+ SearchResult,
9
+ SearchPage,
10
+ } from '@ouraihub/aether-contracts'
11
+ export type {
12
+ SearchCap,
13
+ SearchKey,
14
+ SearchPreferences,
15
+ SearchProvider,
16
+ SearchSettingsDto,
17
+ SearchSave,
18
+ SearchResult,
19
+ SearchHit,
20
+ SearchPage,
21
+ } from '@ouraihub/aether-contracts'
22
+
23
+ export interface SearchPort {
24
+ revealNeedsPassword?: boolean
25
+ settings(capability: SearchCap, options?: CallOptions): Promise<SearchSettingsDto>
26
+ save(input: SearchSave, options?: CallOptions): Promise<SearchSettingsDto>
27
+ setDefault(capability: SearchCap, provider: string, options?: CallOptions): Promise<SearchSettingsDto>
28
+ savePreferences(input: SearchPreferences, options?: CallOptions): Promise<SearchPreferences>
29
+ saveKey(
30
+ capability: SearchCap,
31
+ provider: string,
32
+ value: string,
33
+ keyId?: string,
34
+ options?: CallOptions,
35
+ ): Promise<SearchSettingsDto>
36
+ removeKey(capability: SearchCap, provider: string, keyId: string, options?: CallOptions): Promise<SearchSettingsDto>
37
+ reveal(
38
+ capability: SearchCap,
39
+ provider: string,
40
+ keyId: string,
41
+ currentPassword?: string,
42
+ options?: CallOptions,
43
+ ): Promise<string>
44
+ check(capability: SearchCap, provider: string, options?: CallOptions): Promise<{ count: number }>
45
+ search(query: string, options?: CallOptions): Promise<SearchResult>
46
+ fetch(url: string, options?: CallOptions): Promise<SearchPage>
47
+ }
@@ -0,0 +1,45 @@
1
+ import type { ModelRef, RunAccepted, RunEvent, Source } from '@ouraihub/aether-contracts'
2
+ import type { CallOptions } from './contract'
3
+
4
+ export interface SkillCandidate {
5
+ name: string
6
+ description: string
7
+ url: string
8
+ origin: string
9
+ }
10
+ export interface SystemSkillCandidate {
11
+ name: string
12
+ path: string
13
+ origin: string
14
+ }
15
+ export interface PreparedSkill {
16
+ source: Source
17
+ origin: string
18
+ ignored?: string[]
19
+ }
20
+ /** A multi-turn skill-creator workspace; its package is installed only through preview + digest. */
21
+ export interface SkillAuthoringPort {
22
+ create(options?: CallOptions): Promise<string>
23
+ send(
24
+ sessionId: string,
25
+ input: string,
26
+ model: ModelRef,
27
+ onEvent: (event: RunEvent) => void,
28
+ options?: CallOptions,
29
+ ): Promise<RunAccepted>
30
+ approve(sessionId: string, runId: string, callId: string, allow: boolean): Promise<void>
31
+ cancel(sessionId: string, runId: string): Promise<void>
32
+ prepare(sessionId: string, options?: CallOptions): Promise<PreparedSkill>
33
+ close(sessionId: string): Promise<void>
34
+ }
35
+
36
+ /** Optional acquisition capabilities; missing desktop actions never appear in Web hosts. */
37
+ export interface SkillSourcesPort {
38
+ search(query: string, options?: CallOptions): Promise<SkillCandidate[]>
39
+ prepare(url: string, options?: CallOptions): Promise<PreparedSkill>
40
+ discover(options?: CallOptions): Promise<SystemSkillCandidate[]>
41
+ prepareSystem(path: string, options?: CallOptions): Promise<PreparedSkill>
42
+ pickDirectory(options?: CallOptions): Promise<PreparedSkill | null>
43
+ pickSearchDirectory(options?: CallOptions): Promise<SystemSkillCandidate[] | null>
44
+ authoring?: SkillAuthoringPort
45
+ }