@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.
- package/package.json +3 -3
- package/src/chat/ApprovalCard.svelte +8 -2
- package/src/chat/ChatComposer.svelte +319 -0
- package/src/chat/ChatMessage.svelte +262 -0
- package/src/chat/ChatPanel.css +386 -0
- package/src/chat/ChatPanel.svelte +276 -1880
- package/src/chat/ChatPanelApprovals.svelte +28 -0
- package/src/chat/ChatPanelComposer.svelte +336 -0
- package/src/chat/ChatPanelDialogs.svelte +242 -0
- package/src/chat/ChatPanelHeader.svelte +108 -0
- package/src/chat/ChatPanelMessages.svelte +166 -0
- package/src/chat/ChatPanelMode.svelte +190 -0
- package/src/chat/ChatPanelState.svelte.ts +1 -0
- package/src/chat/ChatPanelToolbar.svelte +436 -0
- package/src/chat/ChatSurface.svelte +168 -0
- package/src/chat/ContextUsageStatus.svelte +97 -0
- package/src/chat/MessageItem.svelte +111 -214
- package/src/chat/ModelEffortPicker.svelte +326 -0
- package/src/chat/PickerEffort.svelte +71 -0
- package/src/chat/ReasoningControl.svelte +184 -0
- package/src/chat/ReasoningSlider.svelte +174 -0
- package/src/chat/SkillPicker.svelte +14 -6
- package/src/chat/UsageControl.svelte +223 -0
- package/src/chat/UsageDetails.svelte +44 -90
- package/src/chat/chat-attachments.svelte.ts +78 -0
- package/src/chat/chat-panel-history.svelte.ts +430 -0
- package/src/chat/chat-panel-models.svelte.ts +97 -0
- package/src/chat/chat-panel-props.ts +38 -0
- package/src/chat/context-status.ts +101 -0
- package/src/chat/icons/LICENSE-REMIX +186 -0
- package/src/chat/icons/LICENSE-SIMPLE-ICONS +30 -0
- package/src/chat/icons/PathIcon.svelte +8 -0
- package/src/chat/icons/effort-icons.ts +65 -0
- package/src/chat/icons/status-icons.ts +105 -0
- package/src/chat/index.ts +25 -1
- package/src/chat/model-effort.ts +177 -0
- package/src/chat/run-session-history.ts +56 -0
- package/src/chat/run-session-types.ts +69 -0
- package/src/chat/run-session.svelte.ts +73 -135
- package/src/chat/test-support/chat-fixture.ts +194 -0
- package/src/chat/test-support/conversation-fixtures.ts +32 -0
- package/src/chat/test-support/run-session-fixture.ts +112 -0
- package/src/dev/Demo.svelte +3 -1
- package/src/i18n/en-chat.d.ts +1 -0
- package/src/i18n/en-chat.js +201 -0
- package/src/i18n/en.ts +58 -180
- package/src/i18n/index.ts +7 -2
- package/src/i18n/zh-CN-chat.d.ts +1 -0
- package/src/i18n/zh-CN-chat.js +190 -0
- package/src/i18n/zh-CN.ts +8 -153
- package/src/logs/LogPanel.svelte +9 -3
- package/src/logs/LogSettings.svelte +3 -1
- package/src/mcp/McpClientEditor.svelte +204 -0
- package/src/mcp/McpClientSettings.svelte +57 -130
- package/src/mcp/McpServerSettings.svelte +47 -11
- package/src/mcp/input.ts +2 -1
- package/src/ports/contract.ts +11 -2
- package/src/ports/fake-optional.ts +81 -0
- package/src/ports/fake.ts +90 -86
- package/src/ports/search.ts +11 -2
- package/src/ports/test-support/fake-fixture.ts +63 -0
- package/src/providers/EndpointKeysDialog.svelte +27 -13
- package/src/providers/EndpointModels.svelte +311 -0
- package/src/providers/EndpointPanel.svelte +33 -258
- package/src/providers/ModelPickerDialog.svelte +59 -16
- package/src/providers/ModelPricing.svelte +125 -0
- package/src/providers/ModelSelect.svelte +22 -8
- package/src/providers/ModelSettingsDialog.svelte +61 -126
- package/src/providers/ProviderDialogs.svelte +120 -0
- package/src/providers/ProviderList.svelte +159 -0
- package/src/providers/ProvidersSettings.svelte +67 -207
- package/src/providers/RequestConfigDialog.svelte +40 -12
- package/src/providers/SecretInput.svelte +11 -3
- package/src/providers/draft.ts +39 -13
- package/src/providers/edit-session.ts +31 -7
- package/src/providers/reasoning.ts +18 -4
- package/src/providers/revision.svelte.ts +3 -1
- package/src/providers/test-support/providers-fixture.ts +120 -0
- package/src/search/SearchAdvancedTools.svelte +182 -0
- package/src/search/SearchProviderCard.svelte +195 -0
- package/src/search/SearchSettings.svelte +62 -265
- package/src/search/settings-flow.ts +8 -3
- package/src/skills/SkillAdd.svelte +51 -18
- package/src/skills/SkillAuthoring.svelte +69 -20
- package/src/skills/SkillEditor.svelte +13 -3
- package/src/skills/SkillPreviewDialog.svelte +19 -5
- package/src/skills/SkillsSettings.svelte +11 -4
- package/src/skills/test-support/skills-fixture.ts +139 -0
- package/src/tauri/index.ts +23 -7
- package/src/web/index.ts +90 -25
package/src/providers/draft.ts
CHANGED
|
@@ -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(
|
|
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(
|
|
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) =>
|
|
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(
|
|
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:
|
|
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'
|
|
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'
|
|
211
|
+
return modelType === 'chat'
|
|
212
|
+
? next
|
|
213
|
+
: { ...next, reasoning: null, nativeWebSearch: false, pricing: null }
|
|
195
214
|
}
|
|
196
215
|
|
|
197
|
-
export type ModelField =
|
|
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))
|
|
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) =>
|
|
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 =>
|
|
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 =
|
|
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
|
|
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 {
|
|
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({
|
|
21
|
+
new KitClientError({
|
|
22
|
+
code: 'cancelled',
|
|
23
|
+
message: 'Provider editing session cancelled',
|
|
24
|
+
details: {},
|
|
25
|
+
})
|
|
16
26
|
const identityError = () =>
|
|
17
|
-
new KitClientError({
|
|
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(
|
|
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(
|
|
130
|
+
const endpoint = snapshot(
|
|
131
|
+
state.readDraft().endpoints.find((item) => item.slug === slug) ?? null,
|
|
132
|
+
)
|
|
115
133
|
if (!endpoint)
|
|
116
|
-
throw new KitClientError({
|
|
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), {
|
|
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[] = [
|
|
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')
|
|
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 =>
|
|
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(
|
|
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 }>({
|
|
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>
|