@iflow-ai/iflow-plugin 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.md +209 -0
- package/dist/index.js +81 -0
- package/dist/src/client.js +181 -0
- package/dist/src/config.js +55 -0
- package/dist/src/errors.js +69 -0
- package/dist/src/normalize.js +80 -0
- package/dist/src/tools.js +165 -0
- package/dist/src/web-search-provider.js +53 -0
- package/openclaw.plugin.json +69 -0
- package/package.json +69 -0
- package/skills/iflow-search/SKILL.md +138 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 iflow-ai
|
|
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
|
|
13
|
+
all 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
|
|
21
|
+
THE SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
# @iflow-ai/iflow-plugin
|
|
2
|
+
|
|
3
|
+
[iFlow Search (心流搜索)](https://platform.iflow.cn) plugin for [OpenClaw](https://docs.openclaw.ai).
|
|
4
|
+
|
|
5
|
+
Three agent tools backed by iFlow's `/api/search/*` endpoints:
|
|
6
|
+
|
|
7
|
+
| Tool | Purpose |
|
|
8
|
+
|---|---|
|
|
9
|
+
| `iflow_web_search` | Public web search with structured results (title / url / snippet / position / date). |
|
|
10
|
+
| `iflow_image_search` | Image search with source-page attribution. |
|
|
11
|
+
| `iflow_web_fetch` | Read the clean content of a single web page. |
|
|
12
|
+
|
|
13
|
+
The plugin also registers `iflow` as a `web_search` provider so users can route
|
|
14
|
+
the managed `web_search` tool through iFlow with one config line — this is
|
|
15
|
+
**best-effort** and activates only if the running OpenClaw exposes the
|
|
16
|
+
provider-registration API; otherwise the plugin runs in tools-only mode.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
openclaw plugins install @iflow-ai/iflow-plugin
|
|
22
|
+
openclaw gateway restart
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Or from a local checkout (during development):
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
git clone <repo> ~/.openclaw/extensions/iflow-plugin
|
|
29
|
+
cd ~/.openclaw/extensions/iflow-plugin
|
|
30
|
+
npm install --omit=dev
|
|
31
|
+
openclaw gateway restart
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Configuration
|
|
35
|
+
|
|
36
|
+
### 1. API key
|
|
37
|
+
|
|
38
|
+
Get one from the [iFlow Open Platform](https://platform.iflow.cn).
|
|
39
|
+
|
|
40
|
+
Either set the env var:
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
export IFLOW_API_KEY=sk-...
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Or put it in `~/.openclaw/openclaw.json`:
|
|
47
|
+
|
|
48
|
+
```json5
|
|
49
|
+
{
|
|
50
|
+
plugins: {
|
|
51
|
+
entries: {
|
|
52
|
+
iflow: {
|
|
53
|
+
enabled: true,
|
|
54
|
+
config: {
|
|
55
|
+
webSearch: {
|
|
56
|
+
apiKey: "sk-...", // sensitive; can also be a SecretRef object
|
|
57
|
+
baseUrl: "https://platform.iflow.cn", // optional override
|
|
58
|
+
timeoutSeconds: 30, // optional, default 30
|
|
59
|
+
cacheTtlMinutes: 15 // optional, default 15; set 0 to disable
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
### 2. Route managed `web_search` through iFlow (optional)
|
|
69
|
+
|
|
70
|
+
```json5
|
|
71
|
+
{
|
|
72
|
+
tools: {
|
|
73
|
+
web: {
|
|
74
|
+
search: {
|
|
75
|
+
provider: "iflow"
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
If your OpenClaw runtime does not support third-party `web_search` providers,
|
|
83
|
+
this line has no effect — the explicit tools below still work.
|
|
84
|
+
|
|
85
|
+
## Tools
|
|
86
|
+
|
|
87
|
+
### `iflow_web_search`
|
|
88
|
+
|
|
89
|
+
| Param | Type | Required | Default | Notes |
|
|
90
|
+
|---|---|---|---|---|
|
|
91
|
+
| `query` | string | yes | — | Sent as iFlow `keywords` |
|
|
92
|
+
| `count` | number | no | 10 | Clamped to `[1, 10]`. Sent as iFlow `num` |
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
|
|
96
|
+
```json
|
|
97
|
+
{
|
|
98
|
+
"query": "...",
|
|
99
|
+
"provider": "iflow",
|
|
100
|
+
"count": 3,
|
|
101
|
+
"tookMs": 1383,
|
|
102
|
+
"results": [
|
|
103
|
+
{ "title": "...", "url": "...", "snippet": "...", "position": 1, "date": null }
|
|
104
|
+
]
|
|
105
|
+
}
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### `iflow_image_search`
|
|
109
|
+
|
|
110
|
+
| Param | Type | Required | Default | Notes |
|
|
111
|
+
|---|---|---|---|---|
|
|
112
|
+
| `query` | string | yes | — | Sent as iFlow `keywords` |
|
|
113
|
+
| `count` | number | no | 10 | Clamped to `[1, 20]`. Sent as iFlow `num` |
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"query": "...",
|
|
120
|
+
"provider": "iflow",
|
|
121
|
+
"count": 3,
|
|
122
|
+
"tookMs": 1715,
|
|
123
|
+
"images": [
|
|
124
|
+
{ "url": "...jpg", "title": "...", "sourceUrl": "..." }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### `iflow_web_fetch`
|
|
130
|
+
|
|
131
|
+
| Param | Type | Required | Notes |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| `url` | string | yes | Must be an http(s) URL |
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
|
|
137
|
+
```json
|
|
138
|
+
{
|
|
139
|
+
"title": "...",
|
|
140
|
+
"url": "...",
|
|
141
|
+
"content": "...",
|
|
142
|
+
"fromCache": true,
|
|
143
|
+
"provider": "iflow",
|
|
144
|
+
"tookMs": 335
|
|
145
|
+
}
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
## Local development
|
|
149
|
+
|
|
150
|
+
```bash
|
|
151
|
+
npm install
|
|
152
|
+
npm run typecheck # tsc --noEmit
|
|
153
|
+
npm test # vitest (uses mocked fetch, no live API calls)
|
|
154
|
+
|
|
155
|
+
# Optional: live probe of all three endpoints.
|
|
156
|
+
# Reads IFLOW_API_KEY from the env. Does NOT log the key.
|
|
157
|
+
IFLOW_API_KEY=sk-... npm run smoke
|
|
158
|
+
IFLOW_API_KEY=sk-... npm run smoke web # web only
|
|
159
|
+
IFLOW_API_KEY=sk-... npm run smoke image
|
|
160
|
+
IFLOW_API_KEY=sk-... npm run smoke fetch
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
To exercise the plugin inside a real OpenClaw gateway:
|
|
164
|
+
|
|
165
|
+
```bash
|
|
166
|
+
# 1. Pack and install from the local build
|
|
167
|
+
npm pack
|
|
168
|
+
openclaw plugins install npm-pack:./iflow-ai-iflow-plugin-0.1.0.tgz
|
|
169
|
+
openclaw gateway restart
|
|
170
|
+
|
|
171
|
+
# 2. Verify what was registered
|
|
172
|
+
openclaw plugins inspect iflow --runtime --json
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
## Troubleshooting
|
|
176
|
+
|
|
177
|
+
| Symptom | Likely cause | Fix |
|
|
178
|
+
|---|---|---|
|
|
179
|
+
| Every call returns `missing_api_key` | Env var not loaded by the gateway | Restart gateway after exporting `IFLOW_API_KEY`, or put the key in `openclaw.json` |
|
|
180
|
+
| `api_error` with `status: 401` | Wrong / revoked key | Rotate the key on the iFlow platform |
|
|
181
|
+
| `api_error` with `status: 403` | Account not entitled to this endpoint | Check the iFlow plan / contact iFlow support |
|
|
182
|
+
| `api_error` with `status: 429` | Rate limit | Lower request rate; the plugin caches identical queries for `cacheTtlMinutes` |
|
|
183
|
+
| `network_timeout` | Slow upstream / cold cache | Increase `timeoutSeconds`; retry |
|
|
184
|
+
| `api_business_error` with code `4xxx` | iFlow returned `success: false` | Inspect `message` / `code` in the error payload |
|
|
185
|
+
| Results have a `url` field on iFlow but show as empty | iFlow renamed `link` → `url` (or similar) | Update `src/normalize.ts` mapping; tests in `src/__tests__/normalize.test.ts` show where |
|
|
186
|
+
| Plugin loads but `web_search` doesn't route through iFlow | OpenClaw runtime doesn't expose `registerWebSearchProvider` (info log will say so) | Use the explicit `iflow_web_search` tool instead, or upgrade OpenClaw |
|
|
187
|
+
|
|
188
|
+
## Compatibility
|
|
189
|
+
|
|
190
|
+
- `peerDependency`: `openclaw >= 2025.0.0` (optional)
|
|
191
|
+
- **Tools mode** (3 explicit tools): always on.
|
|
192
|
+
- **Provider mode** (managed `web_search` routing): activates only when the
|
|
193
|
+
runtime exposes `api.registerWebSearchProvider` AND the SDK subpath
|
|
194
|
+
`openclaw/plugin-sdk/provider-web-search-config-contract` is importable.
|
|
195
|
+
Failure is logged at info level; the plugin keeps working in tools mode.
|
|
196
|
+
|
|
197
|
+
## Acknowledgements
|
|
198
|
+
|
|
199
|
+
This plugin includes an OpenClaw-specific skill definition under
|
|
200
|
+
`skills/iflow-search/SKILL.md`. It is adapted from the official iFlow skill
|
|
201
|
+
catalog, but uses OpenClaw tool names and normalized parameters / return
|
|
202
|
+
fields instead of the upstream shell-script interface.
|
|
203
|
+
|
|
204
|
+
Official iFlow skill reference:
|
|
205
|
+
https://github.com/iflow-ai/iflow-skills/tree/main/skills/iflow-search
|
|
206
|
+
|
|
207
|
+
## License
|
|
208
|
+
|
|
209
|
+
MIT
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @iflow-ai/iflow-plugin — iFlow Search plugin for OpenClaw.
|
|
3
|
+
*
|
|
4
|
+
* Capability tiers:
|
|
5
|
+
* - Tools mode (stable): iflow_web_search, iflow_image_search, iflow_web_fetch
|
|
6
|
+
* registered via api.registerTool.
|
|
7
|
+
* - Provider mode (best-effort): iflow registered as a web_search provider
|
|
8
|
+
* via api.registerWebSearchProvider, only when the
|
|
9
|
+
* running OpenClaw runtime exposes that API AND the
|
|
10
|
+
* openclaw/plugin-sdk/provider-web-search-config-contract
|
|
11
|
+
* subpath is importable.
|
|
12
|
+
*
|
|
13
|
+
* Both modes share the same HTTP client and normalize layer.
|
|
14
|
+
*/
|
|
15
|
+
import { resolveConfig, redactApiKey } from "./src/config.js";
|
|
16
|
+
import { createIflowClient } from "./src/client.js";
|
|
17
|
+
import { createImageSearchTool, createWebFetchTool, createWebSearchTool, } from "./src/tools.js";
|
|
18
|
+
import { createIflowWebSearchProvider } from "./src/web-search-provider.js";
|
|
19
|
+
const PLUGIN_ID = "iflow";
|
|
20
|
+
const iflowPlugin = {
|
|
21
|
+
id: PLUGIN_ID,
|
|
22
|
+
name: "iFlow Search",
|
|
23
|
+
description: "iFlow Search (心流搜索) — web search, image search, and web content fetch tools for OpenClaw agents.",
|
|
24
|
+
kind: "tools",
|
|
25
|
+
register(api) {
|
|
26
|
+
const cfg = resolveConfig(api.pluginConfig);
|
|
27
|
+
if (!cfg.apiKey) {
|
|
28
|
+
api.logger.warn("iflow: no API key found. Set IFLOW_API_KEY or plugins.entries.iflow.config.webSearch.apiKey. Plugin idle.");
|
|
29
|
+
api.registerService({
|
|
30
|
+
id: PLUGIN_ID,
|
|
31
|
+
start: () => api.logger.info("iflow: idle (no API key)"),
|
|
32
|
+
stop: () => { },
|
|
33
|
+
});
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
const client = createIflowClient({ config: cfg, logger: api.logger });
|
|
37
|
+
api.logger.info(`iflow: initialized (baseUrl=${cfg.baseUrl}, timeout=${Math.round(cfg.timeoutMs / 1000)}s, cacheTtl=${Math.round(cfg.cacheTtlMs / 60_000)}min, apiKey=${redactApiKey(cfg.apiKey)})`);
|
|
38
|
+
// Tier 1 — tools mode (stable baseline)
|
|
39
|
+
registerTools(api, client);
|
|
40
|
+
// Tier 2 — provider mode (best-effort)
|
|
41
|
+
void tryRegisterProvider(api, client).catch((err) => {
|
|
42
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
43
|
+
api.logger.info(`iflow: provider mode unavailable, staying in tools-only mode (${msg})`);
|
|
44
|
+
});
|
|
45
|
+
api.registerService({
|
|
46
|
+
id: PLUGIN_ID,
|
|
47
|
+
start: () => api.logger.info("iflow: service started"),
|
|
48
|
+
stop: () => {
|
|
49
|
+
client.clearCache();
|
|
50
|
+
api.logger.info("iflow: service stopped, cache cleared");
|
|
51
|
+
},
|
|
52
|
+
});
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
function registerTools(api, client) {
|
|
56
|
+
api.registerTool(createWebSearchTool(client), { source: PLUGIN_ID });
|
|
57
|
+
api.registerTool(createImageSearchTool(client), { source: PLUGIN_ID });
|
|
58
|
+
api.registerTool(createWebFetchTool(client), { source: PLUGIN_ID });
|
|
59
|
+
}
|
|
60
|
+
async function tryRegisterProvider(api, client) {
|
|
61
|
+
if (typeof api.registerWebSearchProvider !== "function") {
|
|
62
|
+
throw new Error("registerWebSearchProvider not exposed by this OpenClaw runtime");
|
|
63
|
+
}
|
|
64
|
+
// Dynamic import: a missing subpath becomes a caught promise rejection,
|
|
65
|
+
// not a module-load error for this plugin.
|
|
66
|
+
const sdk = (await import(
|
|
67
|
+
/* @vite-ignore */ "openclaw/plugin-sdk/provider-web-search-config-contract").catch((err) => {
|
|
68
|
+
throw new Error(`openclaw/plugin-sdk/provider-web-search-config-contract not importable: ${err instanceof Error ? err.message : String(err)}`);
|
|
69
|
+
}));
|
|
70
|
+
const factory = sdk.createWebSearchProviderContractFields;
|
|
71
|
+
if (typeof factory !== "function") {
|
|
72
|
+
throw new Error("createWebSearchProviderContractFields not exported by the SDK subpath");
|
|
73
|
+
}
|
|
74
|
+
const provider = createIflowWebSearchProvider({
|
|
75
|
+
client,
|
|
76
|
+
createContractFields: factory,
|
|
77
|
+
});
|
|
78
|
+
api.registerWebSearchProvider(provider);
|
|
79
|
+
api.logger.info("iflow: registered as web_search provider (best-effort)");
|
|
80
|
+
}
|
|
81
|
+
export default iflowPlugin;
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP client for the iFlow Search API.
|
|
3
|
+
*
|
|
4
|
+
* Responsibilities:
|
|
5
|
+
* - Build Bearer-authenticated POST requests
|
|
6
|
+
* - Enforce per-request timeout via AbortController
|
|
7
|
+
* - Classify failures into the IflowError taxonomy
|
|
8
|
+
* - Maintain a small in-memory cache (15-minute default) keyed by call params
|
|
9
|
+
* - Never log or echo the API key
|
|
10
|
+
*/
|
|
11
|
+
import { apiBusinessError, apiHttpError, isIflowError, networkError, networkTimeoutError, } from "./errors.js";
|
|
12
|
+
const MAX_CACHE_ENTRIES = 100;
|
|
13
|
+
const ENDPOINTS = {
|
|
14
|
+
webSearch: "/api/search/webSearch",
|
|
15
|
+
imageSearch: "/api/search/imageSearch",
|
|
16
|
+
webFetch: "/api/search/webFetch",
|
|
17
|
+
};
|
|
18
|
+
export function createIflowClient(opts) {
|
|
19
|
+
const { config, logger } = opts;
|
|
20
|
+
const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
|
|
21
|
+
const now = opts.now ?? Date.now;
|
|
22
|
+
if (typeof fetchImpl !== "function") {
|
|
23
|
+
throw new Error("createIflowClient: global fetch is not available; pass fetchImpl explicitly.");
|
|
24
|
+
}
|
|
25
|
+
const cache = new Map();
|
|
26
|
+
function readCache(key) {
|
|
27
|
+
if (config.cacheTtlMs <= 0)
|
|
28
|
+
return null;
|
|
29
|
+
const entry = cache.get(key);
|
|
30
|
+
if (!entry)
|
|
31
|
+
return null;
|
|
32
|
+
if (now() > entry.expiresAt) {
|
|
33
|
+
cache.delete(key);
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
// Refresh LRU position
|
|
37
|
+
cache.delete(key);
|
|
38
|
+
cache.set(key, entry);
|
|
39
|
+
return entry.value;
|
|
40
|
+
}
|
|
41
|
+
function writeCache(key, value) {
|
|
42
|
+
if (config.cacheTtlMs <= 0)
|
|
43
|
+
return;
|
|
44
|
+
if (cache.size >= MAX_CACHE_ENTRIES) {
|
|
45
|
+
const oldest = cache.keys().next();
|
|
46
|
+
if (!oldest.done)
|
|
47
|
+
cache.delete(oldest.value);
|
|
48
|
+
}
|
|
49
|
+
cache.set(key, { value, expiresAt: now() + config.cacheTtlMs });
|
|
50
|
+
}
|
|
51
|
+
async function call(endpoint, body, cacheKey, externalSignal) {
|
|
52
|
+
const start = now();
|
|
53
|
+
const cached = readCache(cacheKey);
|
|
54
|
+
if (cached) {
|
|
55
|
+
return { ok: true, data: cached, tookMs: 0, fromCache: true };
|
|
56
|
+
}
|
|
57
|
+
const url = `${config.baseUrl}${ENDPOINTS[endpoint]}`;
|
|
58
|
+
const controller = new AbortController();
|
|
59
|
+
const timer = setTimeout(() => controller.abort(), config.timeoutMs);
|
|
60
|
+
if (externalSignal) {
|
|
61
|
+
if (externalSignal.aborted)
|
|
62
|
+
controller.abort();
|
|
63
|
+
else
|
|
64
|
+
externalSignal.addEventListener("abort", () => controller.abort(), { once: true });
|
|
65
|
+
}
|
|
66
|
+
let response;
|
|
67
|
+
try {
|
|
68
|
+
response = await fetchImpl(url, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: {
|
|
71
|
+
Authorization: `Bearer ${config.apiKey ?? ""}`,
|
|
72
|
+
"Content-Type": "application/json",
|
|
73
|
+
Accept: "application/json",
|
|
74
|
+
},
|
|
75
|
+
body: JSON.stringify(body),
|
|
76
|
+
signal: controller.signal,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
const tookMs = now() - start;
|
|
81
|
+
const e = err;
|
|
82
|
+
if (e.name === "AbortError") {
|
|
83
|
+
logger.warn(`iflow ${endpoint}: timeout after ${tookMs}ms`);
|
|
84
|
+
return { ok: false, error: networkTimeoutError(config.timeoutMs), tookMs };
|
|
85
|
+
}
|
|
86
|
+
logger.warn(`iflow ${endpoint}: network error: ${e.message ?? String(err)}`);
|
|
87
|
+
return { ok: false, error: networkError(e.message ?? String(err)), tookMs };
|
|
88
|
+
}
|
|
89
|
+
finally {
|
|
90
|
+
clearTimeout(timer);
|
|
91
|
+
}
|
|
92
|
+
const tookMs = now() - start;
|
|
93
|
+
if (!response.ok) {
|
|
94
|
+
let detail = "";
|
|
95
|
+
try {
|
|
96
|
+
detail = (await response.text()).slice(0, 500);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// ignore
|
|
100
|
+
}
|
|
101
|
+
logger.warn(`iflow ${endpoint}: HTTP ${response.status} (${tookMs}ms)`);
|
|
102
|
+
return {
|
|
103
|
+
ok: false,
|
|
104
|
+
error: apiHttpError(response.status, detail || response.statusText),
|
|
105
|
+
tookMs,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
let parsed;
|
|
109
|
+
try {
|
|
110
|
+
parsed = await response.json();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
const e = err;
|
|
114
|
+
logger.warn(`iflow ${endpoint}: invalid JSON response: ${e.message ?? String(err)}`);
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
error: { error: "api_error", status: response.status, message: "iFlow returned non-JSON response." },
|
|
118
|
+
tookMs,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
const envelope = parsed;
|
|
122
|
+
if (envelope?.success !== true) {
|
|
123
|
+
const dataObj = envelope?.data && typeof envelope.data === "object" && !Array.isArray(envelope.data)
|
|
124
|
+
? envelope.data
|
|
125
|
+
: undefined;
|
|
126
|
+
logger.warn(`iflow ${endpoint}: api business error code=${String(envelope?.code)} message=${String(envelope?.message)}`);
|
|
127
|
+
return {
|
|
128
|
+
ok: false,
|
|
129
|
+
error: apiBusinessError({
|
|
130
|
+
code: envelope?.code ?? null,
|
|
131
|
+
message: envelope?.message ?? null,
|
|
132
|
+
errorMsg: dataObj?.errorMsg ?? null,
|
|
133
|
+
errorCode: dataObj?.errorCode ?? null,
|
|
134
|
+
}),
|
|
135
|
+
tookMs,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
writeCache(cacheKey, parsed);
|
|
139
|
+
return { ok: true, data: parsed, tookMs, fromCache: false };
|
|
140
|
+
}
|
|
141
|
+
function ensureKey() {
|
|
142
|
+
if (!config.apiKey) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
error: {
|
|
146
|
+
error: "missing_api_key",
|
|
147
|
+
message: "iFlow Search needs an API key. Set IFLOW_API_KEY in the environment, or configure plugins.entries.iflow.config.webSearch.apiKey.",
|
|
148
|
+
},
|
|
149
|
+
tookMs: 0,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
async webSearch(query, num, signal) {
|
|
156
|
+
const missing = ensureKey();
|
|
157
|
+
if (missing)
|
|
158
|
+
return missing;
|
|
159
|
+
const key = `webSearch:${num}:${query.toLowerCase()}`;
|
|
160
|
+
return call("webSearch", { keywords: query, num }, key, signal);
|
|
161
|
+
},
|
|
162
|
+
async imageSearch(query, num, signal) {
|
|
163
|
+
const missing = ensureKey();
|
|
164
|
+
if (missing)
|
|
165
|
+
return missing;
|
|
166
|
+
const key = `imageSearch:${num}:${query.toLowerCase()}`;
|
|
167
|
+
return call("imageSearch", { keywords: query, num }, key, signal);
|
|
168
|
+
},
|
|
169
|
+
async webFetch(url, signal) {
|
|
170
|
+
const missing = ensureKey();
|
|
171
|
+
if (missing)
|
|
172
|
+
return missing;
|
|
173
|
+
const key = `webFetch:${url.toLowerCase()}`;
|
|
174
|
+
return call("webFetch", { url }, key, signal);
|
|
175
|
+
},
|
|
176
|
+
clearCache() {
|
|
177
|
+
cache.clear();
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export { isIflowError };
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Config resolution for the iFlow plugin.
|
|
3
|
+
*
|
|
4
|
+
* The plugin reads `pluginConfig.webSearch.*` and falls back to env vars
|
|
5
|
+
* for the API key. SecretRef objects (resolved by OpenClaw upstream) are
|
|
6
|
+
* tolerated by accepting `string | { value: string } | unknown`.
|
|
7
|
+
*/
|
|
8
|
+
export const DEFAULT_BASE_URL = "https://platform.iflow.cn";
|
|
9
|
+
export const DEFAULT_TIMEOUT_SECONDS = 30;
|
|
10
|
+
export const DEFAULT_CACHE_TTL_MINUTES = 15;
|
|
11
|
+
export const ENV_API_KEY = "IFLOW_API_KEY";
|
|
12
|
+
function readSecretString(value) {
|
|
13
|
+
if (typeof value === "string") {
|
|
14
|
+
const trimmed = value.trim();
|
|
15
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
16
|
+
}
|
|
17
|
+
if (value && typeof value === "object") {
|
|
18
|
+
const v = value.value;
|
|
19
|
+
if (typeof v === "string") {
|
|
20
|
+
const trimmed = v.trim();
|
|
21
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
function readEnv(name) {
|
|
27
|
+
const v = process.env?.[name];
|
|
28
|
+
if (typeof v !== "string")
|
|
29
|
+
return undefined;
|
|
30
|
+
const trimmed = v.trim();
|
|
31
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
32
|
+
}
|
|
33
|
+
export function resolveConfig(pluginConfig) {
|
|
34
|
+
const cfg = (pluginConfig ?? {});
|
|
35
|
+
const ws = cfg.webSearch ?? {};
|
|
36
|
+
const apiKey = readSecretString(ws.apiKey) ?? readEnv(ENV_API_KEY);
|
|
37
|
+
const rawBaseUrl = readSecretString(ws.baseUrl) ?? DEFAULT_BASE_URL;
|
|
38
|
+
const baseUrl = rawBaseUrl.replace(/\/+$/u, "") || DEFAULT_BASE_URL;
|
|
39
|
+
const timeoutSeconds = typeof ws.timeoutSeconds === "number" && Number.isFinite(ws.timeoutSeconds) && ws.timeoutSeconds > 0
|
|
40
|
+
? ws.timeoutSeconds
|
|
41
|
+
: DEFAULT_TIMEOUT_SECONDS;
|
|
42
|
+
const timeoutMs = Math.round(timeoutSeconds * 1000);
|
|
43
|
+
const cacheTtlMinutes = typeof ws.cacheTtlMinutes === "number" && Number.isFinite(ws.cacheTtlMinutes) && ws.cacheTtlMinutes >= 0
|
|
44
|
+
? ws.cacheTtlMinutes
|
|
45
|
+
: DEFAULT_CACHE_TTL_MINUTES;
|
|
46
|
+
const cacheTtlMs = Math.round(cacheTtlMinutes * 60_000);
|
|
47
|
+
return { apiKey, baseUrl, timeoutMs, cacheTtlMs };
|
|
48
|
+
}
|
|
49
|
+
export function redactApiKey(key) {
|
|
50
|
+
if (!key)
|
|
51
|
+
return "<unset>";
|
|
52
|
+
if (key.length <= 8)
|
|
53
|
+
return "***";
|
|
54
|
+
return `${key.slice(0, 4)}***${key.slice(-2)}`;
|
|
55
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error taxonomy for the iFlow plugin.
|
|
3
|
+
*
|
|
4
|
+
* Every failure path returns one of these tagged shapes so that the tool
|
|
5
|
+
* handler can render a consistent JSON payload back to the agent without
|
|
6
|
+
* leaking the API key or sensitive headers.
|
|
7
|
+
*/
|
|
8
|
+
export const IFLOW_ERROR_DOCS_URL = "https://platform.iflow.cn/docs/";
|
|
9
|
+
export function missingApiKeyError() {
|
|
10
|
+
return {
|
|
11
|
+
error: "missing_api_key",
|
|
12
|
+
message: "iFlow Search needs an API key. Set IFLOW_API_KEY in the environment, or configure plugins.entries.iflow.config.webSearch.apiKey.",
|
|
13
|
+
docs: IFLOW_ERROR_DOCS_URL,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function missingParamError(name) {
|
|
17
|
+
return {
|
|
18
|
+
error: "missing_param",
|
|
19
|
+
message: `Parameter "${name}" is required.`,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export function invalidParamError(name, detail) {
|
|
23
|
+
return {
|
|
24
|
+
error: "invalid_param",
|
|
25
|
+
message: `Parameter "${name}" is invalid: ${detail}`,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function networkTimeoutError(timeoutMs) {
|
|
29
|
+
return {
|
|
30
|
+
error: "network_timeout",
|
|
31
|
+
message: `Request to iFlow timed out after ${Math.round(timeoutMs)}ms.`,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
export function networkError(detail) {
|
|
35
|
+
return {
|
|
36
|
+
error: "network_error",
|
|
37
|
+
message: `Network error talking to iFlow: ${detail}`,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export function apiHttpError(status, message) {
|
|
41
|
+
let hint = message || `HTTP ${status}`;
|
|
42
|
+
if (status === 401)
|
|
43
|
+
hint = "401 Unauthorized — the iFlow API key is missing or invalid.";
|
|
44
|
+
else if (status === 403)
|
|
45
|
+
hint = "403 Forbidden — the iFlow API key is not allowed for this endpoint.";
|
|
46
|
+
else if (status === 429)
|
|
47
|
+
hint = "429 Too Many Requests — iFlow rate limit reached. Slow down or retry later.";
|
|
48
|
+
return { error: "api_error", status, message: hint, docs: IFLOW_ERROR_DOCS_URL };
|
|
49
|
+
}
|
|
50
|
+
export function apiBusinessError(opts) {
|
|
51
|
+
const parts = [];
|
|
52
|
+
if (opts.message)
|
|
53
|
+
parts.push(String(opts.message));
|
|
54
|
+
if (opts.errorMsg && opts.errorMsg !== opts.message)
|
|
55
|
+
parts.push(`detail: ${opts.errorMsg}`);
|
|
56
|
+
const message = parts.join(" — ") || "iFlow API returned success=false without a message.";
|
|
57
|
+
return {
|
|
58
|
+
error: "api_business_error",
|
|
59
|
+
code: opts.errorCode ?? opts.code ?? undefined,
|
|
60
|
+
message,
|
|
61
|
+
docs: IFLOW_ERROR_DOCS_URL,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function isIflowError(value) {
|
|
65
|
+
return (typeof value === "object" &&
|
|
66
|
+
value !== null &&
|
|
67
|
+
typeof value.error === "string" &&
|
|
68
|
+
typeof value.message === "string");
|
|
69
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Map raw iFlow API responses to the plugin's normalized output.
|
|
3
|
+
*
|
|
4
|
+
* Field mapping is based on real responses captured on 2026-05-12. If iFlow
|
|
5
|
+
* changes their schema, only this file (and its test) should need to move.
|
|
6
|
+
*
|
|
7
|
+
* --- FIELDS TO REVISIT IF IFLOW API CHANGES ---
|
|
8
|
+
* webSearch:
|
|
9
|
+
* - data.organic[].link ← iFlow uses "link", we expose it as "url"
|
|
10
|
+
* - data.organic[].position ← may be null on later results
|
|
11
|
+
* - data.organic[].date ← string or null (Chinese formatting observed: "2023年12月4日")
|
|
12
|
+
* imageSearch:
|
|
13
|
+
* - data is a flat array (NOT data.images)
|
|
14
|
+
* - data[].refUrl ← exposed as "sourceUrl"
|
|
15
|
+
* webFetch:
|
|
16
|
+
* - data.fromCache may be absent on first call (we treat undefined as null)
|
|
17
|
+
* - data.content is a single string; large pages may exceed token budgets
|
|
18
|
+
* Error envelope (all endpoints):
|
|
19
|
+
* - { success, code, message, exception, data: { errorMsg, errorCode } }
|
|
20
|
+
*/
|
|
21
|
+
function asString(v, fallback = "") {
|
|
22
|
+
return typeof v === "string" ? v : fallback;
|
|
23
|
+
}
|
|
24
|
+
function asNullableString(v) {
|
|
25
|
+
return typeof v === "string" && v.length > 0 ? v : null;
|
|
26
|
+
}
|
|
27
|
+
function asNullableNumber(v) {
|
|
28
|
+
return typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
29
|
+
}
|
|
30
|
+
export function normalizeWebSearch(raw, requestQuery, tookMs) {
|
|
31
|
+
const data = raw?.data;
|
|
32
|
+
const organic = Array.isArray(data?.organic) ? data.organic : [];
|
|
33
|
+
const results = organic
|
|
34
|
+
.filter((r) => r !== null && typeof r === "object")
|
|
35
|
+
.map((r) => ({
|
|
36
|
+
title: asString(r.title),
|
|
37
|
+
url: asString(r.link),
|
|
38
|
+
snippet: asString(r.snippet),
|
|
39
|
+
position: asNullableNumber(r.position),
|
|
40
|
+
date: asNullableString(r.date),
|
|
41
|
+
}));
|
|
42
|
+
return {
|
|
43
|
+
query: asString(data?.query, requestQuery),
|
|
44
|
+
provider: "iflow",
|
|
45
|
+
count: results.length,
|
|
46
|
+
tookMs,
|
|
47
|
+
results,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
export function normalizeImageSearch(raw, requestQuery, tookMs) {
|
|
51
|
+
const data = raw?.data;
|
|
52
|
+
const arr = Array.isArray(data) ? data : [];
|
|
53
|
+
const images = arr
|
|
54
|
+
.filter((r) => r !== null && typeof r === "object")
|
|
55
|
+
.map((r) => ({
|
|
56
|
+
url: asString(r.url),
|
|
57
|
+
title: asNullableString(r.title),
|
|
58
|
+
sourceUrl: asNullableString(r.refUrl),
|
|
59
|
+
}))
|
|
60
|
+
.filter((img) => img.url.length > 0);
|
|
61
|
+
return {
|
|
62
|
+
query: requestQuery,
|
|
63
|
+
provider: "iflow",
|
|
64
|
+
count: images.length,
|
|
65
|
+
tookMs,
|
|
66
|
+
images,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function normalizeWebFetch(raw, requestUrl, tookMs) {
|
|
70
|
+
const data = raw?.data;
|
|
71
|
+
const fromCache = typeof data?.fromCache === "boolean" ? data.fromCache : null;
|
|
72
|
+
return {
|
|
73
|
+
title: asNullableString(data?.title),
|
|
74
|
+
url: asString(data?.url, requestUrl),
|
|
75
|
+
content: asString(data?.content),
|
|
76
|
+
fromCache,
|
|
77
|
+
provider: "iflow",
|
|
78
|
+
tookMs,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three explicit OpenClaw tools exposed by this plugin.
|
|
3
|
+
*
|
|
4
|
+
* Schemas use @sinclair/typebox, matching openclaw-tavily. Each tool returns
|
|
5
|
+
* the OpenClaw-standard `{ content: [{ type: "text", text }], details: {} }`
|
|
6
|
+
* shape, where `text` is a stringified JSON payload.
|
|
7
|
+
*
|
|
8
|
+
* Tool parameter names follow the Tavily/Brave canonical convention
|
|
9
|
+
* (`query`, `count`, `url`) for agent-side consistency. The iFlow API's
|
|
10
|
+
* actual body fields (`keywords`, `num`) are produced inside `client.ts`.
|
|
11
|
+
*/
|
|
12
|
+
import { Type } from "@sinclair/typebox";
|
|
13
|
+
import { invalidParamError, missingParamError, } from "./errors.js";
|
|
14
|
+
import { normalizeImageSearch, normalizeWebFetch, normalizeWebSearch, } from "./normalize.js";
|
|
15
|
+
// -- Schemas ----------------------------------------------------------------
|
|
16
|
+
export const WEB_SEARCH_DEFAULT_COUNT = 10;
|
|
17
|
+
export const WEB_SEARCH_MAX_COUNT = 10;
|
|
18
|
+
export const IMAGE_SEARCH_DEFAULT_COUNT = 10;
|
|
19
|
+
export const IMAGE_SEARCH_MAX_COUNT = 20;
|
|
20
|
+
export const IflowWebSearchSchema = Type.Object({
|
|
21
|
+
query: Type.String({
|
|
22
|
+
minLength: 1,
|
|
23
|
+
description: "Search query. Forwarded to iFlow as 'keywords'.",
|
|
24
|
+
}),
|
|
25
|
+
count: Type.Optional(Type.Number({
|
|
26
|
+
minimum: 1,
|
|
27
|
+
maximum: WEB_SEARCH_MAX_COUNT,
|
|
28
|
+
description: `Number of results (1-${WEB_SEARCH_MAX_COUNT}). Default: ${WEB_SEARCH_DEFAULT_COUNT}. Forwarded to iFlow as 'num'.`,
|
|
29
|
+
})),
|
|
30
|
+
});
|
|
31
|
+
export const IflowImageSearchSchema = Type.Object({
|
|
32
|
+
query: Type.String({
|
|
33
|
+
minLength: 1,
|
|
34
|
+
description: "Image search query. Forwarded to iFlow as 'keywords'.",
|
|
35
|
+
}),
|
|
36
|
+
count: Type.Optional(Type.Number({
|
|
37
|
+
minimum: 1,
|
|
38
|
+
maximum: IMAGE_SEARCH_MAX_COUNT,
|
|
39
|
+
description: `Number of images (1-${IMAGE_SEARCH_MAX_COUNT}). Default: ${IMAGE_SEARCH_DEFAULT_COUNT}. Forwarded to iFlow as 'num'.`,
|
|
40
|
+
})),
|
|
41
|
+
});
|
|
42
|
+
export const IflowWebFetchSchema = Type.Object({
|
|
43
|
+
url: Type.String({
|
|
44
|
+
minLength: 1,
|
|
45
|
+
description: "HTTP(S) URL to fetch.",
|
|
46
|
+
}),
|
|
47
|
+
});
|
|
48
|
+
function ok(payload) {
|
|
49
|
+
return {
|
|
50
|
+
content: [{ type: "text", text: JSON.stringify(payload, null, 2) }],
|
|
51
|
+
details: {},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
function fail(err) {
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: JSON.stringify(err, null, 2) }],
|
|
57
|
+
details: {},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function readQuery(params) {
|
|
61
|
+
const raw = params.query;
|
|
62
|
+
if (typeof raw !== "string")
|
|
63
|
+
return missingParamError("query");
|
|
64
|
+
const query = raw.trim();
|
|
65
|
+
if (query.length === 0)
|
|
66
|
+
return missingParamError("query");
|
|
67
|
+
return { query };
|
|
68
|
+
}
|
|
69
|
+
function readCount(params, defaultCount, maxCount) {
|
|
70
|
+
const raw = params.count;
|
|
71
|
+
if (typeof raw !== "number" || !Number.isFinite(raw))
|
|
72
|
+
return defaultCount;
|
|
73
|
+
const intVal = Math.floor(raw);
|
|
74
|
+
if (intVal < 1)
|
|
75
|
+
return 1;
|
|
76
|
+
if (intVal > maxCount)
|
|
77
|
+
return maxCount;
|
|
78
|
+
return intVal;
|
|
79
|
+
}
|
|
80
|
+
function readUrl(params) {
|
|
81
|
+
const raw = params.url;
|
|
82
|
+
if (typeof raw !== "string")
|
|
83
|
+
return missingParamError("url");
|
|
84
|
+
const url = raw.trim();
|
|
85
|
+
if (url.length === 0)
|
|
86
|
+
return missingParamError("url");
|
|
87
|
+
try {
|
|
88
|
+
const parsed = new URL(url);
|
|
89
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
90
|
+
return invalidParamError("url", "must be an http:// or https:// URL");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return invalidParamError("url", "not a valid URL");
|
|
95
|
+
}
|
|
96
|
+
return { url };
|
|
97
|
+
}
|
|
98
|
+
// -- Tool factories ---------------------------------------------------------
|
|
99
|
+
export function createWebSearchTool(client) {
|
|
100
|
+
return {
|
|
101
|
+
name: "iflow_web_search",
|
|
102
|
+
label: "iFlow Web Search",
|
|
103
|
+
description: "Search the public web via iFlow Search (心流搜索). Returns titles, URLs, snippets, position, and (when available) publish date. Chinese-language results are first-class.",
|
|
104
|
+
parameters: IflowWebSearchSchema,
|
|
105
|
+
async execute(_toolCallId, params) {
|
|
106
|
+
const q = readQuery(params);
|
|
107
|
+
if ("error" in q)
|
|
108
|
+
return fail(q);
|
|
109
|
+
const count = readCount(params, WEB_SEARCH_DEFAULT_COUNT, WEB_SEARCH_MAX_COUNT);
|
|
110
|
+
const result = await client.webSearch(q.query, count);
|
|
111
|
+
if (!result.ok)
|
|
112
|
+
return fail(result.error);
|
|
113
|
+
const normalized = normalizeWebSearch(result.data, q.query, result.tookMs);
|
|
114
|
+
const payload = { ...normalized };
|
|
115
|
+
if (result.fromCache)
|
|
116
|
+
payload.cached = true;
|
|
117
|
+
return ok(payload);
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
export function createImageSearchTool(client) {
|
|
122
|
+
return {
|
|
123
|
+
name: "iflow_image_search",
|
|
124
|
+
label: "iFlow Image Search",
|
|
125
|
+
description: "Search the public web for images via iFlow Search. Returns image URLs, titles, and source page URLs.",
|
|
126
|
+
parameters: IflowImageSearchSchema,
|
|
127
|
+
async execute(_toolCallId, params) {
|
|
128
|
+
const q = readQuery(params);
|
|
129
|
+
if ("error" in q)
|
|
130
|
+
return fail(q);
|
|
131
|
+
const count = readCount(params, IMAGE_SEARCH_DEFAULT_COUNT, IMAGE_SEARCH_MAX_COUNT);
|
|
132
|
+
const result = await client.imageSearch(q.query, count);
|
|
133
|
+
if (!result.ok)
|
|
134
|
+
return fail(result.error);
|
|
135
|
+
const normalized = normalizeImageSearch(result.data, q.query, result.tookMs);
|
|
136
|
+
const payload = { ...normalized };
|
|
137
|
+
if (result.fromCache)
|
|
138
|
+
payload.cached = true;
|
|
139
|
+
return ok(payload);
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
export function createWebFetchTool(client) {
|
|
144
|
+
return {
|
|
145
|
+
name: "iflow_web_fetch",
|
|
146
|
+
label: "iFlow Web Fetch",
|
|
147
|
+
description: "Fetch the readable content of a single web page via iFlow Search. Returns title, plain-text/markdown content, and a cache hint.",
|
|
148
|
+
parameters: IflowWebFetchSchema,
|
|
149
|
+
async execute(_toolCallId, params) {
|
|
150
|
+
const u = readUrl(params);
|
|
151
|
+
if ("error" in u)
|
|
152
|
+
return fail(u);
|
|
153
|
+
const result = await client.webFetch(u.url);
|
|
154
|
+
if (!result.ok)
|
|
155
|
+
return fail(result.error);
|
|
156
|
+
const normalized = normalizeWebFetch(result.data, u.url, result.tookMs);
|
|
157
|
+
const payload = { ...normalized };
|
|
158
|
+
if (result.fromCache)
|
|
159
|
+
payload.cached = true;
|
|
160
|
+
return ok(payload);
|
|
161
|
+
},
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
// Exported for test fixtures.
|
|
165
|
+
export const _internals = { readQuery, readCount, readUrl };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort `web_search` provider for OpenClaw's managed search router.
|
|
3
|
+
*
|
|
4
|
+
* The shape of the returned object mirrors @openclaw/brave-plugin's
|
|
5
|
+
* `createBraveWebSearchProvider()` exactly, with iFlow-specific values
|
|
6
|
+
* substituted. This is a REFERENCE alignment — we make no guarantee that the
|
|
7
|
+
* third-party plugin contract is stable. The plugin entry wraps the use of
|
|
8
|
+
* `registerWebSearchProvider` and this factory in try/catch so any signature
|
|
9
|
+
* drift downgrades to tools-only mode without breaking plugin load.
|
|
10
|
+
*/
|
|
11
|
+
import { normalizeWebSearch } from "./normalize.js";
|
|
12
|
+
const CREDENTIAL_PATH = "plugins.entries.iflow.config.webSearch.apiKey";
|
|
13
|
+
export function createIflowWebSearchProvider(opts) {
|
|
14
|
+
const { client, createContractFields } = opts;
|
|
15
|
+
const contractFields = createContractFields({
|
|
16
|
+
credentialPath: CREDENTIAL_PATH,
|
|
17
|
+
searchCredential: { type: "top-level" },
|
|
18
|
+
configuredCredential: { pluginId: "iflow" },
|
|
19
|
+
});
|
|
20
|
+
return {
|
|
21
|
+
...contractFields,
|
|
22
|
+
id: "iflow",
|
|
23
|
+
label: "iFlow Search",
|
|
24
|
+
hint: "Chinese-language web search · structured snippets",
|
|
25
|
+
onboardingScopes: ["text-inference"],
|
|
26
|
+
credentialLabel: "iFlow API key",
|
|
27
|
+
envVars: ["IFLOW_API_KEY"],
|
|
28
|
+
placeholder: "sk-...",
|
|
29
|
+
signupUrl: "https://platform.iflow.cn",
|
|
30
|
+
docsUrl: "https://platform.iflow.cn/docs/",
|
|
31
|
+
autoDetectOrder: 80,
|
|
32
|
+
credentialPath: CREDENTIAL_PATH,
|
|
33
|
+
createTool: () => null,
|
|
34
|
+
/**
|
|
35
|
+
* If the host runtime invokes this entry point instead of routing through
|
|
36
|
+
* createTool() (newer SDKs are observed to do both), perform the search
|
|
37
|
+
* and return a normalized payload. This is a forward-compatibility hook —
|
|
38
|
+
* if the runtime doesn't call it, no harm done.
|
|
39
|
+
*/
|
|
40
|
+
async runManagedWebSearch(params) {
|
|
41
|
+
const query = String(params.query ?? "").trim();
|
|
42
|
+
const count = typeof params.count === "number" && Number.isFinite(params.count) && params.count > 0
|
|
43
|
+
? Math.min(10, Math.max(1, Math.floor(params.count)))
|
|
44
|
+
: 10;
|
|
45
|
+
if (!query)
|
|
46
|
+
return { error: "missing_param", message: 'Parameter "query" is required.' };
|
|
47
|
+
const result = await client.webSearch(query, count);
|
|
48
|
+
if (!result.ok)
|
|
49
|
+
return result.error;
|
|
50
|
+
return normalizeWebSearch(result.data, query, result.tookMs);
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id": "iflow",
|
|
3
|
+
"name": "iFlow Search",
|
|
4
|
+
"description": "iFlow Search (心流搜索) — Chinese-first web search, image search, and web content fetch. Exposes iflow_web_search, iflow_image_search, iflow_web_fetch, and registers as the 'iflow' web_search provider when the runtime supports it.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"kind": "tools",
|
|
7
|
+
"skills": ["./skills"],
|
|
8
|
+
"activation": { "onStartup": false },
|
|
9
|
+
"providerAuthEnvVars": { "iflow": ["IFLOW_API_KEY"] },
|
|
10
|
+
"setup": {
|
|
11
|
+
"providers": [
|
|
12
|
+
{ "id": "iflow", "authMethods": ["api-key"], "envVars": ["IFLOW_API_KEY"] }
|
|
13
|
+
]
|
|
14
|
+
},
|
|
15
|
+
"contracts": {
|
|
16
|
+
"tools": ["iflow_web_search", "iflow_image_search", "iflow_web_fetch"],
|
|
17
|
+
"webSearchProviders": ["iflow"]
|
|
18
|
+
},
|
|
19
|
+
"configSchema": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"additionalProperties": false,
|
|
22
|
+
"properties": {
|
|
23
|
+
"webSearch": {
|
|
24
|
+
"type": "object",
|
|
25
|
+
"additionalProperties": false,
|
|
26
|
+
"properties": {
|
|
27
|
+
"apiKey": {
|
|
28
|
+
"type": ["string", "object"],
|
|
29
|
+
"description": "iFlow API key (string or SecretRef). Falls back to the IFLOW_API_KEY env var."
|
|
30
|
+
},
|
|
31
|
+
"baseUrl": {
|
|
32
|
+
"type": ["string", "object"],
|
|
33
|
+
"description": "Override the iFlow API base URL. Defaults to https://platform.iflow.cn."
|
|
34
|
+
},
|
|
35
|
+
"timeoutSeconds": {
|
|
36
|
+
"type": "number",
|
|
37
|
+
"description": "HTTP timeout per request in seconds. Default: 30."
|
|
38
|
+
},
|
|
39
|
+
"cacheTtlMinutes": {
|
|
40
|
+
"type": "number",
|
|
41
|
+
"description": "In-memory cache TTL in minutes. Default: 15. Set 0 to disable."
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"uiHints": {
|
|
48
|
+
"webSearch.apiKey": {
|
|
49
|
+
"label": "iFlow API Key",
|
|
50
|
+
"help": "iFlow Search API key. Falls back to the IFLOW_API_KEY environment variable.",
|
|
51
|
+
"sensitive": true,
|
|
52
|
+
"placeholder": "sk-..."
|
|
53
|
+
},
|
|
54
|
+
"webSearch.baseUrl": {
|
|
55
|
+
"label": "iFlow Base URL",
|
|
56
|
+
"help": "Optional base URL override for trusted proxies. Defaults to https://platform.iflow.cn.",
|
|
57
|
+
"advanced": true
|
|
58
|
+
},
|
|
59
|
+
"webSearch.timeoutSeconds": {
|
|
60
|
+
"label": "Timeout (seconds)",
|
|
61
|
+
"advanced": true
|
|
62
|
+
},
|
|
63
|
+
"webSearch.cacheTtlMinutes": {
|
|
64
|
+
"label": "Cache TTL (minutes)",
|
|
65
|
+
"help": "How long to cache identical search/fetch responses. Set 0 to disable.",
|
|
66
|
+
"advanced": true
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@iflow-ai/iflow-plugin",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "iFlow Search plugin for OpenClaw — exposes iflow_web_search, iflow_image_search, iflow_web_fetch, and registers (best-effort) as the web_search provider 'iflow'",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "iflow-ai",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/zhengyanglsun/openclaw-iflow-plugin.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/zhengyanglsun/openclaw-iflow-plugin/issues"
|
|
14
|
+
},
|
|
15
|
+
"homepage": "https://platform.iflow.cn",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"openclaw",
|
|
18
|
+
"openclaw-plugin",
|
|
19
|
+
"iflow",
|
|
20
|
+
"iflow-search",
|
|
21
|
+
"web-search",
|
|
22
|
+
"image-search",
|
|
23
|
+
"web-fetch",
|
|
24
|
+
"ai-search"
|
|
25
|
+
],
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./dist/index.js"
|
|
29
|
+
},
|
|
30
|
+
"files": [
|
|
31
|
+
"dist",
|
|
32
|
+
"openclaw.plugin.json",
|
|
33
|
+
"skills",
|
|
34
|
+
"README.md",
|
|
35
|
+
"LICENSE"
|
|
36
|
+
],
|
|
37
|
+
"scripts": {
|
|
38
|
+
"typecheck": "tsc --noEmit",
|
|
39
|
+
"build": "tsc -p tsconfig.build.json",
|
|
40
|
+
"prepack": "npm run build",
|
|
41
|
+
"test": "vitest run",
|
|
42
|
+
"test:watch": "vitest",
|
|
43
|
+
"smoke": "node scripts/smoke.mjs"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@sinclair/typebox": "0.34.47"
|
|
47
|
+
},
|
|
48
|
+
"peerDependencies": {
|
|
49
|
+
"openclaw": ">=2025.0.0"
|
|
50
|
+
},
|
|
51
|
+
"peerDependenciesMeta": {
|
|
52
|
+
"openclaw": {
|
|
53
|
+
"optional": true
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"engines": {
|
|
57
|
+
"node": ">=18"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@types/node": "^20.0.0",
|
|
61
|
+
"typescript": "^5.4.0",
|
|
62
|
+
"vitest": "^1.6.0"
|
|
63
|
+
},
|
|
64
|
+
"openclaw": {
|
|
65
|
+
"extensions": [
|
|
66
|
+
"./dist/index.js"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: iflow-search
|
|
3
|
+
description: 中文优先的网页搜索 / 图片搜索 / 网页内容抓取,via iFlow Search (心流搜索) API. Use when the agent needs fresh public web information, image references, or to read the content of a specific URL.
|
|
4
|
+
metadata:
|
|
5
|
+
{
|
|
6
|
+
"openclaw":
|
|
7
|
+
{
|
|
8
|
+
"emoji": "🔍",
|
|
9
|
+
"requires": { "env": ["IFLOW_API_KEY"] },
|
|
10
|
+
"primaryEnv": "IFLOW_API_KEY",
|
|
11
|
+
},
|
|
12
|
+
}
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
# iFlow Search
|
|
16
|
+
|
|
17
|
+
AI-optimized web tools using the [iFlow Open Platform API](https://platform.iflow.cn).
|
|
18
|
+
Three tools for search, image search, and single-page content fetch.
|
|
19
|
+
|
|
20
|
+
## When to use which tool
|
|
21
|
+
|
|
22
|
+
- **`iflow_web_search`** — Whenever the agent needs current, public web
|
|
23
|
+
information: news, fact-checking, finding references, looking up Chinese-language
|
|
24
|
+
resources, comparing claims across sources.
|
|
25
|
+
- **`iflow_image_search`** — When the user wants visual references: photos of
|
|
26
|
+
people, places, animals, products, landmarks, diagrams, screenshots.
|
|
27
|
+
- **`iflow_web_fetch`** — When the user provides a specific URL and asks the
|
|
28
|
+
agent to read it, summarize it, extract data from it, or quote from it.
|
|
29
|
+
|
|
30
|
+
## Default routing
|
|
31
|
+
|
|
32
|
+
When this plugin is active and registered as the `web_search` provider, the
|
|
33
|
+
managed `web_search` tool routes to iFlow automatically. The three explicit
|
|
34
|
+
tools above are always available regardless of provider routing.
|
|
35
|
+
|
|
36
|
+
## Recommended research flow
|
|
37
|
+
|
|
38
|
+
For multi-source research questions:
|
|
39
|
+
|
|
40
|
+
1. Call `iflow_web_search` with a focused query.
|
|
41
|
+
2. From the returned results, pick 2–4 URLs that look most relevant.
|
|
42
|
+
3. Call `iflow_web_fetch` on each selected URL to get clean page content.
|
|
43
|
+
4. Summarize, compare, or quote across the fetched content. Cite source URLs.
|
|
44
|
+
|
|
45
|
+
For visual / product / location questions:
|
|
46
|
+
|
|
47
|
+
1. Call `iflow_image_search` to surface candidate images.
|
|
48
|
+
2. If the user wants context (article, product page, source attribution),
|
|
49
|
+
follow up with `iflow_web_fetch` on the image's `sourceUrl`.
|
|
50
|
+
|
|
51
|
+
## When NOT to use
|
|
52
|
+
|
|
53
|
+
- The user asks about files on the local disk, internal databases, or private
|
|
54
|
+
artifacts. These tools only see the public web.
|
|
55
|
+
- The user explicitly asks for a different search provider.
|
|
56
|
+
- The query is plainly conversational ("write a poem about cats") with no
|
|
57
|
+
factual lookup component.
|
|
58
|
+
|
|
59
|
+
## Setup
|
|
60
|
+
|
|
61
|
+
The plugin needs an API key from the [iFlow Open Platform](https://platform.iflow.cn).
|
|
62
|
+
Either set `IFLOW_API_KEY` in the gateway environment, or configure it in your
|
|
63
|
+
OpenClaw config:
|
|
64
|
+
|
|
65
|
+
```json
|
|
66
|
+
{
|
|
67
|
+
"plugins": {
|
|
68
|
+
"entries": {
|
|
69
|
+
"iflow": {
|
|
70
|
+
"enabled": true,
|
|
71
|
+
"config": {
|
|
72
|
+
"webSearch": { "apiKey": "sk-..." }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
## Tool response shapes
|
|
81
|
+
|
|
82
|
+
`iflow_web_search`:
|
|
83
|
+
|
|
84
|
+
```json
|
|
85
|
+
{
|
|
86
|
+
"query": "...",
|
|
87
|
+
"provider": "iflow",
|
|
88
|
+
"count": 3,
|
|
89
|
+
"tookMs": 1383,
|
|
90
|
+
"results": [
|
|
91
|
+
{ "title": "...", "url": "...", "snippet": "...", "position": 1, "date": null }
|
|
92
|
+
]
|
|
93
|
+
}
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
`iflow_image_search`:
|
|
97
|
+
|
|
98
|
+
```json
|
|
99
|
+
{
|
|
100
|
+
"query": "...",
|
|
101
|
+
"provider": "iflow",
|
|
102
|
+
"count": 3,
|
|
103
|
+
"tookMs": 1715,
|
|
104
|
+
"images": [
|
|
105
|
+
{ "url": "...jpg", "title": "...", "sourceUrl": "..." }
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
`iflow_web_fetch`:
|
|
111
|
+
|
|
112
|
+
```json
|
|
113
|
+
{
|
|
114
|
+
"title": "...",
|
|
115
|
+
"url": "...",
|
|
116
|
+
"content": "...",
|
|
117
|
+
"fromCache": true,
|
|
118
|
+
"provider": "iflow",
|
|
119
|
+
"tookMs": 335
|
|
120
|
+
}
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
On failure, every tool returns `{ "error": "<code>", "message": "...", "status"?, "code"?, "docs"? }`.
|
|
124
|
+
Error codes: `missing_api_key`, `missing_param`, `invalid_param`,
|
|
125
|
+
`network_timeout`, `network_error`, `api_error`, `api_business_error`.
|
|
126
|
+
|
|
127
|
+
## Links
|
|
128
|
+
|
|
129
|
+
- iFlow Open Platform docs: https://platform.iflow.cn/docs/
|
|
130
|
+
- Plugin package: `@iflow-ai/iflow-plugin`
|
|
131
|
+
|
|
132
|
+
## Acknowledgements
|
|
133
|
+
|
|
134
|
+
This OpenClaw skill is adapted from the official iFlow skill catalog:
|
|
135
|
+
|
|
136
|
+
https://github.com/iflow-ai/iflow-skills/tree/main/skills/iflow-search
|
|
137
|
+
|
|
138
|
+
The capability surface is aligned with the official iFlow Search skill. Tool names, parameter shape, normalized return fields, and error handling are adapted for the OpenClaw plugin runtime.
|