dsh-exa-web-search 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.
- package/LICENSE +21 -0
- package/README.en.md +62 -0
- package/README.md +61 -2
- package/cordis.patch.yml +24 -0
- package/icon.png +0 -0
- package/lib/client.js +271 -0
- package/lib/client.locales.js +290 -0
- package/lib/index.js +619 -0
- package/locale/en.json +7 -0
- package/locale/zh.json +7 -0
- package/package.json +79 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# dsh-exa-web-search
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[中文说明](README.md)
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-exa-web-search)
|
|
8
|
+
[](https://www.npmjs.com/package/dsh-exa-web-search)
|
|
9
|
+
[](https://github.com/Windsland52/dsh-exa-web-search/actions/workflows/check.yml)
|
|
10
|
+
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
[](package.json)
|
|
13
|
+
[](package.json)
|
|
14
|
+
|
|
15
|
+
Wires Exa into DeepSeek Harness's built-in web search: keep using the `web_search` tool as usual, but the results now come from Exa. **No extra tools, no system-prompt changes.**
|
|
16
|
+
|
|
17
|
+
## Two channels, switched automatically
|
|
18
|
+
|
|
19
|
+
| Mode | When | Route |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `auto` (default) | a key is available (configured, or in an environment variable) | official Exa API |
|
|
22
|
+
| `auto` | no key | keyless public channel |
|
|
23
|
+
| `anonymous` | chosen manually | force the public channel; any key is ignored |
|
|
24
|
+
| `keyed` | chosen manually | force the official API; unavailable without a key |
|
|
25
|
+
|
|
26
|
+
The public channel (labelled "anonymous" in the UI) is a free shared service: at most 2 queries run at once, the rest queue and retry once, and heavy use may be rate-limited. Use a key on the official API when you need reliability.
|
|
27
|
+
|
|
28
|
+
A rejected key (401) reports `INVALID_CREDENTIAL` instead of switching to the public channel.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
**Desktop app**: open **Plugins** → **+ Add plugin** (top right) → enter the package name `dsh-exa-web-search` → install.
|
|
33
|
+
|
|
34
|
+
**Command line**:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dsh plugin add dsh-exa-web-search
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
No settings to change afterwards: web search goes through Exa by default. Uninstalling restores everything and leaves no trace in your config. To switch to another search source, change it in your own settings.
|
|
41
|
+
|
|
42
|
+
## Configuration (optional)
|
|
43
|
+
|
|
44
|
+
| Field | Default | Meaning |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `mode` | `auto` | `auto` / `anonymous` / `keyed`; behaviour in the table above |
|
|
47
|
+
| `apiKey` | — | the key typed in directly; when empty, read from the variable named by `apiKeyEnv` |
|
|
48
|
+
| `apiKeyEnv` | `EXA_API_KEY` | which environment variable holds the key |
|
|
49
|
+
| `baseURL` | `https://api.exa.ai` | official API address (keyed channel) |
|
|
50
|
+
| `mcpURL` | `https://mcp.exa.ai/mcp?tools=web_search_advanced_exa,web_search_exa` | public channel address (keyless) |
|
|
51
|
+
| `searchType` | `auto` | Exa search type (keyed only): `auto` / `keyword` / `neural` / `lite` / `deep-lite` / `deep` / `deep-reasoning` |
|
|
52
|
+
|
|
53
|
+
You normally don't edit config files by hand: open **Plugins**, find `dsh-exa-web-search`, and type the key into the **Exa API Key** card (masked, and never echoed back after saving). The card carries only the key; **open the plugin's row** for the rest — mode, endpoint addresses, and search type.
|
|
54
|
+
|
|
55
|
+
## How it differs from the official Exa plugin
|
|
56
|
+
|
|
57
|
+
The official Exa plugin only registers the search source; you still have to make it the default yourself.
|
|
58
|
+
|
|
59
|
+
This plugin registers as the `exa` search source itself (replacing the official implementation), so it is the default on install, is undone on uninstall, and leaves your other config alone:
|
|
60
|
+
|
|
61
|
+
- **Search even without a key**: with a key it uses the official API, without one the keyless public channel, and `auto` switches for you.
|
|
62
|
+
- **Zero intrusion**: no new tools, no system-prompt changes; keep using `web_search` as usual.
|
package/README.md
CHANGED
|
@@ -1,3 +1,62 @@
|
|
|
1
|
-
#
|
|
1
|
+
# dsh-exa-web-search
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
[English](README.en.md)
|
|
6
|
+
|
|
7
|
+
[](https://www.npmjs.com/package/dsh-exa-web-search)
|
|
8
|
+
[](https://www.npmjs.com/package/dsh-exa-web-search)
|
|
9
|
+
[](https://github.com/Windsland52/dsh-exa-web-search/actions/workflows/check.yml)
|
|
10
|
+
|
|
11
|
+
[](LICENSE)
|
|
12
|
+
[](package.json)
|
|
13
|
+
[](package.json)
|
|
14
|
+
|
|
15
|
+
把 Exa 接入 DeepSeek Harness 的内置联网搜索:`web_search` 工具照常用,只是搜索结果改由 Exa 提供。**没有额外工具,不改系统提示词。**
|
|
16
|
+
|
|
17
|
+
## 两个通道,自动切换
|
|
18
|
+
|
|
19
|
+
| 模式 | 什么情况 | 走哪条路 |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `auto`(默认) | 有 Key(填在配置里,或环境变量里) | Exa 官方 API |
|
|
22
|
+
| `auto` | 没有 Key | 免 Key 的公共通道 |
|
|
23
|
+
| `anonymous` | 手动选择 | 强制公共通道,忽略 Key |
|
|
24
|
+
| `keyed` | 手动选择 | 强制官方 API;没有 Key 时不可用 |
|
|
25
|
+
|
|
26
|
+
公共通道(界面里显示为「匿名」)是免费的共享服务,同时只跑 2 个查询,超出的排队等待并重试一次;量大时可能被限流。要求稳定就用 Key 走官方 API。
|
|
27
|
+
|
|
28
|
+
Key 被拒(401)时直接报 `INVALID_CREDENTIAL`,不会改用公共通道。
|
|
29
|
+
|
|
30
|
+
## 安装
|
|
31
|
+
|
|
32
|
+
**桌面版**:打开 **插件** 页 → 右上角 **+ 添加插件** → 输入包名 `dsh-exa-web-search` → 安装。
|
|
33
|
+
|
|
34
|
+
**命令行**:
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
dsh plugin add dsh-exa-web-search
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
装好后**不用改任何设置**:联网搜索默认就走 Exa。卸载时自动还原,不会在你的配置里留下痕迹。想换成别的搜索源,在你自己的设置里改即可。
|
|
41
|
+
|
|
42
|
+
## 配置(可选)
|
|
43
|
+
|
|
44
|
+
| 字段 | 默认 | 说明 |
|
|
45
|
+
| --- | --- | --- |
|
|
46
|
+
| `mode` | `auto` | `auto` / `anonymous` / `keyed`,行为见上表 |
|
|
47
|
+
| `apiKey` | — | 直接填入的 Key;留空则从 `apiKeyEnv` 指定的环境变量读取 |
|
|
48
|
+
| `apiKeyEnv` | `EXA_API_KEY` | 从哪个环境变量读 Key |
|
|
49
|
+
| `baseURL` | `https://api.exa.ai` | 官方 API 地址(带 Key 通道) |
|
|
50
|
+
| `mcpURL` | `https://mcp.exa.ai/mcp?tools=web_search_advanced_exa,web_search_exa` | 公共通道地址(免 Key) |
|
|
51
|
+
| `searchType` | `auto` | Exa 检索类型(仅带 Key 时生效):`auto` / `keyword` / `neural` / `lite` / `deep-lite` / `deep` / `deep-reasoning` |
|
|
52
|
+
|
|
53
|
+
平时不用手改配置文件:在**插件**页找到 `dsh-exa-web-search`,往 **Exa API Key** 卡片里填 Key 就行(界面自动打码,保存后不回显)。插件卡片上只放 Key;**点开这个插件的那一行**,行详情页还有模式、接口地址、检索类型等完整配置。
|
|
54
|
+
|
|
55
|
+
## 和官方 Exa 插件的区别
|
|
56
|
+
|
|
57
|
+
官方 Exa 插件只负责注册搜索源,装完还得自己把它设成默认。
|
|
58
|
+
|
|
59
|
+
本插件直接以 `exa` 作为搜索源(顶替官方实现),装上即为默认,卸载即还原,不动你其它配置:
|
|
60
|
+
|
|
61
|
+
- **没 Key 也能搜**:有 Key 走官方接口,没 Key 走免 Key 的公共通道,`auto` 自动切。
|
|
62
|
+
- **零打扰**:不加新工具、不改系统提示词,`web_search` 照常用。
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# dsh-exa-web-search bundle patch.
|
|
2
|
+
#
|
|
3
|
+
# Two patches in this layer:
|
|
4
|
+
#
|
|
5
|
+
# 1. Insert this plugin's own row (registers provider id `exa`).
|
|
6
|
+
# 2. Override the base `web` row to select it. A non-insert patch that names
|
|
7
|
+
# an existing row id replaces that row's fields — this is the same idiom
|
|
8
|
+
# official bundle layers use (`dsh-web-app` overrides base's `tool-web`
|
|
9
|
+
# and others). `config` is replaced wholesale, never merged; the `web`
|
|
10
|
+
# row only defines `searchProvider`/`fetchProvider`, so restating both is
|
|
11
|
+
# complete.
|
|
12
|
+
#
|
|
13
|
+
# Because selection lives in THIS bundle's patch — not a runtime write and
|
|
14
|
+
# not the user's own cordis.patch.yml — uninstalling the bundle removes the
|
|
15
|
+
# pin automatically. No `searchProvider`/`fetchProvider` residue is left
|
|
16
|
+
# behind in the user layer the way dsh-free-search's runtime takeover did.
|
|
17
|
+
- insert:
|
|
18
|
+
- id: exa-web-search
|
|
19
|
+
name: dsh-exa-web-search
|
|
20
|
+
|
|
21
|
+
- id: web
|
|
22
|
+
config:
|
|
23
|
+
searchProvider: exa
|
|
24
|
+
fetchProvider: http
|
package/icon.png
ADDED
|
Binary file
|
package/lib/client.js
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client half of dsh-exa-web-search: two Plugins-page configuration cards.
|
|
3
|
+
* The bundle page carries just the API key; the row page carries the whole
|
|
4
|
+
* deployment section — mode, URLs, retrieval knobs — so the unobtrusive page
|
|
5
|
+
* is the one that has everything. `apiKey` is a `role('secret')` config
|
|
6
|
+
* field: the card learns only whether one is set (from the describe mirror's
|
|
7
|
+
* sidecar) and writes it like any other field; the literal never rides back.
|
|
8
|
+
* The card dictionaries live in the on-demand `client.locales.js` chunk.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
window.__ModuleLoader__.load({
|
|
12
|
+
id: 'dsh-exa-web-search',
|
|
13
|
+
factory(require) {
|
|
14
|
+
const React = require('react');
|
|
15
|
+
const {
|
|
16
|
+
SettingsForm, SettingsSecretField, SettingsValueField,
|
|
17
|
+
SettingsFormModel, settingsTextField,
|
|
18
|
+
Menu, IconChevronDownOutlineRegular,
|
|
19
|
+
} = require('@deepseek-ai/dsh-client-ui-primitives');
|
|
20
|
+
|
|
21
|
+
const h = React.createElement;
|
|
22
|
+
const NS = 'exaWebSearch';
|
|
23
|
+
/** The row id the bundle patch declares; also its settings namespace. */
|
|
24
|
+
const ENTRY_NS = 'exa-web-search';
|
|
25
|
+
/** The package-level slot key the bundle page dispatches under. */
|
|
26
|
+
const BUNDLE_KEY = 'dsh-exa-web-search';
|
|
27
|
+
/** `<package>#<row>` key the Plugins page dispatches row config slots by. */
|
|
28
|
+
const SLOT_KEY = `${BUNDLE_KEY}#exa-web-search`;
|
|
29
|
+
/** Exa search reference, linked from the retrieval-mode hint. */
|
|
30
|
+
const DOCS_URL = 'https://exa.ai/docs/reference/search';
|
|
31
|
+
/** On-demand dictionaries chunk; see client.locales.js. */
|
|
32
|
+
const loadLocales = () => require.async('./client.locales.js');
|
|
33
|
+
|
|
34
|
+
installStyles();
|
|
35
|
+
|
|
36
|
+
/** One-time styling for the enum dropdown; the module system owns and removes this tag. */
|
|
37
|
+
function installStyles() {
|
|
38
|
+
if (typeof document === 'undefined' || document.querySelector('style[data-exa-styles="1"]') !== null) return;
|
|
39
|
+
const el = document.createElement('style');
|
|
40
|
+
el.setAttribute('data-exa-styles', '1');
|
|
41
|
+
el.textContent = [
|
|
42
|
+
'.exa-field{margin:14px 0;display:flex;flex-direction:column;gap:6px}',
|
|
43
|
+
'.exa-head{display:flex;align-items:center;justify-content:space-between;gap:8px}',
|
|
44
|
+
'.exa-label{font-size:13px;font-weight:600;line-height:1.3}',
|
|
45
|
+
'.exa-reset{background:none;border:none;padding:0;color:inherit;opacity:.65;font:inherit;font-size:12px;cursor:pointer}',
|
|
46
|
+
'.exa-reset:hover{opacity:1}',
|
|
47
|
+
'.exa-trigger{display:flex;align-items:center;justify-content:space-between;gap:8px;width:100%;box-sizing:border-box;padding:7px 10px;border:1px solid rgba(128,128,128,.35);border-radius:8px;background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer}',
|
|
48
|
+
'.exa-trigger:focus-visible{outline:2px solid rgba(80,140,255,.6);outline-offset:1px}',
|
|
49
|
+
'.exa-trigger:disabled{opacity:.5;cursor:default}',
|
|
50
|
+
'.exa-value{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
|
|
51
|
+
'.exa-hint{font-size:12px;line-height:1.4;opacity:.7;margin:0}',
|
|
52
|
+
'.exa-doclink{margin-left:6px;color:inherit;text-decoration:underline;text-underline-offset:2px}',
|
|
53
|
+
].join('\n');
|
|
54
|
+
document.head.appendChild(el);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether the row's `apiKey` currently holds a value, read from the shared
|
|
59
|
+
* describe mirror's secret sidecar (`path` + `set`), never from the section.
|
|
60
|
+
* @param view - the shared describe snapshot's namespaces list.
|
|
61
|
+
* @returns true when the effective section reports a stored key.
|
|
62
|
+
*/
|
|
63
|
+
function apiKeyConfigured(view) {
|
|
64
|
+
const entry = (view?.namespaces ?? []).find(item => item.ns === ENTRY_NS);
|
|
65
|
+
return (entry?.secrets ?? []).some(secret => secret.path.length === 1 && secret.path[0] === 'apiKey' && secret.set) === true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A field accepting one of a fixed set of strings; anything else is an
|
|
70
|
+
* invalid draft that blocks the save. An empty draft clears the override.
|
|
71
|
+
* @param field - section field name.
|
|
72
|
+
* @param values - the accepted values.
|
|
73
|
+
* @returns the field's conversion spec.
|
|
74
|
+
*/
|
|
75
|
+
function enumField(field, values) {
|
|
76
|
+
return {
|
|
77
|
+
field,
|
|
78
|
+
format: value => typeof value === 'string' ? value : '',
|
|
79
|
+
parse: (text) => {
|
|
80
|
+
const trimmed = text.trim();
|
|
81
|
+
if (trimmed === '') return { kind: 'clear' };
|
|
82
|
+
return values.includes(trimmed) ? { kind: 'set', value: trimmed } : undefined;
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Form frame copy, read from this page's dictionary. */
|
|
88
|
+
function formLabels(t) {
|
|
89
|
+
return {
|
|
90
|
+
unavailable: t('unavailable'), readOnly: t('readOnly'),
|
|
91
|
+
saveFailed: t('saveFailed'), save: t('save'), saving: t('saving'),
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One editable section field control bound to the shared form. */
|
|
96
|
+
function valueField(t, state, actions, field, id, label, hint, extra) {
|
|
97
|
+
return h(SettingsValueField, {
|
|
98
|
+
id, label, hint,
|
|
99
|
+
overriddenLabel: t('overridden'),
|
|
100
|
+
resetLabel: t('reset'),
|
|
101
|
+
invalidLabel: t('invalidNumber'),
|
|
102
|
+
disabled: !state.writable || state.saving,
|
|
103
|
+
...state[field],
|
|
104
|
+
...(extra === 'numeric' ? { numeric: true } : {}),
|
|
105
|
+
onEdit: (text) => { actions.edit(field, text) },
|
|
106
|
+
onReset: () => { actions.resetField(field) },
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* A dropdown for one enumerated section field, matching the Plugins page's
|
|
112
|
+
* own provider menu (a value button that opens a Menu). Picking a value
|
|
113
|
+
* stages it; the reset affordance clears the override back to the default.
|
|
114
|
+
* The hint explains the selected value and links its documentation.
|
|
115
|
+
*/
|
|
116
|
+
function enumControl(t, state, actions, field, id, label, choices, hints, link) {
|
|
117
|
+
const current = state[field];
|
|
118
|
+
const value = current.text === '' ? choices[0] : current.text;
|
|
119
|
+
return h(EnumDropdown, {
|
|
120
|
+
id, label, choices, value,
|
|
121
|
+
disabled: !state.writable || state.saving,
|
|
122
|
+
overridden: current.overridden === true,
|
|
123
|
+
invalid: current.invalid === true,
|
|
124
|
+
resetLabel: t('reset'),
|
|
125
|
+
invalidLabel: t('invalidEnum'),
|
|
126
|
+
hint: hints?.[value],
|
|
127
|
+
link,
|
|
128
|
+
onSelect: (next) => { actions.edit(field, next) },
|
|
129
|
+
onReset: () => { actions.resetField(field) },
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The enum dropdown body: a controlled Menu anchored to its value button. */
|
|
134
|
+
function EnumDropdown(props) {
|
|
135
|
+
const [open, setOpen] = React.useState(false);
|
|
136
|
+
const trigger = React.useRef(null);
|
|
137
|
+
const shown = open && !props.disabled;
|
|
138
|
+
const items = props.choices.map(choice => ({ id: choice, label: choice }));
|
|
139
|
+
const anchor = h('button', {
|
|
140
|
+
ref: trigger, id: props.id, type: 'button', className: 'exa-trigger', disabled: props.disabled,
|
|
141
|
+
'aria-haspopup': 'menu', 'aria-expanded': shown,
|
|
142
|
+
onClick: () => { setOpen(value => !value) },
|
|
143
|
+
},
|
|
144
|
+
h('span', { className: 'exa-value' }, props.value),
|
|
145
|
+
h(IconChevronDownOutlineRegular, { size: 12 }),
|
|
146
|
+
);
|
|
147
|
+
return h('div', { className: 'exa-field' },
|
|
148
|
+
h('div', { className: 'exa-head' },
|
|
149
|
+
h('label', { htmlFor: props.id, className: 'exa-label' }, props.label),
|
|
150
|
+
props.overridden
|
|
151
|
+
? h('button', { type: 'button', className: 'exa-reset', disabled: props.disabled, onClick: props.onReset }, props.resetLabel)
|
|
152
|
+
: null,
|
|
153
|
+
),
|
|
154
|
+
h(Menu, {
|
|
155
|
+
open: shown,
|
|
156
|
+
onClose: () => { setOpen(false) },
|
|
157
|
+
items,
|
|
158
|
+
selectedId: props.value,
|
|
159
|
+
onSelect: (next) => {
|
|
160
|
+
if (trigger.current) trigger.current.focus({ preventScroll: true });
|
|
161
|
+
setOpen(false);
|
|
162
|
+
if (next !== props.value) props.onSelect(next);
|
|
163
|
+
},
|
|
164
|
+
portal: true, autoFocus: true, minWidthFromAnchor: true, anchor,
|
|
165
|
+
}),
|
|
166
|
+
props.invalid ? h('p', { className: 'exa-hint', role: 'status' }, props.invalidLabel) : null,
|
|
167
|
+
(props.hint || props.link)
|
|
168
|
+
? h('p', { className: 'exa-hint' },
|
|
169
|
+
props.hint ? h('span', null, props.hint) : null,
|
|
170
|
+
props.link ? h('a', { className: 'exa-doclink', href: props.link.href, target: '_blank', rel: 'noopener noreferrer' }, props.link.label) : null,
|
|
171
|
+
)
|
|
172
|
+
: null,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The API-key control shared by both cards. */
|
|
177
|
+
function keyField(t, state, actions) {
|
|
178
|
+
return h(SettingsSecretField, {
|
|
179
|
+
id: 'plugin-config-exa-api-key',
|
|
180
|
+
label: t('apiKey'),
|
|
181
|
+
hint: t('apiKeyHint'),
|
|
182
|
+
text: state.apiKey.text,
|
|
183
|
+
disabled: !state.writable || state.saving,
|
|
184
|
+
configured: state.apiKeyConfigured,
|
|
185
|
+
stateLabel: state.apiKeyConfigured ? t('apiKeySet') : t('apiKeyUnset'),
|
|
186
|
+
onEdit: (text) => { actions.edit('apiKey', text) },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The bundle-page card: just the key. `summary` renders the one-liner the
|
|
192
|
+
* row detail shows where a row has no description; `page` renders here.
|
|
193
|
+
*/
|
|
194
|
+
function ExaKeyCard(props) {
|
|
195
|
+
const t = props.t;
|
|
196
|
+
if (props.view === 'summary') return t('summary');
|
|
197
|
+
const actions = props.exaActions;
|
|
198
|
+
const state = props.useExaForm(value => value);
|
|
199
|
+
return h(SettingsForm, { labels: formLabels(t), state, onSave: actions.save, onDiscard: actions.discard },
|
|
200
|
+
keyField(t, state, actions),
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The row-page card: the key plus every deployment-level section field. */
|
|
205
|
+
function ExaConfigCard(props) {
|
|
206
|
+
const t = props.t;
|
|
207
|
+
if (props.view === 'summary') return t('summary');
|
|
208
|
+
const actions = props.exaActions;
|
|
209
|
+
const state = props.useExaForm(value => value);
|
|
210
|
+
return h(SettingsForm, { labels: formLabels(t), state, onSave: actions.save, onDiscard: actions.discard },
|
|
211
|
+
keyField(t, state, actions),
|
|
212
|
+
enumControl(t, state, actions, 'mode', 'plugin-config-exa-mode', t('mode'),
|
|
213
|
+
['auto', 'anonymous', 'keyed'],
|
|
214
|
+
{ auto: t('modeAuto'), anonymous: t('modeAnonymous'), keyed: t('modeKeyed') }, null),
|
|
215
|
+
enumControl(t, state, actions, 'searchType', 'plugin-config-exa-search-type', t('searchType'),
|
|
216
|
+
['auto', 'keyword', 'neural', 'lite', 'deep-lite', 'deep', 'deep-reasoning'],
|
|
217
|
+
{ auto: t('searchTypeAuto'), keyword: t('searchTypeKeyword'), neural: t('searchTypeNeural'), lite: t('searchTypeLite'), 'deep-lite': t('searchTypeDeepLite'), deep: t('searchTypeDeep'), 'deep-reasoning': t('searchTypeDeepReasoning') },
|
|
218
|
+
{ href: DOCS_URL, label: t('docsLink') }),
|
|
219
|
+
valueField(t, state, actions, 'apiKeyEnv', 'plugin-config-exa-key-env', t('apiKeyEnv'), t('apiKeyEnvHint')),
|
|
220
|
+
valueField(t, state, actions, 'baseURL', 'plugin-config-exa-base-url', t('baseURL'), t('baseURLHint')),
|
|
221
|
+
valueField(t, state, actions, 'mcpURL', 'plugin-config-exa-mcp-url', t('mcpURL'), t('mcpURLHint')),
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
inject: ['slots', 'locale', 'configForms'],
|
|
227
|
+
async apply(ctx) {
|
|
228
|
+
const LOCALES = await loadLocales();
|
|
229
|
+
ctx.effect(() => ctx.locale.register(NS, LOCALES), 'exa-web-search: dictionaries');
|
|
230
|
+
const scope = ctx.configForms.get(ENTRY_NS);
|
|
231
|
+
const describe = ctx.configForms.describe();
|
|
232
|
+
const form = new SettingsFormModel(scope, [
|
|
233
|
+
enumField('mode', ['auto', 'anonymous', 'keyed']),
|
|
234
|
+
enumField('searchType', ['auto', 'keyword', 'neural', 'lite', 'deep-lite', 'deep', 'deep-reasoning']),
|
|
235
|
+
settingsTextField('apiKeyEnv'),
|
|
236
|
+
settingsTextField('baseURL'),
|
|
237
|
+
settingsTextField('mcpURL'),
|
|
238
|
+
], [{
|
|
239
|
+
field: 'apiKey',
|
|
240
|
+
write: (text) => scope.mutate([{ op: 'set', path: ['apiKey'], value: text }]),
|
|
241
|
+
}]);
|
|
242
|
+
ctx.effect(() => () => { form.dispose() }, 'exa-web-search: form model');
|
|
243
|
+
const project = () => ({
|
|
244
|
+
...form.shell(),
|
|
245
|
+
apiKey: form.field('apiKey'),
|
|
246
|
+
apiKeyConfigured: apiKeyConfigured(describe.getSnapshot().view),
|
|
247
|
+
mode: form.field('mode'),
|
|
248
|
+
searchType: form.field('searchType'),
|
|
249
|
+
apiKeyEnv: form.field('apiKeyEnv'),
|
|
250
|
+
baseURL: form.field('baseURL'),
|
|
251
|
+
mcpURL: form.field('mcpURL'),
|
|
252
|
+
});
|
|
253
|
+
const formStore = form.bind(project);
|
|
254
|
+
// Secret presence lives outside the section, so a key written or
|
|
255
|
+
// cleared elsewhere would never republish the form on its own.
|
|
256
|
+
ctx.effect(() => describe.subscribe(() => { formStore.set(project()) }), 'exa-web-search: secret presence');
|
|
257
|
+
const actions = form.actions();
|
|
258
|
+
ctx.effect(() => ctx.configForms.whileServed([ENTRY_NS], () => {
|
|
259
|
+
const inject = () => ({ hooks: { exaForm: formStore }, exaActions: actions });
|
|
260
|
+
const key = ctx.slots.inject('plugins.bundle.config', () => ctx.slots.register(
|
|
261
|
+
{ name: 'plugins.bundle.config', key: BUNDLE_KEY, locale: NS, inject },
|
|
262
|
+
ExaKeyCard));
|
|
263
|
+
const row = ctx.slots.inject('plugins.row.config', () => ctx.slots.register(
|
|
264
|
+
{ name: 'plugins.row.config', key: SLOT_KEY, locale: NS, inject },
|
|
265
|
+
ExaConfigCard));
|
|
266
|
+
return () => { key(); row(); };
|
|
267
|
+
}), 'exa-web-search: config cards');
|
|
268
|
+
},
|
|
269
|
+
};
|
|
270
|
+
},
|
|
271
|
+
});
|