@evandu/super-pi 0.1.130 → 0.1.131
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/super-pi-documents/index.ts +92 -0
- package/agents/super-pi-knowledge/index.ts +32 -1
- package/agents/super-pi-task/core.ts +40 -0
- package/agents/super-pi-task/index.ts +15 -1
- package/agents/super-pi-web/index.ts +32 -0
- package/assets/fonts/LICENSE +92 -0
- package/assets/fonts/NotoSansSC-Bold.otf +0 -0
- package/assets/fonts/NotoSansSC-Regular.otf +0 -0
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +5 -5
- package/dist/client/assets/{CodeMirrorEditor-BNVBZZSS.js → CodeMirrorEditor-BlQhH3vs.js} +2 -2
- package/dist/client/assets/{dist-DmAOqr0M.js → dist-B1_1Ui9E.js} +1 -1
- package/dist/client/assets/{dist-GkIV1LTX.js → dist-B448EWTM.js} +1 -1
- package/dist/client/assets/{dist-CiSGSVBZ.js → dist-B68Q8uXX.js} +1 -1
- package/dist/client/assets/{dist-DB5M5fZG.js → dist-BFlq-kiF.js} +1 -1
- package/dist/client/assets/{dist-BykVFExd.js → dist-BXVFgriA.js} +1 -1
- package/dist/client/assets/{dist-CZ9I2JCd.js → dist-BcE6tJvc.js} +1 -1
- package/dist/client/assets/{dist-BhJGK8-b.js → dist-ByIqsb5-.js} +1 -1
- package/dist/client/assets/{dist-DZnUERoW.js → dist-C6qSkkeN.js} +1 -1
- package/dist/client/assets/{dist-BKp4AWEJ.js → dist-C9AkgNbt.js} +1 -1
- package/dist/client/assets/{dist-CRzRB9k3.js → dist-COp25nhe.js} +1 -1
- package/dist/client/assets/{dist-CMnvChDX.js → dist-CpuexUlh.js} +1 -1
- package/dist/client/assets/{dist-ChszWGDv.js → dist-CtdlYgGc.js} +1 -1
- package/dist/client/assets/{dist-Dq4-QFAJ.js → dist-Cybsoapc.js} +1 -1
- package/dist/client/assets/{dist-Ur37340K.js → dist-D6PoSxR5.js} +1 -1
- package/dist/client/assets/{dist-C90MPTnk.js → dist-D7bj81KQ.js} +1 -1
- package/dist/client/assets/{dist-BBW2SE_R.js → dist-DP2ygAFF.js} +1 -1
- package/dist/client/assets/{dist-B61RSa-K.js → dist-DXNDtyHP.js} +1 -1
- package/dist/client/assets/{dist-CWYRjnj-.js → dist-DxoWS5MH.js} +1 -1
- package/dist/client/assets/{dist-Cxerc7Bb.js → dist-Fhkmvjdx.js} +1 -1
- package/dist/client/assets/{dist-BDygQgZD.js → dist-hfpd-aWa.js} +1 -1
- package/dist/client/assets/{dist-B2nPVdKh.js → dist-lQWWWxh9.js} +1 -1
- package/dist/client/assets/{dist-HdeIBl2J.js → dist-r5Af94tH.js} +1 -1
- package/dist/client/assets/{index-D_c-CYAr.js → index-BApJ3Zbq.js} +3 -3
- package/dist/client/index.html +1 -1
- package/dist/server/application/document-service.d.ts +68 -0
- package/dist/server/application/document-service.js +120 -0
- package/dist/server/application/run-service.js +49 -7
- package/dist/server/documents.d.ts +152 -0
- package/dist/server/documents.js +998 -0
- package/dist/server/file-changes.js +6 -4
- package/dist/server/files.d.ts +32 -0
- package/dist/server/files.js +28 -0
- package/dist/server/http-server.js +113 -2
- package/dist/server/index.js +13 -1
- package/dist/server/inline-thinking.d.ts +90 -0
- package/dist/server/inline-thinking.js +181 -0
- package/dist/server/knowledge.d.ts +24 -14
- package/dist/server/knowledge.js +47 -32
- package/dist/server/pi-session-pool.js +2 -1
- package/dist/server/pi-session.d.ts +4 -0
- package/dist/server/pi-session.js +11 -0
- package/dist/server/presentations/schema.d.ts +120 -0
- package/dist/server/presentations/schema.js +128 -0
- package/dist/server/runtime.d.ts +46 -0
- package/dist/server/runtime.js +150 -16
- package/dist/server/server-context.d.ts +3 -0
- package/dist/server/subagents.js +33 -6
- package/dist/server/web.d.ts +24 -0
- package/dist/server/web.js +180 -0
- package/package.json +31 -5
- package/skills/presentation/SKILL.md +30 -0
- package/skills/presentation/references/data.md +10 -0
- package/skills/presentation/references/quality.md +12 -0
- package/skills/presentation/references/story.md +12 -0
- package/skills/presentation/references/templates-and-editing.md +11 -0
- package/skills/presentation/references/visual-system.md +10 -0
package/dist/server/runtime.js
CHANGED
|
@@ -2,7 +2,9 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { RpcChannel } from "./rpc-channel.js";
|
|
3
3
|
export { RpcChannel } from "./rpc-channel.js";
|
|
4
4
|
import { randomUUID } from "node:crypto";
|
|
5
|
-
import { KNOWLEDGE_TOOL_NAME } from "./knowledge.js";
|
|
5
|
+
import { KNOWLEDGE_IMPORT_TOOL_NAME, KNOWLEDGE_TOOL_NAME } from "./knowledge.js";
|
|
6
|
+
import { DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME } from "./documents.js";
|
|
7
|
+
import { WEB_FETCH_TOOL_NAME, WEB_SEARCH_TOOL_NAME } from "./web.js";
|
|
6
8
|
import { existsSync } from "node:fs";
|
|
7
9
|
import { mkdir, stat } from "node:fs/promises";
|
|
8
10
|
import { forkSessionFile, truncateSessionFile } from "./session-repository.js";
|
|
@@ -22,6 +24,7 @@ import { TASK_CONVERSATION_ENV, TASK_DEPTH_ENV, TASK_ENDPOINT_ENV, TASK_EXEC_END
|
|
|
22
24
|
import { BLOCKING_UI_METHODS, EXTENSION_UI_METHODS, } from "./types.js";
|
|
23
25
|
import { loadConversationImages } from "./upload.js";
|
|
24
26
|
import { describeToolCall, describeToolCallArgs, describeToolCallInProgress, extractPartialResultText, extractToolResultText, readQuestionCall } from "./tool-calls.js";
|
|
27
|
+
import { InlineThinkingSplitter, stripInlineThinking } from "./inline-thinking.js";
|
|
25
28
|
import { toolTargetPath } from "./file-changes.js";
|
|
26
29
|
const traceLatency = (conversationId, phase, startedAt, extra = "") => {
|
|
27
30
|
if (process.env.SUPER_PI_TRACE_LATENCY !== "1")
|
|
@@ -238,6 +241,13 @@ export const catalogKey = (selection) => (selection?.provider && selection.model
|
|
|
238
241
|
export function taskExtensionPath(from = import.meta.dirname) {
|
|
239
242
|
return resolve(from, "../../agents/super-pi-task/index.ts");
|
|
240
243
|
}
|
|
244
|
+
/**
|
|
245
|
+
* The document extension's file, resolved the same way the task extension's is: two `..` segments
|
|
246
|
+
* from `import.meta.dirname` reach the repository root both after `tsc -b` and under `tsx watch`.
|
|
247
|
+
*/
|
|
248
|
+
export function documentExtensionPath(from = import.meta.dirname) {
|
|
249
|
+
return resolve(from, "../../agents/super-pi-documents/index.ts");
|
|
250
|
+
}
|
|
241
251
|
/** Bridges a Pi session to Super Pi through Pi's documented JSONL RPC protocol. */
|
|
242
252
|
/**
|
|
243
253
|
* The tool's name as far as the stream has revealed it so far, or an empty string while the model is
|
|
@@ -356,6 +366,8 @@ export class PiRpcRuntime {
|
|
|
356
366
|
modelEpoch = 0;
|
|
357
367
|
/** Latches the "no extension on disk" warning, so it is said once rather than once per turn. */
|
|
358
368
|
warnedMissingExtension = false;
|
|
369
|
+
/** Latches the "the document extension is missing" warning, for the same reason. */
|
|
370
|
+
warnedMissingDocumentExtension = false;
|
|
359
371
|
/** Latches the "Pi's SDK is not installed" warning, for the same reason. */
|
|
360
372
|
warnedMissingSdk = false;
|
|
361
373
|
/**
|
|
@@ -541,6 +553,30 @@ export class PiRpcRuntime {
|
|
|
541
553
|
}
|
|
542
554
|
return ["--extension", path];
|
|
543
555
|
}
|
|
556
|
+
/**
|
|
557
|
+
* The document extension's spawn argument.
|
|
558
|
+
*
|
|
559
|
+
* IT IS MOUNTED UNCONDITIONALLY, unlike the knowledge extension. The knowledge extension is only
|
|
560
|
+
* worth mounting when the workspace HAS documents, because its tool returns nothing otherwise; the
|
|
561
|
+
* document tools are about the files in the workspace, and a workspace with no PDF in it is exactly
|
|
562
|
+
* the workspace where an agent might be asked to WRITE one.
|
|
563
|
+
*
|
|
564
|
+
* The endpoint is the gate: without it the extension returns without registering anything, so a
|
|
565
|
+
* runtime with no document endpoint mounts nothing rather than a tool whose every call fails.
|
|
566
|
+
*/
|
|
567
|
+
documentExtensionArgs() {
|
|
568
|
+
if (!this.options.documentsEndpoint)
|
|
569
|
+
return [];
|
|
570
|
+
const path = documentExtensionPath();
|
|
571
|
+
if (!existsSync(path)) {
|
|
572
|
+
if (!this.warnedMissingDocumentExtension) {
|
|
573
|
+
this.warnedMissingDocumentExtension = true;
|
|
574
|
+
console.warn(`PiRpcRuntime: ${path} does not exist, so the primary agent cannot read or write PDF, Word and Excel files.`);
|
|
575
|
+
}
|
|
576
|
+
return [];
|
|
577
|
+
}
|
|
578
|
+
return ["--extension", path];
|
|
579
|
+
}
|
|
544
580
|
/**
|
|
545
581
|
* The five values the extension reads out of its own environment. See `task-tool.ts` for why they are
|
|
546
582
|
* written out on both sides rather than imported.
|
|
@@ -579,6 +615,14 @@ export class PiRpcRuntime {
|
|
|
579
615
|
[WORKFLOW_ENDPOINT_ENV]: this.options.workflowEndpoint ?? "",
|
|
580
616
|
SUPER_PI_QUESTION_ENDPOINT: this.options.questionEndpoint ?? "",
|
|
581
617
|
SUPER_PI_TOOLS_ENDPOINT: this.options.toolsEndpoint ?? "",
|
|
618
|
+
// The document tools' endpoint and the conversation they act for. The CREDENTIAL is not here:
|
|
619
|
+
// it is the session's own capability, which the session pool adds to this environment when the
|
|
620
|
+
// session is born, and the document extension reads that same value. Minting a second
|
|
621
|
+
// credential for the same session would be a second thing to rotate and a second thing to
|
|
622
|
+
// forget to release.
|
|
623
|
+
SUPER_PI_DOCUMENTS_ENDPOINT: this.options.documentsEndpoint ?? "",
|
|
624
|
+
SUPER_PI_DOCUMENTS_CONVERSATION: conversationId,
|
|
625
|
+
SUPER_PI_WEB_ENDPOINT: process.env.SUPER_PI_WEB_ENDPOINT ?? "",
|
|
582
626
|
};
|
|
583
627
|
}
|
|
584
628
|
/**
|
|
@@ -590,8 +634,7 @@ export class PiRpcRuntime {
|
|
|
590
634
|
* here. A session lives for many turns, so its credential is minted when the session is created (see
|
|
591
635
|
* `PiSessionPool.acquire`) and the server separately requires a running turn for it to be usable.
|
|
592
636
|
*/
|
|
593
|
-
async oneShotExtensionEnv(conversationId) {
|
|
594
|
-
const capability = mintCapability(conversationId);
|
|
637
|
+
async oneShotExtensionEnv(conversationId, capability) {
|
|
595
638
|
return {
|
|
596
639
|
...(await this.taskExtensionEnv(conversationId)),
|
|
597
640
|
[TASK_CAPABILITY_ENV]: capability,
|
|
@@ -1016,10 +1059,65 @@ export class PiRpcRuntime {
|
|
|
1016
1059
|
if (this.memory)
|
|
1017
1060
|
names.push("memory", "skill_manage", "session_search");
|
|
1018
1061
|
if (this.knowledge)
|
|
1019
|
-
names.push(KNOWLEDGE_TOOL_NAME);
|
|
1062
|
+
names.push(KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME);
|
|
1063
|
+
if (process.env.SUPER_PI_WEB_ENDPOINT)
|
|
1064
|
+
names.push(WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME);
|
|
1065
|
+
// The document tools are named whenever the endpoint is configured, because that is what decides
|
|
1066
|
+
// whether the extension registers them. An agent that declares `tools: {"*": false, read: true}`
|
|
1067
|
+
// would otherwise get a loaded-but-undeclared tool - broken with no error to explain it.
|
|
1068
|
+
if (this.options.documentsEndpoint)
|
|
1069
|
+
names.push(DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME);
|
|
1020
1070
|
return names;
|
|
1021
1071
|
}
|
|
1072
|
+
/**
|
|
1073
|
+
* A TURN, WITH THE REASONING TAKEN OUT OF THE ANSWER.
|
|
1074
|
+
*
|
|
1075
|
+
* This is the ONE place every turn passes through, whichever path it runs on - the persistent
|
|
1076
|
+
* session or a one-shot process - so it is where the model's own text is read once and read
|
|
1077
|
+
* properly.
|
|
1078
|
+
*
|
|
1079
|
+
* WHAT IT DOES. A model that has no reasoning channel answers in one stream of text and marks its
|
|
1080
|
+
* thinking up inside it with `<think>…</think>`. Pi's adapter passes that through as ordinary
|
|
1081
|
+
* text, because Pi only knows the four reasoning FIELDS a provider can use and a tag is not one
|
|
1082
|
+
* of them. Left alone, the reasoning would be drawn as part of the answer, unlabelled, with the
|
|
1083
|
+
* tags in it.
|
|
1084
|
+
*
|
|
1085
|
+
* WHY IT IS HERE AND NOT IN THE USER'S `models.json`. The alternative is to tell every reader to
|
|
1086
|
+
* go and set a vendor-specific switch in a file Super Pi owns but Pi reads. A user who never
|
|
1087
|
+
* hears of it sees a broken transcript and has no way to find out why. Super Pi reads the tags
|
|
1088
|
+
* itself, for every provider, so the transcript reads the same way whichever endpoint is behind
|
|
1089
|
+
* it - and the reasoning goes where the reasoning goes: into a thinking block, over the same
|
|
1090
|
+
* channel Pi's own reasoning events use, so the fold, the live indicator and the persisted
|
|
1091
|
+
* transcript all work without knowing where the reasoning came from.
|
|
1092
|
+
*/
|
|
1022
1093
|
async *answer(input) {
|
|
1094
|
+
const splitter = new InlineThinkingSplitter();
|
|
1095
|
+
for await (const chunk of this.answerTurn(input)) {
|
|
1096
|
+
for (const segment of splitter.push(chunk)) {
|
|
1097
|
+
if (segment.kind === "text")
|
|
1098
|
+
yield segment.text;
|
|
1099
|
+
else if (segment.kind === "thinking")
|
|
1100
|
+
input.onThinking?.(segment.text);
|
|
1101
|
+
else
|
|
1102
|
+
input.onThinkingEnd?.();
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
// THE END OF THE TURN. A model that was still thinking when the turn ended leaves an unclosed
|
|
1106
|
+
// region behind, and it belongs in the block rather than in the answer.
|
|
1107
|
+
for (const segment of splitter.flush()) {
|
|
1108
|
+
if (segment.kind === "text")
|
|
1109
|
+
yield segment.text;
|
|
1110
|
+
else if (segment.kind === "thinking")
|
|
1111
|
+
input.onThinking?.(segment.text);
|
|
1112
|
+
else
|
|
1113
|
+
input.onThinkingEnd?.();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* THE TURN ITSELF, on whichever path it runs. See `answer`, which is the seam the reasoning is
|
|
1118
|
+
* separated at.
|
|
1119
|
+
*/
|
|
1120
|
+
async *answerTurn(input) {
|
|
1023
1121
|
// The one-shot paths keep their own child process, and they are NOT the same thing as a conversation
|
|
1024
1122
|
// turn: a memory review and a knowledge reranker have their own ids, their own tool sets and their own
|
|
1025
1123
|
// lifetimes, and folding them into a conversation's session would give them that conversation's
|
|
@@ -1111,15 +1209,30 @@ export class PiRpcRuntime {
|
|
|
1111
1209
|
const memoryToken = memoryEnabled ? this.memory.issue(validationId, { review: memoryReview }) : undefined;
|
|
1112
1210
|
const memoryExtension = resolve(import.meta.dirname, "../../agents/super-pi-memory/index.ts");
|
|
1113
1211
|
const memoryArgs = memoryEnabled ? ["--extension", memoryExtension] : [];
|
|
1114
|
-
// Mounted
|
|
1115
|
-
//
|
|
1116
|
-
//
|
|
1117
|
-
//
|
|
1118
|
-
//
|
|
1119
|
-
|
|
1212
|
+
// Mounted on EVERY primary turn, empty base or not. The old condition (hasDocuments) saved a
|
|
1213
|
+
// catalogue round trip and a tool slot when there was nothing to find, but it also made the one
|
|
1214
|
+
// workspace where a user most plausibly says 「把这些文件加入知识库」 the one workspace whose
|
|
1215
|
+
// agent had no import tool. knowledge_search on an empty base answers no hits and costs nothing;
|
|
1216
|
+
// knowledge_import has to exist before the first document does. Review turns never mount it: a
|
|
1217
|
+
// background reflection has no business reading or writing documents, and its `--tools` line is a
|
|
1218
|
+
// closed list. A reranker never mounts it either - it must not be able to call what it ranks.
|
|
1219
|
+
const knowledgeEnabled = !memoryReview && !knowledgeRerank && !!this.knowledge && this.knowledge.enabled();
|
|
1120
1220
|
const knowledgeToken = knowledgeEnabled ? this.knowledge.issue(conversation.id) : undefined;
|
|
1121
1221
|
const knowledgeExtension = resolve(import.meta.dirname, "../../agents/super-pi-knowledge/index.ts");
|
|
1122
1222
|
const knowledgeArgs = knowledgeEnabled ? ["--extension", knowledgeExtension] : [];
|
|
1223
|
+
const webExtension = resolve(import.meta.dirname, "../../agents/super-pi-web/index.ts");
|
|
1224
|
+
const webEnabled = !memoryReview && !knowledgeRerank && existsSync(webExtension) && !!process.env.SUPER_PI_WEB_ENDPOINT;
|
|
1225
|
+
const webArgs = webEnabled ? ["--extension", webExtension] : [];
|
|
1226
|
+
// The document tools are mounted for every ordinary turn, and NOT for a review or a rerank: a
|
|
1227
|
+
// background reflection has no business reading a PDF it was not asked about, and a reranker
|
|
1228
|
+
// must not be able to reach the tools it is ranking.
|
|
1229
|
+
const documentsEnabled = !memoryReview && !knowledgeRerank && this.documentExtensionArgs().length > 0;
|
|
1230
|
+
const documentArgs = documentsEnabled ? this.documentExtensionArgs() : [];
|
|
1231
|
+
// THE ONE-SHOT CHILD'S CREDENTIAL, MINTED ONCE. The server holds ONE capability per
|
|
1232
|
+
// conversation, so minting a second one for the same conversation would replace the first and
|
|
1233
|
+
// leave the task dispatch holding a token the server has already forgotten. Both the task
|
|
1234
|
+
// extension and the document extension therefore share this one value.
|
|
1235
|
+
const oneShotCapability = (extensionArgs.length || documentsEnabled || webEnabled) ? mintCapability(conversation.id) : undefined;
|
|
1123
1236
|
// BOTH extension tools, or neither. Naming one while the other goes unmentioned would be a lie in a
|
|
1124
1237
|
// different direction: Pi filters its registry through `--tools`, so an agent that declares
|
|
1125
1238
|
// `tools: {"*": false, read: true}` gets whichever of the two is on this line and silently not the
|
|
@@ -1132,13 +1245,13 @@ export class PiRpcRuntime {
|
|
|
1132
1245
|
// allowlist and naming a tool Pi has not registered would change the spawn line for every other
|
|
1133
1246
|
// mode. The prompt asks for a bare JSON array and nothing else; if the model spends a turn on a
|
|
1134
1247
|
// tool call anyway the reranker times out and falls back to the lexical order.
|
|
1135
|
-
const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME] : [])] });
|
|
1248
|
+
const agentArgs = memoryReview ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates", "--tools", "memory,skill_manage,session_search"] : knowledgeRerank ? ["--no-extensions", "--no-skills", "--no-mcp", "--no-context-files", "--no-prompt-templates"] : await agentSpawnArgs(this.agents.promptDir, agent, { extraAllowedTools: [...(extensionArgs.length ? [TASK_TOOL_NAME, TODO_TOOL_NAME, WORKFLOW_TOOL_NAME, RUN_ASYNC_TOOL_NAME, "question", "tool_register"] : []), ...(memoryEnabled ? ["memory", "skill_manage", "session_search"] : []), ...(knowledgeEnabled ? [KNOWLEDGE_TOOL_NAME, KNOWLEDGE_IMPORT_TOOL_NAME] : []), ...(documentsEnabled ? [DOCUMENT_READ_TOOL_NAME, DOCUMENT_WRITE_TOOL_NAME] : []), ...(webEnabled ? [WEB_SEARCH_TOOL_NAME, WEB_FETCH_TOOL_NAME] : [])] });
|
|
1136
1249
|
// Refused here, with the reason, rather than handed to the spawn: an ENOENT from `spawn` reads as
|
|
1137
1250
|
// "the app is broken" and hides the one fact that matters, which is where it looked for Pi.
|
|
1138
1251
|
if (!this.pi.found)
|
|
1139
1252
|
throw new Error(this.pi.reason);
|
|
1140
1253
|
// npm exposes global CLIs as .cmd shims on Windows; Node requires a shell to launch those shims.
|
|
1141
|
-
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...agentArgs], {
|
|
1254
|
+
const child = spawn(shellQuote(this.pi.command), ["--mode", "rpc", "--session-dir", this.sessionDir, "--session-id", conversation.id, ...extensionArgs, ...memoryArgs, ...knowledgeArgs, ...documentArgs, ...webArgs, ...agentArgs], {
|
|
1142
1255
|
cwd: childCwd,
|
|
1143
1256
|
stdio: ["pipe", "pipe", "pipe"],
|
|
1144
1257
|
shell: process.platform === "win32",
|
|
@@ -1146,13 +1259,26 @@ export class PiRpcRuntime {
|
|
|
1146
1259
|
// every turn would silently replace the child's environment with whatever THIS process has at
|
|
1147
1260
|
// spawn time. Inheriting is what an omitted `env` already does, so the two paths differ in nothing
|
|
1148
1261
|
// but the four documented additions.
|
|
1149
|
-
...((extensionArgs.length || memoryEnabled || knowledgeEnabled) ? { env: { ...process.env, ...(
|
|
1262
|
+
...((extensionArgs.length || memoryEnabled || knowledgeEnabled || documentsEnabled || webEnabled) ? { env: { ...process.env, ...(oneShotCapability ? await this.oneShotExtensionEnv(conversation.id, oneShotCapability) : {}), ...(memoryEnabled ? {
|
|
1150
1263
|
SUPER_PI_MEMORY_ENDPOINT: process.env.SUPER_PI_MEMORY_ENDPOINT,
|
|
1151
1264
|
SUPER_PI_MEMORY_TOKEN: memoryToken,
|
|
1152
1265
|
SUPER_PI_MEMORY_REVIEW: memoryReview ? "1" : "0",
|
|
1153
1266
|
} : {}), ...(knowledgeEnabled ? {
|
|
1154
1267
|
SUPER_PI_KNOWLEDGE_ENDPOINT: process.env.SUPER_PI_KNOWLEDGE_ENDPOINT,
|
|
1155
1268
|
SUPER_PI_KNOWLEDGE_TOKEN: knowledgeToken,
|
|
1269
|
+
} : {}), ...(documentsEnabled ? {
|
|
1270
|
+
SUPER_PI_DOCUMENTS_ENDPOINT: this.options.documentsEndpoint ?? "",
|
|
1271
|
+
// The document tools carry the SESSION's capability, the same credential the task dispatch
|
|
1272
|
+
// uses: it proves which conversation is asking, and the server checks that the turn is
|
|
1273
|
+
// running. The conversation id travels with it, because the workspace is derived from the
|
|
1274
|
+
// conversation and never from the request.
|
|
1275
|
+
//
|
|
1276
|
+
// IT IS THE SAME VALUE THE TASK EXTENSION GETS. A one-shot child has no session to borrow a
|
|
1277
|
+
// credential from, so the token is minted for the turn - and minted ONCE, because the server
|
|
1278
|
+
// holds one capability per conversation and a second mint would replace the first in the
|
|
1279
|
+
// map, leaving the task dispatch holding a token the server had already forgotten.
|
|
1280
|
+
SUPER_PI_DOCUMENTS_TOKEN: oneShotCapability ?? "",
|
|
1281
|
+
SUPER_PI_DOCUMENTS_CONVERSATION: conversation.id,
|
|
1156
1282
|
} : {}) } } : {}),
|
|
1157
1283
|
});
|
|
1158
1284
|
const channel = new RpcChannel(child);
|
|
@@ -2070,7 +2196,11 @@ export class PiRpcRuntime {
|
|
|
2070
2196
|
// A model asked for "only the title" still wraps it now and then. Stripping the wrappers is not
|
|
2071
2197
|
// cosmetic: without it a leading quote or a `Title:` prefix becomes the first characters of every
|
|
2072
2198
|
// title in the sidebar.
|
|
2073
|
-
|
|
2199
|
+
//
|
|
2200
|
+
// AND THE MODEL'S OWN REASONING IS STRIPPED FIRST, because a model that thinks inside its
|
|
2201
|
+
// answer puts the thinking in front of the title: the title would then start with `<think>`,
|
|
2202
|
+
// and the sidebar would show the markup of a reasoning trace where a label belongs.
|
|
2203
|
+
const title = stripInlineThinking(text)
|
|
2074
2204
|
.replace(/^\s*(?:title|标题)\s*[::]\s*/iu, "")
|
|
2075
2205
|
.replace(/^[`"'“”‘’「『]+/u, "")
|
|
2076
2206
|
.replace(/[`"'“”‘’」』]+$/u, "")
|
|
@@ -2125,9 +2255,13 @@ export class PiRpcRuntime {
|
|
|
2125
2255
|
}
|
|
2126
2256
|
if (!settled)
|
|
2127
2257
|
throw new Error(`模型在 ${Math.round(input.timeoutMs / 1000)} 秒内没有返回`);
|
|
2128
|
-
|
|
2258
|
+
// THE MODEL'S OWN REASONING IS NOT THE TOOL'S OUTPUT. A model that thinks inside its answer
|
|
2259
|
+
// would otherwise hand the caller a `<think>`-prefixed string, and the caller - a tool, a
|
|
2260
|
+
// title, a card - would store the reasoning trace as its result.
|
|
2261
|
+
const answer = stripInlineThinking(text);
|
|
2262
|
+
if (!answer.trim())
|
|
2129
2263
|
throw new Error("模型没有返回任何内容");
|
|
2130
|
-
return
|
|
2264
|
+
return answer;
|
|
2131
2265
|
}
|
|
2132
2266
|
finally {
|
|
2133
2267
|
if (child.exitCode === null && child.pid !== undefined)
|
|
@@ -39,6 +39,9 @@ export interface EventBroadcaster {
|
|
|
39
39
|
export interface ServerContext {
|
|
40
40
|
memory?: import("./active-memory.js").ActiveMemory;
|
|
41
41
|
knowledge?: import("./knowledge.js").KnowledgeBase;
|
|
42
|
+
web?: import("./web.js").WebService;
|
|
43
|
+
/** The agent-facing document tools. Optional so the server can start without them. */
|
|
44
|
+
documentService?: import("./application/document-service.js").DocumentService;
|
|
42
45
|
paths: ServerPaths;
|
|
43
46
|
store: Store;
|
|
44
47
|
runtime: AgentRuntime;
|
package/dist/server/subagents.js
CHANGED
|
@@ -6,6 +6,7 @@ import { agentSpawnArgs, readAgents } from "./agents.js";
|
|
|
6
6
|
import { resolvePiCommand, shellQuote } from "./pi-command.js";
|
|
7
7
|
import { buildExtensionUiResponse, describeExtensionUiRequest, killTree, } from "./runtime.js";
|
|
8
8
|
import { RpcChannel } from "./rpc-channel.js";
|
|
9
|
+
import { InlineThinkingSplitter, stripInlineThinking } from "./inline-thinking.js";
|
|
9
10
|
import { describeToolCall, TOOL_DETAIL_CAP } from "./tool-calls.js";
|
|
10
11
|
/**
|
|
11
12
|
* The tool-call derivation now lives in `tool-calls.js`, because the primary agent's runtime needs the
|
|
@@ -284,6 +285,15 @@ export class SubagentSupervisor {
|
|
|
284
285
|
let current = "";
|
|
285
286
|
/** The last assistant message's text, which is the run's output. */
|
|
286
287
|
let finalText = "";
|
|
288
|
+
/**
|
|
289
|
+
* THE CHILD'S OWN REASONING, taken out of its answer.
|
|
290
|
+
*
|
|
291
|
+
* A model that has no reasoning channel writes its thinking into the answer, marked up with
|
|
292
|
+
* `<think>…</think>`. This supervisor's contract is `finalText` - the child's OUTPUT - and the
|
|
293
|
+
* reasoning is not part of it, so the tags and what they wrap never reach the card. The splitter
|
|
294
|
+
* is per child, because what it holds back belongs to one child's stream and to nothing else.
|
|
295
|
+
*/
|
|
296
|
+
const inlineThinking = new InlineThinkingSplitter();
|
|
287
297
|
const usage = { ...NO_USAGE };
|
|
288
298
|
/** Set rather than thrown so a child's own failure is reported AFTER its output has been drained. */
|
|
289
299
|
let failed;
|
|
@@ -420,10 +430,18 @@ export class SubagentSupervisor {
|
|
|
420
430
|
// three messages would report all three concatenated as one answer.
|
|
421
431
|
if (event?.type === "text_delta") {
|
|
422
432
|
const delta = event.delta ?? "";
|
|
423
|
-
|
|
424
|
-
|
|
433
|
+
// THE REASONING COMES OUT FIRST. What the card shows is the child's answer, and a model
|
|
434
|
+
// that thinks inside its answer would otherwise hand it a string that opens with
|
|
435
|
+
// `<think>` - the markup of a reasoning trace where the child's result belongs.
|
|
436
|
+
const answer = inlineThinking.push(delta)
|
|
437
|
+
.filter((segment) => segment.kind === "text")
|
|
438
|
+
.map((segment) => segment.text)
|
|
439
|
+
.join("");
|
|
440
|
+
streamed += answer;
|
|
441
|
+
current += answer;
|
|
425
442
|
enter("turn");
|
|
426
|
-
|
|
443
|
+
if (answer)
|
|
444
|
+
onTextDelta?.(answer);
|
|
427
445
|
}
|
|
428
446
|
else if (event?.type === "start") {
|
|
429
447
|
current = "";
|
|
@@ -432,16 +450,18 @@ export class SubagentSupervisor {
|
|
|
432
450
|
else if (event?.type === "done") {
|
|
433
451
|
// `done` carries the AUTHORITATIVE message, so its text wins over what the deltas added up
|
|
434
452
|
// to; the accumulated text stays as the fallback for a child that streams without a `done`.
|
|
435
|
-
|
|
453
|
+
// The authoritative text is stripped the same way the deltas were: a child whose answer
|
|
454
|
+
// ends in a reasoning region must not turn that region into its result.
|
|
455
|
+
current = stripInlineThinking(assistantText(event.message)) || current;
|
|
436
456
|
finalText = current;
|
|
437
457
|
addUsage(usage, record.usage);
|
|
438
458
|
enter("turn");
|
|
439
459
|
const reason = event.message?.stopReason ?? event.reason;
|
|
440
460
|
if (reason === "error" || reason === "aborted")
|
|
441
|
-
failed ??= `sub-agent ${agentId} (${id}) stopped with reason ${reason}: ${assistantText(event.message) || "no output"}`;
|
|
461
|
+
failed ??= `sub-agent ${agentId} (${id}) stopped with reason ${reason}: ${stripInlineThinking(assistantText(event.message)) || "no output"}`;
|
|
442
462
|
}
|
|
443
463
|
else if (event?.type === "error") {
|
|
444
|
-
failed ??= `sub-agent ${agentId} (${id}) reported ${event.reason ?? "an error"}: ${assistantText(event.message) || "no detail from Pi"}`;
|
|
464
|
+
failed ??= `sub-agent ${agentId} (${id}) reported ${event.reason ?? "an error"}: ${stripInlineThinking(assistantText(event.message)) || "no detail from Pi"}`;
|
|
445
465
|
}
|
|
446
466
|
else if (event?.type === "thinking_delta") {
|
|
447
467
|
// CONSUMED, not discarded. A reasoning model spends the first seconds of every turn here,
|
|
@@ -524,6 +544,13 @@ export class SubagentSupervisor {
|
|
|
524
544
|
throw new Error(failed);
|
|
525
545
|
// No `done` at all is a child that streamed and vanished; the deltas are then the best answer there
|
|
526
546
|
// is, and returning them beats returning "" for work that demonstrably happened.
|
|
547
|
+
//
|
|
548
|
+
// THE SPLITTER IS RELEASED FIRST. It may still be holding back the beginning of a tag, and that
|
|
549
|
+
// text belongs to the child: a run that ended mid-word must not lose the word.
|
|
550
|
+
for (const segment of inlineThinking.flush()) {
|
|
551
|
+
if (segment.kind === "text")
|
|
552
|
+
streamed += segment.text;
|
|
553
|
+
}
|
|
527
554
|
return { id, sessionId: id, finalText: finalText || streamed, usage };
|
|
528
555
|
}
|
|
529
556
|
finally {
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export declare const WEB_SEARCH_TOOL_NAME = "web_search";
|
|
2
|
+
export declare const WEB_FETCH_TOOL_NAME = "web_fetch";
|
|
3
|
+
export declare const MAX_WEB_REQUEST_BYTES: number;
|
|
4
|
+
export type WebSearchResult = {
|
|
5
|
+
title: string;
|
|
6
|
+
url: string;
|
|
7
|
+
snippet: string;
|
|
8
|
+
};
|
|
9
|
+
export type WebFetchResult = {
|
|
10
|
+
title: string;
|
|
11
|
+
url: string;
|
|
12
|
+
text: string;
|
|
13
|
+
truncated: boolean;
|
|
14
|
+
contentType: string;
|
|
15
|
+
};
|
|
16
|
+
/** Parse Bing's ordinary result page. Kept separate so captured fixtures can guard markup changes. */
|
|
17
|
+
export declare function parseBingResults(html: string, limit?: number): WebSearchResult[];
|
|
18
|
+
export declare class WebService {
|
|
19
|
+
search(query: string, limit?: number, signal?: AbortSignal): Promise<{
|
|
20
|
+
query: string;
|
|
21
|
+
results: WebSearchResult[];
|
|
22
|
+
}>;
|
|
23
|
+
fetch(url: string, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
24
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { isIP } from "node:net";
|
|
2
|
+
import { lookup } from "node:dns/promises";
|
|
3
|
+
import { request as httpRequest } from "node:http";
|
|
4
|
+
import { request as httpsRequest } from "node:https";
|
|
5
|
+
import { brotliDecompressSync, gunzipSync, inflateSync } from "node:zlib";
|
|
6
|
+
export const WEB_SEARCH_TOOL_NAME = "web_search";
|
|
7
|
+
export const WEB_FETCH_TOOL_NAME = "web_fetch";
|
|
8
|
+
export const MAX_WEB_REQUEST_BYTES = 64 * 1024;
|
|
9
|
+
const SEARCH_LIMIT = 10;
|
|
10
|
+
const RESPONSE_LIMIT = 2 * 1024 * 1024;
|
|
11
|
+
const TEXT_LIMIT = 30_000;
|
|
12
|
+
const REDIRECT_LIMIT = 5;
|
|
13
|
+
const TIMEOUT_MS = 15_000;
|
|
14
|
+
const USER_AGENT = "Mozilla/5.0 (compatible; SuperPi/1.0; +https://www.npmjs.com/package/@evandu/super-pi)";
|
|
15
|
+
function decodeEntities(value) {
|
|
16
|
+
const named = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " " };
|
|
17
|
+
return value.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (_all, entity) => {
|
|
18
|
+
if (entity[0] === "#") {
|
|
19
|
+
const code = entity[1]?.toLowerCase() === "x" ? Number.parseInt(entity.slice(2), 16) : Number.parseInt(entity.slice(1), 10);
|
|
20
|
+
return Number.isFinite(code) ? String.fromCodePoint(code) : "";
|
|
21
|
+
}
|
|
22
|
+
return named[entity.toLowerCase()] ?? `&${entity};`;
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
function plainText(html) {
|
|
26
|
+
return decodeEntities(html.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ").replace(/<[^>]+>/g, " "))
|
|
27
|
+
.replace(/\s+/g, " ").trim();
|
|
28
|
+
}
|
|
29
|
+
/** Parse Bing's ordinary result page. Kept separate so captured fixtures can guard markup changes. */
|
|
30
|
+
export function parseBingResults(html, limit = 5) {
|
|
31
|
+
const results = [];
|
|
32
|
+
const blocks = html.match(/<li\b[^>]*class=["'][^"']*\bb_algo\b[^"']*["'][^>]*>[\s\S]*?<\/li>/gi) ?? [];
|
|
33
|
+
for (const block of blocks) {
|
|
34
|
+
const anchor = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href=["']([^"']+)["'][^>]*>([\s\S]*?)<\/a>/i.exec(block);
|
|
35
|
+
if (!anchor)
|
|
36
|
+
continue;
|
|
37
|
+
let url;
|
|
38
|
+
try {
|
|
39
|
+
url = new URL(decodeEntities(anchor[1]));
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (!/^https?:$/.test(url.protocol))
|
|
45
|
+
continue;
|
|
46
|
+
const caption = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block)?.[1] ?? "";
|
|
47
|
+
results.push({ title: plainText(anchor[2]), url: url.href, snippet: plainText(caption) });
|
|
48
|
+
if (results.length >= Math.min(SEARCH_LIMIT, Math.max(1, limit)))
|
|
49
|
+
break;
|
|
50
|
+
}
|
|
51
|
+
return results;
|
|
52
|
+
}
|
|
53
|
+
function blockedAddress(address) {
|
|
54
|
+
if (address === "::" || address === "::1")
|
|
55
|
+
return true;
|
|
56
|
+
const v4 = address.startsWith("::ffff:") ? address.slice(7) : address;
|
|
57
|
+
if (isIP(v4) === 4) {
|
|
58
|
+
const [a, b] = v4.split(".").map(Number);
|
|
59
|
+
return a === 0 || a === 10 || a === 127 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
|
|
60
|
+
}
|
|
61
|
+
const normalized = address.toLowerCase();
|
|
62
|
+
return normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe8") || normalized.startsWith("fe9") || normalized.startsWith("fea") || normalized.startsWith("feb") || normalized.startsWith("ff");
|
|
63
|
+
}
|
|
64
|
+
async function publicAddress(hostname) {
|
|
65
|
+
if (hostname.toLowerCase() === "localhost" || hostname.endsWith(".localhost"))
|
|
66
|
+
throw new Error("不允许访问本机或私网地址");
|
|
67
|
+
const records = isIP(hostname) ? [{ address: hostname, family: isIP(hostname) }] : await lookup(hostname, { all: true, verbatim: true });
|
|
68
|
+
const record = records.find(item => !blockedAddress(item.address));
|
|
69
|
+
if (!record || records.some(item => blockedAddress(item.address)))
|
|
70
|
+
throw new Error("不允许访问本机或私网地址");
|
|
71
|
+
return record;
|
|
72
|
+
}
|
|
73
|
+
async function download(input, signal, redirects = 0) {
|
|
74
|
+
const url = new URL(input);
|
|
75
|
+
if (!/^https?:$/.test(url.protocol) || url.username || url.password)
|
|
76
|
+
throw new Error("只允许无凭据的 HTTP/HTTPS URL");
|
|
77
|
+
if (redirects > REDIRECT_LIMIT)
|
|
78
|
+
throw new Error("网页重定向次数过多");
|
|
79
|
+
const resolved = await publicAddress(url.hostname);
|
|
80
|
+
const requester = url.protocol === "https:" ? httpsRequest : httpRequest;
|
|
81
|
+
// Node may ask a custom lookup for one address or an array (`all: true`). Honor both overloads;
|
|
82
|
+
// returning the wrong shape makes net.connect report the misleading "Invalid IP address: undefined".
|
|
83
|
+
const pinnedLookup = ((_hostname, options, callback) => {
|
|
84
|
+
if (options?.all)
|
|
85
|
+
callback(null, [resolved]);
|
|
86
|
+
else
|
|
87
|
+
callback(null, resolved.address, resolved.family);
|
|
88
|
+
});
|
|
89
|
+
return new Promise((resolve, reject) => {
|
|
90
|
+
const req = requester(url, {
|
|
91
|
+
headers: { "user-agent": USER_AGENT, accept: "text/html,text/plain,application/xhtml+xml", "accept-encoding": "gzip, deflate, br" },
|
|
92
|
+
lookup: pinnedLookup,
|
|
93
|
+
servername: url.hostname,
|
|
94
|
+
timeout: TIMEOUT_MS,
|
|
95
|
+
signal,
|
|
96
|
+
}, response => {
|
|
97
|
+
const status = response.statusCode ?? 0;
|
|
98
|
+
const location = response.headers.location;
|
|
99
|
+
if (status >= 300 && status < 400 && location) {
|
|
100
|
+
response.resume();
|
|
101
|
+
download(new URL(location, url).href, signal, redirects + 1).then(resolve, reject);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
if (status < 200 || status >= 300) {
|
|
105
|
+
response.resume();
|
|
106
|
+
reject(new Error(`网页请求失败(HTTP ${status})`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const chunks = [];
|
|
110
|
+
let bytes = 0;
|
|
111
|
+
response.on("data", chunk => {
|
|
112
|
+
bytes += chunk.length;
|
|
113
|
+
if (bytes > RESPONSE_LIMIT) {
|
|
114
|
+
req.destroy(new Error("网页响应超过 2 MB 限制"));
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
chunks.push(chunk);
|
|
118
|
+
});
|
|
119
|
+
response.on("end", () => {
|
|
120
|
+
try {
|
|
121
|
+
let body = Buffer.concat(chunks);
|
|
122
|
+
const encoding = String(response.headers["content-encoding"] ?? "").toLowerCase();
|
|
123
|
+
if (encoding === "gzip")
|
|
124
|
+
body = gunzipSync(body);
|
|
125
|
+
else if (encoding === "deflate")
|
|
126
|
+
body = inflateSync(body);
|
|
127
|
+
else if (encoding === "br")
|
|
128
|
+
body = brotliDecompressSync(body);
|
|
129
|
+
resolve({ url: url.href, body, contentType: String(response.headers["content-type"] ?? "application/octet-stream") });
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
reject(error);
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
req.on("timeout", () => req.destroy(new Error("网页请求超时")));
|
|
137
|
+
req.on("error", reject);
|
|
138
|
+
req.end();
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
function extractHtml(html) {
|
|
142
|
+
const title = plainText(/<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(html)?.[1] ?? "");
|
|
143
|
+
let body = /<article\b[^>]*>([\s\S]*?)<\/article>/i.exec(html)?.[1]
|
|
144
|
+
?? /<main\b[^>]*>([\s\S]*?)<\/main>/i.exec(html)?.[1]
|
|
145
|
+
?? /<body\b[^>]*>([\s\S]*?)<\/body>/i.exec(html)?.[1]
|
|
146
|
+
?? html;
|
|
147
|
+
body = body.replace(/<(nav|aside|footer|form|noscript|svg)\b[^>]*>[\s\S]*?<\/\1>/gi, " ")
|
|
148
|
+
.replace(/<(br|p|div|li|h[1-6]|tr)\b[^>]*>/gi, "\n")
|
|
149
|
+
.replace(/<script\b[^>]*>[\s\S]*?<\/script>/gi, " ").replace(/<style\b[^>]*>[\s\S]*?<\/style>/gi, " ")
|
|
150
|
+
.replace(/<[^>]+>/g, " ");
|
|
151
|
+
const text = decodeEntities(body).replace(/[ \t]+/g, " ").replace(/\n\s*/g, "\n").replace(/\n{3,}/g, "\n\n").trim();
|
|
152
|
+
return { title, text };
|
|
153
|
+
}
|
|
154
|
+
export class WebService {
|
|
155
|
+
async search(query, limit = 5, signal) {
|
|
156
|
+
const clean = query.trim();
|
|
157
|
+
if (!clean)
|
|
158
|
+
throw new Error("搜索词不能为空");
|
|
159
|
+
if (clean.length > 500)
|
|
160
|
+
throw new Error("搜索词不能超过 500 个字符");
|
|
161
|
+
const url = new URL("https://www.bing.com/search");
|
|
162
|
+
url.searchParams.set("q", clean);
|
|
163
|
+
url.searchParams.set("count", String(Math.min(SEARCH_LIMIT, Math.max(1, Math.floor(limit)))));
|
|
164
|
+
url.searchParams.set("setlang", "zh-Hans");
|
|
165
|
+
const response = await download(url.href, signal);
|
|
166
|
+
const results = parseBingResults(response.body.toString("utf8"), limit);
|
|
167
|
+
if (!results.length)
|
|
168
|
+
throw new Error("搜索引擎没有返回可解析的结果,可能暂时限流或页面格式已变化");
|
|
169
|
+
return { query: clean, results };
|
|
170
|
+
}
|
|
171
|
+
async fetch(url, signal) {
|
|
172
|
+
const response = await download(url, signal);
|
|
173
|
+
if (!/^(text\/html|application\/xhtml\+xml|text\/plain)\b/i.test(response.contentType))
|
|
174
|
+
throw new Error(`不支持的网页类型:${response.contentType}`);
|
|
175
|
+
const decoded = response.body.toString("utf8");
|
|
176
|
+
const extracted = /^text\/plain\b/i.test(response.contentType) ? { title: "", text: decoded.trim() } : extractHtml(decoded);
|
|
177
|
+
const truncated = extracted.text.length > TEXT_LIMIT;
|
|
178
|
+
return { title: extracted.title, url: response.url, text: extracted.text.slice(0, TEXT_LIMIT), truncated, contentType: response.contentType };
|
|
179
|
+
}
|
|
180
|
+
}
|