cortico-world-surfing 0.3.0 → 0.3.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/AGENTS.md CHANGED
@@ -2,11 +2,11 @@
2
2
 
3
3
  Before changing this world extension, read [Cortico extension contracts](https://github.com/Pal-AI-Lab/Cortico/blob/main/docs/extensions.md).
4
4
 
5
- - Use a Cortico checkout at tag `v0.2.1` for framework checks; the sibling deployment checkout may run an older version. Match `cortico.api` to the printed contract version.
6
- - After changing tools, events or environment prompt templates, run `pnpm describe:extension <dir>` from the v0.2.1 checkout. Commit the generated file and ship it through `cortico.describe` and `files`.
5
+ - Use a Cortico checkout at tag `v0.2.10` for framework checks; the sibling deployment checkout may run an older version. Match `cortico.api` to the printed contract version.
6
+ - After changing tools, events or environment prompt templates, run `pnpm describe:extension <dir>` from the v0.2.10 checkout. Commit the generated file and ship it through `cortico.describe` and `files`.
7
7
  - Before publishing, select applicable tags from [TAGS.md](https://github.com/Pal-AI-Lab/cortico-extensions/blob/main/TAGS.md), including notice and warning tags. Maintainer tags take precedence; withdrawing a published warning requires a maintainer request.
8
8
  - Add a `## <version>` section to CHANGELOG.md and include it in the package. Declare npm `os` only for supported platform restrictions.
9
- - Run the package tests, TypeScript checks and engine build, then `pnpm check:extension <dir>` from the v0.2.1 checkout. Check the packed file list for the entry, engine bundle, self-description and changelog.
9
+ - Run the package tests, TypeScript checks and engine build, then `pnpm check:extension <dir>` from the v0.2.10 checkout. Check the packed file list for the entry, engine bundle, self-description and changelog.
10
10
  - Build the engine bundle in an isolated copy while a deployed bot uses this checkout. Use fake deployments and scripted models for verification.
11
11
  - Stage explicit paths and make local Conventional Commits. Push, publish and submit maintainer requests only when the user asks.
12
12
  - For tag changes, certification, superseding packages or appeals, follow the [index request format](https://github.com/Pal-AI-Lab/cortico-extensions#readme). Certification requires a maintainer review.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.2 - 2026-11-03
4
+
5
+ - Use Microlink to render pages when local Chromium is unavailable, with an optional API key and keyless requests.
6
+ - Merge reading limits and Microlink settings into one WebUI config panel, with key file actions, key status and step-by-step API key instructions.
7
+
8
+ ## 0.3.1 - 2026-10-30
9
+
10
+ - Align development and CI validation with Cortico 0.2.10.
11
+
3
12
  ## 0.3.0 - 2026-10-21
4
13
 
5
14
  - Add anonymous video description and comment readers, including hot/latest ordering, pinned comments, thread replies and cursor continuation.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- Owner: package.json, src/definition.ts, src/world.ts, src/ENV_PROMPT.md, src/network.ts, src/bili.ts -->
1
+ <!-- Owner: package.json, src/definition.ts, src/world.ts, src/ENV_PROMPT.md, src/network.ts, src/bili.ts, src/microlink.ts, src/webui/client.ts -->
2
2
  # 网上冲浪
3
3
 
4
4
  让 Cortico 阅读公开网页、B站视频简介、字幕和评论,搜索视频,并根据读到的内容回答问题。发送链接或BV号即可使用,B站读取均使用匿名请求。
@@ -18,12 +18,34 @@
18
18
 
19
19
  1. 在 Cortico 扩展管理页输入 `cortico-world-surfing` 安装 npm 包;使用本地源码时选择本项目目录。
20
20
  2. 重启 Cortico 进程,启用“网上冲浪”。
21
- 3. 如需读取依赖脚本加载正文的网页,在扩展目录执行一次:
21
+ 3. 读取依赖脚本加载正文的网页时,优先使用本机 Chromium。可在扩展目录安装:
22
22
 
23
23
  ```powershell
24
24
  corepack pnpm install:browser
25
25
  ```
26
26
 
27
+ 本机 Chromium 不可用时,扩展将目标网页 URL 发送给 Microlink,由其渲染后返回 HTML;正文、编号链接和章节锚点仍由本扩展提取。静态读取、Chromium 和 Microlink 共用单次读取超时及下载上限。Chromium 已启动后的网页错误、超时或取消不触发 Microlink。
28
+
29
+ ## Microlink(可选)
30
+
31
+ 无需 API Key 即可使用 Microlink 后备。配置 Key 后使用认证接口及该 Key 的额度;Key 仅发送给 Microlink,不进入模型回执。
32
+
33
+ 在网上冲浪的「配置」页找到「Microlink 远程渲染」,点击「打开密钥文件」,编辑部署目录的 `.env`:
34
+
35
+ ```dotenv
36
+ CORTICO_SURFING_MICROLINK_API_KEY=
37
+ ```
38
+
39
+ 取得免费 Key 的步骤:
40
+
41
+ 1. 打开 [Microlink 注册页](https://dashboard.microlink.io/signup),注册账号并登录,无需购买付费套餐。
42
+ 2. 在控制台左侧选择「Overview」,点击「Usage」标签。
43
+ 3. 点击「Development」标签右上角的「…」,复制 API Key。
44
+ 4. 将 Key 填在上面的等号后,不加引号,保存文件。
45
+ 5. 点击「刷新密钥状态」,状态变为「API Key 已配置」。下次远程读取使用新 Key;清空值并保存可恢复免 Key 调用。
46
+
47
+ 免 Key 和免费 Key 的额度不同,当前限制见 [Microlink 额度说明](https://microlink.io/docs/api/basics/rate-limit)。额度耗尽或访问失败时,回执说明原因;已有短静态正文仍返回,并标明未取得完整渲染内容。
48
+
27
49
  ## World 兼容性
28
50
 
29
51
  按 [Cortico World 兼容等级](https://github.com/Pal-AI-Lab/Cortico/blob/main/docs/world-compatibility.md),本 World 的最低等级为 **L1**,完整等级为 **L3**。
package/dist/webui.js ADDED
@@ -0,0 +1,216 @@
1
+ // src/config.ts
2
+ var SURFING_MICROLINK_SECRET = "CORTICO_SURFING_MICROLINK_API_KEY";
3
+ var SURFING_DEFAULTS = {
4
+ enabled: false,
5
+ reading: { maxResponseEstimatedTokens: 3e3, maxSourceChars: 3e5 },
6
+ bili: { subtitleGroupSec: 30, maxSubtitleRetries: 2, subtitleRetryDelayMs: 1e3 },
7
+ network: { maxDownloadBytes: 8 * 1024 * 1024, requestTimeoutMs: 2e4 },
8
+ cache: { ttlMs: 9e5, maxBytes: 32 * 1024 * 1024 }
9
+ };
10
+ var SURFING_CONFIG_GROUP = {
11
+ id: "world:surfing",
12
+ owner: "world:surfing",
13
+ schema: { type: "object", title: "\u9605\u8BFB\u4E0E\u5B57\u5E55", properties: {
14
+ "worlds.surfing.reading.maxResponseEstimatedTokens": {
15
+ type: "integer",
16
+ title: "\u5355\u6B21\u8FD4\u56DE\u4E0A\u9650",
17
+ minimum: 1024,
18
+ maximum: 32768,
19
+ "x-suffix": "\u4F30\u7B97 token",
20
+ "x-hot": true,
21
+ description: "\u5305\u542B\u6B63\u6587\u548C\u7EED\u8BFB\u4FE1\u606F\uFF1B\u4E0B\u6B21\u8C03\u7528\u751F\u6548\u3002\u8FDE\u7EED\u7EED\u8BFB\u4F1A\u7D2F\u79EF\u4E0A\u4E0B\u6587\u3002"
22
+ },
23
+ "worlds.surfing.bili.subtitleGroupSec": {
24
+ type: "integer",
25
+ title: "\u5B57\u5E55\u65F6\u95F4\u5206\u7EC4",
26
+ minimum: 1,
27
+ maximum: 120,
28
+ "x-suffix": "\u79D2",
29
+ "x-hot": true,
30
+ description: "\u5408\u5E76\u663E\u793A\u65F6\u95F4\u6233\uFF0C\u4FDD\u7559\u539F\u53E5\uFF1B\u65B0\u8BFB\u53D6\u751F\u6548\u3002"
31
+ },
32
+ "worlds.surfing.bili.maxSubtitleRetries": {
33
+ type: "integer",
34
+ title: "\u7A7A\u5B57\u5E55\u91CD\u8BD5\u6B21\u6570",
35
+ minimum: 0,
36
+ maximum: 5,
37
+ "x-suffix": "\u6B21",
38
+ "x-hot": true,
39
+ description: "\u9996\u6B21\u4E3A\u7A7A\u540E\u989D\u5916\u91CD\u8BD5\uFF1B0 \u8868\u793A\u5173\u95ED\u3002\u65B0\u8BFB\u53D6\u751F\u6548\u3002"
40
+ },
41
+ "worlds.surfing.bili.subtitleRetryDelayMs": {
42
+ type: "integer",
43
+ title: "\u9996\u6B21\u91CD\u8BD5\u7B49\u5F85",
44
+ minimum: 100,
45
+ maximum: 1e4,
46
+ "x-scale": 1e3,
47
+ "x-suffix": "\u79D2",
48
+ "x-hot": true,
49
+ description: "\u540E\u7EED\u6BCF\u6B21\u7B49\u5F85\u7FFB\u500D\uFF0C\u91CD\u8BD5\u5171\u7528\u5355\u6B21\u8BFB\u53D6\u8D85\u65F6\u3002\u65B0\u8BFB\u53D6\u751F\u6548\u3002"
50
+ }
51
+ } }
52
+ };
53
+ var SURFING_LIMITS_CONFIG_GROUP = {
54
+ id: "world:surfing:limits",
55
+ owner: "world:surfing",
56
+ schema: { type: "object", title: "\u83B7\u53D6\u4E0E\u7F13\u5B58\u9650\u5236", properties: {
57
+ "worlds.surfing.reading.maxSourceChars": {
58
+ type: "integer",
59
+ title: "\u5355\u4EFD\u6750\u6599\u4FDD\u7559\u4E0A\u9650",
60
+ minimum: 1e4,
61
+ maximum: 2e6,
62
+ "x-suffix": "\u5B57\u7B26",
63
+ description: "\u6B63\u6587\u3001\u94FE\u63A5\u5730\u5740\u548C\u641C\u7D22\u7ED3\u679C\u5408\u8BA1\uFF1B\u89E6\u9650\u65F6\u6807\u660E\u6750\u6599\u4E0D\u5B8C\u6574\u3002\u65B0\u8BFB\u53D6\u751F\u6548\u3002"
64
+ },
65
+ "worlds.surfing.network.maxDownloadBytes": {
66
+ type: "integer",
67
+ title: "\u5355\u6B21\u8BFB\u53D6\u4E0B\u8F7D\u4E0A\u9650",
68
+ minimum: 1048576,
69
+ maximum: 67108864,
70
+ "x-scale": 1048576,
71
+ "x-suffix": "MiB",
72
+ description: "\u6309\u89E3\u538B\u540E\u6570\u636E\u7D2F\u8BA1\uFF0C\u5305\u542B\u91CD\u5B9A\u5411\u53CA\u6E32\u67D3\u8BF7\u6C42\u3002"
73
+ },
74
+ "worlds.surfing.network.requestTimeoutMs": {
75
+ type: "integer",
76
+ title: "\u5355\u6B21\u8BFB\u53D6\u8D85\u65F6",
77
+ minimum: 3e3,
78
+ maximum: 12e4,
79
+ "x-scale": 1e3,
80
+ "x-suffix": "\u79D2",
81
+ description: "\u6574\u6B21\u8BFB\u53D6\u5171\u7528\u65F6\u9650\uFF1B\u4E0B\u6B21\u8C03\u7528\u751F\u6548\u3002"
82
+ },
83
+ "worlds.surfing.cache.ttlMs": {
84
+ type: "integer",
85
+ title: "\u7EED\u8BFB\u4FDD\u7559\u65F6\u95F4",
86
+ minimum: 6e4,
87
+ maximum: 72e5,
88
+ "x-scale": 6e4,
89
+ "x-suffix": "\u5206\u949F",
90
+ description: "\u5230\u671F\u9700\u91CD\u65B0\u8BFB\u53D6\uFF1B\u65B0\u5FEB\u7167\u751F\u6548\u3002"
91
+ },
92
+ "worlds.surfing.cache.maxBytes": {
93
+ type: "integer",
94
+ title: "\u6587\u672C\u7F13\u5B58\u4E0A\u9650",
95
+ minimum: 8388608,
96
+ maximum: 268435456,
97
+ "x-scale": 1048576,
98
+ "x-suffix": "MiB",
99
+ description: "\u6309\u4FDD\u5B58\u7684\u6570\u636E\u5927\u5C0F\u6838\u7B97\uFF0C\u8D85\u9650\u6DD8\u6C70\u6700\u4E45\u672A\u8BFB\u7684\u6750\u6599\u3002"
100
+ }
101
+ } }
102
+ };
103
+ var SURFING_CONFIG_GROUPS = [SURFING_CONFIG_GROUP, SURFING_LIMITS_CONFIG_GROUP];
104
+
105
+ // src/webui/client.ts
106
+ var errorText = (error) => error instanceof Error ? error.message : "\u64CD\u4F5C\u5931\u8D25";
107
+ async function mount(ctx) {
108
+ const { ui } = ctx;
109
+ const state = await ctx.invoke("state");
110
+ const layout = ui.h("div");
111
+ layout.style.cssText = "display:grid;gap:20px;max-width:960px;margin:auto";
112
+ let saves = Promise.resolve();
113
+ for (const group of SURFING_CONFIG_GROUPS) {
114
+ const sheet = group === SURFING_LIMITS_CONFIG_GROUP ? ui.foldSheet("surfing-limits", { title: group.schema.title, defaultOpen: false }) : ui.sheet({ title: group.schema.title });
115
+ const grid = ui.h("div");
116
+ grid.style.cssText = "display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:16px 24px";
117
+ const saved = ui.msgline("");
118
+ for (const [path, property] of Object.entries(group.schema.properties)) {
119
+ const scale = property["x-scale"] ?? 1;
120
+ const input = ui.input({ type: "number", value: String(state.values[path] / scale), onChange: (value) => {
121
+ saves = saves.then(async () => {
122
+ const next = await ctx.invoke("save", [path, Number(value) * scale]);
123
+ if (input.value === value) input.value = String(next.values[path] / scale);
124
+ saved.textContent = `\u5DF2\u4FDD\u5B58\uFF1A${property.title}`;
125
+ saved.classList.remove("bad");
126
+ void ctx.refresh().catch(() => void 0);
127
+ }).catch((error) => {
128
+ saved.textContent = errorText(error);
129
+ saved.classList.add("bad");
130
+ });
131
+ } });
132
+ input.min = String(property.minimum / scale);
133
+ input.max = String(property.maximum / scale);
134
+ const field = ui.field(`${property.title}\uFF08${property["x-suffix"]}\uFF09`, input);
135
+ const note = ui.h("small", "muted", property.description);
136
+ note.id = path;
137
+ input.setAttribute("aria-describedby", note.id);
138
+ const row = ui.h("div");
139
+ row.append(field, note);
140
+ grid.append(row);
141
+ }
142
+ sheet.body.append(grid, saved);
143
+ layout.append(sheet.el);
144
+ }
145
+ const anchor = (text, href) => {
146
+ const link = ui.h("a", null, text);
147
+ link.href = href;
148
+ link.target = "_blank";
149
+ link.rel = "noopener noreferrer";
150
+ return link;
151
+ };
152
+ const keyStatus = ui.msgline("");
153
+ let keyPill = ui.pill("", "off");
154
+ const showKey = (keySet) => {
155
+ const next = ui.pill(keySet ? "API Key \u5DF2\u914D\u7F6E" : "\u514D Key", keySet ? "on" : "off");
156
+ keyPill.replaceWith(next);
157
+ keyPill = next;
158
+ };
159
+ async function keyAction(run) {
160
+ try {
161
+ keyStatus.textContent = await run();
162
+ keyStatus.classList.remove("bad");
163
+ } catch (error) {
164
+ keyStatus.textContent = errorText(error);
165
+ keyStatus.classList.add("bad");
166
+ }
167
+ }
168
+ const keyBar = ui.rowbar();
169
+ keyBar.append(
170
+ keyPill,
171
+ ui.button("\u6253\u5F00\u5BC6\u94A5\u6587\u4EF6", { onClick: () => {
172
+ void keyAction(async () => {
173
+ await ctx.invoke("openKeyFile");
174
+ return "\u5DF2\u6253\u5F00\u5BC6\u94A5\u6587\u4EF6";
175
+ });
176
+ } }),
177
+ ui.button("\u5237\u65B0\u5BC6\u94A5\u72B6\u6001", { onClick: () => {
178
+ void keyAction(async () => {
179
+ showKey((await ctx.invoke("state")).keySet);
180
+ void ctx.refresh().catch(() => void 0);
181
+ return "\u5DF2\u5237\u65B0\u5BC6\u94A5\u72B6\u6001";
182
+ });
183
+ } }),
184
+ keyStatus
185
+ );
186
+ showKey(state.keySet);
187
+ const steps = ui.h("ol");
188
+ steps.style.cssText = "list-style:decimal;padding-left:24px;margin:6px 0;line-height:1.8";
189
+ const secret = ui.h("code", "mono", `${SURFING_MICROLINK_SECRET}=`);
190
+ for (const parts of [
191
+ ["\u6253\u5F00", anchor("Microlink \u6CE8\u518C\u9875", "https://dashboard.microlink.io/signup"), "\uFF0C\u6CE8\u518C\u8D26\u53F7\u5E76\u767B\u5F55\uFF0C\u65E0\u9700\u8D2D\u4E70\u4ED8\u8D39\u5957\u9910\u3002"],
192
+ ["\u5728\u63A7\u5236\u53F0\u5DE6\u4FA7\u9009\u62E9\u300COverview\u300D\uFF0C\u70B9\u51FB\u300CUsage\u300D\u6807\u7B7E\u3002"],
193
+ ["\u70B9\u51FB\u300CDevelopment\u300D\u6807\u7B7E\u53F3\u4E0A\u89D2\u7684\u300C\u2026\u300D\uFF0C\u590D\u5236 API Key\u3002"],
194
+ ["\u70B9\u51FB\u300C\u6253\u5F00\u5BC6\u94A5\u6587\u4EF6\u300D\uFF0C\u5C06 Key \u586B\u5728 ", secret, " \u540E\u9762\uFF0C\u4E0D\u52A0\u5F15\u53F7\uFF0C\u4FDD\u5B58\u6587\u4EF6\u3002"],
195
+ ["\u4FDD\u5B58\u540E\uFF0C\u4E0B\u6B21\u8FDC\u7A0B\u8BFB\u53D6\u4F7F\u7528\u8BE5 Key \u7684\u989D\u5EA6\u3002\u70B9\u51FB\u300C\u5237\u65B0\u5BC6\u94A5\u72B6\u6001\u300D\u53EF\u67E5\u770B\u914D\u7F6E\u72B6\u6001\u3002\u6E05\u7A7A Key \u7684\u503C\u5E76\u4FDD\u5B58\u53EF\u6062\u590D\u514D Key \u8C03\u7528\u3002"]
196
+ ]) {
197
+ const step = ui.h("li");
198
+ step.append(...parts);
199
+ steps.append(step);
200
+ }
201
+ const limits = ui.h("p", "muted");
202
+ limits.append("\u514D Key \u548C\u514D\u8D39 Key \u7684\u989D\u5EA6\u4E0D\u540C\uFF0C\u5F53\u524D\u9650\u5236\u89C1", anchor("Microlink \u989D\u5EA6\u8BF4\u660E", "https://microlink.io/docs/api/basics/rate-limit"), "\u3002");
203
+ const microlink = ui.sheet({
204
+ title: "Microlink \u8FDC\u7A0B\u6E32\u67D3",
205
+ desc: "\u672C\u673A Chromium \u4E0D\u53EF\u7528\u65F6\uFF0C\u5C06\u7F51\u9875 URL \u4EA4\u7ED9 Microlink \u6E32\u67D3\u3002API Key \u53EF\u9009\uFF0C\u7559\u7A7A\u4F7F\u7528\u514D Key \u989D\u5EA6\u3002"
206
+ });
207
+ const help = ui.foldSheet("surfing-microlink-key", { title: "\u5982\u4F55\u53D6\u5F97 API Key", defaultOpen: false });
208
+ help.body.append(steps, limits);
209
+ microlink.body.append(keyBar, help.el);
210
+ layout.append(microlink.el);
211
+ ctx.root.replaceChildren(layout);
212
+ }
213
+ var client_default = { panels: { config: { mount } } };
214
+ export {
215
+ client_default as default
216
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cortico-world-surfing",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "type": "module",
5
5
  "description": "读取公开网页正文和 B站视频简介、字幕、评论,支持视频搜索、分段续读和可调返回量。",
6
6
  "author": "DTMosken",
@@ -27,7 +27,8 @@
27
27
  "browser",
28
28
  "video"
29
29
  ],
30
- "describe": "describe.json"
30
+ "describe": "describe.json",
31
+ "webUiClient": "dist/webui.js"
31
32
  },
32
33
  "files": [
33
34
  "src",
@@ -43,6 +44,8 @@
43
44
  "scripts": {
44
45
  "test": "node scripts/build-engine.mjs && vitest run",
45
46
  "build:engine": "node scripts/build-engine.mjs",
47
+ "build:web": "esbuild src/webui/client.ts --bundle --format=esm --outfile=dist/webui.js",
48
+ "typecheck:web": "tsc -p tsconfig.web.json --noEmit",
46
49
  "typecheck": "tsc --noEmit",
47
50
  "install:browser": "node scripts/install-browser.mjs"
48
51
  },
@@ -55,7 +58,7 @@
55
58
  "devDependencies": {
56
59
  "@types/jsdom": "^27.0.0",
57
60
  "@types/node": "^22.10.0",
58
- "cortico": "^0.2.1",
61
+ "cortico": "0.2.10",
59
62
  "esbuild": "0.28.2",
60
63
  "typescript": "^5.7.2",
61
64
  "vitest": "^2.1.8"
package/src/config.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import type { ConfigGroup } from 'cortico/core/types.ts';
2
2
 
3
+ export const SURFING_MICROLINK_SECRET = 'CORTICO_SURFING_MICROLINK_API_KEY';
4
+
3
5
  export interface SurfingConfigSection {
4
6
  enabled: boolean;
5
7
  reading: { maxResponseEstimatedTokens: number; maxSourceChars: number };
@@ -18,7 +20,7 @@ export const SURFING_DEFAULTS: SurfingConfigSection = {
18
20
 
19
21
  export const SURFING_CONFIG_GROUP: ConfigGroup = {
20
22
  id: 'world:surfing', owner: 'world:surfing',
21
- schema: { type: 'object', title: '阅读返回量', properties: {
23
+ schema: { type: 'object', title: '阅读与字幕', properties: {
22
24
  'worlds.surfing.reading.maxResponseEstimatedTokens': {
23
25
  type: 'integer', title: '单次返回上限', minimum: 1024, maximum: 32768,
24
26
  'x-suffix': '估算 token', 'x-hot': true,
@@ -66,6 +68,12 @@ export const SURFING_LIMITS_CONFIG_GROUP: ConfigGroup = {
66
68
  } },
67
69
  };
68
70
 
71
+ export const SURFING_CONFIG_GROUPS = [SURFING_CONFIG_GROUP, SURFING_LIMITS_CONFIG_GROUP];
72
+
73
+ export function configValue(config: SurfingConfigSection, path: string): unknown {
74
+ return path.split('.').slice(2).reduce<unknown>((part, key) => (part as Record<string, unknown>)?.[key], config);
75
+ }
76
+
69
77
  export function applyBiliDefaults(config: SurfingConfigSection): void {
70
78
  if (config.bili.maxSubtitleRetries === undefined) config.bili.maxSubtitleRetries = SURFING_DEFAULTS.bili.maxSubtitleRetries;
71
79
  if (config.bili.subtitleRetryDelayMs === undefined) config.bili.subtitleRetryDelayMs = SURFING_DEFAULTS.bili.subtitleRetryDelayMs;
@@ -73,9 +81,9 @@ export function applyBiliDefaults(config: SurfingConfigSection): void {
73
81
 
74
82
  export function validateConfig(config: SurfingConfigSection): void {
75
83
  applyBiliDefaults(config);
76
- for (const group of [SURFING_CONFIG_GROUP, SURFING_LIMITS_CONFIG_GROUP]) {
84
+ for (const group of SURFING_CONFIG_GROUPS) {
77
85
  for (const [path, schema] of Object.entries(group.schema.properties)) {
78
- const value = path.split('.').slice(2).reduce<unknown>((part, key) => (part as Record<string, unknown>)?.[key], config);
86
+ const value = configValue(config, path);
79
87
  if (typeof value !== 'number' || !Number.isInteger(value) || value < schema.minimum! || value > schema.maximum!)
80
88
  throw new Error(`${schema.title}超出配置范围。`);
81
89
  }
@@ -0,0 +1,35 @@
1
+ import { ReadError, validatePublicUrl, type ReadOperation } from './network.ts';
2
+
3
+ export class MicrolinkRenderer {
4
+ constructor(private readonly apiKey: () => string = () => '') {}
5
+
6
+ async render(input: string, operation: ReadOperation, maxBytes: number): Promise<{ html: string; url: string }> {
7
+ const target = validatePublicUrl(input);
8
+ const key = this.apiKey().trim();
9
+ const endpoint = new URL(key ? 'https://pro.microlink.io/' : 'https://api.microlink.io/');
10
+ endpoint.search = new URLSearchParams({ url: target.href, 'data.html.attr': 'html', meta: 'false', prerender: 'true' }).toString();
11
+ const response = await operation.get(endpoint.href, {
12
+ headers: key ? { 'x-api-key': key } : undefined,
13
+ allowHost: hostname => hostname === endpoint.hostname,
14
+ });
15
+ if (response.status === 429) throw new ReadError('access_denied', 'Microlink 请求额度已用完或请求过于频繁,请等待额度重置或检查套餐。');
16
+ if (response.status === 401 || response.status === 403)
17
+ throw new ReadError('access_denied', 'Microlink 未允许本次读取,请检查可选 API Key 和访问权限。');
18
+ if (response.status !== 200) throw new ReadError('network_error', `Microlink 读取失败(HTTP ${response.status})。`);
19
+ let result: { status?: string; code?: string; statusCode?: number; data?: { html?: string; url?: string } };
20
+ try { result = JSON.parse(response.body.toString('utf8')); }
21
+ catch { throw new ReadError('protocol_error', 'Microlink 未返回有效 JSON。'); }
22
+ if (result?.status !== 'success') {
23
+ if (result?.code === 'EPROXYNEEDED') throw new ReadError('access_denied', '该网页需要 Microlink 代理,请配置 API Key 后重试。');
24
+ throw new ReadError('content_unavailable', 'Microlink 未取得渲染后的网页。');
25
+ }
26
+ if (result.statusCode !== 200)
27
+ throw new ReadError(result.statusCode === 404 ? 'not_found' : 'access_denied', '网页未允许本次 Microlink 公开读取。');
28
+ if (typeof result.data?.html !== 'string' || typeof result.data.url !== 'string')
29
+ throw new ReadError('protocol_error', 'Microlink 响应缺少 HTML 或最终网页地址。');
30
+ if (Buffer.byteLength(result.data.html) > maxBytes) throw new ReadError('source_limit', '渲染后的 HTML 超出单次读取上限。');
31
+ const url = validatePublicUrl(result.data.url);
32
+ if (!url.hash) url.hash = target.hash;
33
+ return { html: result.data.html, url: url.href };
34
+ }
35
+ }
package/src/network.ts CHANGED
@@ -76,7 +76,7 @@ export function requestHeaders(options: GetOptions): Record<string, string> {
76
76
  'accept-encoding': 'gzip, deflate, br', accept: '*/*', 'accept-language': '*',
77
77
  };
78
78
  for (const [key, value] of Object.entries(options.headers ?? {})) {
79
- if (['accept', 'accept-language', 'referer', 'user-agent'].includes(key.toLowerCase())) headers[key.toLowerCase()] = value;
79
+ if (['accept', 'accept-language', 'referer', 'user-agent', 'x-api-key'].includes(key.toLowerCase())) headers[key.toLowerCase()] = value;
80
80
  }
81
81
  return headers;
82
82
  }
@@ -3,6 +3,7 @@ import { ReadError, asReadError, validatePublicUrl, waitWithSignal, type Failure
3
3
  import { createServer, type Server } from 'node:net';
4
4
  import { chromium, type Browser } from 'playwright';
5
5
  import type { Material } from './snapshots.ts';
6
+ import { MicrolinkRenderer } from './microlink.ts';
6
7
 
7
8
  const ENGINE = new URL('../dist/page-engine.mjs', import.meta.url);
8
9
  export function pageKey(input: string): string { return 'page:' + validatePublicUrl(input).href; }
@@ -27,7 +28,7 @@ function extract(html: string, url: string, signal: AbortSignal): Promise<Materi
27
28
  }
28
29
 
29
30
  export class PageReader {
30
- constructor(private readonly renderer = new Renderer()) {}
31
+ constructor(private readonly renderer = new Renderer(), private readonly microlink = new MicrolinkRenderer()) {}
31
32
 
32
33
  async read(input: string, operation: ReadOperation, maxBytes: number): Promise<Material> {
33
34
  const original = validatePublicUrl(input);
@@ -65,10 +66,19 @@ export class PageReader {
65
66
  catch (error) {
66
67
  if (!(error instanceof ReadError) || error.kind !== 'content_unavailable') throw error;
67
68
  try {
68
- const rendered = await this.renderer.render(target.href, operation, maxBytes,response);
69
+ let rendered: { html: string; url: string };
70
+ let provider = 'chromium';
71
+ try { rendered = await this.renderer.render(target.href, operation, maxBytes, response); }
72
+ catch (error) {
73
+ if (operation.signal.aborted || !(error instanceof ReadError) || error.kind !== 'browser_unavailable') throw error;
74
+ rendered = await this.microlink.render(target.href, operation, maxBytes);
75
+ provider = 'microlink';
76
+ }
77
+ this.rejectVideo(validatePublicUrl(rendered.url));
69
78
  material = await extract(rendered.html, rendered.url, operation.signal);
70
79
  delete material.scope.mayNeedRendering;
71
80
  material.scope.extraction = 'rendered-html';
81
+ material.scope.renderingProvider = provider;
72
82
  } catch (renderError) {
73
83
  if (!staticMaterial || operation.signal.aborted) throw renderError;
74
84
  const failure = asReadError(renderError);
@@ -0,0 +1,24 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { appendFileSync, existsSync, readFileSync, writeFileSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { SURFING_MICROLINK_SECRET } from './config.ts';
5
+
6
+ export function prepareKeyFile(botDir: string): string {
7
+ const file = join(botDir, '.env');
8
+ const contents = existsSync(file) ? readFileSync(file, 'utf8') : '';
9
+ if (!new RegExp(`^${SURFING_MICROLINK_SECRET}=`, 'm').test(contents)) {
10
+ const line = `${contents && !contents.endsWith('\n') ? '\n' : ''}${SURFING_MICROLINK_SECRET}=\n`;
11
+ if (existsSync(file)) appendFileSync(file, line);
12
+ else writeFileSync(file, line, { mode: 0o600 });
13
+ }
14
+ return file;
15
+ }
16
+
17
+ export function openSecretFile(file: string): Promise<void> {
18
+ const command = process.platform === 'win32' ? 'notepad.exe' : process.platform === 'darwin' ? 'open' : 'xdg-open';
19
+ return new Promise((resolve, reject) => {
20
+ const child = spawn(command, [file], { detached: true, stdio: 'ignore' });
21
+ child.once('error', () => reject(new Error('无法打开密钥文件。')));
22
+ child.once('spawn', () => { child.unref(); resolve(); });
23
+ });
24
+ }
@@ -0,0 +1,97 @@
1
+ import type { WebUiClientBundle, WebUiPanelContext } from 'cortico/web/shared/client-panel.ts';
2
+ import { SURFING_CONFIG_GROUPS, SURFING_LIMITS_CONFIG_GROUP, SURFING_MICROLINK_SECRET } from '../config.ts';
3
+
4
+ interface ConfigState { values: Record<string, number>; keySet: boolean }
5
+
6
+ const errorText = (error: unknown): string => error instanceof Error ? error.message : '操作失败';
7
+
8
+ async function mount(ctx: WebUiPanelContext): Promise<void> {
9
+ const { ui } = ctx;
10
+ const state = await ctx.invoke<ConfigState>('state');
11
+ const layout = ui.h('div');
12
+ layout.style.cssText = 'display:grid;gap:20px;max-width:960px;margin:auto';
13
+ let saves: Promise<void> = Promise.resolve();
14
+ for (const group of SURFING_CONFIG_GROUPS) {
15
+ const sheet = group === SURFING_LIMITS_CONFIG_GROUP
16
+ ? ui.foldSheet('surfing-limits', { title: group.schema.title, defaultOpen: false })
17
+ : ui.sheet({ title: group.schema.title });
18
+ const grid = ui.h('div');
19
+ grid.style.cssText = 'display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,320px),1fr));gap:16px 24px';
20
+ const saved = ui.msgline('');
21
+ for (const [path, property] of Object.entries(group.schema.properties)) {
22
+ const scale = property['x-scale'] ?? 1;
23
+ const input = ui.input({ type: 'number', value: String(state.values[path]! / scale), onChange: value => {
24
+ saves = saves.then(async () => {
25
+ const next = await ctx.invoke<ConfigState>('save', [path, Number(value) * scale]);
26
+ if (input.value === value) input.value = String(next.values[path]! / scale);
27
+ saved.textContent = `已保存:${property.title}`; saved.classList.remove('bad');
28
+ void ctx.refresh().catch(() => undefined);
29
+ }).catch(error => { saved.textContent = errorText(error); saved.classList.add('bad'); });
30
+ } });
31
+ input.min = String(property.minimum! / scale);
32
+ input.max = String(property.maximum! / scale);
33
+ const field = ui.field(`${property.title}(${property['x-suffix']})`, input);
34
+ const note = ui.h('small', 'muted', property.description);
35
+ note.id = path;
36
+ input.setAttribute('aria-describedby', note.id);
37
+ const row = ui.h('div');
38
+ row.append(field, note);
39
+ grid.append(row);
40
+ }
41
+ sheet.body.append(grid, saved);
42
+ layout.append(sheet.el);
43
+ }
44
+
45
+ const anchor = (text: string, href: string): HTMLAnchorElement => {
46
+ const link = ui.h('a', null, text);
47
+ link.href = href; link.target = '_blank'; link.rel = 'noopener noreferrer';
48
+ return link;
49
+ };
50
+ const keyStatus = ui.msgline('');
51
+ let keyPill = ui.pill('', 'off');
52
+ const showKey = (keySet: boolean): void => {
53
+ const next = ui.pill(keySet ? 'API Key 已配置' : '免 Key', keySet ? 'on' : 'off');
54
+ keyPill.replaceWith(next); keyPill = next;
55
+ };
56
+ async function keyAction(run: () => Promise<string>): Promise<void> {
57
+ try { keyStatus.textContent = await run(); keyStatus.classList.remove('bad'); }
58
+ catch (error) { keyStatus.textContent = errorText(error); keyStatus.classList.add('bad'); }
59
+ }
60
+ const keyBar = ui.rowbar();
61
+ keyBar.append(keyPill,
62
+ ui.button('打开密钥文件', { onClick: () => { void keyAction(async () => { await ctx.invoke('openKeyFile'); return '已打开密钥文件'; }); } }),
63
+ ui.button('刷新密钥状态', { onClick: () => { void keyAction(async () => {
64
+ showKey((await ctx.invoke<ConfigState>('state')).keySet);
65
+ void ctx.refresh().catch(() => undefined);
66
+ return '已刷新密钥状态';
67
+ }); } }), keyStatus);
68
+ showKey(state.keySet);
69
+
70
+ const steps = ui.h('ol');
71
+ steps.style.cssText = 'list-style:decimal;padding-left:24px;margin:6px 0;line-height:1.8';
72
+ const secret = ui.h('code', 'mono', `${SURFING_MICROLINK_SECRET}=`);
73
+ for (const parts of [
74
+ ['打开', anchor('Microlink 注册页', 'https://dashboard.microlink.io/signup'), ',注册账号并登录,无需购买付费套餐。'],
75
+ ['在控制台左侧选择「Overview」,点击「Usage」标签。'],
76
+ ['点击「Development」标签右上角的「…」,复制 API Key。'],
77
+ ['点击「打开密钥文件」,将 Key 填在 ', secret, ' 后面,不加引号,保存文件。'],
78
+ ['保存后,下次远程读取使用该 Key 的额度。点击「刷新密钥状态」可查看配置状态。清空 Key 的值并保存可恢复免 Key 调用。'],
79
+ ]) {
80
+ const step = ui.h('li');
81
+ step.append(...parts);
82
+ steps.append(step);
83
+ }
84
+ const limits = ui.h('p', 'muted');
85
+ limits.append('免 Key 和免费 Key 的额度不同,当前限制见', anchor('Microlink 额度说明', 'https://microlink.io/docs/api/basics/rate-limit'), '。');
86
+ const microlink = ui.sheet({
87
+ title: 'Microlink 远程渲染',
88
+ desc: '本机 Chromium 不可用时,将网页 URL 交给 Microlink 渲染。API Key 可选,留空使用免 Key 额度。',
89
+ });
90
+ const help = ui.foldSheet('surfing-microlink-key', { title: '如何取得 API Key', defaultOpen: false });
91
+ help.body.append(steps, limits);
92
+ microlink.body.append(keyBar, help.el);
93
+ layout.append(microlink.el);
94
+ ctx.root.replaceChildren(layout);
95
+ }
96
+
97
+ export default { panels: { config: { mount } } } satisfies WebUiClientBundle;
package/src/world.ts CHANGED
@@ -1,13 +1,16 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { fileURLToPath } from 'node:url';
4
+ import { coerceGroupValues } from 'cortico/core/config-schema.ts';
4
5
  import type { ToolDef, ToolSpec, ToolOutcome, ToolCallContext, World, WorldWebUiDecl, WorldHost } from 'cortico/core/types.ts';
5
6
  import type { WorldContext } from 'cortico/world.ts';
6
- import { SURFING_DEFAULTS, SURFING_CONFIG_GROUP, SURFING_LIMITS_CONFIG_GROUP, applyBiliDefaults, validateConfig, type SurfingConfigSection } from './config.ts';
7
+ import { SURFING_CONFIG_GROUPS, SURFING_MICROLINK_SECRET, applyBiliDefaults, configValue, validateConfig, type SurfingConfigSection } from './config.ts';
7
8
  import { BiliClient, biliKey, biliCommentsKey, type BiliInput, type BiliCommentsInput } from './bili.ts';
8
9
  import { ReadError, asReadError, PublicClient, waitWithSignal, type ReadOperation } from './network.ts';
9
10
  import { PageReader, pageKey } from './page-reader.ts';
10
11
  import { SnapshotCache, estimateTokens, type Material } from './snapshots.ts';
12
+ import { MicrolinkRenderer } from './microlink.ts';
13
+ import { prepareKeyFile, openSecretFile } from './secret-file.ts';
11
14
 
12
15
  const ENV_PROMPT_FILE = fileURLToPath(new URL('./ENV_PROMPT.md', import.meta.url));
13
16
 
@@ -68,14 +71,16 @@ export class SurfingWorld implements World {
68
71
  readonly id = 'surfing';
69
72
  private readonly cache = new SnapshotCache();
70
73
  private readonly bili = new BiliClient();
71
- private readonly pages = new PageReader();
74
+ private readonly pages: PageReader;
72
75
  private readonly pendingBatches = new Map<string,{promise:Promise<string>;signal:AbortSignal}>();
73
76
  private lifecycle = new AbortController();
74
77
  private stopped = false;
75
78
  private last?: { status: string; failed: boolean; estimatedTokens: number; sourceTruncated: boolean };
76
79
 
77
- constructor(private readonly ctx: WorldContext<SurfingConfigSection>, private readonly client = new PublicClient()) {
80
+ constructor(private readonly ctx: WorldContext<SurfingConfigSection>, private readonly client = new PublicClient(),
81
+ private readonly openFile = openSecretFile) {
78
82
  applyBiliDefaults(ctx.cfg);
83
+ this.pages = new PageReader(undefined, new MicrolinkRenderer(() => ctx.secret(SURFING_MICROLINK_SECRET)));
79
84
  }
80
85
 
81
86
  envPromptVars(): Record<string, string> {
@@ -184,6 +189,22 @@ export class SurfingWorld implements World {
184
189
  }
185
190
  }
186
191
 
192
+ private configState(): { values: Record<string, unknown>; keySet: boolean } {
193
+ const paths = SURFING_CONFIG_GROUPS.flatMap(group => Object.keys(group.schema.properties));
194
+ return { values: Object.fromEntries(paths.map(path => [path, configValue(this.ctx.cfg, path)])),
195
+ keySet: !!this.ctx.secret(SURFING_MICROLINK_SECRET).trim() };
196
+ }
197
+
198
+ private saveConfig(path: unknown, value: unknown): void {
199
+ const group = SURFING_CONFIG_GROUPS.find(candidate => typeof path === 'string' && Object.hasOwn(candidate.schema.properties, path));
200
+ if (!group) throw new Error('未知配置项。');
201
+ const key = path as string;
202
+ const result = coerceGroupValues(group, { [key]: value });
203
+ if ('error' in result) throw new Error(result.error);
204
+ const [section, field] = key.split('.').slice(2);
205
+ this.ctx.persist({ [section]: { [field]: result.values[key] } });
206
+ }
207
+
187
208
  webUi(): WorldWebUiDecl {
188
209
  const override = join(this.ctx.botDir, 'prompts', 'worlds', 'surfing', 'ENV_PROMPT.md');
189
210
  return {
@@ -196,7 +217,17 @@ export class SurfingWorld implements World {
196
217
  { label: '源材料', value: this.last ? (this.last.sourceTruncated ? '触及保留上限' : '未触及保留上限') : '尚未读取' },
197
218
  { label: '文本缓存', value: Math.ceil(this.cache.sizeBytes / 1024) + ' KiB' },
198
219
  ],
199
- config: [SURFING_CONFIG_GROUP, SURFING_LIMITS_CONFIG_GROUP],
220
+ panels: [{ id: 'config', title: '配置' }],
221
+ invoke: async (panel, method, args) => {
222
+ if (panel !== 'config') throw new Error('未知面板。');
223
+ if (method === 'state') return this.configState();
224
+ if (method === 'save') { this.saveConfig(args[0], args[1]); return this.configState(); }
225
+ if (method === 'openKeyFile') {
226
+ const file = prepareKeyFile(this.ctx.botDir);
227
+ await this.openFile(file); return { file };
228
+ }
229
+ throw new Error('未知操作。');
230
+ },
200
231
  promptDocs: [{ key: 'worlds.surfing.envPrompt', title: '网上冲浪环境提示词', description: '网页、视频与学习工具的使用指导。',
201
232
  path: existsSync(override) ? override : ENV_PROMPT_FILE, deploymentPath: override, role: 'envPrompt',
202
233
  vars: [