@ouraihub/aether-ui 0.0.0-stage → 0.1.1

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 (212) 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 +29 -0
  28. package/src/chat/ChatComposer.svelte +277 -0
  29. package/src/chat/ChatMessage.svelte +262 -0
  30. package/src/chat/ChatPanel.css +386 -0
  31. package/src/chat/ChatPanel.svelte +496 -0
  32. package/src/chat/ChatPanelApprovals.svelte +28 -0
  33. package/src/chat/ChatPanelComposer.svelte +317 -0
  34. package/src/chat/ChatPanelDialogs.svelte +242 -0
  35. package/src/chat/ChatPanelHeader.svelte +132 -0
  36. package/src/chat/ChatPanelMessages.svelte +166 -0
  37. package/src/chat/ChatPanelMode.svelte +190 -0
  38. package/src/chat/ChatPanelState.svelte.ts +1 -0
  39. package/src/chat/ChatPanelToolbar.svelte +425 -0
  40. package/src/chat/ChatSurface.svelte +168 -0
  41. package/src/chat/MessageItem.svelte +480 -0
  42. package/src/chat/ReasoningControl.svelte +286 -0
  43. package/src/chat/SkillPicker.svelte +133 -0
  44. package/src/chat/ToolCallItem.svelte +23 -0
  45. package/src/chat/UsageControl.svelte +216 -0
  46. package/src/chat/UsageDetails.svelte +76 -0
  47. package/src/chat/chat-attachments.svelte.ts +74 -0
  48. package/src/chat/chat-panel-history.svelte.ts +430 -0
  49. package/src/chat/chat-panel-props.ts +32 -0
  50. package/src/chat/index.ts +6 -0
  51. package/src/chat/run-session-history.ts +56 -0
  52. package/src/chat/run-session-types.ts +69 -0
  53. package/src/chat/run-session.svelte.ts +494 -0
  54. package/src/chat/test-support/chat-fixture.ts +178 -0
  55. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  56. package/src/chat/test-support/run-session-fixture.ts +112 -0
  57. package/src/components/ui/alert/alert-action.svelte +20 -0
  58. package/src/components/ui/alert/alert-description.svelte +23 -0
  59. package/src/components/ui/alert/alert-title.svelte +23 -0
  60. package/src/components/ui/alert/alert.svelte +43 -0
  61. package/src/components/ui/alert/index.ts +17 -0
  62. package/src/components/ui/alert-dialog/alert-dialog-action.svelte +27 -0
  63. package/src/components/ui/alert-dialog/alert-dialog-cancel.svelte +27 -0
  64. package/src/components/ui/alert-dialog/alert-dialog-content.svelte +32 -0
  65. package/src/components/ui/alert-dialog/alert-dialog-description.svelte +17 -0
  66. package/src/components/ui/alert-dialog/alert-dialog-footer.svelte +23 -0
  67. package/src/components/ui/alert-dialog/alert-dialog-header.svelte +20 -0
  68. package/src/components/ui/alert-dialog/alert-dialog-media.svelte +20 -0
  69. package/src/components/ui/alert-dialog/alert-dialog-overlay.svelte +17 -0
  70. package/src/components/ui/alert-dialog/alert-dialog-portal.svelte +7 -0
  71. package/src/components/ui/alert-dialog/alert-dialog-title.svelte +17 -0
  72. package/src/components/ui/alert-dialog/alert-dialog-trigger.svelte +7 -0
  73. package/src/components/ui/alert-dialog/alert-dialog.svelte +7 -0
  74. package/src/components/ui/alert-dialog/index.ts +40 -0
  75. package/src/components/ui/button/button.svelte +81 -0
  76. package/src/components/ui/button/index.ts +17 -0
  77. package/src/components/ui/dialog/dialog-close.svelte +11 -0
  78. package/src/components/ui/dialog/dialog-content.svelte +48 -0
  79. package/src/components/ui/dialog/dialog-description.svelte +17 -0
  80. package/src/components/ui/dialog/dialog-footer.svelte +32 -0
  81. package/src/components/ui/dialog/dialog-header.svelte +20 -0
  82. package/src/components/ui/dialog/dialog-overlay.svelte +17 -0
  83. package/src/components/ui/dialog/dialog-portal.svelte +7 -0
  84. package/src/components/ui/dialog/dialog-title.svelte +17 -0
  85. package/src/components/ui/dialog/dialog-trigger.svelte +11 -0
  86. package/src/components/ui/dialog/dialog.svelte +7 -0
  87. package/src/components/ui/dialog/index.ts +34 -0
  88. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-group.svelte +16 -0
  89. package/src/components/ui/dropdown-menu/dropdown-menu-checkbox-item.svelte +46 -0
  90. package/src/components/ui/dropdown-menu/dropdown-menu-content.svelte +31 -0
  91. package/src/components/ui/dropdown-menu/dropdown-menu-group-heading.svelte +22 -0
  92. package/src/components/ui/dropdown-menu/dropdown-menu-group.svelte +7 -0
  93. package/src/components/ui/dropdown-menu/dropdown-menu-item.svelte +27 -0
  94. package/src/components/ui/dropdown-menu/dropdown-menu-label.svelte +24 -0
  95. package/src/components/ui/dropdown-menu/dropdown-menu-portal.svelte +7 -0
  96. package/src/components/ui/dropdown-menu/dropdown-menu-radio-group.svelte +17 -0
  97. package/src/components/ui/dropdown-menu/dropdown-menu-radio-item.svelte +36 -0
  98. package/src/components/ui/dropdown-menu/dropdown-menu-separator.svelte +17 -0
  99. package/src/components/ui/dropdown-menu/dropdown-menu-shortcut.svelte +20 -0
  100. package/src/components/ui/dropdown-menu/dropdown-menu-sub-content.svelte +31 -0
  101. package/src/components/ui/dropdown-menu/dropdown-menu-sub-trigger.svelte +29 -0
  102. package/src/components/ui/dropdown-menu/dropdown-menu-sub.svelte +7 -0
  103. package/src/components/ui/dropdown-menu/dropdown-menu-trigger.svelte +7 -0
  104. package/src/components/ui/dropdown-menu/dropdown-menu.svelte +7 -0
  105. package/src/components/ui/dropdown-menu/index.ts +54 -0
  106. package/src/components/ui/empty/empty-content.svelte +23 -0
  107. package/src/components/ui/empty/empty-description.svelte +23 -0
  108. package/src/components/ui/empty/empty-header.svelte +20 -0
  109. package/src/components/ui/empty/empty-media.svelte +41 -0
  110. package/src/components/ui/empty/empty-title.svelte +20 -0
  111. package/src/components/ui/empty/empty.svelte +23 -0
  112. package/src/components/ui/empty/index.ts +22 -0
  113. package/src/components/ui/field/field-content.svelte +20 -0
  114. package/src/components/ui/field/field-description.svelte +25 -0
  115. package/src/components/ui/field/field-error.svelte +58 -0
  116. package/src/components/ui/field/field-group.svelte +23 -0
  117. package/src/components/ui/field/field-label.svelte +25 -0
  118. package/src/components/ui/field/field-legend.svelte +24 -0
  119. package/src/components/ui/field/field-separator.svelte +35 -0
  120. package/src/components/ui/field/field-set.svelte +20 -0
  121. package/src/components/ui/field/field-title.svelte +20 -0
  122. package/src/components/ui/field/field.svelte +47 -0
  123. package/src/components/ui/field/index.ts +33 -0
  124. package/src/components/ui/input/index.ts +7 -0
  125. package/src/components/ui/input/input.svelte +48 -0
  126. package/src/components/ui/label/index.ts +7 -0
  127. package/src/components/ui/label/label.svelte +20 -0
  128. package/src/components/ui/select/index.ts +40 -0
  129. package/src/components/ui/select/select-content.svelte +45 -0
  130. package/src/components/ui/select/select-group-heading.svelte +21 -0
  131. package/src/components/ui/select/select-group.svelte +17 -0
  132. package/src/components/ui/select/select-item.svelte +43 -0
  133. package/src/components/ui/select/select-label.svelte +20 -0
  134. package/src/components/ui/select/select-portal.svelte +7 -0
  135. package/src/components/ui/select/select-scroll-down-button.svelte +20 -0
  136. package/src/components/ui/select/select-scroll-up-button.svelte +20 -0
  137. package/src/components/ui/select/select-separator.svelte +18 -0
  138. package/src/components/ui/select/select-trigger.svelte +29 -0
  139. package/src/components/ui/select/select-value.svelte +7 -0
  140. package/src/components/ui/select/select.svelte +11 -0
  141. package/src/components/ui/separator/index.ts +7 -0
  142. package/src/components/ui/separator/separator.svelte +21 -0
  143. package/src/components/ui/sonner/index.ts +1 -0
  144. package/src/components/ui/sonner/sonner.svelte +40 -0
  145. package/src/components/ui/switch/index.ts +7 -0
  146. package/src/components/ui/switch/switch.svelte +34 -0
  147. package/src/components/ui/textarea/index.ts +7 -0
  148. package/src/components/ui/textarea/textarea.svelte +22 -0
  149. package/src/context.ts +34 -0
  150. package/src/dev/Demo.svelte +47 -0
  151. package/src/dev/main.ts +7 -0
  152. package/src/i18n/en.ts +645 -0
  153. package/src/i18n/index.ts +28 -0
  154. package/src/i18n/zh-CN.ts +610 -0
  155. package/src/index.ts +10 -0
  156. package/src/logs/LogPanel.svelte +103 -0
  157. package/src/logs/LogSettings.svelte +58 -0
  158. package/src/logs/index.ts +5 -0
  159. package/src/logs/logger.ts +96 -0
  160. package/src/markdown.ts +8 -0
  161. package/src/mcp/McpClientEditor.svelte +204 -0
  162. package/src/mcp/McpClientSettings.svelte +418 -0
  163. package/src/mcp/McpServerSettings.svelte +363 -0
  164. package/src/mcp/config.ts +26 -0
  165. package/src/mcp/index.ts +3 -0
  166. package/src/mcp/input.ts +74 -0
  167. package/src/notify.svelte.ts +43 -0
  168. package/src/ports/contract.ts +174 -0
  169. package/src/ports/fake-optional.ts +81 -0
  170. package/src/ports/fake.ts +488 -0
  171. package/src/ports/index.ts +7 -0
  172. package/src/ports/search.ts +56 -0
  173. package/src/ports/skill-sources.ts +45 -0
  174. package/src/ports/test-support/fake-fixture.ts +63 -0
  175. package/src/providers/EndpointKeysDialog.svelte +357 -0
  176. package/src/providers/EndpointModels.svelte +311 -0
  177. package/src/providers/EndpointPanel.svelte +266 -0
  178. package/src/providers/ModelPickerDialog.svelte +230 -0
  179. package/src/providers/ModelPricing.svelte +125 -0
  180. package/src/providers/ModelSelect.svelte +448 -0
  181. package/src/providers/ModelSettingsDialog.svelte +388 -0
  182. package/src/providers/ProviderDialogs.svelte +120 -0
  183. package/src/providers/ProviderList.svelte +159 -0
  184. package/src/providers/ProvidersSettings.svelte +491 -0
  185. package/src/providers/RequestConfigDialog.svelte +278 -0
  186. package/src/providers/SecretInput.svelte +284 -0
  187. package/src/providers/draft.ts +355 -0
  188. package/src/providers/edit-session.ts +170 -0
  189. package/src/providers/index.ts +5 -0
  190. package/src/providers/reasoning.ts +55 -0
  191. package/src/providers/revision.svelte.ts +6 -0
  192. package/src/providers/test-support/providers-fixture.ts +120 -0
  193. package/src/search/SearchAdvancedTools.svelte +182 -0
  194. package/src/search/SearchProviderCard.svelte +195 -0
  195. package/src/search/SearchSettings.svelte +500 -0
  196. package/src/search/index.ts +13 -0
  197. package/src/search/settings-flow.ts +67 -0
  198. package/src/session.ts +20 -0
  199. package/src/skills/SkillAdd.svelte +380 -0
  200. package/src/skills/SkillAuthoring.svelte +430 -0
  201. package/src/skills/SkillEditor.svelte +104 -0
  202. package/src/skills/SkillPreviewDialog.svelte +136 -0
  203. package/src/skills/SkillsSettings.svelte +147 -0
  204. package/src/skills/error.ts +32 -0
  205. package/src/skills/index.ts +4 -0
  206. package/src/skills/test-support/skills-fixture.ts +139 -0
  207. package/src/styles/kit.css +137 -0
  208. package/src/styles/tokens.css +22 -0
  209. package/src/tauri/index.ts +298 -0
  210. package/src/utils.ts +22 -0
  211. package/src/web/index.ts +441 -0
  212. package/README.md +0 -3
