@ouraihub/aether-ui 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/package.json +3 -3
  2. package/src/chat/ApprovalCard.svelte +8 -2
  3. package/src/chat/ChatComposer.svelte +319 -0
  4. package/src/chat/ChatMessage.svelte +262 -0
  5. package/src/chat/ChatPanel.css +386 -0
  6. package/src/chat/ChatPanel.svelte +276 -1880
  7. package/src/chat/ChatPanelApprovals.svelte +28 -0
  8. package/src/chat/ChatPanelComposer.svelte +336 -0
  9. package/src/chat/ChatPanelDialogs.svelte +242 -0
  10. package/src/chat/ChatPanelHeader.svelte +108 -0
  11. package/src/chat/ChatPanelMessages.svelte +166 -0
  12. package/src/chat/ChatPanelMode.svelte +190 -0
  13. package/src/chat/ChatPanelState.svelte.ts +1 -0
  14. package/src/chat/ChatPanelToolbar.svelte +436 -0
  15. package/src/chat/ChatSurface.svelte +168 -0
  16. package/src/chat/ContextUsageStatus.svelte +97 -0
  17. package/src/chat/MessageItem.svelte +111 -214
  18. package/src/chat/ModelEffortPicker.svelte +326 -0
  19. package/src/chat/PickerEffort.svelte +71 -0
  20. package/src/chat/ReasoningControl.svelte +184 -0
  21. package/src/chat/ReasoningSlider.svelte +174 -0
  22. package/src/chat/SkillPicker.svelte +14 -6
  23. package/src/chat/UsageControl.svelte +223 -0
  24. package/src/chat/UsageDetails.svelte +44 -90
  25. package/src/chat/chat-attachments.svelte.ts +78 -0
  26. package/src/chat/chat-panel-history.svelte.ts +430 -0
  27. package/src/chat/chat-panel-models.svelte.ts +97 -0
  28. package/src/chat/chat-panel-props.ts +38 -0
  29. package/src/chat/context-status.ts +101 -0
  30. package/src/chat/icons/LICENSE-REMIX +186 -0
  31. package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
  32. package/src/chat/icons/PathIcon.svelte +8 -0
  33. package/src/chat/icons/effort-icons.ts +65 -0
  34. package/src/chat/icons/status-icons.ts +105 -0
  35. package/src/chat/index.ts +25 -1
  36. package/src/chat/model-effort.ts +177 -0
  37. package/src/chat/run-session-history.ts +56 -0
  38. package/src/chat/run-session-types.ts +69 -0
  39. package/src/chat/run-session.svelte.ts +73 -135
  40. package/src/chat/test-support/chat-fixture.ts +194 -0
  41. package/src/chat/test-support/conversation-fixtures.ts +32 -0
  42. package/src/chat/test-support/run-session-fixture.ts +112 -0
  43. package/src/dev/Demo.svelte +3 -1
  44. package/src/i18n/en-chat.d.ts +1 -0
  45. package/src/i18n/en-chat.js +201 -0
  46. package/src/i18n/en.ts +58 -180
  47. package/src/i18n/index.ts +7 -2
  48. package/src/i18n/zh-CN-chat.d.ts +1 -0
  49. package/src/i18n/zh-CN-chat.js +190 -0
  50. package/src/i18n/zh-CN.ts +8 -153
  51. package/src/logs/LogPanel.svelte +9 -3
  52. package/src/logs/LogSettings.svelte +3 -1
  53. package/src/mcp/McpClientEditor.svelte +204 -0
  54. package/src/mcp/McpClientSettings.svelte +57 -130
  55. package/src/mcp/McpServerSettings.svelte +47 -11
  56. package/src/mcp/input.ts +2 -1
  57. package/src/ports/contract.ts +11 -2
  58. package/src/ports/fake-optional.ts +81 -0
  59. package/src/ports/fake.ts +90 -86
  60. package/src/ports/search.ts +11 -2
  61. package/src/ports/test-support/fake-fixture.ts +63 -0
  62. package/src/providers/EndpointKeysDialog.svelte +27 -13
  63. package/src/providers/EndpointModels.svelte +311 -0
  64. package/src/providers/EndpointPanel.svelte +33 -258
  65. package/src/providers/ModelPickerDialog.svelte +59 -16
  66. package/src/providers/ModelPricing.svelte +125 -0
  67. package/src/providers/ModelSelect.svelte +22 -8
  68. package/src/providers/ModelSettingsDialog.svelte +61 -126
  69. package/src/providers/ProviderDialogs.svelte +120 -0
  70. package/src/providers/ProviderList.svelte +159 -0
  71. package/src/providers/ProvidersSettings.svelte +67 -207
  72. package/src/providers/RequestConfigDialog.svelte +40 -12
  73. package/src/providers/SecretInput.svelte +11 -3
  74. package/src/providers/draft.ts +39 -13
  75. package/src/providers/edit-session.ts +31 -7
  76. package/src/providers/reasoning.ts +18 -4
  77. package/src/providers/revision.svelte.ts +3 -1
  78. package/src/providers/test-support/providers-fixture.ts +120 -0
  79. package/src/search/SearchAdvancedTools.svelte +182 -0
  80. package/src/search/SearchProviderCard.svelte +195 -0
  81. package/src/search/SearchSettings.svelte +62 -265
  82. package/src/search/settings-flow.ts +8 -3
  83. package/src/skills/SkillAdd.svelte +51 -18
  84. package/src/skills/SkillAuthoring.svelte +69 -20
  85. package/src/skills/SkillEditor.svelte +13 -3
  86. package/src/skills/SkillPreviewDialog.svelte +19 -5
  87. package/src/skills/SkillsSettings.svelte +11 -4
  88. package/src/skills/test-support/skills-fixture.ts +139 -0
  89. package/src/tauri/index.ts +23 -7
  90. package/src/web/index.ts +90 -25
