@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,703 @@
1
+ <script lang="ts">
2
+ import { onMount } from 'svelte'
3
+ import ActivityIcon from '@lucide/svelte/icons/activity'
4
+ import ListIcon from '@lucide/svelte/icons/list'
5
+ import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
6
+ import ExternalLinkIcon from '@lucide/svelte/icons/external-link'
7
+ import { Button } from '#lib/components/ui/button/index.js'
8
+ import { Input } from '#lib/components/ui/input/index.js'
9
+ import CopyInput from '../CopyInput.svelte'
10
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
11
+ import * as Alert from '#lib/components/ui/alert/index.js'
12
+ import * as Empty from '#lib/components/ui/empty/index.js'
13
+ import * as Field from '#lib/components/ui/field/index.js'
14
+ import { getKitContext } from '../context'
15
+ import { isKitClientError } from '../ports'
16
+ import { createClientSession } from '../session'
17
+ import { saveSearchSettings } from './settings-flow'
18
+ import type {
19
+ SearchCap,
20
+ SearchKey,
21
+ SearchPage,
22
+ SearchPreferences,
23
+ SearchProvider,
24
+ SearchResult,
25
+ SearchSettingsDto,
26
+ } from '../ports'
27
+
28
+ const { ports, i18n, notify } = getKitContext()
29
+ const { t } = i18n
30
+ const port = ports.search
31
+ const caps: SearchCap[] = ['search', 'fetch']
32
+ type Draft = { baseUrl?: string; basicAuthUsername?: string }
33
+ let settings = $state<Partial<Record<SearchCap, SearchSettingsDto>>>({})
34
+ let drafts = $state<Record<string, Draft>>({})
35
+ let keyDrafts = $state<Record<string, string>>({})
36
+ const draftRevisions: Record<string, Partial<Record<keyof Draft, number>>> = {}
37
+ const keyRevisions: Record<string, number> = {}
38
+ const flowSessions = { search: createClientSession(), fetch: createClientSession() }
39
+ let pending = $state<Record<string, boolean>>({})
40
+ let errors = $state<Record<string, string>>({})
41
+ let checks = $state<Record<string, number | undefined>>({})
42
+ let preferences = $state<SearchPreferences>({
43
+ maxResults: 5,
44
+ compression: 'none',
45
+ cutoffLimit: 2000,
46
+ blacklist: [],
47
+ modelToolsPreferred: false,
48
+ })
49
+ let blacklist = $state('')
50
+ let loading = $state(true)
51
+ let loadError = $state('')
52
+ let query = $state('')
53
+ let pageUrl = $state('')
54
+ let result = $state<SearchResult | null>(null)
55
+ let page = $state<SearchPage | null>(null)
56
+ const controller = new AbortController()
57
+ const options = { signal: controller.signal }
58
+ let keyDialog: HTMLDialogElement
59
+ let keyTarget = $state<{ cap: SearchCap; provider: string } | null>(null)
60
+ let editingId = $state<string | undefined>()
61
+ let editValue = $state('')
62
+ let password = $state('')
63
+ let revealed = $state<Record<string, string>>({})
64
+ let dialogError = $state('')
65
+ const dialogSession = createClientSession()
66
+ let editRevision = 0
67
+ let passwordRevision = 0
68
+
69
+ const keyOf = (cap: SearchCap, provider: string) => `${cap}:${provider}`
70
+ const errorText = (error: unknown) => (isKitClientError(error) ? t(`errors.${error.code}`) : t('errors.internal'))
71
+ const activeProvider = (cap: SearchCap) =>
72
+ settings[cap]?.providers.find((p) => p.id === settings[cap]?.defaultProvider)
73
+ const dialogProvider = $derived.by(() => {
74
+ const target = keyTarget
75
+ return target ? settings[target.cap]?.providers.find((p) => p.id === target.provider) : null
76
+ })
77
+ const dialogBusy = $derived(keyTarget !== null && pending[keyTarget.cap] === true)
78
+ const draftOf = (cap: SearchCap, provider: string) => drafts[keyOf(cap, provider)] ?? {}
79
+ function apply(next: SearchSettingsDto) {
80
+ if (!controller.signal.aborted) settings = { ...settings, [next.capability]: next }
81
+ }
82
+ function setPending(key: string, value: boolean) {
83
+ pending = { ...pending, [key]: value }
84
+ }
85
+ function setError(key: string, value: string) {
86
+ errors = { ...errors, [key]: value }
87
+ }
88
+ function safeUrl(raw?: string) {
89
+ if (!raw) return null
90
+ try {
91
+ const url = new URL(raw)
92
+ return ['https:', 'http:'].includes(url.protocol) ? url.href : null
93
+ } catch {
94
+ return null
95
+ }
96
+ }
97
+ function patchDraft(cap: SearchCap, provider: string, patch: Draft) {
98
+ const key = keyOf(cap, provider)
99
+ drafts = { ...drafts, [key]: { ...drafts[key], ...patch } }
100
+ const revisions = draftRevisions[key] ?? {}
101
+ for (const field of Object.keys(patch) as (keyof Draft)[]) revisions[field] = (revisions[field] ?? 0) + 1
102
+ draftRevisions[key] = revisions
103
+ checks = { ...checks, [key]: undefined }
104
+ }
105
+ async function load() {
106
+ if (!port) return
107
+ loading = true
108
+ loadError = ''
109
+ try {
110
+ const list = await Promise.all(caps.map((cap) => port.settings(cap, options)))
111
+ if (controller.signal.aborted) return
112
+ for (const next of list) apply(next)
113
+ const first = list[0]
114
+ if (!first) throw new Error('Search settings missing')
115
+ preferences = first.preferences
116
+ blacklist = preferences.blacklist.join('\n')
117
+ } catch (error) {
118
+ if (!controller.signal.aborted) loadError = errorText(error)
119
+ } finally {
120
+ if (!controller.signal.aborted) loading = false
121
+ }
122
+ }
123
+ async function choose(cap: SearchCap, provider: string) {
124
+ if (!port || pending[cap]) return
125
+ setPending(cap, true)
126
+ setError(cap, '')
127
+ try {
128
+ apply(await port.setDefault(cap, provider, options))
129
+ } catch (error) {
130
+ if (!controller.signal.aborted) setError(cap, errorText(error))
131
+ } finally {
132
+ setPending(cap, false)
133
+ }
134
+ }
135
+ async function saveAndCheck(cap: SearchCap, provider: SearchProvider, check = false) {
136
+ if (!port || pending[cap]) return
137
+ const key = keyOf(cap, provider.id)
138
+ const draft = { ...draftOf(cap, provider.id) }
139
+ const revisions = { ...draftRevisions[key] }
140
+ const keyRevision = keyRevisions[key] ?? 0
141
+ const value = keyDrafts[key]?.trim()
142
+ const session = flowSessions[cap]
143
+ session.invalidate()
144
+ const token = session.capture()
145
+ const isCurrent = () => !controller.signal.aborted && session.isCurrent(token)
146
+ setPending(cap, true)
147
+ setError(cap, '')
148
+ checks = { ...checks, [key]: undefined }
149
+ try {
150
+ if (value && value.length > 4096) throw new Error('credential too long')
151
+ const saved = await saveSearchSettings(
152
+ port,
153
+ {
154
+ config: {
155
+ capability: cap,
156
+ provider: provider.id,
157
+ ...(draft.baseUrl !== undefined ? { baseUrl: draft.baseUrl.trim() } : {}),
158
+ ...(provider.basicAuth && draft.basicAuthUsername !== undefined
159
+ ? { basicAuthUsername: draft.basicAuthUsername.trim() }
160
+ : {}),
161
+ },
162
+ ...(value ? { key: { value } } : {}),
163
+ check,
164
+ },
165
+ {
166
+ isCurrent,
167
+ onSettings: apply,
168
+ onConfigSaved(sent) {
169
+ const next = { ...drafts[key] }
170
+ for (const field of ['baseUrl', 'basicAuthUsername'] as const) {
171
+ if (sent[field] !== undefined && draftRevisions[key]?.[field] === revisions[field]) delete next[field]
172
+ }
173
+ drafts = { ...drafts, [key]: next }
174
+ },
175
+ onKeySaved() {
176
+ if ((keyRevisions[key] ?? 0) === keyRevision) keyDrafts = { ...keyDrafts, [key]: '' }
177
+ },
178
+ },
179
+ options,
180
+ )
181
+ if (!isCurrent()) return
182
+ if (saved.checked) checks = { ...checks, [key]: saved.checked.count }
183
+ else notify.toast('success', t('search.saved'))
184
+ } catch (error) {
185
+ if (isCurrent()) setError(cap, errorText(error))
186
+ } finally {
187
+ if (isCurrent()) setPending(cap, false)
188
+ }
189
+ }
190
+ function clearDialog() {
191
+ dialogSession.invalidate()
192
+ if (keyTarget) keyDrafts = { ...keyDrafts, [keyOf(keyTarget.cap, keyTarget.provider)]: '' }
193
+ keyTarget = null
194
+ editingId = undefined
195
+ editRevision++
196
+ editValue = ''
197
+ passwordRevision++
198
+ password = ''
199
+ revealed = {}
200
+ dialogError = ''
201
+ }
202
+ function openKeys(cap: SearchCap, provider: string) {
203
+ clearDialog()
204
+ keyTarget = { cap, provider }
205
+ keyDialog.showModal()
206
+ }
207
+ function invalidateCredentials() {
208
+ dialogSession.invalidate()
209
+ passwordRevision++
210
+ password = ''
211
+ revealed = {}
212
+ dialogError = ''
213
+ }
214
+ async function saveEditedKey() {
215
+ if (!port || !keyTarget || dialogBusy || !editValue.trim()) return
216
+ const target = { ...keyTarget }
217
+ const value = editValue.trim()
218
+ const id = editingId
219
+ const revision = editRevision
220
+ invalidateCredentials()
221
+ const token = dialogSession.capture()
222
+ const isCurrent = () => !controller.signal.aborted && dialogSession.isCurrent(token)
223
+ setPending(target.cap, true)
224
+ try {
225
+ const next = await port.saveKey(target.cap, target.provider, value, id, options)
226
+ if (!isCurrent()) return
227
+ apply(next)
228
+ if (revision === editRevision) {
229
+ editValue = ''
230
+ editingId = undefined
231
+ editRevision++
232
+ }
233
+ } catch (error) {
234
+ if (isCurrent()) dialogError = errorText(error)
235
+ } finally {
236
+ if (!controller.signal.aborted) setPending(target.cap, false)
237
+ }
238
+ }
239
+ async function removeKey(key: SearchKey) {
240
+ if (!port || !keyTarget || dialogBusy) return
241
+ const target = { ...keyTarget }
242
+ const revision = editRevision
243
+ invalidateCredentials()
244
+ const token = dialogSession.capture()
245
+ const isCurrent = () => !controller.signal.aborted && dialogSession.isCurrent(token)
246
+ setPending(target.cap, true)
247
+ try {
248
+ const next = await port.removeKey(target.cap, target.provider, key.id, options)
249
+ if (!isCurrent()) return
250
+ apply(next)
251
+ if (editingId === key.id && revision === editRevision) {
252
+ editingId = undefined
253
+ editValue = ''
254
+ editRevision++
255
+ }
256
+ } catch (error) {
257
+ if (isCurrent()) dialogError = errorText(error)
258
+ } finally {
259
+ if (!controller.signal.aborted) setPending(target.cap, false)
260
+ }
261
+ }
262
+ async function revealKey(key: SearchKey, copy = false) {
263
+ if (!port || !keyTarget || dialogBusy) return
264
+ const target = { ...keyTarget }
265
+ const token = dialogSession.capture()
266
+ const revision = passwordRevision
267
+ const currentPassword = port.revealNeedsPassword ? password : undefined
268
+ const isCurrent = () => !controller.signal.aborted && dialogSession.isCurrent(token)
269
+ setPending(target.cap, true)
270
+ dialogError = ''
271
+ try {
272
+ const value = await port.reveal(target.cap, target.provider, key.id, currentPassword, options)
273
+ if (!isCurrent()) return
274
+ if (copy) {
275
+ await navigator.clipboard.writeText(value)
276
+ if (isCurrent()) notify.toast('success', t('search.copied'))
277
+ } else revealed = { ...revealed, [key.id]: value }
278
+ } catch (error) {
279
+ if (isCurrent()) dialogError = copy && !isKitClientError(error) ? t('search.copyFailed') : errorText(error)
280
+ } finally {
281
+ if (isCurrent() && passwordRevision === revision) {
282
+ password = ''
283
+ passwordRevision++
284
+ }
285
+ if (!controller.signal.aborted) setPending(target.cap, false)
286
+ }
287
+ }
288
+ async function savePreferences() {
289
+ if (!port || pending.preferences) return
290
+ setPending('preferences', true)
291
+ setError('preferences', '')
292
+ try {
293
+ const saved = await port.savePreferences(
294
+ {
295
+ ...preferences,
296
+ blacklist: blacklist
297
+ .split('\n')
298
+ .map((rule) => rule.trim())
299
+ .filter(Boolean),
300
+ },
301
+ options,
302
+ )
303
+ if (!controller.signal.aborted) {
304
+ preferences = saved
305
+ blacklist = saved.blacklist.join('\n')
306
+ notify.toast('success', t('search.saved'))
307
+ }
308
+ } catch (error) {
309
+ if (!controller.signal.aborted) setError('preferences', errorText(error))
310
+ } finally {
311
+ setPending('preferences', false)
312
+ }
313
+ }
314
+ async function search() {
315
+ if (!port || pending.query || !query.trim()) return
316
+ setPending('query', true)
317
+ setError('query', '')
318
+ result = null
319
+ try {
320
+ const next = await port.search(query.trim(), options)
321
+ if (!controller.signal.aborted) result = next
322
+ } catch (error) {
323
+ if (!controller.signal.aborted) setError('query', errorText(error))
324
+ } finally {
325
+ setPending('query', false)
326
+ }
327
+ }
328
+ async function readPage() {
329
+ if (!port || pending.page || !pageUrl.trim()) return
330
+ setPending('page', true)
331
+ setError('page', '')
332
+ page = null
333
+ try {
334
+ const next = await port.fetch(pageUrl.trim(), options)
335
+ if (!controller.signal.aborted) page = next
336
+ } catch (error) {
337
+ if (!controller.signal.aborted) setError('page', errorText(error))
338
+ } finally {
339
+ setPending('page', false)
340
+ }
341
+ }
342
+ onMount(() => {
343
+ void load()
344
+ return () => {
345
+ controller.abort()
346
+ for (const session of Object.values(flowSessions)) session.invalidate()
347
+ keyDrafts = {}
348
+ keyDialog?.close()
349
+ clearDialog()
350
+ }
351
+ })
352
+ </script>
353
+
354
+ {#if !port}
355
+ <Empty.Root><Empty.Description>{t('search.unavailable')}</Empty.Description></Empty.Root>
356
+ {:else if loading}
357
+ <p class="text-muted-foreground text-kit-sm">{t('search.loading')}</p>
358
+ {:else if loadError}
359
+ <Alert.Root variant="destructive"><Alert.Description>{loadError}</Alert.Description></Alert.Root>
360
+ <Button variant="outline" onclick={() => void load()}>{t('chat.retry')}</Button>
361
+ {:else}
362
+ <div class="mx-auto flex w-full min-w-0 max-w-3xl flex-col gap-4">
363
+ {#each caps as cap (cap)}
364
+ {@const provider = activeProvider(cap)}
365
+ {#if provider}
366
+ {@const key = keyOf(cap, provider.id)}
367
+ <section class="flex min-w-0 flex-col gap-3 rounded-kit border p-4" aria-labelledby={`search-${cap}`}>
368
+ <header class="flex flex-wrap items-start justify-between gap-3">
369
+ <div class="min-w-0 flex-1">
370
+ <h2 id={`search-${cap}`} class="text-kit-base font-semibold">{t(`search.${cap}`)}</h2>
371
+ <p class="text-muted-foreground text-kit-sm">
372
+ {provider.description}
373
+ {#if safeUrl(provider.officialWebsite)}<a
374
+ class="text-primary inline-flex"
375
+ href={safeUrl(provider.officialWebsite) ?? ''}
376
+ target="_blank"
377
+ rel="noopener noreferrer"
378
+ aria-label={t('search.website')}
379
+ title={t('search.website')}><ExternalLinkIcon size={13} /></a
380
+ >{/if}
381
+ </p>
382
+ </div>
383
+ <select
384
+ class="bg-background border-input h-kit-control max-w-full rounded-kit-sm border px-2 text-kit-sm"
385
+ aria-label={t(`search.${cap}`)}
386
+ disabled={pending[cap]}
387
+ value={settings[cap]?.defaultProvider}
388
+ onchange={(event) => void choose(cap, event.currentTarget.value)}
389
+ >
390
+ {#each settings[cap]?.providers ?? [] as option (option.id)}<option value={option.id}
391
+ >{option.name}</option
392
+ >{/each}
393
+ </select>
394
+ </header>
395
+ {#if errors[cap]}<Alert.Root variant="destructive"
396
+ ><Alert.Description>{errors[cap]}</Alert.Description></Alert.Root
397
+ >{/if}
398
+ <Field.Group class="gap-3 border-t pt-3">
399
+ {#if provider.keyMode !== 'none' || provider.basicAuth}
400
+ <Field.Field data-disabled={pending[cap]}>
401
+ <div class="flex flex-wrap items-baseline gap-2">
402
+ <Field.Label for={`search-key-${cap}`}
403
+ >{provider.basicAuth ? t('search.password') : t('search.apiKey')}</Field.Label
404
+ >
405
+ {#if safeUrl(provider.apiKeyWebsite)}<a
406
+ class="text-primary text-kit-sm hover:underline"
407
+ href={safeUrl(provider.apiKeyWebsite) ?? ''}
408
+ target="_blank"
409
+ rel="noopener noreferrer">{t('search.apiKeyWebsite')}</a
410
+ >{/if}
411
+ </div>
412
+ <div class="flex min-w-0 items-center gap-2">
413
+ <Input
414
+ id={`search-key-${cap}`}
415
+ class="min-w-0 flex-1"
416
+ type="password"
417
+ autocomplete="off"
418
+ spellcheck={false}
419
+ maxlength={4096}
420
+ disabled={pending[cap]}
421
+ placeholder={t('search.newKey')}
422
+ value={keyDrafts[key] ?? ''}
423
+ oninput={(event) => {
424
+ keyDrafts = { ...keyDrafts, [key]: event.currentTarget.value }
425
+ keyRevisions[key] = (keyRevisions[key] ?? 0) + 1
426
+ checks = { ...checks, [key]: undefined }
427
+ }}
428
+ />
429
+ <Button
430
+ type="button"
431
+ variant="outline"
432
+ size="icon-sm"
433
+ disabled={pending[cap]}
434
+ aria-label={t('search.manageKeys')}
435
+ title={t('search.manageKeys')}
436
+ onclick={() => openKeys(cap, provider.id)}><ListIcon /></Button
437
+ >
438
+ <Button
439
+ type="button"
440
+ variant="outline"
441
+ size="icon-sm"
442
+ disabled={pending[cap]}
443
+ aria-label={t('search.check')}
444
+ title={t('search.check')}
445
+ onclick={() => void saveAndCheck(cap, provider, true)}
446
+ >{#if pending[cap]}<LoaderCircleIcon class="animate-spin" />{:else}<ActivityIcon />{/if}</Button
447
+ >
448
+ </div>
449
+ <Field.Description
450
+ >{provider.keys.length
451
+ ? provider.keys.map((item) => item.masked).join(' · ')
452
+ : t('search.keyNotSet')}</Field.Description
453
+ >
454
+ </Field.Field>
455
+ {/if}
456
+ {#if provider.defaultBaseUrl !== undefined}
457
+ <Field.Field data-disabled={pending[cap]}>
458
+ <Field.Label for={`search-url-${cap}`}>{t('search.baseUrl')}</Field.Label>
459
+ <div class="flex min-w-0 items-center gap-2">
460
+ <CopyInput
461
+ id={`search-url-${cap}`}
462
+ class="min-w-0 flex-1"
463
+ disabled={pending[cap]}
464
+ value={draftOf(cap, provider.id).baseUrl ?? provider.baseUrl ?? provider.defaultBaseUrl}
465
+ oninput={(event) => patchDraft(cap, provider.id, { baseUrl: event.currentTarget.value })}
466
+ />
467
+ {#if provider.keyMode === 'none' && !provider.basicAuth}<Button
468
+ type="button"
469
+ variant="outline"
470
+ size="icon-sm"
471
+ disabled={pending[cap]}
472
+ aria-label={t('search.check')}
473
+ title={t('search.check')}
474
+ onclick={() => void saveAndCheck(cap, provider, true)}><ActivityIcon /></Button
475
+ >{/if}
476
+ </div>
477
+ </Field.Field>
478
+ {/if}
479
+ {#if provider.basicAuth}
480
+ <Field.Field
481
+ ><Field.Label for={`search-user-${cap}`}>{t('search.username')}</Field.Label><Input
482
+ id={`search-user-${cap}`}
483
+ disabled={pending[cap]}
484
+ value={draftOf(cap, provider.id).basicAuthUsername ?? provider.basicAuthUsername ?? ''}
485
+ oninput={(event) => patchDraft(cap, provider.id, { basicAuthUsername: event.currentTarget.value })}
486
+ /></Field.Field
487
+ >
488
+ {/if}
489
+ </Field.Group>
490
+ <div class="flex flex-wrap items-center gap-2">
491
+ {#if provider.defaultBaseUrl !== undefined || provider.keyMode !== 'none'}<Button
492
+ type="button"
493
+ size="sm"
494
+ disabled={pending[cap]}
495
+ onclick={() => void saveAndCheck(cap, provider)}>{t('search.save')}</Button
496
+ >{/if}
497
+ {#if pending[cap]}<span class="text-muted-foreground text-kit-sm" role="status">{t('search.checking')}</span
498
+ >{:else if checks[key] !== undefined}<span class="text-muted-foreground text-kit-sm" role="status"
499
+ >{t('search.resultCount', { count: checks[key] ?? 0 })}</span
500
+ >{/if}
501
+ </div>
502
+ </section>
503
+ {/if}
504
+ {/each}
505
+ <section class="rounded-kit border p-4">
506
+ <details>
507
+ <summary class="cursor-pointer text-kit-base font-semibold">{t('search.advanced')}</summary>
508
+ <Field.Group class="mt-3 gap-3">
509
+ <Field.Field orientation="horizontal"
510
+ ><Field.Label for="search-max-results">{t('search.maxResults')}</Field.Label><Input
511
+ id="search-max-results"
512
+ class="ml-auto w-20"
513
+ type="number"
514
+ min="1"
515
+ max="50"
516
+ disabled={pending.preferences}
517
+ bind:value={preferences.maxResults}
518
+ /></Field.Field
519
+ >
520
+ <Field.Field orientation="horizontal"
521
+ ><Field.Label for="search-compression">{t('search.compression')}</Field.Label><select
522
+ id="search-compression"
523
+ class="bg-background border-input h-kit-control ml-auto rounded-kit-sm border px-2 text-kit-sm"
524
+ disabled={pending.preferences}
525
+ bind:value={preferences.compression}
526
+ ><option value="none">{t('search.compressionNone')}</option><option value="cutoff"
527
+ >{t('search.compressionCutoff')}</option
528
+ ></select
529
+ ></Field.Field
530
+ >
531
+ {#if preferences.compression === 'cutoff'}<Field.Field orientation="horizontal"
532
+ ><Field.Label for="search-cutoff">{t('search.cutoffLimit')}</Field.Label><Input
533
+ id="search-cutoff"
534
+ class="ml-auto w-24"
535
+ type="number"
536
+ min="100"
537
+ max="20000"
538
+ disabled={pending.preferences}
539
+ bind:value={preferences.cutoffLimit}
540
+ /></Field.Field
541
+ >{/if}
542
+ <Field.Field
543
+ ><Field.Label for="search-blacklist">{t('search.blacklist')}</Field.Label><Textarea
544
+ id="search-blacklist"
545
+ rows={3}
546
+ disabled={pending.preferences}
547
+ placeholder="example.com\n*://*.example.org/*"
548
+ bind:value={blacklist}
549
+ /></Field.Field
550
+ >
551
+ </Field.Group>
552
+ </details>
553
+ <Field.Field class="mt-3 border-t pt-3"
554
+ ><label class="flex items-center gap-2 text-kit-sm"
555
+ ><input type="checkbox" disabled={pending.preferences} bind:checked={preferences.modelToolsPreferred} />{t(
556
+ 'search.nativePreferred',
557
+ )}</label
558
+ ><Field.Description>{t('search.nativePrivacy')}</Field.Description></Field.Field
559
+ >
560
+ {#if errors.preferences}<p role="alert" class="text-destructive text-kit-sm">{errors.preferences}</p>{/if}
561
+ <Button class="mt-3" type="button" size="sm" disabled={pending.preferences} onclick={() => void savePreferences()}
562
+ >{t('search.save')}</Button
563
+ >
564
+ </section>
565
+ <section class="flex min-w-0 flex-col gap-3 rounded-kit border p-4" aria-label={t('search.tools')}>
566
+ <Field.Field
567
+ ><Field.Label for="search-query">{t('search.query')}</Field.Label>
568
+ <div class="flex min-w-0 gap-2">
569
+ <Input id="search-query" class="min-w-0 flex-1" disabled={pending.query} bind:value={query} /><Button
570
+ type="button"
571
+ disabled={pending.query || !query.trim()}
572
+ onclick={() => void search()}>{t('search.runSearch')}</Button
573
+ >
574
+ </div></Field.Field
575
+ >
576
+ {#if errors.query}<p class="text-destructive text-kit-sm" role="alert">{errors.query}</p>{/if}
577
+ {#if result}<p class="text-muted-foreground text-kit-sm">
578
+ {t('search.resultCount', { count: result.hits.length })}
579
+ </p>
580
+ {#each result.hits as hit (hit.url)}<article class="min-w-0 text-kit-sm">
581
+ <a
582
+ class="text-primary break-words hover:underline"
583
+ href={safeUrl(hit.url) ?? ''}
584
+ target="_blank"
585
+ rel="noopener noreferrer">{hit.title}</a
586
+ >
587
+ <p class="whitespace-pre-wrap break-words">{hit.snippet}</p>
588
+ </article>{/each}{/if}
589
+ <Field.Field
590
+ ><Field.Label for="search-page-url">{t('search.pageUrl')}</Field.Label>
591
+ <div class="flex min-w-0 gap-2">
592
+ <Input id="search-page-url" class="min-w-0 flex-1" disabled={pending.page} bind:value={pageUrl} /><Button
593
+ type="button"
594
+ disabled={pending.page || !pageUrl.trim()}
595
+ onclick={() => void readPage()}>{t('search.read')}</Button
596
+ >
597
+ </div></Field.Field
598
+ >
599
+ {#if errors.page}<p class="text-destructive text-kit-sm" role="alert">{errors.page}</p>{/if}
600
+ {#if page}<h3 class="text-kit-base font-medium">{page.title}</h3>
601
+ <a
602
+ class="text-primary break-words text-kit-sm"
603
+ href={safeUrl(page.url) ?? ''}
604
+ target="_blank"
605
+ rel="noopener noreferrer">{page.url}</a
606
+ >
607
+ <pre class="max-h-96 overflow-auto whitespace-pre-wrap break-words text-kit-sm">{page.content}</pre>{/if}
608
+ </section>
609
+ </div>
610
+ {/if}
611
+
612
+ <dialog
613
+ bind:this={keyDialog}
614
+ aria-labelledby="search-key-dialog-title"
615
+ onclose={clearDialog}
616
+ 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"
617
+ >
618
+ <h2 id="search-key-dialog-title" class="text-kit-base font-semibold">
619
+ {t('search.manageKeys')}{#if dialogProvider}
620
+ · {dialogProvider.name}{/if}
621
+ </h2>
622
+ {#if dialogProvider}
623
+ <Field.Group class="mt-3 gap-3">
624
+ {#if port?.revealNeedsPassword}<Field.Field
625
+ ><Field.Label for="search-key-password">{t('search.currentPassword')}</Field.Label><Input
626
+ id="search-key-password"
627
+ type="password"
628
+ autocomplete="off"
629
+ disabled={dialogBusy}
630
+ value={password}
631
+ oninput={(event) => {
632
+ password = event.currentTarget.value
633
+ passwordRevision++
634
+ }}
635
+ /></Field.Field
636
+ >{/if}
637
+ <ul class="flex flex-col gap-3">
638
+ {#each dialogProvider.keys as key (key.id)}
639
+ <li class="flex min-w-0 flex-col gap-1 border-b pb-2 text-kit-sm">
640
+ <span class="break-all font-mono">{revealed[key.id] ?? key.masked}</span>
641
+ <div class="flex flex-wrap gap-1">
642
+ <Button
643
+ type="button"
644
+ variant="ghost"
645
+ size="sm"
646
+ disabled={dialogBusy}
647
+ onclick={() => {
648
+ invalidateCredentials()
649
+ editingId = key.id
650
+ editValue = ''
651
+ editRevision++
652
+ }}>{t('search.editKey')}</Button
653
+ >
654
+ <Button
655
+ type="button"
656
+ variant="ghost"
657
+ size="sm"
658
+ disabled={dialogBusy || (port?.revealNeedsPassword && !password)}
659
+ onclick={() => void revealKey(key)}>{t('search.reveal')}</Button
660
+ >
661
+ <Button
662
+ type="button"
663
+ variant="ghost"
664
+ size="sm"
665
+ disabled={dialogBusy || (port?.revealNeedsPassword && !password)}
666
+ onclick={() => void revealKey(key, true)}>{t('search.copy')}</Button
667
+ >
668
+ <Button type="button" variant="ghost" size="sm" disabled={dialogBusy} onclick={() => void removeKey(key)}
669
+ >{t('search.delete')}</Button
670
+ >
671
+ </div>
672
+ </li>
673
+ {/each}
674
+ </ul>
675
+ <Field.Field
676
+ ><Field.Label for="search-edit-key">{t(editingId ? 'search.editKey' : 'search.newKey')}</Field.Label><Input
677
+ id="search-edit-key"
678
+ type="password"
679
+ autocomplete="off"
680
+ spellcheck={false}
681
+ maxlength={4096}
682
+ disabled={dialogBusy}
683
+ value={editValue}
684
+ oninput={(event) => {
685
+ editValue = event.currentTarget.value
686
+ editRevision++
687
+ }}
688
+ /></Field.Field
689
+ >
690
+ {#if dialogError}<p class="text-destructive text-kit-sm" role="alert">{dialogError}</p>{/if}
691
+ <div class="flex flex-wrap justify-end gap-2">
692
+ <Button
693
+ type="button"
694
+ size="sm"
695
+ disabled={dialogBusy || !editValue.trim() || (!editingId && dialogProvider.keys.length >= 16)}
696
+ onclick={() => void saveEditedKey()}>{t(editingId ? 'search.save' : 'search.addKey')}</Button
697
+ ><Button type="button" variant="outline" size="sm" onclick={() => keyDialog.close()}
698
+ >{t('common.cancel')}</Button
699
+ >
700
+ </div>
701
+ </Field.Group>
702
+ {/if}
703
+ </dialog>