@@ -0,0 +1,500 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import { Button } from '#lib/components/ui/button/index.js'
4
+ import { Input } from '#lib/components/ui/input/index.js'
5
+ import * as Alert from '#lib/components/ui/alert/index.js'
6
+ import * as Empty from '#lib/components/ui/empty/index.js'
7
+ import * as Field from '#lib/components/ui/field/index.js'
8
+ import SearchProviderCard from './SearchProviderCard.svelte'
9
+ import SearchAdvancedTools from './SearchAdvancedTools.svelte'
10
+ import { getKitContext } from '../context'
11
+ import { isKitClientError } from '../ports'
12
+ import { createClientSession } from '../session'
13
+ import { saveSearchSettings } from './settings-flow'
14
+ import type {
15
+ SearchCap,
16
+ SearchKey,
17
+ SearchPage,
18
+ SearchPreferences,
19
+ SearchProvider,
20
+ SearchResult,
21
+ SearchSettingsDto,
22
+ } from '../ports'
23
+
24
+ const { ports, i18n, notify } = getKitContext()
25
+ const { t } = i18n
26
+ const port = ports.search
27
+ const caps: SearchCap[] = ['search', 'fetch']
28
+ type Draft = { baseUrl?: string; basicAuthUsername?: string }
29
+ let settings = $state<Partial<Record<SearchCap, SearchSettingsDto>>>({})
30
+ let drafts = $state<Record<string, Draft>>({})
31
+ let keyDrafts = $state<Record<string, string>>({})
32
+ const draftRevisions: Record<string, Partial<Record<keyof Draft, number>>> = {}
33
+ const keyRevisions: Record<string, number> = {}
34
+ const flowSessions = { search: createClientSession(), fetch: createClientSession() }
35
+ let pending = $state<Record<string, boolean>>({})
36
+ let errors = $state<Record<string, string>>({})
37
+ let checks = $state<Record<string, number | undefined>>({})
38
+ let preferences = $state<SearchPreferences>({
39
+ maxResults: 5,
40
+ compression: 'none',
41
+ cutoffLimit: 2000,
42
+ blacklist: [],
43
+ modelToolsPreferred: false,
44
+ })
45
+ let blacklist = $state('')
46
+ let loading = $state(true)
47
+ let loadError = $state('')
48
+ let query = $state('')
49
+ let pageUrl = $state('')
50
+ let result = $state<SearchResult | null>(null)
51
+ let page = $state<SearchPage | null>(null)
52
+ const controller = new AbortController()
53
+ const options = { signal: controller.signal }
54
+ let keyDialog: HTMLDialogElement
55
+ let keyTarget = $state<{ cap: SearchCap; provider: string } | null>(null)
56
+ let editingId = $state<string | undefined>()
57
+ let editValue = $state('')
58
+ let password = $state('')
59
+ let revealed = $state<Record<string, string>>({})
60
+ let dialogError = $state('')
61
+ const dialogSession = createClientSession()
62
+ let editRevision = 0
63
+ let passwordRevision = 0
64
+
65
+ const keyOf = (cap: SearchCap, provider: string) => `${cap}:${provider}`
66
+ const errorText = (error: unknown) =>
67
+ isKitClientError(error) ? t(`errors.${error.code}`) : t('errors.internal')
68
+ const activeProvider = (cap: SearchCap) =>
69
+ settings[cap]?.providers.find((p) => p.id === settings[cap]?.defaultProvider)
70
+ const dialogProvider = $derived.by(() => {
71
+ const target = keyTarget
72
+ return target ? settings[target.cap]?.providers.find((p) => p.id === target.provider) : null
73
+ })
74
+ const dialogBusy = $derived(keyTarget !== null && pending[keyTarget.cap] === true)
75
+ const draftOf = (cap: SearchCap, provider: string) => drafts[keyOf(cap, provider)] ?? {}
76
+ function apply(next: SearchSettingsDto) {
77
+ if (!controller.signal.aborted) settings = { ...settings, [next.capability]: next }
78
+ }
79
+ function setPending(key: string, value: boolean) {
80
+ pending = { ...pending, [key]: value }
81
+ }
82
+ function setError(key: string, value: string) {
83
+ errors = { ...errors, [key]: value }
84
+ }
85
+ function patchDraft(cap: SearchCap, provider: string, patch: Draft) {
86
+ const key = keyOf(cap, provider)
87
+ drafts = { ...drafts, [key]: { ...drafts[key], ...patch } }
88
+ const revisions = draftRevisions[key] ?? {}
89
+ for (const field of Object.keys(patch) as (keyof Draft)[])
90
+ revisions[field] = (revisions[field] ?? 0) + 1
91
+ draftRevisions[key] = revisions
92
+ checks = { ...checks, [key]: undefined }
93
+ }
94
+ async function load() {
95
+ if (!port) return
96
+ loading = true
97
+ loadError = ''
98
+ try {
99
+ const list = await Promise.all(caps.map((cap) => port.settings(cap, options)))
100
+ if (controller.signal.aborted) return
101
+ for (const next of list) apply(next)
102
+ const first = list[0]
103
+ if (!first) throw new Error('Search settings missing')
104
+ preferences = first.preferences
105
+ blacklist = preferences.blacklist.join('\n')
106
+ } catch (error) {
107
+ if (!controller.signal.aborted) loadError = errorText(error)
108
+ } finally {
109
+ if (!controller.signal.aborted) loading = false
110
+ }
111
+ }
112
+ async function choose(cap: SearchCap, provider: string) {
113
+ if (!port || pending[cap]) return
114
+ setPending(cap, true)
115
+ setError(cap, '')
116
+ try {
117
+ apply(await port.setDefault(cap, provider, options))
118
+ } catch (error) {
119
+ if (!controller.signal.aborted) setError(cap, errorText(error))
120
+ } finally {
121
+ setPending(cap, false)
122
+ }
123
+ }
124
+ async function saveAndCheck(cap: SearchCap, provider: SearchProvider, check = false) {
125
+ if (!port || pending[cap]) return
126
+ const key = keyOf(cap, provider.id)
127
+ const draft = { ...draftOf(cap, provider.id) }
128
+ const revisions = { ...draftRevisions[key] }
129
+ const keyRevision = keyRevisions[key] ?? 0
130
+ const value = keyDrafts[key]?.trim()
131
+ const session = flowSessions[cap]
132
+ session.invalidate()
133
+ const token = session.capture()
134
+ const isCurrent = () => !controller.signal.aborted && session.isCurrent(token)
135
+ setPending(cap, true)
136
+ setError(cap, '')
137
+ checks = { ...checks, [key]: undefined }
138
+ try {
139
+ if (value && value.length > 4096) throw new Error('credential too long')
140
+ const saved = await saveSearchSettings(
141
+ port,
142
+ {
143
+ config: {
144
+ capability: cap,
145
+ provider: provider.id,
146
+ ...(draft.baseUrl !== undefined ? { baseUrl: draft.baseUrl.trim() } : {}),
147
+ ...(provider.basicAuth && draft.basicAuthUsername !== undefined
148
+ ? { basicAuthUsername: draft.basicAuthUsername.trim() }
149
+ : {}),
150
+ },
151
+ ...(value ? { key: { value } } : {}),
152
+ check,
153
+ },
154
+ {
155
+ isCurrent,
156
+ onSettings: apply,
157
+ onConfigSaved(sent) {
158
+ const next = { ...drafts[key] }
159
+ for (const field of ['baseUrl', 'basicAuthUsername'] as const) {
160
+ if (sent[field] !== undefined && draftRevisions[key]?.[field] === revisions[field])
161
+ delete next[field]
162
+ }
163
+ drafts = { ...drafts, [key]: next }
164
+ },
165
+ onKeySaved() {
166
+ if ((keyRevisions[key] ?? 0) === keyRevision) keyDrafts = { ...keyDrafts, [key]: '' }
167
+ },
168
+ },
169
+ options,
170
+ )
171
+ if (!isCurrent()) return
172
+ if (saved.checked) checks = { ...checks, [key]: saved.checked.count }
173
+ else notify.toast('success', t('search.saved'))
174
+ } catch (error) {
175
+ if (isCurrent()) setError(cap, errorText(error))
176
+ } finally {
177
+ if (isCurrent()) setPending(cap, false)
178
+ }
179
+ }
180
+ function clearDialog() {
181
+ dialogSession.invalidate()
182
+ if (keyTarget) keyDrafts = { ...keyDrafts, [keyOf(keyTarget.cap, keyTarget.provider)]: '' }
183
+ keyTarget = null
184
+ editingId = undefined
185
+ editRevision++
186
+ editValue = ''
187
+ passwordRevision++
188
+ password = ''
189
+ revealed = {}
190
+ dialogError = ''
191
+ }
192
+ function openKeys(cap: SearchCap, provider: string) {
193
+ clearDialog()
194
+ keyTarget = { cap, provider }
195
+ keyDialog.showModal()
196
+ }
197
+ function invalidateCredentials() {
198
+ dialogSession.invalidate()
199
+ passwordRevision++
200
+ password = ''
201
+ revealed = {}
202
+ dialogError = ''
203
+ }
204
+ async function saveEditedKey() {
205
+ if (!port || !keyTarget || dialogBusy || !editValue.trim()) return
206
+ const target = { ...keyTarget }
207
+ const value = editValue.trim()
208
+ const id = editingId
209
+ const revision = editRevision
210
+ invalidateCredentials()
211
+ const token = dialogSession.capture()
212
+ const isCurrent = () => !controller.signal.aborted && dialogSession.isCurrent(token)
213
+ setPending(target.cap, true)
214
+ try {
215
+ const next = await port.saveKey(target.cap, target.provider, value, id, options)
216
+ if (!isCurrent()) return
217
+ apply(next)
218
+ if (revision === editRevision) {
219
+ editValue = ''
220
+ editingId = undefined
221
+ editRevision++
222
+ }
223
+ } catch (error) {
224
+ if (isCurrent()) dialogError = errorText(error)
225
+ } finally {
226
+ if (!controller.signal.aborted) setPending(target.cap, false)
227
+ }
228
+ }
229
+ async function removeKey(key: SearchKey) {
230
+ if (!port || !keyTarget || dialogBusy) return
231
+ const target = { ...keyTarget }
232
+ const revision = editRevision
233
+ invalidateCredentials()
234
+ const token = dialogSession.capture()
235
+ const isCurrent = () => !controller.signal.aborted && dialogSession.isCurrent(token)
236
+ setPending(target.cap, true)
237
+ try {
238
+ const next = await port.removeKey(target.cap, target.provider, key.id, options)
239
+ if (!isCurrent()) return
240
+ apply(next)
241
+ if (editingId === key.id && revision === editRevision) {
242
+ editingId = undefined
243
+ editValue = ''
244
+ editRevision++
245
+ }
246
+ } catch (error) {
247
+ if (isCurrent()) dialogError = errorText(error)
248
+ } finally {
249
+ if (!controller.signal.aborted) setPending(target.cap, false)
250
+ }
251
+ }
252
+ async function revealKey(key: SearchKey, copy = false) {
253
+ if (!port || !keyTarget || dialogBusy) return
254
+ const target = { ...keyTarget }
255
+ const token = dialogSession.capture()
256
+ const revision = passwordRevision
257
+ const currentPassword = port.revealNeedsPassword ? password : undefined
258
+ const isCurrent = () => !controller.signal.aborted && dialogSession.isCurrent(token)
259
+ setPending(target.cap, true)
260
+ dialogError = ''
261
+ try {
262
+ const value = await port.reveal(target.cap, target.provider, key.id, currentPassword, options)
263
+ if (!isCurrent()) return
264
+ if (copy) {
265
+ await navigator.clipboard.writeText(value)
266
+ if (isCurrent()) notify.toast('success', t('search.copied'))
267
+ } else revealed = { ...revealed, [key.id]: value }
268
+ } catch (error) {
269
+ if (isCurrent())
270
+ dialogError = copy && !isKitClientError(error) ? t('search.copyFailed') : errorText(error)
271
+ } finally {
272
+ if (isCurrent() && passwordRevision === revision) {
273
+ password = ''
274
+ passwordRevision++
275
+ }
276
+ if (!controller.signal.aborted) setPending(target.cap, false)
277
+ }
278
+ }
279
+ async function savePreferences() {
280
+ if (!port || pending.preferences) return
281
+ setPending('preferences', true)
282
+ setError('preferences', '')
283
+ try {
284
+ const saved = await port.savePreferences(
285
+ {
286
+ ...preferences,
287
+ blacklist: blacklist
288
+ .split('\n')
289
+ .map((rule) => rule.trim())
290
+ .filter(Boolean),
291
+ },
292
+ options,
293
+ )
294
+ if (!controller.signal.aborted) {
295
+ preferences = saved
296
+ blacklist = saved.blacklist.join('\n')
297
+ notify.toast('success', t('search.saved'))
298
+ }
299
+ } catch (error) {
300
+ if (!controller.signal.aborted) setError('preferences', errorText(error))
301
+ } finally {
302
+ setPending('preferences', false)
303
+ }
304
+ }
305
+ async function search() {
306
+ if (!port || pending.query || !query.trim()) return
307
+ setPending('query', true)
308
+ setError('query', '')
309
+ result = null
310
+ try {
311
+ const next = await port.search(query.trim(), options)
312
+ if (!controller.signal.aborted) result = next
313
+ } catch (error) {
314
+ if (!controller.signal.aborted) setError('query', errorText(error))
315
+ } finally {
316
+ setPending('query', false)
317
+ }
318
+ }
319
+ async function readPage() {
320
+ if (!port || pending.page || !pageUrl.trim()) return
321
+ setPending('page', true)
322
+ setError('page', '')
323
+ page = null
324
+ try {
325
+ const next = await port.fetch(pageUrl.trim(), options)
326
+ if (!controller.signal.aborted) page = next
327
+ } catch (error) {
328
+ if (!controller.signal.aborted) setError('page', errorText(error))
329
+ } finally {
330
+ setPending('page', false)
331
+ }
332
+ }
333
+ onMount(() => {
334
+ void load()
335
+ return () => {
336
+ controller.abort()
337
+ for (const session of Object.values(flowSessions)) session.invalidate()
338
+ keyDrafts = {}
339
+ keyDialog?.close()
340
+ clearDialog()
341
+ }
342
+ })
343
+ </script>
344
+
345
+ {#if !port}
346
+ <Empty.Root><Empty.Description>{t('search.unavailable')}</Empty.Description></Empty.Root>
347
+ {:else if loading}
348
+ <p class="text-muted-foreground text-kit-sm">{t('search.loading')}</p>
349
+ {:else if loadError}
350
+ <Alert.Root variant="destructive"><Alert.Description>{loadError}</Alert.Description></Alert.Root>
351
+ <Button variant="outline" onclick={() => void load()}>{t('chat.retry')}</Button>
352
+ {:else}
353
+ <div class="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-4">
354
+ {#each caps as cap (cap)}
355
+ {@const provider = activeProvider(cap)}
356
+ {@const current = settings[cap]}
357
+ {#if provider && current}
358
+ {@const key = keyOf(cap, provider.id)}
359
+ <SearchProviderCard
360
+ {cap}
361
+ {provider}
362
+ settings={current}
363
+ pending={pending[cap] === true}
364
+ error={errors[cap] ?? ''}
365
+ draft={draftOf(cap, provider.id)}
366
+ keyDraft={keyDrafts[key] ?? ''}
367
+ check={checks[key]}
368
+ onChoose={(next) => void choose(cap, next)}
369
+ onOpenKeys={() => openKeys(cap, provider.id)}
370
+ onKeyInput={(value) => {
371
+ keyDrafts = { ...keyDrafts, [key]: value }
372
+ keyRevisions[key] = (keyRevisions[key] ?? 0) + 1
373
+ checks = { ...checks, [key]: undefined }
374
+ }}
375
+ onPatchDraft={(patch) => patchDraft(cap, provider.id, patch)}
376
+ onSave={(check = false) => void saveAndCheck(cap, provider, check)}
377
+ />
378
+ {/if}
379
+ {/each}
380
+ <SearchAdvancedTools
381
+ bind:preferences
382
+ bind:blacklist
383
+ bind:query
384
+ bind:pageUrl
385
+ preferencesPending={pending.preferences === true}
386
+ preferencesError={errors.preferences ?? ''}
387
+ {result}
388
+ {page}
389
+ queryPending={pending.query === true}
390
+ pagePending={pending.page === true}
391
+ queryError={errors.query ?? ''}
392
+ pageError={errors.page ?? ''}
393
+ onSavePreferences={() => void savePreferences()}
394
+ onSearch={() => void search()}
395
+ onReadPage={() => void readPage()}
396
+ />
397
+ </div>
398
+ {/if}
399
+
400
+ <dialog
401
+ bind:this={keyDialog}
402
+ aria-labelledby="search-key-dialog-title"
403
+ onclose={clearDialog}
404
+ class="bg-background text-foreground border-border m-auto max-h-[80dvh] w-[min(560px,calc(100vw-32px))] overflow-y-auto rounded-kit border p-4"
405
+ >
406
+ <h2 id="search-key-dialog-title" class="text-kit-base font-semibold">
407
+ {t('search.manageKeys')}{#if dialogProvider}
408
+ · {dialogProvider.name}{/if}
409
+ </h2>
410
+ {#if dialogProvider}
411
+ <Field.Group class="mt-3 gap-3">
412
+ {#if port?.revealNeedsPassword}<Field.Field
413
+ ><Field.Label for="search-key-password">{t('search.currentPassword')}</Field.Label><Input
414
+ id="search-key-password"
415
+ type="password"
416
+ autocomplete="off"
417
+ disabled={dialogBusy}
418
+ value={password}
419
+ oninput={(event) => {
420
+ password = event.currentTarget.value
421
+ passwordRevision++
422
+ }}
423
+ /></Field.Field
424
+ >{/if}
425
+ <ul class="flex flex-col gap-3">
426
+ {#each dialogProvider.keys as key (key.id)}
427
+ <li class="flex min-w-0 flex-col gap-1 border-b pb-2 text-kit-sm">
428
+ <span class="break-all font-mono">{revealed[key.id] ?? key.masked}</span>
429
+ <div class="flex flex-wrap gap-1">
430
+ <Button
431
+ type="button"
432
+ variant="ghost"
433
+ size="sm"
434
+ disabled={dialogBusy}
435
+ onclick={() => {
436
+ invalidateCredentials()
437
+ editingId = key.id
438
+ editValue = ''
439
+ editRevision++
440
+ }}>{t('search.editKey')}</Button
441
+ >
442
+ <Button
443
+ type="button"
444
+ variant="ghost"
445
+ size="sm"
446
+ disabled={dialogBusy || (port?.revealNeedsPassword && !password)}
447
+ onclick={() => void revealKey(key)}>{t('search.reveal')}</Button
448
+ >
449
+ <Button
450
+ type="button"
451
+ variant="ghost"
452
+ size="sm"
453
+ disabled={dialogBusy || (port?.revealNeedsPassword && !password)}
454
+ onclick={() => void revealKey(key, true)}>{t('search.copy')}</Button
455
+ >
456
+ <Button
457
+ type="button"
458
+ variant="ghost"
459
+ size="sm"
460
+ disabled={dialogBusy}
461
+ onclick={() => void removeKey(key)}>{t('search.delete')}</Button
462
+ >
463
+ </div>
464
+ </li>
465
+ {/each}
466
+ </ul>
467
+ <Field.Field
468
+ ><Field.Label for="search-edit-key"
469
+ >{t(editingId ? 'search.editKey' : 'search.newKey')}</Field.Label
470
+ ><Input
471
+ id="search-edit-key"
472
+ type="password"
473
+ autocomplete="off"
474
+ spellcheck={false}
475
+ maxlength={4096}
476
+ disabled={dialogBusy}
477
+ value={editValue}
478
+ oninput={(event) => {
479
+ editValue = event.currentTarget.value
480
+ editRevision++
481
+ }}
482
+ /></Field.Field
483
+ >
484
+ {#if dialogError}<p class="text-destructive text-kit-sm" role="alert">{dialogError}</p>{/if}
485
+ <div class="flex flex-wrap justify-end gap-2">
486
+ <Button
487
+ type="button"
488
+ size="sm"
489
+ disabled={dialogBusy ||
490
+ !editValue.trim() ||
491
+ (!editingId && dialogProvider.keys.length >= 16)}
492
+ onclick={() => void saveEditedKey()}
493
+ >{t(editingId ? 'search.save' : 'search.addKey')}</Button
494
+ ><Button type="button" variant="outline" size="sm" onclick={() => keyDialog.close()}
495
+ >{t('common.cancel')}</Button
496
+ >
497
+ </div>
498
+ </Field.Group>
499
+ {/if}
500
+ </dialog>
@@ -0,0 +1,13 @@
1
+ export { default as SearchSettings } from './SearchSettings.svelte'
2
+ export type {
3
+ SearchCap,
4
+ SearchKey,
5
+ SearchPreferences,
6
+ SearchHit,
7
+ SearchPage,
8
+ SearchPort,
9
+ SearchProvider,
10
+ SearchResult,
11
+ SearchSave,
12
+ SearchSettingsDto,
13
+ } from '../ports/search'
@@ -0,0 +1,67 @@
1
+ import { KitClientError } from '../ports/contract'
2
+ import type { CallOptions } from '../ports/contract'
3
+ import type { SearchPort, SearchSave, SearchSettingsDto } from '../ports/search'
4
+
5
+ /** Applies each persisted step before attempting the next (docs/30 §4). */
6
+ export async function saveSearchSettings(
7
+ port: SearchPort,
8
+ input: { config: SearchSave; key?: { value: string; keyId?: string }; check: boolean },
9
+ session: {
10
+ isCurrent(): boolean
11
+ onSettings(next: SearchSettingsDto): void
12
+ onConfigSaved(sent: SearchSave): void
13
+ onKeySaved(): void
14
+ },
15
+ options?: CallOptions,
16
+ ): Promise<{ settings: SearchSettingsDto; checked?: { count: number } }> {
17
+ const config = { ...input.config }
18
+ const key = input.key ? { ...input.key } : undefined
19
+ const check = input.check
20
+ const cancelled = () =>
21
+ new KitClientError({ code: 'cancelled', message: 'Search settings cancelled', details: {} })
22
+ function assertCurrent() {
23
+ if (options?.signal?.aborted || !session.isCurrent()) throw cancelled()
24
+ }
25
+ // Consume an in-flight response even if its transport does not honor abort.
26
+ async function step<T>(call: () => Promise<T>): Promise<T> {
27
+ assertCurrent()
28
+ const signal = options?.signal
29
+ let onAbort: (() => void) | undefined
30
+ try {
31
+ const operation = call()
32
+ let value: T
33
+ if (signal) {
34
+ const abort = Promise.withResolvers<never>()
35
+ onAbort = () => abort.reject(cancelled())
36
+ signal.addEventListener('abort', onAbort, { once: true })
37
+ if (signal.aborted) onAbort()
38
+ value = await Promise.race([operation, abort.promise])
39
+ } else value = await operation
40
+ assertCurrent()
41
+ return value
42
+ } catch (error) {
43
+ assertCurrent()
44
+ throw error
45
+ } finally {
46
+ if (signal && onAbort) signal.removeEventListener('abort', onAbort)
47
+ }
48
+ }
49
+
50
+ let settings = await step(() => port.save(config, options))
51
+ session.onSettings(settings)
52
+ assertCurrent()
53
+ session.onConfigSaved(config)
54
+ if (key?.value.trim()) {
55
+ settings = await step(() =>
56
+ port.saveKey(config.capability, config.provider, key.value, key.keyId, options),
57
+ )
58
+ session.onSettings(settings)
59
+ assertCurrent()
60
+ session.onKeySaved()
61
+ }
62
+ const checked = check
63
+ ? await step(() => port.check(config.capability, config.provider, options))
64
+ : undefined
65
+ assertCurrent()
66
+ return checked ? { settings, checked } : { settings }
67
+ }
package/src/session.ts ADDED
@@ -0,0 +1,20 @@
1
+ /** A token belongs to exactly one session instance and one target generation. */
2
+ export interface SessionToken {
3
+ readonly generation: number
4
+ }
5
+ export interface ClientSession {
6
+ capture(): SessionToken
7
+ isCurrent(token: SessionToken): boolean
8
+ invalidate(): void
9
+ }
10
+ export function createClientSession(): ClientSession {
11
+ let generation = 0
12
+ let current: SessionToken = { generation }
13
+ return {
14
+ capture: () => current,
15
+ isCurrent: (token) => token === current,
16
+ invalidate: () => {
17
+ current = { generation: ++generation }
18
+ },
19
+ }
20
+ }