Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
224 changes: 116 additions & 108 deletions packages/desktop-electron/resources/dsh/web-search/lib/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ window.__ModuleLoader__.load({
description: "Which engine answers the agent's searches.",
backend: "Search engine",
backendHint: "Applies to the next search; no restart needed.",
baseURL: "Base URL",
baseURLHint: "Optional Exa-compatible endpoint. Leave blank to use the built-in service. Requests go to /search.",
baseURLInvalid: "Enter an absolute HTTP or HTTPS URL without credentials, a query or a fragment.",
baseURLInsecure: "An endpoint receiving an API key must use HTTPS. HTTP is allowed only on loopback.",
apiKeyCustom: "Optional. Enter a key if your custom endpoint requires one.",
exa: "Exa",
deepseek: "DeepSeek",
apiKey: "API key",
Expand All @@ -129,6 +134,11 @@ window.__ModuleLoader__.load({
description: "agent 搜索网页时用哪个引擎。",
backend: "搜索源",
backendHint: "下一次搜索即生效,无需重启。",
baseURL: "Base URL",
baseURLHint: "可选的 Exa 兼容服务地址,留空使用内置服务。请求会发到 /search。",
baseURLInvalid: "请输入完整的 HTTP 或 HTTPS 地址,不含用户名、密码、查询参数或片段。",
baseURLInsecure: "接收 API Key 的服务必须使用 HTTPS;只有本机回环地址可以使用 HTTP。",
apiKeyCustom: "可选;自定义服务需要密钥时再填写。",
exa: "Exa",
deepseek: "DeepSeek",
apiKey: "API Key",
Expand All @@ -146,29 +156,14 @@ window.__ModuleLoader__.load({
}

/**
* The card's staged form over the `pawwork-web-search` section.
*
* Two controls, staged together so one save covers both: the backend, which
* lives in the section, and the key for whichever backend is selected, which
* does not — a secret never rides a settings response, so the card learns only
* whether one is configured and writes it through the credentials domain.
*
* One key draft, and it carries the engine it was typed under. An API key has
* no meaning apart from the engine it authenticates, so a staged key the card
* is not showing must never be written: type an Exa key, switch the engine,
* save, and a secret the user believes they abandoned reaches a second
* vendor. `stagedKey` answers with the draft only while its engine is the one
* on screen, which keeps what a save can write equal to what the card
* displays.
*
* For the same reason `pendingWrites` is the single description of the work a
* save has to do: the button's enabled state and the writes themselves read
* it, so "there is something to save" cannot mean one thing to the user and
* another to the code.
* Staged settings and credentials for the selected search engine.
* A key draft remains tied to the engine it was typed under, so switching
* engines hides it and excludes it from Save without discarding it.
*/
class CardController {
/** `{ backend }`, `{ reset: true }`, or undefined — never two of them. */
backendDraft = undefined
baseURLDraft = undefined
keyDraft = undefined
saving = false
/** The field a save did not land, with the deployment's own words when it had any: `{ field, message }`, or undefined. */
Expand All @@ -180,8 +175,10 @@ window.__ModuleLoader__.load({
/**
* @param scope - the bound settings scope for this card's namespace.
* @param credentials - the credential face for the reference the section names.
* @param t - the section translator for form feedback.
*/
constructor(scope, credentials) {
constructor(scope, credentials, t) {
this.t = t
this.scope = scope
this.credentials = credentials
this.store = createSnapshotStore(this.projection())
Expand Down Expand Up @@ -232,26 +229,11 @@ window.__ModuleLoader__.load({
return this.keyDraft?.backend === this.backend() ? this.keyDraft : undefined
}

/**
* Describe every write a save would perform, in the order it performs them.
*
* The key goes first. The two stores cannot commit together — the section
* and the credential are separate authorities — so the order decides what a
* half-finished save leaves behind, and a key written before the engine
* moves means the engine never runs a moment without the credential it was
* chosen for. Should the engine write then fail, the key is still filed
* under the vendor the user was looking at when they typed it.
*
* Its reference comes from the draft's own engine rather than the current
* selection: deriving the destination from anything that can move between
* staging and writing is exactly what sent one vendor's secret to another.
*
* What counts as a write is decided once, here, and "staged" is not it: a
* key that is only whitespace and an engine already in force are both
* nothing to save, and the Save button agrees because it asks this rather
* than deciding for itself.
* @returns the staged writes, empty when there is nothing to save.
*/
baseURL() {
return this.baseURLDraft ?? this.scope.getSnapshot().value?.exaBaseURL ?? ""
}

/** @returns the staged writes shared by Save and its enabled state. */
pendingWrites() {
const staged = this.stagedKey()
const writes = []
Expand All @@ -265,14 +247,19 @@ window.__ModuleLoader__.load({
this.backendDraft !== undefined &&
this.backendDraft.backend !== this.scope.getSnapshot().value?.backend
) {
writes.push({ field: "backend", backend: this.backendDraft.backend })
writes.push({ field: "backend", value: this.backendDraft.backend })
}
if (this.backend() === "exa" && this.baseURLDraft !== undefined &&
this.baseURLDraft.trim() !== (this.scope.getSnapshot().value?.exaBaseURL ?? "")) {
writes.push({ field: "exaBaseURL", value: this.baseURLDraft.trim() })
}
return writes
}

/** @returns whether any control holds a draft, whether or not it would write. */
staged() {
return this.backendDraft !== undefined || this.stagedKey() !== undefined
return this.backendDraft !== undefined || this.stagedKey() !== undefined ||
(this.backend() === "exa" && this.baseURLDraft !== undefined)
}

/** @returns whether a save would write anything. */
Expand All @@ -295,11 +282,12 @@ window.__ModuleLoader__.load({
saving: this.saving,
failure: this.failure,
backend: this.backend(),
baseURL: this.baseURL(),
// Offered while there is an override to remove and removing it is not
// already staged — the control is how you stage it, so leaving it up
// afterwards would invite pressing it twice for one effect.
backendOverridden: Object.hasOwn(user ?? {}, "backend") && this.backendDraft?.reset !== true,
keyless: spec.keyless,
keyless: spec.keyless && this.baseURL().trim() === "",
keyText: this.stagedKey()?.text ?? "",
keyConfigured: this.held.configured,
keyWritable: this.held.writable,
Expand Down Expand Up @@ -366,6 +354,12 @@ window.__ModuleLoader__.load({
if (this.failure?.field === "key") this.failure = undefined
this.publish()
},
editBaseURL: (text) => {
if (this.saving) return
this.baseURLDraft = text
if (this.failure?.field === "exaBaseURL") this.failure = undefined
this.publish()
},
// Stages the reset; it does not perform it. Restoring the default is
// not a different kind of act from choosing an engine, so it stages
// like one and `save` stays the only path to the deployment — which is
Expand All @@ -385,6 +379,7 @@ window.__ModuleLoader__.load({
discard: () => {
if (this.saving) return
this.backendDraft = undefined
this.baseURLDraft = undefined
this.keyDraft = undefined
this.failure = undefined
this.publish()
Expand All @@ -393,80 +388,77 @@ window.__ModuleLoader__.load({
}
}

/**
* Run one settings write and report whether it completed without throwing.
*
* The scope reports that a write did not land, never why, so a throw is the
* only signal that the call itself could not be made.
* @param field - the field this write belongs to, for the failure report.
* @param write - performs the write; its resolved value is not inspected.
* @returns whether the write completed without throwing.
*/
async commit(field, write) {
try {
await write()
return true
} catch (failure) {
console.error(`[pawwork-web-search] the ${field} write could not be made:`, failure)
this.failure = { field }
return false
/** Localized form feedback; the Host still checks every search destination. */
endpointFailure() {
if (this.backend() !== "exa" || this.baseURL().trim() === "") return undefined
let url
try { url = new URL(this.baseURL().trim()) } catch { return "baseURLInvalid" }
if (!["http:", "https:"].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
return "baseURLInvalid"
}
const loopback = url.hostname === "localhost" || url.hostname === "[::1]" || /^127(?:\.\d{1,3}){3}$/.test(url.hostname)
if (url.protocol === "http:" && !loopback && (this.held.configured || this.stagedKey()?.text.trim())) {
return "baseURLInsecure"
}
}

/**
* The card's only path to the deployment.
*
* Every control stages; this writes. That is why `saving` alone is enough
* to serialize the card — there is no second writer to serialize against.
*
* The Host decides whether a value landed, so the outcome is read back
* rather than predicted, and a field that did not land keeps its draft.
* The two stores cannot commit together, so the writes are reported
* independently: rolling the engine back because the key failed would be a
* third write that can fail too, contradicting a Host that already holds
* the new engine.
*/
/** Store secrets separately, then activate the settings in one mutation. */
async save() {
if (this.saving) return
const writes = this.pendingWrites()
if (writes.length === 0) return
const problem = this.endpointFailure()
if (problem !== undefined) {
this.failure = { field: "exaBaseURL", message: this.t(problem) }
this.publish()
return
}
const revision = this.scope.getSnapshot().revision
const key = writes.find((write) => write.field === "key")
const exaKey = key !== undefined && this.backend() === "exa"
// Never overwrite a reference an older search may still be resolving.
if (exaKey) key.ref = `PAWWORK_EXA_${crypto.randomUUID().replaceAll("-", "_")}`
const settings = writes.filter((write) => write.field !== "key")
if (exaKey) {
settings.push({ field: "exaApiKeyEnv", value: key.ref })
if (!settings.some((write) => write.field === "exaBaseURL")) {
settings.push({ field: "exaBaseURL", value: this.baseURL().trim() })
}
}
this.saving = true
this.failure = undefined
this.publish()
try {
for (const write of writes) {
if (write.field === "key") {
// The deployment's answer decides, and `configured` cannot stand in
// for it: that flag is already true whenever a key was set before,
// so a rejected rotation would read as a successful one.
const failed = await this.credentials.store(write.ref, write.value)
if (failed !== undefined) {
// The key goes first so the engine never runs a moment without
// the credential it was chosen for — which is exactly what
// carrying on would produce. Any pending engine write selects
// the engine this key was typed under, so it stays staged and
// Save retries both rather than moving the user onto an engine
// whose key the deployment just refused.
this.failure = { field: "key", ...failed }
break
}
this.keyDraft = undefined
continue
if (key !== undefined) {
const failed = await this.credentials.store(key.ref, key.value)
if (failed !== undefined) {
this.failure = { field: "key", ...failed }
return
}
if (!exaKey) this.keyDraft = undefined
}
if (settings.length > 0) {
let made = false
try {
await this.scope.mutate(settings.map((write) => write.reset === true
? { op: "unset", path: [write.field] }
: { op: "set", path: [write.field], value: write.value }), revision)
made = true
} catch (failure) {
console.error("[pawwork-web-search] the settings mutation could not be made:", failure)
}
const user = this.scope.getSnapshot().user ?? {}
const missing = settings.find((write) => write.reset === true
? Object.hasOwn(user, write.field)
: user[write.field] !== write.value)
if (made && missing === undefined) {
if (settings.some((write) => write.field === "backend")) this.backendDraft = undefined
if (settings.some((write) => write.field === "exaBaseURL")) this.baseURLDraft = undefined
if (exaKey) this.keyDraft = undefined
} else {
const field = (missing ?? settings[0]).field
this.failure = { field: field === "exaApiKeyEnv" ? "key" : field }
}
// Read back either way. A Host that accepts the call without moving
// the value is the case this exists for, and a reset that silently
// did not land is the same lie as an engine that silently did not.
const made =
write.reset === true
? await this.commit("backend", () => this.scope.unset("backend"))
: await this.commit("backend", () => this.scope.set("backend", write.backend))
const wrote =
made &&
(write.reset === true
? !Object.hasOwn(this.scope.getSnapshot().user ?? {}, "backend")
: this.scope.getSnapshot().user?.backend === write.backend)
if (wrote) this.backendDraft = undefined
else if (made) this.failure = { field: "backend" }
}
await this.readCredential()
} finally {
Expand Down Expand Up @@ -614,6 +606,21 @@ window.__ModuleLoader__.load({
label: t("backend"),
labelledControl: true,
}),
state.backend === "exa" ? h(Field, {
control: h("input", {
autoComplete: "off",
className: "pawwork-websearch-input",
disabled: disabled || state.saving,
id: "pawwork-websearch-base-url",
onChange: (event) => props.editBaseURL(event.target.value),
placeholder: "https://api.exa.ai",
type: "url",
value: state.baseURL,
}),
hint: t("baseURLHint"),
id: "pawwork-websearch-base-url",
label: t("baseURL"),
}) : null,
h(Field, {
badges: keyBadge,
control: h("input", {
Expand All @@ -628,7 +635,8 @@ window.__ModuleLoader__.load({
}),
hint: state.keyConfigured
? t("apiKeyHint")
: state.keyless ? t("apiKeyUnsetFree") : t("apiKeyUnsetRequired"),
: state.backend === "exa" && !state.keyless ? t("apiKeyCustom")
: state.keyless ? t("apiKeyUnsetFree") : t("apiKeyUnsetRequired"),
id: "pawwork-websearch-key",
label: t("apiKey"),
}),
Expand Down Expand Up @@ -658,12 +666,12 @@ window.__ModuleLoader__.load({

function apply(ctx) {
ctx.effect(() => ctx.locale.register(NS, { en, zh }), "pawwork-web-search: card dictionaries")
const card = new CardController(ctx.configForms.get(NS), credentialFace(ctx))
const t = ctx.locale.bind(NS)
const card = new CardController(ctx.configForms.get(NS), credentialFace(ctx), t)
ctx.effect(
() => ctx.remote.$on("credentials/reference-updated", (ref) => card.refreshCredential(ref)),
"pawwork-web-search: credential invalidations",
)
const t = ctx.locale.bind(NS)
ctx.effect(() => ctx.configForms.whileServed([NS], () => ctx.slots.inject("plugins.item", () => ctx.slots.register({
name: "plugins.item",
id: NS,
Expand Down
20 changes: 18 additions & 2 deletions packages/desktop-electron/resources/dsh/web-search/lib/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const DEEPSEEK_KEY_MESSAGE =

export const Config = z.object({
backend: z.union(['exa', 'deepseek']).default('exa').volatile(),
exaBaseURL: z.string().default('').volatile(),
exaApiKeyEnv: z.string().role('credential-ref').default(DEFAULT_EXA_API_KEY_ENV).volatile(),
deepseekApiKeyEnv: z.string().role('credential-ref').default(DEFAULT_DEEPSEEK_API_KEY_ENV).volatile(),
});
Expand Down Expand Up @@ -99,10 +100,24 @@ async function resolveKey(ctx, ref) {
*/
async function searchExa(ctx, config, request, signal) {
const apiKey = await resolveKey(ctx, resolveRef(config.exaApiKeyEnv, DEFAULT_EXA_API_KEY_ENV));
if (apiKey.length > 0) {
const baseURL = (config.exaBaseURL ?? '').trim().replace(/\/+$/, '');
if (baseURL.length > 0) {
let url;
try { url = new URL(baseURL); } catch {
throw new WebError('The Exa Base URL must be an absolute HTTP or HTTPS URL.', 'WEB_PROVIDER_ERROR');
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash) {
throw new WebError('The Exa Base URL must be an HTTP or HTTPS URL without credentials, a query or a fragment.', 'WEB_PROVIDER_ERROR');
}
const loopback = url.hostname === 'localhost' || url.hostname === '[::1]' || /^127(?:\.\d{1,3}){3}$/.test(url.hostname);
if (apiKey.length > 0 && url.protocol === 'http:' && !loopback) {
throw new WebError('An Exa endpoint receiving an API key must use HTTPS (HTTP is allowed only on loopback).', 'WEB_PROVIDER_ERROR');
}
}
if (baseURL.length > 0 || apiKey.length > 0) {
return new ExaSearchProvider({
apiKey,
baseURL: 'https://api.exa.ai',
baseURL: baseURL || 'https://api.exa.ai',
Comment thread
Astro-Han marked this conversation as resolved.
searchType: 'auto',
highlightsPerResult: 1,
}).search(request, signal);
Expand Down Expand Up @@ -200,6 +215,7 @@ export class PawWorkSearchProvider {
export function apply(ctx, config) {
ctx.web.registerSearchProvider(new PawWorkSearchProvider(ctx, () => ({
backend: config.backend.get(),
exaBaseURL: config.exaBaseURL.get(),
exaApiKeyEnv: config.exaApiKeyEnv.get(),
deepseekApiKeyEnv: config.deepseekApiKeyEnv.get(),
})));
Expand Down
Loading
Loading