155 lines
5.1 KiB
JavaScript
155 lines
5.1 KiB
JavaScript
import { randomUUID } from "node:crypto";
|
|
import { config } from "../config.js";
|
|
import { logger } from "../logger.js";
|
|
const allowedMethods = new Set(["GET", "POST", "PUT", "PATCH", "DELETE"]);
|
|
const resultStore = new Map();
|
|
export class DynamicHttpExecutor {
|
|
async execute(input, context) {
|
|
const method = (input.method ?? "GET").trim().toUpperCase();
|
|
if (!allowedMethods.has(method)) {
|
|
throw new Error(`unsupported method: ${method}`);
|
|
}
|
|
const path = input.path.trim();
|
|
if (!path.startsWith("/")) {
|
|
throw new Error("path must start with '/'");
|
|
}
|
|
const query = buildQuery(input.arguments ?? {});
|
|
const url = new URL(path, config.TJWATER_API_BASE_URL);
|
|
for (const [key, value] of query) {
|
|
url.searchParams.append(key, value);
|
|
}
|
|
// 这里复用 chat session 绑定的用户上下文,保持后端鉴权与项目隔离语义不变。
|
|
const headers = new Headers({
|
|
Accept: "application/json",
|
|
"x-trace-id": context.traceId,
|
|
});
|
|
if (context.accessToken) {
|
|
headers.set("Authorization", `Bearer ${context.accessToken}`);
|
|
}
|
|
if (context.projectId) {
|
|
headers.set("x-project-id", context.projectId);
|
|
}
|
|
const startedAt = Date.now();
|
|
const response = await fetch(url, {
|
|
method,
|
|
headers,
|
|
signal: AbortSignal.timeout(config.TJWATER_API_TIMEOUT_MS),
|
|
});
|
|
const durationMs = Date.now() - startedAt;
|
|
logger.info({
|
|
method,
|
|
path,
|
|
statusCode: response.status,
|
|
durationMs,
|
|
traceId: context.traceId,
|
|
projectId: context.projectId,
|
|
}, "dynamic_http_call completed");
|
|
const contentType = response.headers.get("content-type") ?? "";
|
|
const rawText = await response.text();
|
|
const data = contentType.includes("application/json") && rawText
|
|
? JSON.parse(rawText)
|
|
: rawText;
|
|
if (!response.ok) {
|
|
return {
|
|
ok: false,
|
|
trace_id: context.traceId,
|
|
upstream: {
|
|
method,
|
|
path,
|
|
status_code: response.status,
|
|
},
|
|
error: {
|
|
message: "upstream API returned error",
|
|
detail: data,
|
|
},
|
|
};
|
|
}
|
|
return {
|
|
ok: true,
|
|
trace_id: context.traceId,
|
|
upstream: {
|
|
method,
|
|
path,
|
|
status_code: response.status,
|
|
},
|
|
...normalizeSuccessResult(data, context),
|
|
};
|
|
}
|
|
getResult(resultRef) {
|
|
return resultStore.get(resultRef);
|
|
}
|
|
}
|
|
export const dynamicHttpExecutor = new DynamicHttpExecutor();
|
|
const buildQuery = (argumentsObject) => {
|
|
const pairs = [];
|
|
for (const [key, value] of Object.entries(argumentsObject)) {
|
|
if (value === undefined || value === null) {
|
|
continue;
|
|
}
|
|
if (Array.isArray(value)) {
|
|
if (value.length === 0) {
|
|
continue;
|
|
}
|
|
pairs.push([key, value.map(String).join(",")]);
|
|
continue;
|
|
}
|
|
pairs.push([key, String(value)]);
|
|
}
|
|
return pairs;
|
|
};
|
|
const normalizeSuccessResult = (data, context) => {
|
|
const sizeBytes = estimateBytes(data);
|
|
if (sizeBytes <= config.MAX_INLINE_RESULT_BYTES) {
|
|
return {
|
|
result_mode: "inline",
|
|
result_size_bytes: sizeBytes,
|
|
data,
|
|
};
|
|
}
|
|
const resultRef = `res-${randomUUID().slice(0, 16)}`;
|
|
// 大结果先落本地引用,避免工具输出把模型上下文直接撑爆。
|
|
resultStore.set(resultRef, {
|
|
rawResult: data,
|
|
traceId: context.traceId,
|
|
projectId: context.projectId,
|
|
});
|
|
return {
|
|
result_mode: "referenced",
|
|
result_size_bytes: sizeBytes,
|
|
result_ref: resultRef,
|
|
preview: buildPreview(data),
|
|
};
|
|
};
|
|
const estimateBytes = (data) => Buffer.byteLength(JSON.stringify(data));
|
|
const buildPreview = (data) => {
|
|
if (Array.isArray(data)) {
|
|
const sample = data.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS);
|
|
const fields = sample.length > 0 && isRecord(sample[0])
|
|
? Object.keys(sample[0]).slice(0, 30)
|
|
: [];
|
|
return {
|
|
count: data.length,
|
|
fields,
|
|
sample,
|
|
summary: `list[${data.length}]`,
|
|
};
|
|
}
|
|
if (isRecord(data)) {
|
|
const fields = Object.keys(data).slice(0, 30);
|
|
const sample = Object.fromEntries(fields.slice(0, config.MAX_PREVIEW_SAMPLE_ITEMS).map((field) => [field, data[field]]));
|
|
return {
|
|
count: fields.length,
|
|
fields,
|
|
sample,
|
|
summary: `object<${fields.length} fields>`,
|
|
};
|
|
}
|
|
return {
|
|
count: 1,
|
|
fields: [],
|
|
sample: String(data).slice(0, 300),
|
|
summary: `scalar<${typeof data}>`,
|
|
};
|
|
};
|
|
const isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|