@@ -88,7 +88,9 @@ export const validUrl = (url: string): boolean => /^https?:\/\/\S+$/.test(url.tr
88
88
  export function savable(draft: ProviderDraft): ProviderDraft {
89
89
  return {
90
90
  ...draft,
91
- endpoints: draft.endpoints.filter((e) => e.saved !== null || !e.baseUrl.trim() || validUrl(e.baseUrl)),
91
+ endpoints: draft.endpoints.filter(
92
+ (e) => e.saved !== null || !e.baseUrl.trim() || validUrl(e.baseUrl),
93
+ ),
92
94
  }
93
95
  }
94
96
 
@@ -96,7 +98,11 @@ export function savable(draft: ProviderDraft): ProviderDraft {
96
98
  * Takes the stored provider after a save without discarding what the user typed meanwhile:
97
99
  * sent endpoints get their stored record (new ones matched by slug); edits stay in the draft.
98
100
  */
99
- export function adopt(current: ProviderDraft, sent: ProviderDraft, stored: Provider): ProviderDraft {
101
+ export function adopt(
102
+ current: ProviderDraft,
103
+ sent: ProviderDraft,
104
+ stored: Provider,
105
+ ): ProviderDraft {
100
106
  const sentEndpoints = new Map(sent.endpoints.map((endpoint) => [endpoint.slug, endpoint]))
101
107
  return {
102
108
  ...current,
@@ -105,7 +111,9 @@ export function adopt(current: ProviderDraft, sent: ProviderDraft, stored: Provi
105
111
  const original = sentEndpoints.get(endpoint.slug)
106
112
  const record =
107
113
  original &&
108
- stored.endpoints.find((item) => (original.saved ? item.id === original.saved.id : item.slug === original.slug))
114
+ stored.endpoints.find((item) =>
115
+ original.saved ? item.id === original.saved.id : item.slug === original.slug,
116
+ )
109
117
  return record ? { ...endpoint, saved: record } : endpoint
110
118
  }),
111
119
  }
@@ -120,7 +128,9 @@ export function mergeModels(
120
128
  ): Model[] {
121
129
  const fetched = new Set(probed.map((model) => model.id))
122
130
  const existing = new Map(current.map((model) => [model.id, model]))
123
- const merged = new Map(current.filter((model) => !fetched.has(model.id)).map((model) => [model.id, model]))
131
+ const merged = new Map(
132
+ current.filter((model) => !fetched.has(model.id)).map((model) => [model.id, model]),
133
+ )
124
134
  for (const model of probed) {
125
135
  if (!checked[model.id] || merged.has(model.id)) continue
126
136
  const previous = existing.get(model.id)
@@ -156,7 +166,8 @@ export function toSave(draft: ProviderDraft): ProviderSave {
156
166
  models: e.models.map((model) => {
157
167
  const save: ModelSave = {
158
168
  ...model,
159
- nativeWebSearch: model.modelType === 'chat' && e.protocol !== 'openai-chat' && model.nativeWebSearch,
169
+ nativeWebSearch:
170
+ model.modelType === 'chat' && e.protocol !== 'openai-chat' && model.nativeWebSearch,
160
171
  }
161
172
  // Contract §2: omitted reasoning keeps the stored value, so an untouched (possibly inferred) value is not frozen.
162
173
  // Key order matches: edits spread the stored config and append `budgetTokens` last, like serde.
@@ -182,7 +193,13 @@ export const groupKey = (model: Model): string => model.groupName ?? idPrefix(mo
182
193
  /** Purpose maps onto existing fields (docs/41 §3.3): edit = image + canVision. */
183
194
  export type Purpose = 'chat' | 'image' | 'edit'
184
195
  export const purposeOf = (model: Model): Purpose | null =>
185
- model.modelType === 'chat' ? 'chat' : model.modelType === 'image' ? (model.canVision ? 'edit' : 'image') : null
196
+ model.modelType === 'chat'
197
+ ? 'chat'
198
+ : model.modelType === 'image'
199
+ ? model.canVision
200
+ ? 'edit'
201
+ : 'image'
202
+ : null
186
203
  export function withPurpose(model: Model, purpose: Purpose): Model {
187
204
  const typed = withType(model, purpose === 'chat' ? 'chat' : 'image')
188
205
  return purpose === 'chat' ? typed : { ...typed, canVision: purpose === 'edit' }
@@ -191,10 +208,13 @@ export function withPurpose(model: Model, purpose: Purpose): Model {
191
208
  export function withType(model: Model, modelType: ModelType): Model {
192
209
  if (modelType === model.modelType) return model
193
210
  const next: Model = { ...model, modelType, typeSource: 'user' }
194
- return modelType === 'chat' ? next : { ...next, reasoning: null, nativeWebSearch: false, pricing: null }
211
+ return modelType === 'chat'
212
+ ? next
213
+ : { ...next, reasoning: null, nativeWebSearch: false, pricing: null }
195
214
  }
196
215
 
197
- export type ModelField = 'name' | 'groupName' | 'contextWindow' | 'maxOutput' | 'reasoning' | 'pricing'
216
+ export type ModelField =
217
+ 'name' | 'groupName' | 'contextWindow' | 'maxOutput' | 'reasoning' | 'pricing'
198
218
  export interface ModelError {
199
219
  field: ModelField
200
220
  reason: string
@@ -211,7 +231,8 @@ export function validateModel(model: Model, protocol: Protocol): ModelError[] {
211
231
  // Non-integers also fail the backend's integer schema; doc §4.2 shows them as the same `invalid`.
212
232
  if (contextWindow !== null && !(Number.isInteger(contextWindow) && contextWindow >= 1))
213
233
  add('contextWindow', 'invalid')
214
- if (maxOutput !== null && !(Number.isInteger(maxOutput) && maxOutput >= 1)) add('maxOutput', 'invalid')
234
+ if (maxOutput !== null && !(Number.isInteger(maxOutput) && maxOutput >= 1))
235
+ add('maxOutput', 'invalid')
215
236
  else if (chat && contextWindow !== null && maxOutput !== null && maxOutput >= contextWindow)
216
237
  add('maxOutput', 'exceeds_context')
217
238
  if (reasoning) {
@@ -234,7 +255,8 @@ export function validateModel(model: Model, protocol: Protocol): ModelError[] {
234
255
  }
235
256
  if (pricing) {
236
257
  const { tiers } = pricing
237
- const price = (value: number | null) => value === null || (Number.isFinite(value) && value >= 0 && value <= 1e6)
258
+ const price = (value: number | null) =>
259
+ value === null || (Number.isFinite(value) && value >= 0 && value <= 1e6)
238
260
  if (
239
261
  !chat ||
240
262
  !tiers.length ||
@@ -274,7 +296,8 @@ const CHAT_PATH: Record<Protocol, string> = {
274
296
  'anthropic-messages': 'messages',
275
297
  }
276
298
  /** Mirrors `aether_chat::protocol` request paths. */
277
- export const chatUrl = (baseUrl: string, protocol: Protocol): string => `${apiBase(baseUrl)}/${CHAT_PATH[protocol]}`
299
+ export const chatUrl = (baseUrl: string, protocol: Protocol): string =>
300
+ `${apiBase(baseUrl)}/${CHAT_PATH[protocol]}`
278
301
 
279
302
  const HEADER_NAME = /^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,100}$/
280
303
  const RESERVED_HEADERS: Record<string, true> = {
@@ -289,13 +312,16 @@ const RESERVED_HEADERS: Record<string, true> = {
289
312
  'transfer-encoding': true,
290
313
  cookie: true,
291
314
  }
292
- export type HeadersError = { key: 'headers_too_many' | 'headers_invalid' } | { key: 'headers_reserved'; name: string }
315
+ export type HeadersError =
316
+ { key: 'headers_too_many' | 'headers_invalid' } | { key: 'headers_reserved'; name: string }
293
317
 
294
318
  /** Front-end mirror of the backend header rules (docs/36 §3.3); blank rows are dropped. */
295
319
  export function checkHeaders(
296
320
  rows: readonly { name: string; value: string }[],
297
321
  ): { headers: Record<string, string> } | { error: HeadersError } {
298
- const kept = rows.map((r) => ({ name: r.name.trim(), value: r.value })).filter((r) => r.name || r.value)
322
+ const kept = rows
323
+ .map((r) => ({ name: r.name.trim(), value: r.value }))
324
+ .filter((r) => r.name || r.value)
299
325
  if (kept.length > 20) return { error: { key: 'headers_too_many' } }
300
326
  const headers: Record<string, string> = {}
301
327
  const seen = new Set<string>()
@@ -1,4 +1,10 @@
1
- import { KitClientError, type CallOptions, type ProbeResult, type Provider, type ProvidersPort } from '../ports'
1
+ import {
2
+ KitClientError,
3
+ type CallOptions,
4
+ type ProbeResult,
5
+ type Provider,
6
+ type ProvidersPort,
7
+ } from '../ports'
2
8
  import { adopt, probeTarget, savable, toSave, type ProviderDraft } from './draft'
3
9
 
4
10
  export interface ProviderProbeBatch {
@@ -12,14 +18,24 @@ export interface ProviderEditSession {
12
18
  }
13
19
 
14
20
  const cancelled = () =>
15
- new KitClientError({ code: 'cancelled', message: 'Provider editing session cancelled', details: {} })
21
+ new KitClientError({
22
+ code: 'cancelled',
23
+ message: 'Provider editing session cancelled',
24
+ details: {},
25
+ })
16
26
  const identityError = () =>
17
- new KitClientError({ code: 'internal', message: 'Saved provider identity is missing or ambiguous', details: {} })
27
+ new KitClientError({
28
+ code: 'internal',
29
+ message: 'Saved provider identity is missing or ambiguous',
30
+ details: {},
31
+ })
18
32
  // Copy plain DTO data without losing invalid numeric edits or retaining Svelte proxies.
19
33
  function snapshot<T>(value: T): T {
20
34
  if (Array.isArray(value)) return value.map((item) => snapshot(item)) as T
21
35
  if (value !== null && typeof value === 'object')
22
- return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, snapshot(item)])) as T
36
+ return Object.fromEntries(
37
+ Object.entries(value).map(([key, item]) => [key, snapshot(item)]),
38
+ ) as T
23
39
  return value
24
40
  }
25
41
  export function createProviderEditSession(
@@ -111,15 +127,23 @@ export function createProviderEditSession(
111
127
  const run = async (): Promise<ProviderProbeBatch> => {
112
128
  await commit()
113
129
  ensureCurrent()
114
- const endpoint = snapshot(state.readDraft().endpoints.find((item) => item.slug === slug) ?? null)
130
+ const endpoint = snapshot(
131
+ state.readDraft().endpoints.find((item) => item.slug === slug) ?? null,
132
+ )
115
133
  if (!endpoint)
116
- throw new KitClientError({ code: 'not_found', message: 'Provider endpoint no longer exists', details: {} })
134
+ throw new KitClientError({
135
+ code: 'not_found',
136
+ message: 'Provider endpoint no longer exists',
137
+ details: {},
138
+ })
117
139
  const enabled = endpoint.saved?.keys.filter((key) => key.enabled) ?? []
118
140
  const keyIds = enabled.length >= 2 ? enabled.map((key) => key.id) : [null]
119
141
  const batch: ProviderProbeBatch = { results: [], usable: null }
120
142
  for (const keyId of keyIds) {
121
143
  ensureCurrent()
122
- const result = await port.probe(probeTarget(endpoint, keyId ?? undefined), { signal: controller.signal })
144
+ const result = await port.probe(probeTarget(endpoint, keyId ?? undefined), {
145
+ signal: controller.signal,
146
+ })
123
147
  ensureCurrent()
124
148
  batch.results.push({ keyId, result })
125
149
  if (result.error === null) batch.usable ??= result
@@ -1,6 +1,15 @@
1
1
  import type { Protocol, ReasoningEffort, ReasoningDialect, ReasoningConfig } from '../ports'
2
2
 
3
- export const REASONING_EFFORTS: ReasoningEffort[] = ['none', 'auto', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']
3
+ export const REASONING_EFFORTS: ReasoningEffort[] = [
4
+ 'none',
5
+ 'auto',
6
+ 'minimal',
7
+ 'low',
8
+ 'medium',
9
+ 'high',
10
+ 'xhigh',
11
+ 'max',
12
+ ]
4
13
  export const REASONING_DIALECTS: ReasoningDialect[] = [
5
14
  'openai',
6
15
  'anthropic-budget',
@@ -11,13 +20,15 @@ export const REASONING_DIALECTS: ReasoningDialect[] = [
11
20
 
12
21
  /** Whether `dialect` is written on `protocol` (same rule as the backends). */
13
22
  export function dialectFits(dialect: ReasoningDialect, protocol: Protocol): boolean {
14
- if (dialect === 'anthropic-budget' || dialect === 'anthropic-adaptive') return protocol === 'anthropic-messages'
23
+ if (dialect === 'anthropic-budget' || dialect === 'anthropic-adaptive')
24
+ return protocol === 'anthropic-messages'
15
25
  if (dialect === 'openai') return protocol !== 'anthropic-messages'
16
26
  return protocol === 'openai-chat'
17
27
  }
18
28
 
19
29
  /** `auto` (on, depth left to the provider) exists only for switch-style dialects. */
20
- export const allowsAuto = (dialect: ReasoningDialect): boolean => dialect === 'thinking-type' || dialect === 'qwen'
30
+ export const allowsAuto = (dialect: ReasoningDialect): boolean =>
31
+ dialect === 'thinking-type' || dialect === 'qwen'
21
32
 
22
33
  export function emptyReasoning(dialect: ReasoningDialect = 'openai'): ReasoningConfig {
23
34
  return {
@@ -28,7 +39,10 @@ export function emptyReasoning(dialect: ReasoningDialect = 'openai'): ReasoningC
28
39
  }
29
40
 
30
41
  /** The saved choice if allowed, else the nearest allowed effort (ties go higher); `default` without one. */
31
- export function nearestEffort(chosen: ReasoningEffort, allowed: readonly ReasoningEffort[]): ReasoningEffort {
42
+ export function nearestEffort(
43
+ chosen: ReasoningEffort,
44
+ allowed: readonly ReasoningEffort[],
45
+ ): ReasoningEffort {
32
46
  if (chosen === 'default' || allowed.includes(chosen)) return allowed.length ? chosen : 'default'
33
47
  const at = REASONING_EFFORTS.indexOf(chosen)
34
48
  let best: ReasoningEffort = 'default'
@@ -1,4 +1,6 @@
1
1
  // Bumped after a provider save/remove so open pickers and chat panels reload model capabilities.
2
2
  export const providersRevision = $state({ value: 0 })
3
3
  /** Model the next `ProvidersSettings` mount opens; set by a chat "open settings" action, cleared once used. */
4
- export const settingsTarget = $state<{ value: { endpointId: string; modelId: string } | null }>({ value: null })
4
+ export const settingsTarget = $state<{ value: { endpointId: string; modelId: string } | null }>({
5
+ value: null,
6
+ })
@@ -0,0 +1,120 @@
1
+ import { render } from '@testing-library/svelte'
2
+ import { vi } from 'vitest'
3
+ import { createI18n } from '../../i18n'
4
+ import {
5
+ createFakePorts,
6
+ type Endpoint,
7
+ type Provider,
8
+ type ProviderPreset,
9
+ type ProviderSave,
10
+ } from '../../ports'
11
+ import Harness from '../Harness.test.svelte'
12
+
13
+ export const endpoint: Endpoint = {
14
+ id: 'e1',
15
+ slug: 'main',
16
+ baseUrl: 'http://127.0.0.1:9/v1',
17
+ protocol: 'openai-chat',
18
+ keys: [
19
+ {
20
+ id: 'k1',
21
+ label: '',
22
+ enabled: true,
23
+ masked: 'sk-a****wxyz',
24
+ readable: true,
25
+ exhaustedUntil: null,
26
+ exhaustedReason: null,
27
+ },
28
+ ],
29
+ keyStrategy: 'round_robin',
30
+ probeUrl: 'http://127.0.0.1:9/v1/models',
31
+ imageBaseUrl: null,
32
+ imageEditBaseUrl: null,
33
+ headers: { 'X-Team': 'a' },
34
+ models: [],
35
+ }
36
+
37
+ export const stored: Provider = {
38
+ id: 'p1',
39
+ name: 'Local',
40
+ enabled: true,
41
+ notes: '',
42
+ presetId: null,
43
+ endpoints: [endpoint],
44
+ }
45
+
46
+ export const twoKeys: Endpoint = {
47
+ ...endpoint,
48
+ keys: [
49
+ ...endpoint.keys,
50
+ {
51
+ id: 'k2',
52
+ label: 'backup',
53
+ enabled: true,
54
+ masked: 'sk-b****1234',
55
+ readable: true,
56
+ exhaustedUntil: null,
57
+ exhaustedReason: null,
58
+ },
59
+ ],
60
+ }
61
+
62
+ export const model = {
63
+ name: null,
64
+ groupName: null,
65
+ pricing: null,
66
+ contextWindow: null,
67
+ maxOutput: null,
68
+ canVision: false,
69
+ supportsTools: null,
70
+ nativeWebSearch: false,
71
+ } as const
72
+
73
+ export function setup(
74
+ overrides: NonNullable<Parameters<typeof createFakePorts>[0]>['providers'] = {},
75
+ list = stored,
76
+ presets?: ProviderPreset[],
77
+ ) {
78
+ const saves: ProviderSave[] = []
79
+ const ports = createFakePorts({
80
+ providers: {
81
+ list: () => Promise.resolve([list]),
82
+ save: (input) => {
83
+ saves.push(input)
84
+ return Promise.resolve([stored])
85
+ },
86
+ probe: () =>
87
+ Promise.resolve({
88
+ probeUrl: endpoint.probeUrl,
89
+ error: null,
90
+ suggestedProtocol: null,
91
+ models: [
92
+ {
93
+ ...model,
94
+ id: 'gpt-4o',
95
+ group: 'gpt',
96
+ modelType: 'chat',
97
+ typeSource: 'inferred',
98
+ reasoning: null,
99
+ },
100
+ {
101
+ ...model,
102
+ id: 'text-embedding-3',
103
+ group: null,
104
+ modelType: 'embedding',
105
+ typeSource: 'inferred',
106
+ reasoning: null,
107
+ },
108
+ ],
109
+ }),
110
+ ...overrides,
111
+ },
112
+ })
113
+ const notify = { toast: vi.fn(), confirm: () => Promise.resolve(true) }
114
+ const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }
115
+ render(Harness, {
116
+ kit: { ports, i18n: createI18n('en'), notify, logger },
117
+ ...(presets ? { presets } : {}),
118
+ })
119
+ return { saves, notify }
120
+ }
@@ -0,0 +1,182 @@
1
+ <script lang="ts">
2
+ import { Button } from '#lib/components/ui/button/index.js'
3
+ import { Input } from '#lib/components/ui/input/index.js'
4
+ import { Textarea } from '#lib/components/ui/textarea/index.js'
5
+ import * as Field from '#lib/components/ui/field/index.js'
6
+ import { getKitContext } from '../context'
7
+ import type { SearchPage, SearchPreferences, SearchResult } from '../ports'
8
+ let {
9
+ preferences = $bindable(),
10
+ blacklist = $bindable(),
11
+ query = $bindable(),
12
+ pageUrl = $bindable(),
13
+ preferencesPending,
14
+ preferencesError,
15
+ result,
16
+ page,
17
+ queryPending,
18
+ pagePending,
19
+ queryError,
20
+ pageError,
21
+ onSavePreferences,
22
+ onSearch,
23
+ onReadPage,
24
+ }: {
25
+ preferences: SearchPreferences
26
+ blacklist: string
27
+ query: string
28
+ pageUrl: string
29
+ preferencesPending: boolean
30
+ preferencesError: string
31
+ result: SearchResult | null
32
+ page: SearchPage | null
33
+ queryPending: boolean
34
+ pagePending: boolean
35
+ queryError: string
36
+ pageError: string
37
+ onSavePreferences: () => void
38
+ onSearch: () => void
39
+ onReadPage: () => void
40
+ } = $props()
41
+
42
+ const { i18n } = getKitContext()
43
+ const { t } = i18n
44
+ const safeUrl = (raw?: string) => {
45
+ if (!raw) return null
46
+ try {
47
+ const url = new URL(raw)
48
+ return ['https:', 'http:'].includes(url.protocol) ? url.href : null
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+ </script>
54
+
55
+ <section class="rounded-kit border p-4">
56
+ <details>
57
+ <summary class="cursor-pointer text-kit-base font-semibold">{t('search.advanced')}</summary>
58
+ <Field.Group class="mt-3 gap-3">
59
+ <Field.Field orientation="horizontal"
60
+ ><Field.Label for="search-max-results">{t('search.maxResults')}</Field.Label><Input
61
+ id="search-max-results"
62
+ class="ml-auto w-20"
63
+ type="number"
64
+ min="1"
65
+ max="50"
66
+ disabled={preferencesPending}
67
+ value={preferences.maxResults}
68
+ oninput={(event) => (preferences.maxResults = +event.currentTarget.value)}
69
+ /></Field.Field
70
+ >
71
+ <Field.Field orientation="horizontal"
72
+ ><Field.Label for="search-compression">{t('search.compression')}</Field.Label><select
73
+ id="search-compression"
74
+ class="bg-background border-input h-kit-control ml-auto rounded-kit-sm border px-2 text-kit-sm"
75
+ disabled={preferencesPending}
76
+ value={preferences.compression}
77
+ onchange={(event) =>
78
+ (preferences.compression = event.currentTarget
79
+ .value as SearchPreferences['compression'])}
80
+ ><option value="none">{t('search.compressionNone')}</option><option value="cutoff"
81
+ >{t('search.compressionCutoff')}</option
82
+ ></select
83
+ ></Field.Field
84
+ >
85
+ {#if preferences.compression === 'cutoff'}<Field.Field orientation="horizontal"
86
+ ><Field.Label for="search-cutoff">{t('search.cutoffLimit')}</Field.Label><Input
87
+ id="search-cutoff"
88
+ class="ml-auto w-24"
89
+ type="number"
90
+ min="100"
91
+ max="20000"
92
+ disabled={preferencesPending}
93
+ value={preferences.cutoffLimit}
94
+ oninput={(event) => (preferences.cutoffLimit = +event.currentTarget.value)}
95
+ /></Field.Field
96
+ >{/if}
97
+ <Field.Field
98
+ ><Field.Label for="search-blacklist">{t('search.blacklist')}</Field.Label><Textarea
99
+ id="search-blacklist"
100
+ rows={3}
101
+ disabled={preferencesPending}
102
+ placeholder="example.com\n*://*.example.org/*"
103
+ value={blacklist}
104
+ oninput={(event) => (blacklist = event.currentTarget.value)}
105
+ /></Field.Field
106
+ >
107
+ </Field.Group>
108
+ </details>
109
+ <Field.Field class="mt-3 border-t pt-3"
110
+ ><label class="flex items-center gap-2 text-kit-sm"
111
+ ><input
112
+ type="checkbox"
113
+ disabled={preferencesPending}
114
+ checked={preferences.modelToolsPreferred}
115
+ onchange={(event) => (preferences.modelToolsPreferred = event.currentTarget.checked)}
116
+ />{t('search.nativePreferred')}</label
117
+ ><Field.Description>{t('search.nativePrivacy')}</Field.Description></Field.Field
118
+ >
119
+ {#if preferencesError}<p role="alert" class="text-destructive text-kit-sm">
120
+ {preferencesError}
121
+ </p>{/if}
122
+ <Button
123
+ class="mt-3"
124
+ type="button"
125
+ size="sm"
126
+ disabled={preferencesPending}
127
+ onclick={onSavePreferences}>{t('search.save')}</Button
128
+ >
129
+ </section>
130
+ <section class="flex min-w-0 flex-col gap-3 rounded-kit border p-4" aria-label={t('search.tools')}>
131
+ <Field.Field
132
+ ><Field.Label for="search-query">{t('search.query')}</Field.Label>
133
+ <div class="flex min-w-0 gap-2">
134
+ <Input
135
+ id="search-query"
136
+ class="min-w-0 flex-1"
137
+ disabled={queryPending}
138
+ value={query}
139
+ oninput={(event) => (query = event.currentTarget.value)}
140
+ /><Button type="button" disabled={queryPending || !query.trim()} onclick={onSearch}
141
+ >{t('search.runSearch')}</Button
142
+ >
143
+ </div></Field.Field
144
+ >
145
+ {#if queryError}<p class="text-destructive text-kit-sm" role="alert">{queryError}</p>{/if}
146
+ {#if result}<p class="text-muted-foreground text-kit-sm">
147
+ {t('search.resultCount', { count: result.hits.length })}
148
+ </p>
149
+ {#each result.hits as hit (hit.url)}<article class="min-w-0 text-kit-sm">
150
+ <a
151
+ class="text-primary break-words hover:underline"
152
+ href={safeUrl(hit.url) ?? ''}
153
+ target="_blank"
154
+ rel="noopener noreferrer">{hit.title}</a
155
+ >
156
+ <p class="whitespace-pre-wrap break-words">{hit.snippet}</p>
157
+ </article>{/each}{/if}
158
+ <Field.Field
159
+ ><Field.Label for="search-page-url">{t('search.pageUrl')}</Field.Label>
160
+ <div class="flex min-w-0 gap-2">
161
+ <Input
162
+ id="search-page-url"
163
+ class="min-w-0 flex-1"
164
+ disabled={pagePending}
165
+ value={pageUrl}
166
+ oninput={(event) => (pageUrl = event.currentTarget.value)}
167
+ /><Button type="button" disabled={pagePending || !pageUrl.trim()} onclick={onReadPage}
168
+ >{t('search.read')}</Button
169
+ >
170
+ </div></Field.Field
171
+ >
172
+ {#if pageError}<p class="text-destructive text-kit-sm" role="alert">{pageError}</p>{/if}
173
+ {#if page}<h3 class="text-kit-base font-medium">{page.title}</h3>
174
+ <a
175
+ class="text-primary break-words text-kit-sm"
176
+ href={safeUrl(page.url) ?? ''}
177
+ target="_blank"
178
+ rel="noopener noreferrer">{page.url}</a
179
+ >
180
+ <pre
181
+ class="max-h-96 overflow-auto whitespace-pre-wrap break-words text-kit-sm">{page.content}</pre>{/if}
182
+ </section>