@webskill/sdk 0.14.0 → 0.15.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/dist/agent.d.ts +3 -3
- package/dist/agent.js +2442 -3
- package/dist/{dist-Bfga1TmS.js → approval-BnLLCOG0.js} +235 -2932
- package/dist/browser.d.ts +106 -5
- package/dist/browser.js +364 -10
- package/dist/{openUiLibrary-BKXW7Iwx-DjvEnMlJ.js → dist-CTsxblSD.js} +496 -28
- package/dist/{echarts-De78wXqV.js → echarts-BE7oV_Dl.js} +1 -1
- package/dist/{env-8cY40DXB-CGnEVZby.js → env-Bj1MI2Ww.js} +1 -1
- package/dist/errors-BDZNpC13.js +22 -0
- package/dist/{eventTypes-FllCrX-Z-DNDeHWoG.js → eventTypes-Y07N8IAC.js} +14 -2
- package/dist/external-_ZRQe-V9.js +53 -0
- package/dist/governance.d.ts +15 -3
- package/dist/governance.js +86 -4
- package/dist/{index-DQwGvHAI.d.ts → index-CQYo4tcT.d.ts} +85 -6
- package/dist/{index-sd-gVKey.d.ts → index-CU2md8R1.d.ts} +26 -18
- package/dist/{index-CWoKOFBP.d.ts → index-Oc3H89TJ.d.ts} +159 -3
- package/dist/index.d.ts +4 -4
- package/dist/index.js +1001 -5
- package/dist/kind-DxgS8LM-.js +77 -0
- package/dist/linkedDocument-xNXF-z8n.js +94 -0
- package/dist/llm-B7lLH0ZI.js +1767 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.js +41 -4
- package/dist/memoryArtifactStore-D6dLeY55.js +48 -0
- package/dist/miniChart-owdCdaw-.js +533 -0
- package/dist/node.d.ts +3 -3
- package/dist/node.js +106 -9
- package/dist/openUiLibrary-CY_ADoe0.js +30 -0
- package/dist/openUiSpecLang-DuOCdw9G.js +1849 -0
- package/dist/pathSecurity-B1owvJAF.js +65 -0
- package/dist/processSandboxEntry.js +5 -1
- package/dist/renderResult-D9Q-Vu2x.js +143 -0
- package/dist/{rolldown-runtime-BOF7iYI8.js → rolldown-runtime-BHkdefai.js} +1 -2
- package/dist/sandboxWorkerEntry.js +5 -1
- package/dist/{dist-D5YhlCdY.js → skill-CAJMsLod.js} +44 -372
- package/dist/{skillVersionStore-D-qHk9ZE-C0OFDTQY.d.ts → skillVersionStore-B24Jjjry-C4zomhMZ.d.ts} +14 -2
- package/dist/surface-DVGiCmwq.js +145 -0
- package/dist/testing.d.ts +1 -1
- package/dist/testing.js +64 -3
- package/dist/{types-C3V6lAeO-BgeOvc1Y.d.ts → types-C3V6lAeO-BIf7UB2e.d.ts} +17 -1
- package/dist/types-TUydnxaj.js +78 -0
- package/dist/ui-react.d.ts +12 -5
- package/dist/ui-react.js +128548 -4729
- package/dist/ui-vue.d.ts +1 -1
- package/dist/ui-vue.js +11 -2
- package/dist/ui.d.ts +3 -3
- package/dist/ui.js +1254 -3
- package/dist/urlSafety-CiSuCJvX.js +65 -0
- package/dist/webSkillApi-DOOyz2G5.js +106 -0
- package/dist/{webskillLitCatalog-D_zCqeQF-swXsWoAe.js → webskillLitCatalog-DSqVFQjK.js} +11 -3
- package/package.json +1 -1
- package/dist/catalogComponents-BgAJN0p8-Cr55ouOg.js +0 -124384
- package/dist/client-BCM6Z3yq-qUQNkKrK.js +0 -7787
- package/dist/dist-BnqAkSS6.js +0 -2384
- package/dist/dist-CiaDIqkV.js +0 -3509
- package/dist/dist-DxyxmXPU-DGC-dpXf.js +0 -5621
- package/dist/memoryArtifactStore-52Zn9npI-BxtUkrgV.js +0 -86
- package/dist/stdio-CFMoANJJ-BxrTeXh7.js +0 -31
- package/dist/testing-Cm8MseLF.js +0 -142
- package/dist/types-WovEf4ED-CZSDiiBU.js +0 -6215
|
@@ -0,0 +1,1767 @@
|
|
|
1
|
+
import { t as WebSkillError } from "./errors-BDZNpC13.js";
|
|
2
|
+
|
|
3
|
+
//#region ../runtime/src/llm/parts.ts
|
|
4
|
+
/** 纯文本消息内容的构造快捷方式(引擎内部绝大多数消息仍是纯文本) */
|
|
5
|
+
const textParts = (text) => [{
|
|
6
|
+
type: "text",
|
|
7
|
+
text
|
|
8
|
+
}];
|
|
9
|
+
/** 取出 parts 中的文本(非文本分片在纯文本语境下无法表达,此处按丢弃处理——调用方须先校验) */
|
|
10
|
+
const partsToText = (parts) => (parts ?? []).reduce((acc, part) => part.type === "text" ? acc + part.text : acc, "");
|
|
11
|
+
/**
|
|
12
|
+
* run 的提示词文本投影:`RuntimeRun.userPrompt` 与快照都是字符串字段,
|
|
13
|
+
* 而多模态提示词里的图片/文件分片没有文本形态,用占位符标注,避免记录成空串。
|
|
14
|
+
*/
|
|
15
|
+
const promptText = (prompt) => {
|
|
16
|
+
if (typeof prompt === "string") return prompt;
|
|
17
|
+
return prompt.map((part) => part.type === "text" ? part.text : `[${part.type}: ${part.mimeType}]`).join("\n");
|
|
18
|
+
};
|
|
19
|
+
const isContentPart = (value) => {
|
|
20
|
+
if (typeof value !== "object" || value === null) return false;
|
|
21
|
+
const part = value;
|
|
22
|
+
if (part["type"] === "text") return typeof part["text"] === "string";
|
|
23
|
+
if (part["type"] === "image" || part["type"] === "file") return typeof part["mimeType"] === "string" && typeof part["data"] === "string";
|
|
24
|
+
return false;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* 0.4.0 起 `content` 是 parts 数组。旧的 `string` content 一律拒绝,不做静默转换:
|
|
28
|
+
* 转换会让「模型看到的输入」与「历史记录的输入」悄悄分叉。
|
|
29
|
+
*/
|
|
30
|
+
function validateLlmMessages(messages) {
|
|
31
|
+
messages.forEach((message, index) => {
|
|
32
|
+
if (typeof message.content === "string") throw new WebSkillError("VALIDATION_FAILED", `messages[${index}].content is a string; since schema version 2 it must be an array of content parts. Legacy data is not converted automatically.`);
|
|
33
|
+
if (!Array.isArray(message.content) || !message.content.every(isContentPart)) throw new WebSkillError("VALIDATION_FAILED", `messages[${index}].content is not a valid array of content parts`);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** provider 不支持某类分片时的统一拒绝:静默丢弃会让模型回答一个它没看见的附件 */
|
|
37
|
+
function rejectUnsupportedPart(part, provider, where) {
|
|
38
|
+
throw new WebSkillError("VALIDATION_FAILED", `${provider} does not support ${part.type} content in ${where} messages`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
//#endregion
|
|
42
|
+
//#region ../runtime/src/llm/sealToolCallPairs.ts
|
|
43
|
+
/** 与正常工具结果同构:模型读到的是「这次调用被中断了」,而不是一个凭空消失的调用 */
|
|
44
|
+
function interruptedToolResult(call, options = {}) {
|
|
45
|
+
const code = options.code ?? "RUN_INTERRUPTED";
|
|
46
|
+
const because = options.reason === void 0 ? "" : ` (${options.reason})`;
|
|
47
|
+
return {
|
|
48
|
+
ok: false,
|
|
49
|
+
content: [],
|
|
50
|
+
error: {
|
|
51
|
+
code,
|
|
52
|
+
message: `Tool call "${call.name}" produced no result: the run ended before it finished${because}.`
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
/** 尚无 `role:'tool'` 应答的工具调用,按消息顺序 */
|
|
57
|
+
function findUnpairedToolCalls(messages) {
|
|
58
|
+
const answered = new Set(messages.filter((m) => m.role === "tool").map((m) => m.toolCallId));
|
|
59
|
+
return messages.filter((m) => m.role === "assistant" && m.toolCalls?.length).flatMap((m) => m.toolCalls ?? []).filter((call) => !answered.has(call.id));
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* 为每个未应答的工具调用补一条结构化的中断说明。
|
|
63
|
+
*
|
|
64
|
+
* 补齐消息紧跟在该 assistant 已有的 tool 兄弟之后——供应商装配按相邻块分组,
|
|
65
|
+
* 插到序列末尾会让它归属到别的 assistant 消息上。
|
|
66
|
+
* 本函数**不改写入参**,也不写盘(AC-11.8)。
|
|
67
|
+
*/
|
|
68
|
+
function sealToolCallPairs(messages, options = {}) {
|
|
69
|
+
const answered = new Set(messages.filter((m) => m.role === "tool").map((m) => m.toolCallId));
|
|
70
|
+
const code = options.code ?? "RUN_INTERRUPTED";
|
|
71
|
+
const sealed = [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (let i = 0; i < messages.length; i += 1) {
|
|
74
|
+
const message = messages[i];
|
|
75
|
+
out.push(message);
|
|
76
|
+
if (message.role !== "assistant" || !message.toolCalls?.length) continue;
|
|
77
|
+
const missing = message.toolCalls.filter((call) => !answered.has(call.id));
|
|
78
|
+
if (missing.length === 0) continue;
|
|
79
|
+
while (i + 1 < messages.length && messages[i + 1].role === "tool") {
|
|
80
|
+
out.push(messages[i + 1]);
|
|
81
|
+
i += 1;
|
|
82
|
+
}
|
|
83
|
+
for (const call of missing) {
|
|
84
|
+
out.push({
|
|
85
|
+
role: "tool",
|
|
86
|
+
toolCallId: call.id,
|
|
87
|
+
content: textParts(JSON.stringify(interruptedToolResult(call, {
|
|
88
|
+
...options,
|
|
89
|
+
code
|
|
90
|
+
})))
|
|
91
|
+
});
|
|
92
|
+
answered.add(call.id);
|
|
93
|
+
sealed.push({
|
|
94
|
+
callId: call.id,
|
|
95
|
+
toolName: call.name,
|
|
96
|
+
code
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
messages: sealed.length === 0 ? [...messages] : out,
|
|
102
|
+
sealed
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//#endregion
|
|
107
|
+
//#region ../runtime/src/llm/portableSchema.ts
|
|
108
|
+
/**
|
|
109
|
+
* 自引用展开时允许的最大 **schema 节点** 嵌套深度(properties/$defs 这类容器映射本身不计一层)。
|
|
110
|
+
* 超出即抛错而非静默截断:静默截断会让模型收到残缺 schema 后产出无法校验的参数,
|
|
111
|
+
* 症状比直接失败更难查。
|
|
112
|
+
*/
|
|
113
|
+
const PORTABLE_SCHEMA_MAX_DEPTH = 8;
|
|
114
|
+
/**
|
|
115
|
+
* $defs 必须与 type / properties 同级,位于每个工具 inputSchema 的根。
|
|
116
|
+
* 已验证:放进 properties.spec 内层会得到同样的 "Unsupported ref: #" ——
|
|
117
|
+
* Qwen / LM Studio 按当前 schema 上下文解析 $defs,不回溯根文档。
|
|
118
|
+
* 因此本模块只在**根**写入 $defs,不得改成就近放置。
|
|
119
|
+
*/
|
|
120
|
+
const DEFS_KEY = "$defs";
|
|
121
|
+
const SELF_REF = "#";
|
|
122
|
+
const NODE_NAME = "Node";
|
|
123
|
+
const COMPONENT_PREFIX = "Component_";
|
|
124
|
+
/** 值为「名称 → 子 schema」的映射。包内导出:vendorSchema 要用同一套下钻规则(两套会漂移)。 */
|
|
125
|
+
const SCHEMA_MAP_KEYS = /* @__PURE__ */ new Set([
|
|
126
|
+
"properties",
|
|
127
|
+
"patternProperties",
|
|
128
|
+
"dependentSchemas",
|
|
129
|
+
"$defs",
|
|
130
|
+
"definitions"
|
|
131
|
+
]);
|
|
132
|
+
/** 值为「子 schema 数组」。 */
|
|
133
|
+
const SCHEMA_LIST_KEYS = /* @__PURE__ */ new Set([
|
|
134
|
+
"oneOf",
|
|
135
|
+
"anyOf",
|
|
136
|
+
"allOf",
|
|
137
|
+
"prefixItems"
|
|
138
|
+
]);
|
|
139
|
+
/** 值为单个子 schema(items 在 draft-07 遗留写法里也可能是数组)。 */
|
|
140
|
+
const SCHEMA_KEYS = /* @__PURE__ */ new Set([
|
|
141
|
+
"items",
|
|
142
|
+
"additionalItems",
|
|
143
|
+
"additionalProperties",
|
|
144
|
+
"unevaluatedItems",
|
|
145
|
+
"unevaluatedProperties",
|
|
146
|
+
"contains",
|
|
147
|
+
"propertyNames",
|
|
148
|
+
"not",
|
|
149
|
+
"if",
|
|
150
|
+
"then",
|
|
151
|
+
"else"
|
|
152
|
+
]);
|
|
153
|
+
function isRecord$2(value) {
|
|
154
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
155
|
+
}
|
|
156
|
+
function isSelfRef(value) {
|
|
157
|
+
return isRecord$2(value) && value["$ref"] === SELF_REF;
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* 按 JSON Schema 关键字下降到子 schema。
|
|
161
|
+
* 只走 schema 位置,`enum` / `const` / `default` 等数据位置一律不进——
|
|
162
|
+
* 那里出现的 `{ $ref: '#' }` 是字面数据而非引用,改写它会破坏语义。
|
|
163
|
+
*/
|
|
164
|
+
function forEachSubSchema(node, visit) {
|
|
165
|
+
for (const [key, value] of Object.entries(node)) {
|
|
166
|
+
if (SCHEMA_MAP_KEYS.has(key)) {
|
|
167
|
+
if (isRecord$2(value)) for (const name of Object.keys(value)) visit(value[name], [key, name]);
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
if (SCHEMA_LIST_KEYS.has(key)) {
|
|
171
|
+
if (Array.isArray(value)) value.forEach((item, index) => visit(item, [key, index]));
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (SCHEMA_KEYS.has(key)) if (Array.isArray(value)) value.forEach((item, index) => visit(item, [key, index]));
|
|
175
|
+
else visit(value, [key]);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function pathKey(path) {
|
|
179
|
+
return path.join("\0");
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* 找出所有 `$ref: '#'` 所属的 schema resource 根。
|
|
183
|
+
* `#` 解析到最近的带 `$id` 的祖先;没有则是文档根。
|
|
184
|
+
*/
|
|
185
|
+
function collectSelfRefRoots(node, path, resourceRoot, out) {
|
|
186
|
+
if (!isRecord$2(node)) return;
|
|
187
|
+
if (isSelfRef(node)) {
|
|
188
|
+
out.set(pathKey(resourceRoot), resourceRoot);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
const nextRoot = typeof node["$id"] === "string" && path.length > 0 ? path : resourceRoot;
|
|
192
|
+
forEachSubSchema(node, (child, childPath) => {
|
|
193
|
+
collectSelfRefRoots(child, [...path, ...childPath], nextRoot, out);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
function getAt(schema, path) {
|
|
197
|
+
let current = schema;
|
|
198
|
+
for (const segment of path) if (Array.isArray(current) && typeof segment === "number") current = current[segment];
|
|
199
|
+
else if (isRecord$2(current) && typeof segment === "string") current = current[segment];
|
|
200
|
+
else return void 0;
|
|
201
|
+
return current;
|
|
202
|
+
}
|
|
203
|
+
/** 返回把 `path` 处替换为 `replacement` 后的新对象;沿途节点浅拷贝,其余共享。 */
|
|
204
|
+
function setAt(schema, path, replacement) {
|
|
205
|
+
if (path.length === 0) return replacement;
|
|
206
|
+
const [head, ...rest] = path;
|
|
207
|
+
const child = getAt(schema, [head]);
|
|
208
|
+
const nextChild = rest.length === 0 ? replacement : setAt(child, rest, replacement);
|
|
209
|
+
if (Array.isArray(schema) && typeof head === "number") {
|
|
210
|
+
const copy = [...schema];
|
|
211
|
+
copy[head] = nextChild;
|
|
212
|
+
return copy;
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
...schema,
|
|
216
|
+
[head]: nextChild
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 深拷贝并把 `{ $ref: '#' }` 改写为指向根 `$defs` 中的具名节点。
|
|
221
|
+
* 同时剥掉非根位置的 `$id` / `$schema`:它们会重设 base URI,
|
|
222
|
+
* 正是严格解析器无法解析 `#` 的直接原因。
|
|
223
|
+
*/
|
|
224
|
+
function rewriteSelfRefs(node, target, depth) {
|
|
225
|
+
if (!isRecord$2(node)) return node;
|
|
226
|
+
if (depth > 8) throw new WebSkillError("TOOL_SCHEMA_UNAVAILABLE", `Tool schema nesting exceeds ${8} levels; cannot produce a portable form`);
|
|
227
|
+
if (isSelfRef(node)) {
|
|
228
|
+
const rest = {};
|
|
229
|
+
for (const [key, value] of Object.entries(node)) if (key !== "$ref") rest[key] = value;
|
|
230
|
+
return {
|
|
231
|
+
$ref: `#/${DEFS_KEY}/${target}`,
|
|
232
|
+
...rest
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const result = {};
|
|
236
|
+
for (const [key, value] of Object.entries(node)) {
|
|
237
|
+
if (key === "$id" || key === "$schema") continue;
|
|
238
|
+
if (SCHEMA_MAP_KEYS.has(key) && isRecord$2(value)) {
|
|
239
|
+
const mapped = {};
|
|
240
|
+
for (const [name, child] of Object.entries(value)) mapped[name] = rewriteSelfRefs(child, target, depth + 1);
|
|
241
|
+
result[key] = mapped;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (SCHEMA_LIST_KEYS.has(key) || SCHEMA_KEYS.has(key)) {
|
|
245
|
+
result[key] = Array.isArray(value) ? value.map((child) => rewriteSelfRefs(child, target, depth + 1)) : rewriteSelfRefs(value, target, depth + 1);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
result[key] = value;
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
function uniqueName(base, taken) {
|
|
253
|
+
if (!taken.has(base)) return base;
|
|
254
|
+
const prefixed = `Portable${base}`;
|
|
255
|
+
if (!taken.has(prefixed)) return prefixed;
|
|
256
|
+
let index = 2;
|
|
257
|
+
while (taken.has(`${prefixed}_${index}`)) index += 1;
|
|
258
|
+
return `${prefixed}_${index}`;
|
|
259
|
+
}
|
|
260
|
+
function renameDefRefs(node, renames) {
|
|
261
|
+
if (Array.isArray(node)) return node.map((child) => renameDefRefs(child, renames));
|
|
262
|
+
if (!isRecord$2(node)) return node;
|
|
263
|
+
const out = {};
|
|
264
|
+
const prefix = `#/${DEFS_KEY}/`;
|
|
265
|
+
for (const [key, value] of Object.entries(node)) {
|
|
266
|
+
if (key === "$ref" && typeof value === "string" && value.startsWith(prefix)) {
|
|
267
|
+
const [head, ...tail] = value.slice(prefix.length).split("/");
|
|
268
|
+
const mapped = head === void 0 ? void 0 : renames.get(head);
|
|
269
|
+
out[key] = mapped === void 0 ? value : [`${prefix}${mapped}`, ...tail].join("/");
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
out[key] = renameDefRefs(value, renames);
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
}
|
|
276
|
+
/**
|
|
277
|
+
* 把非根位置的 `$defs` 搬到根。zod 每次 `toJSONSchema()` 各自产出 `$defs.__schemaN`,
|
|
278
|
+
* 嵌进大 schema 后其 `#/$defs/...` 从大根解析不到——提升即修复。重名时避让并改写引用。
|
|
279
|
+
*/
|
|
280
|
+
function hoistNestedDefs(schema) {
|
|
281
|
+
const rootDefs = isRecord$2(schema[DEFS_KEY]) ? { ...schema[DEFS_KEY] } : {};
|
|
282
|
+
const taken = new Set(Object.keys(rootDefs));
|
|
283
|
+
let hoisted = false;
|
|
284
|
+
function visit(node, isRoot) {
|
|
285
|
+
if (Array.isArray(node)) return node.map((child) => visit(child, false));
|
|
286
|
+
if (!isRecord$2(node)) return node;
|
|
287
|
+
const nested = !isRoot && isRecord$2(node[DEFS_KEY]) ? node[DEFS_KEY] : void 0;
|
|
288
|
+
const body = {};
|
|
289
|
+
for (const [key, value] of Object.entries(node)) {
|
|
290
|
+
if (key === DEFS_KEY && nested !== void 0) continue;
|
|
291
|
+
if (key === DEFS_KEY) {
|
|
292
|
+
body[key] = value;
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
if (SCHEMA_MAP_KEYS.has(key) && isRecord$2(value)) {
|
|
296
|
+
const mapped = {};
|
|
297
|
+
for (const [name, child] of Object.entries(value)) mapped[name] = visit(child, false);
|
|
298
|
+
body[key] = mapped;
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
if (SCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
|
|
302
|
+
body[key] = value.map((child) => visit(child, false));
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (SCHEMA_KEYS.has(key)) {
|
|
306
|
+
body[key] = visit(value, false);
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
body[key] = value;
|
|
310
|
+
}
|
|
311
|
+
if (nested === void 0) return body;
|
|
312
|
+
hoisted = true;
|
|
313
|
+
const renames = /* @__PURE__ */ new Map();
|
|
314
|
+
for (const name of Object.keys(nested)) {
|
|
315
|
+
const target = uniqueName(name, taken);
|
|
316
|
+
taken.add(target);
|
|
317
|
+
if (target !== name) renames.set(name, target);
|
|
318
|
+
}
|
|
319
|
+
for (const [name, def] of Object.entries(nested)) {
|
|
320
|
+
const moved = visit(def, false);
|
|
321
|
+
rootDefs[renames.get(name) ?? name] = renames.size > 0 ? renameDefRefs(moved, renames) : moved;
|
|
322
|
+
}
|
|
323
|
+
return renames.size > 0 ? renameDefRefs(body, renames) : body;
|
|
324
|
+
}
|
|
325
|
+
const out = visit(schema, true);
|
|
326
|
+
return hoisted ? {
|
|
327
|
+
...out,
|
|
328
|
+
[DEFS_KEY]: rootDefs
|
|
329
|
+
} : schema;
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* 把工具 inputSchema 重写为严格解析器可接受的 JSON Schema 2020-12 形态:
|
|
333
|
+
* 非根 `$defs` 提到根,自引用的 schema resource 提到根 `$defs`,
|
|
334
|
+
* 所有 `$ref: '#'` 改写为 `$ref: '#/$defs/<Node>'`。
|
|
335
|
+
*
|
|
336
|
+
* 幂等:不含自引用也不含嵌套 `$defs` 的 schema 原样返回。
|
|
337
|
+
*
|
|
338
|
+
* 这是**传输层适配**,只应在出站请求体上调用;不要下沉到 AgentLoop,
|
|
339
|
+
* 否则 trace / eventBus 观测到的 schema 会与技能作者写的不一致。
|
|
340
|
+
*/
|
|
341
|
+
function toPortableToolSchema(input) {
|
|
342
|
+
const schema = hoistNestedDefs(input);
|
|
343
|
+
const roots = /* @__PURE__ */ new Map();
|
|
344
|
+
collectSelfRefRoots(schema, [], [], roots);
|
|
345
|
+
if (roots.size === 0) return schema;
|
|
346
|
+
if (roots.size > 1) throw new WebSkillError("TOOL_SCHEMA_UNAVAILABLE", "Tool schema contains self references in more than one schema resource; cannot produce a portable form");
|
|
347
|
+
const rootPath = [...roots.values()][0] ?? [];
|
|
348
|
+
const resource = getAt(schema, rootPath);
|
|
349
|
+
if (!isRecord$2(resource)) return schema;
|
|
350
|
+
const existingDefs = isRecord$2(schema[DEFS_KEY]) ? schema[DEFS_KEY] : void 0;
|
|
351
|
+
const taken = new Set(Object.keys(existingDefs ?? {}));
|
|
352
|
+
const nodeName = uniqueName(NODE_NAME, taken);
|
|
353
|
+
taken.add(nodeName);
|
|
354
|
+
const added = {};
|
|
355
|
+
const branches = resource["oneOf"];
|
|
356
|
+
if (Array.isArray(branches)) {
|
|
357
|
+
const refs = branches.map((branch, index) => {
|
|
358
|
+
const name = uniqueName(`${COMPONENT_PREFIX}${index}`, taken);
|
|
359
|
+
taken.add(name);
|
|
360
|
+
added[name] = rewriteSelfRefs(branch, nodeName, 1);
|
|
361
|
+
return { $ref: `#/${DEFS_KEY}/${name}` };
|
|
362
|
+
});
|
|
363
|
+
const description = resource["description"];
|
|
364
|
+
added[nodeName] = typeof description === "string" ? {
|
|
365
|
+
description,
|
|
366
|
+
oneOf: refs
|
|
367
|
+
} : { oneOf: refs };
|
|
368
|
+
} else {
|
|
369
|
+
const body = {};
|
|
370
|
+
for (const [key, value] of Object.entries(resource)) if (key !== DEFS_KEY) body[key] = value;
|
|
371
|
+
added[nodeName] = rewriteSelfRefs(body, nodeName, 1);
|
|
372
|
+
}
|
|
373
|
+
return {
|
|
374
|
+
...setAt(schema, rootPath, { $ref: `#/${DEFS_KEY}/${nodeName}` }),
|
|
375
|
+
[DEFS_KEY]: {
|
|
376
|
+
...existingDefs ?? {},
|
|
377
|
+
...added
|
|
378
|
+
}
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
//#endregion
|
|
383
|
+
//#region ../runtime/src/llm/modelUnfillableParams.ts
|
|
384
|
+
function isRecord$1(value) {
|
|
385
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* 与 `interaction/schemaToForm` 的 file 判定保持一致:JSON Schema 里二进制内容的
|
|
389
|
+
* 两种标准写法(`format: binary` / `contentEncoding: base64`)。
|
|
390
|
+
*/
|
|
391
|
+
function isModelUnfillable(node) {
|
|
392
|
+
return isRecord$1(node) && node["type"] === "string" && (node["format"] === "binary" || node["contentEncoding"] === "base64");
|
|
393
|
+
}
|
|
394
|
+
function stripNode(node) {
|
|
395
|
+
if (Array.isArray(node)) return node.map(stripNode);
|
|
396
|
+
if (!isRecord$1(node)) return node;
|
|
397
|
+
const out = {};
|
|
398
|
+
for (const [key, value] of Object.entries(node)) {
|
|
399
|
+
if (SCHEMA_MAP_KEYS.has(key) && isRecord$1(value)) {
|
|
400
|
+
const mapped = {};
|
|
401
|
+
for (const [name, child] of Object.entries(value)) {
|
|
402
|
+
if (key === "properties" && isModelUnfillable(child)) continue;
|
|
403
|
+
mapped[name] = stripNode(child);
|
|
404
|
+
}
|
|
405
|
+
out[key] = mapped;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
if (SCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
|
|
409
|
+
out[key] = value.map(stripNode);
|
|
410
|
+
continue;
|
|
411
|
+
}
|
|
412
|
+
if (SCHEMA_KEYS.has(key)) {
|
|
413
|
+
out[key] = stripNode(value);
|
|
414
|
+
continue;
|
|
415
|
+
}
|
|
416
|
+
out[key] = value;
|
|
417
|
+
}
|
|
418
|
+
const required = out["required"];
|
|
419
|
+
const properties = out["properties"];
|
|
420
|
+
if (Array.isArray(required) && isRecord$1(properties)) out["required"] = required.filter((name) => typeof name !== "string" || name in properties);
|
|
421
|
+
return out;
|
|
422
|
+
}
|
|
423
|
+
/**
|
|
424
|
+
* 把模型不可能自己产出的字段(本地文件二进制)从**出站**工具 schema 里剥离。
|
|
425
|
+
*
|
|
426
|
+
* 模型拿不到用户磁盘上的文件,留着这个字段只有两种下场:模型拒绝调用工具
|
|
427
|
+
* (实测 DeepSeek 直接回文本「请上传文件」,file-pick 永远触发不了),或编造一个
|
|
428
|
+
* 占位字符串把技能跑挂。剥掉后模型以缺参形式调用,引擎的 missing-params 链路
|
|
429
|
+
* 会弹 file-pick 让用户用系统文件选择器补这个字段。
|
|
430
|
+
*
|
|
431
|
+
* 这是**传输层适配**,只应在出站请求体上调用;不要下沉到 AgentLoop——
|
|
432
|
+
* 引擎侧的 missing 校验看的必须是技能作者写的完整 inputSchema。
|
|
433
|
+
*
|
|
434
|
+
* 已知边界(0.10.0 复核注记):只剥 `properties` 里的 inline 二进制字段;
|
|
435
|
+
* 通过 `$ref` 指向 `$defs` 中二进制定义的字段仍会下发(剥掉会留悬空 $ref),
|
|
436
|
+
* 这类技能依旧可能拿不到 file-pick——需要完整修复时再补「剥 $def + 剥指向它的 $ref」。
|
|
437
|
+
*/
|
|
438
|
+
function stripModelUnfillableParams(schema) {
|
|
439
|
+
return stripNode(schema);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
//#endregion
|
|
443
|
+
//#region ../runtime/src/llm/vendorSchema.ts
|
|
444
|
+
/** 各家都认的基础关键字。Google `FunctionDeclaration.parameters` 是其中最窄的一家。 */
|
|
445
|
+
const BASE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
446
|
+
"type",
|
|
447
|
+
"description",
|
|
448
|
+
"title",
|
|
449
|
+
"default",
|
|
450
|
+
"enum",
|
|
451
|
+
"const",
|
|
452
|
+
"properties",
|
|
453
|
+
"required",
|
|
454
|
+
"items",
|
|
455
|
+
"anyOf",
|
|
456
|
+
"oneOf",
|
|
457
|
+
"minimum",
|
|
458
|
+
"maximum",
|
|
459
|
+
"minItems",
|
|
460
|
+
"maxItems",
|
|
461
|
+
"nullable",
|
|
462
|
+
"format"
|
|
463
|
+
]);
|
|
464
|
+
/** 宽松侧:保留 0.6.0 的透传行为,本版不主动收紧(没有实证之前收紧只会制造新的破坏) */
|
|
465
|
+
const PERMISSIVE_KEYWORDS = /* @__PURE__ */ new Set([
|
|
466
|
+
...BASE_KEYWORDS,
|
|
467
|
+
"additionalProperties",
|
|
468
|
+
"patternProperties",
|
|
469
|
+
"dependentSchemas",
|
|
470
|
+
"definitions",
|
|
471
|
+
"$defs",
|
|
472
|
+
"$ref",
|
|
473
|
+
"$schema",
|
|
474
|
+
"$id",
|
|
475
|
+
"allOf",
|
|
476
|
+
"not",
|
|
477
|
+
"if",
|
|
478
|
+
"then",
|
|
479
|
+
"else",
|
|
480
|
+
"prefixItems",
|
|
481
|
+
"additionalItems",
|
|
482
|
+
"unevaluatedItems",
|
|
483
|
+
"unevaluatedProperties",
|
|
484
|
+
"contains",
|
|
485
|
+
"propertyNames",
|
|
486
|
+
"exclusiveMinimum",
|
|
487
|
+
"exclusiveMaximum",
|
|
488
|
+
"multipleOf",
|
|
489
|
+
"minLength",
|
|
490
|
+
"maxLength",
|
|
491
|
+
"pattern",
|
|
492
|
+
"uniqueItems",
|
|
493
|
+
"examples",
|
|
494
|
+
"readOnly",
|
|
495
|
+
"writeOnly",
|
|
496
|
+
"deprecated"
|
|
497
|
+
]);
|
|
498
|
+
const OPENAI_SCHEMA_PROFILE = {
|
|
499
|
+
id: "openai-compatible",
|
|
500
|
+
allowedKeywords: PERMISSIVE_KEYWORDS,
|
|
501
|
+
supportsRefs: true
|
|
502
|
+
};
|
|
503
|
+
const ANTHROPIC_SCHEMA_PROFILE = {
|
|
504
|
+
id: "anthropic",
|
|
505
|
+
allowedKeywords: PERMISSIVE_KEYWORDS,
|
|
506
|
+
supportsRefs: true
|
|
507
|
+
};
|
|
508
|
+
/**
|
|
509
|
+
* Google Gen AI 的 `FunctionDeclaration.parameters` 收到 `additionalProperties`
|
|
510
|
+
* 直接回 400 `Unknown name "additionalProperties"`,且不解析 `$ref` / `$defs`。
|
|
511
|
+
*
|
|
512
|
+
* 本 profile 描述的是 **`parameters` 这一字段**的能力,不是 Google 的能力上限:
|
|
513
|
+
* 完整 JSON Schema(含 `$ref` / `$defs`)走另一个字段 `parametersJsonSchema`。
|
|
514
|
+
* 把 `supportsRefs: false` 读成「Google 不支持引用」是错的,分流见 `googleGenAiClient.toGenAiTools`。
|
|
515
|
+
*/
|
|
516
|
+
const GOOGLE_SCHEMA_PROFILE = {
|
|
517
|
+
id: "google",
|
|
518
|
+
allowedKeywords: BASE_KEYWORDS,
|
|
519
|
+
supportsRefs: false
|
|
520
|
+
};
|
|
521
|
+
function isRecord(value) {
|
|
522
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
523
|
+
}
|
|
524
|
+
/** 只在 schema 位置上找 `$ref`:enum / const / default 里的同名键是字面数据,不是引用 */
|
|
525
|
+
function containsRef(node) {
|
|
526
|
+
if (Array.isArray(node)) return node.some(containsRef);
|
|
527
|
+
if (!isRecord(node)) return false;
|
|
528
|
+
if (typeof node["$ref"] === "string") return true;
|
|
529
|
+
for (const [key, value] of Object.entries(node)) {
|
|
530
|
+
if (SCHEMA_MAP_KEYS.has(key)) {
|
|
531
|
+
if (isRecord(value) && Object.values(value).some(containsRef)) return true;
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
if (SCHEMA_LIST_KEYS.has(key)) {
|
|
535
|
+
if (Array.isArray(value) && value.some(containsRef)) return true;
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (SCHEMA_KEYS.has(key) && containsRef(value)) return true;
|
|
539
|
+
}
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
function sanitizeNode(node, profile) {
|
|
543
|
+
if (Array.isArray(node)) return node.map((item) => sanitizeNode(item, profile));
|
|
544
|
+
if (!isRecord(node)) return node;
|
|
545
|
+
const out = {};
|
|
546
|
+
for (const [key, value] of Object.entries(node)) {
|
|
547
|
+
if (!profile.allowedKeywords.has(key)) continue;
|
|
548
|
+
if (SCHEMA_MAP_KEYS.has(key) && isRecord(value)) {
|
|
549
|
+
const mapped = {};
|
|
550
|
+
for (const [name, child] of Object.entries(value)) mapped[name] = sanitizeNode(child, profile);
|
|
551
|
+
out[key] = mapped;
|
|
552
|
+
continue;
|
|
553
|
+
}
|
|
554
|
+
if (SCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
|
|
555
|
+
out[key] = value.map((child) => sanitizeNode(child, profile));
|
|
556
|
+
continue;
|
|
557
|
+
}
|
|
558
|
+
if (SCHEMA_KEYS.has(key)) {
|
|
559
|
+
out[key] = sanitizeNode(value, profile);
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
out[key] = value;
|
|
563
|
+
}
|
|
564
|
+
return out;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* 按供应商 profile 清洗出站 schema。产出新对象,不修改入参(工具源可能复用同一个实例)。
|
|
568
|
+
*
|
|
569
|
+
* 这是**传输层适配**,只应在出站请求体上调用;不要下沉到 AgentLoop,
|
|
570
|
+
* 否则 trace / eventBus 观测到的 schema 会与技能作者写的不一致。
|
|
571
|
+
*
|
|
572
|
+
* @throws WebSkillError `TOOL_SCHEMA_UNAVAILABLE` — schema 依赖该供应商不支持的引用
|
|
573
|
+
*/
|
|
574
|
+
function sanitizeForVendor(schema, profile, toolName) {
|
|
575
|
+
if (!profile.supportsRefs && containsRef(schema)) throw new WebSkillError("TOOL_SCHEMA_UNAVAILABLE", `Tool "${toolName}" requires $ref support that provider "${profile.id}" does not accept`);
|
|
576
|
+
return sanitizeNode(schema, profile);
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* schema 能否走 profile 描述的受限通道。与 `sanitizeForVendor` 同判据,但不抛错——
|
|
580
|
+
* 调用方需要的是「选哪条通道」而不是「失败」。
|
|
581
|
+
*/
|
|
582
|
+
function isExpressibleForVendor(schema, profile) {
|
|
583
|
+
return profile.supportsRefs || !containsRef(schema);
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* 放宽不可终止的递归环:Gemini 只接受「可选 / 可为 null / 可零长数组」的引用环。
|
|
587
|
+
* 对 `items` 直接是 `$ref` 的数组节点删去 `minItems`。
|
|
588
|
+
*
|
|
589
|
+
* **只作用于出站副本**:catalog 本体的校验判据不变。
|
|
590
|
+
*/
|
|
591
|
+
function relaxRefLoops(schema) {
|
|
592
|
+
return relaxNode(schema);
|
|
593
|
+
}
|
|
594
|
+
function relaxNode(node) {
|
|
595
|
+
if (Array.isArray(node)) return node.map(relaxNode);
|
|
596
|
+
if (!isRecord(node)) return node;
|
|
597
|
+
const out = {};
|
|
598
|
+
for (const [key, value] of Object.entries(node)) {
|
|
599
|
+
if (key === "minItems" && isRecord(node["items"]) && typeof node["items"]["$ref"] === "string") continue;
|
|
600
|
+
if (SCHEMA_MAP_KEYS.has(key) && isRecord(value)) {
|
|
601
|
+
const mapped = {};
|
|
602
|
+
for (const [name, child] of Object.entries(value)) mapped[name] = relaxNode(child);
|
|
603
|
+
out[key] = mapped;
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
if (SCHEMA_LIST_KEYS.has(key) && Array.isArray(value)) {
|
|
607
|
+
out[key] = value.map(relaxNode);
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
if (SCHEMA_KEYS.has(key)) {
|
|
611
|
+
out[key] = relaxNode(value);
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
out[key] = value;
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region ../runtime/src/llm/sse.ts
|
|
621
|
+
function createSseFrameReader() {
|
|
622
|
+
let buffer = "";
|
|
623
|
+
let data = [];
|
|
624
|
+
const dispatch = (out) => {
|
|
625
|
+
if (data.length === 0) return;
|
|
626
|
+
out.push(data.join("\n"));
|
|
627
|
+
data = [];
|
|
628
|
+
};
|
|
629
|
+
const consumeLine = (line, out) => {
|
|
630
|
+
if (line === "") {
|
|
631
|
+
dispatch(out);
|
|
632
|
+
return;
|
|
633
|
+
}
|
|
634
|
+
if (line.startsWith(":")) return;
|
|
635
|
+
const colon = line.indexOf(":");
|
|
636
|
+
if (colon < 0) return;
|
|
637
|
+
if (line.slice(0, colon) !== "data") return;
|
|
638
|
+
const value = line.slice(colon + 1);
|
|
639
|
+
data.push(value.startsWith(" ") ? value.slice(1) : value);
|
|
640
|
+
};
|
|
641
|
+
return {
|
|
642
|
+
push(chunk) {
|
|
643
|
+
const out = [];
|
|
644
|
+
buffer += chunk;
|
|
645
|
+
buffer = buffer.replace(/\r\n/g, "\n");
|
|
646
|
+
let newline;
|
|
647
|
+
while ((newline = buffer.indexOf("\n")) >= 0) {
|
|
648
|
+
const line = buffer.slice(0, newline);
|
|
649
|
+
buffer = buffer.slice(newline + 1);
|
|
650
|
+
consumeLine(line, out);
|
|
651
|
+
}
|
|
652
|
+
return out;
|
|
653
|
+
},
|
|
654
|
+
flush() {
|
|
655
|
+
const out = [];
|
|
656
|
+
if (buffer !== "") {
|
|
657
|
+
const line = buffer;
|
|
658
|
+
buffer = "";
|
|
659
|
+
consumeLine(line, out);
|
|
660
|
+
}
|
|
661
|
+
dispatch(out);
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
//#endregion
|
|
668
|
+
//#region ../runtime/src/llm/thinkTags.ts
|
|
669
|
+
/**
|
|
670
|
+
* 内联 `<think>` 段落切分器(内部模块,不进公开导出)。
|
|
671
|
+
*
|
|
672
|
+
* 本地部署的思考型模型(Qwen 等经 OpenAI 兼容端点)不下发 `reasoning_content`,
|
|
673
|
+
* 而是把推理直接写进 `content`,用 `<think>` … `</think>` 包起来。不切分的话
|
|
674
|
+
* 思考正文会当成回答正文流进界面、进消息历史、进落盘,既污染上下文也没法折叠。
|
|
675
|
+
*/
|
|
676
|
+
const OPEN = "<think>";
|
|
677
|
+
const CLOSE = "</think>";
|
|
678
|
+
/** buffer 是否为 tag 的真前缀(还不能判定,得再等) */
|
|
679
|
+
const isPartialPrefix = (buffer, tag) => buffer.length < tag.length && tag.startsWith(buffer);
|
|
680
|
+
function createThinkTagSplitter() {
|
|
681
|
+
let buffer = "";
|
|
682
|
+
let inThink = false;
|
|
683
|
+
const consume = () => {
|
|
684
|
+
let text = "";
|
|
685
|
+
let thinking = "";
|
|
686
|
+
for (;;) {
|
|
687
|
+
const tag = inThink ? CLOSE : OPEN;
|
|
688
|
+
const hit = buffer.indexOf(tag);
|
|
689
|
+
if (hit >= 0) {
|
|
690
|
+
const before = buffer.slice(0, hit);
|
|
691
|
+
if (inThink) thinking += before;
|
|
692
|
+
else text += before;
|
|
693
|
+
buffer = buffer.slice(hit + tag.length);
|
|
694
|
+
inThink = !inThink;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const start = buffer.lastIndexOf("<");
|
|
698
|
+
const keep = start >= 0 && isPartialPrefix(buffer.slice(start), tag) ? start : buffer.length;
|
|
699
|
+
const emit = buffer.slice(0, keep);
|
|
700
|
+
if (inThink) thinking += emit;
|
|
701
|
+
else text += emit;
|
|
702
|
+
buffer = buffer.slice(keep);
|
|
703
|
+
return {
|
|
704
|
+
text,
|
|
705
|
+
thinking
|
|
706
|
+
};
|
|
707
|
+
}
|
|
708
|
+
};
|
|
709
|
+
return {
|
|
710
|
+
push(delta) {
|
|
711
|
+
buffer += delta;
|
|
712
|
+
return consume();
|
|
713
|
+
},
|
|
714
|
+
flush() {
|
|
715
|
+
const rest = buffer;
|
|
716
|
+
buffer = "";
|
|
717
|
+
return inThink ? {
|
|
718
|
+
text: "",
|
|
719
|
+
thinking: rest
|
|
720
|
+
} : {
|
|
721
|
+
text: rest,
|
|
722
|
+
thinking: ""
|
|
723
|
+
};
|
|
724
|
+
}
|
|
725
|
+
};
|
|
726
|
+
}
|
|
727
|
+
/** 非流式整段切分:返回正文与思考正文(无 `<think>` 时 thinking 为 undefined) */
|
|
728
|
+
function splitThinkTags(content) {
|
|
729
|
+
if (!content.includes(OPEN)) return { text: content };
|
|
730
|
+
const splitter = createThinkTagSplitter();
|
|
731
|
+
const first = splitter.push(content);
|
|
732
|
+
const last = splitter.flush();
|
|
733
|
+
const thinking = first.thinking + last.thinking;
|
|
734
|
+
return {
|
|
735
|
+
text: first.text + last.text,
|
|
736
|
+
...thinking !== "" ? { thinking } : {}
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
//#endregion
|
|
741
|
+
//#region ../runtime/src/llm/openAiCompatibleClient.ts
|
|
742
|
+
const dataUrl = (part) => `data:${part.mimeType};base64,${part.data}`;
|
|
743
|
+
/** parts → OpenAI content;纯文本折叠成字符串(兼容端点对数组形态支持不一) */
|
|
744
|
+
const toOpenAiContent = (parts, where) => {
|
|
745
|
+
if (parts.every((p) => p.type === "text")) return partsToText(parts);
|
|
746
|
+
return parts.map((part) => {
|
|
747
|
+
switch (part.type) {
|
|
748
|
+
case "text": return {
|
|
749
|
+
type: "text",
|
|
750
|
+
text: part.text
|
|
751
|
+
};
|
|
752
|
+
case "image": return {
|
|
753
|
+
type: "image_url",
|
|
754
|
+
image_url: { url: dataUrl(part) }
|
|
755
|
+
};
|
|
756
|
+
case "file": return {
|
|
757
|
+
type: "file",
|
|
758
|
+
file: {
|
|
759
|
+
...part.name !== void 0 ? { filename: part.name } : {},
|
|
760
|
+
file_data: dataUrl(part)
|
|
761
|
+
}
|
|
762
|
+
};
|
|
763
|
+
default: return rejectUnsupportedPart(part, "OpenAI", where);
|
|
764
|
+
}
|
|
765
|
+
});
|
|
766
|
+
};
|
|
767
|
+
/** system / tool 消息只接受文本:这两类在 OpenAI 协议里没有多模态形态 */
|
|
768
|
+
const toOpenAiText = (parts, where) => {
|
|
769
|
+
const nonText = parts.find((p) => p.type !== "text");
|
|
770
|
+
if (nonText) rejectUnsupportedPart(nonText, "OpenAI", where);
|
|
771
|
+
return partsToText(parts);
|
|
772
|
+
};
|
|
773
|
+
const toOpenAiMessage = (msg) => {
|
|
774
|
+
if (msg.role === "tool") return {
|
|
775
|
+
role: "tool",
|
|
776
|
+
tool_call_id: msg.toolCallId,
|
|
777
|
+
content: toOpenAiText(msg.content, "tool")
|
|
778
|
+
};
|
|
779
|
+
if (msg.role === "system") return {
|
|
780
|
+
role: "system",
|
|
781
|
+
content: toOpenAiText(msg.content, "system")
|
|
782
|
+
};
|
|
783
|
+
if (msg.role === "assistant" && msg.toolCalls?.length) {
|
|
784
|
+
const content = toOpenAiContent(msg.content, "assistant");
|
|
785
|
+
return {
|
|
786
|
+
role: "assistant",
|
|
787
|
+
content: content === "" ? null : content,
|
|
788
|
+
tool_calls: msg.toolCalls.map((call) => ({
|
|
789
|
+
id: call.id,
|
|
790
|
+
type: "function",
|
|
791
|
+
function: {
|
|
792
|
+
name: call.name,
|
|
793
|
+
arguments: JSON.stringify(call.arguments)
|
|
794
|
+
}
|
|
795
|
+
}))
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
return {
|
|
799
|
+
role: msg.role,
|
|
800
|
+
content: toOpenAiContent(msg.content, msg.role)
|
|
801
|
+
};
|
|
802
|
+
};
|
|
803
|
+
const toOpenAiTools = (tools) => tools.map((tool) => ({
|
|
804
|
+
type: "function",
|
|
805
|
+
function: {
|
|
806
|
+
name: tool.name,
|
|
807
|
+
...tool.description ? { description: tool.description } : {},
|
|
808
|
+
parameters: sanitizeForVendor(toPortableToolSchema(stripModelUnfillableParams(tool.inputSchema)), OPENAI_SCHEMA_PROFILE, tool.name)
|
|
809
|
+
}
|
|
810
|
+
}));
|
|
811
|
+
const errorMessage$2 = (e) => e instanceof Error ? e.message : String(e);
|
|
812
|
+
/** OpenAI chat completions 协议客户端(兼容端点通用) */
|
|
813
|
+
var OpenAiCompatibleClient = class {
|
|
814
|
+
#config;
|
|
815
|
+
#fetch;
|
|
816
|
+
constructor(config) {
|
|
817
|
+
this.#config = config;
|
|
818
|
+
this.#fetch = config.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
819
|
+
}
|
|
820
|
+
#requireConfig() {
|
|
821
|
+
const { baseUrl, apiKey, model } = this.#config;
|
|
822
|
+
if (!baseUrl || !apiKey || !model) throw new WebSkillError("LLM_UNAVAILABLE", "LLM client is not configured: baseUrl, apiKey and model are all required");
|
|
823
|
+
return {
|
|
824
|
+
baseUrl: baseUrl.replace(/\/+$/, ""),
|
|
825
|
+
apiKey,
|
|
826
|
+
model
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
async complete(input) {
|
|
830
|
+
const res = await this.#postChat(input, false);
|
|
831
|
+
let data;
|
|
832
|
+
try {
|
|
833
|
+
data = await res.json();
|
|
834
|
+
} catch (e) {
|
|
835
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to parse LLM response: ${errorMessage$2(e)}`, e);
|
|
836
|
+
}
|
|
837
|
+
return this.#parseResponse(data);
|
|
838
|
+
}
|
|
839
|
+
/** SSE 流式:fetch + ReadableStream 按行解析 data: 帧(Node/浏览器通用) */
|
|
840
|
+
async *stream(input) {
|
|
841
|
+
const res = await this.#postChat(input, true);
|
|
842
|
+
if (!res.body) throw new WebSkillError("LLM_REQUEST_FAILED", "LLM streaming response has no body");
|
|
843
|
+
const toolCallsByIndex = /* @__PURE__ */ new Map();
|
|
844
|
+
const decoder = new TextDecoder();
|
|
845
|
+
const reader = res.body.getReader();
|
|
846
|
+
const frames = createSseFrameReader();
|
|
847
|
+
const think = createThinkTagSplitter();
|
|
848
|
+
let done = false;
|
|
849
|
+
let usage;
|
|
850
|
+
const handleFrame = function* (data) {
|
|
851
|
+
if (data === "[DONE]") {
|
|
852
|
+
done = true;
|
|
853
|
+
return;
|
|
854
|
+
}
|
|
855
|
+
let chunk;
|
|
856
|
+
try {
|
|
857
|
+
chunk = JSON.parse(data);
|
|
858
|
+
} catch (e) {
|
|
859
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to parse LLM stream frame: ${errorMessage$2(e)}`, data);
|
|
860
|
+
}
|
|
861
|
+
const usageRaw = chunk.usage;
|
|
862
|
+
if (usageRaw && (typeof usageRaw["prompt_tokens"] === "number" || typeof usageRaw["completion_tokens"] === "number")) usage = {
|
|
863
|
+
inputTokens: typeof usageRaw["prompt_tokens"] === "number" ? usageRaw["prompt_tokens"] : 0,
|
|
864
|
+
outputTokens: typeof usageRaw["completion_tokens"] === "number" ? usageRaw["completion_tokens"] : 0
|
|
865
|
+
};
|
|
866
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
867
|
+
if (!delta) return;
|
|
868
|
+
if (typeof delta["reasoning_content"] === "string" && delta["reasoning_content"] !== "") yield {
|
|
869
|
+
type: "thinking-delta",
|
|
870
|
+
delta: delta["reasoning_content"]
|
|
871
|
+
};
|
|
872
|
+
if (typeof delta["content"] === "string" && delta["content"] !== "") {
|
|
873
|
+
const split = think.push(delta["content"]);
|
|
874
|
+
if (split.thinking !== "") yield {
|
|
875
|
+
type: "thinking-delta",
|
|
876
|
+
delta: split.thinking
|
|
877
|
+
};
|
|
878
|
+
if (split.text !== "") yield {
|
|
879
|
+
type: "text-delta",
|
|
880
|
+
delta: split.text
|
|
881
|
+
};
|
|
882
|
+
}
|
|
883
|
+
const toolDeltas = delta["tool_calls"];
|
|
884
|
+
for (const td of toolDeltas ?? []) {
|
|
885
|
+
const index = td.index ?? 0;
|
|
886
|
+
const acc = toolCallsByIndex.get(index) ?? {
|
|
887
|
+
id: "",
|
|
888
|
+
name: "",
|
|
889
|
+
arguments: ""
|
|
890
|
+
};
|
|
891
|
+
if (td.id) acc.id = td.id;
|
|
892
|
+
if (td.function?.name) acc.name = td.function.name;
|
|
893
|
+
if (td.function?.arguments) acc.arguments += td.function.arguments;
|
|
894
|
+
toolCallsByIndex.set(index, acc);
|
|
895
|
+
}
|
|
896
|
+
};
|
|
897
|
+
try {
|
|
898
|
+
for (;;) {
|
|
899
|
+
const { value, done: readerDone } = await reader.read();
|
|
900
|
+
const payloads = readerDone ? frames.flush() : frames.push(decoder.decode(value, { stream: true }));
|
|
901
|
+
for (const payload of payloads) {
|
|
902
|
+
yield* handleFrame(payload);
|
|
903
|
+
if (done) break;
|
|
904
|
+
}
|
|
905
|
+
if (done || readerDone) break;
|
|
906
|
+
}
|
|
907
|
+
} finally {
|
|
908
|
+
reader.releaseLock();
|
|
909
|
+
}
|
|
910
|
+
const tail = think.flush();
|
|
911
|
+
if (tail.thinking !== "") yield {
|
|
912
|
+
type: "thinking-delta",
|
|
913
|
+
delta: tail.thinking
|
|
914
|
+
};
|
|
915
|
+
if (tail.text !== "") yield {
|
|
916
|
+
type: "text-delta",
|
|
917
|
+
delta: tail.text
|
|
918
|
+
};
|
|
919
|
+
if (toolCallsByIndex.size > 0) yield {
|
|
920
|
+
type: "tool-calls",
|
|
921
|
+
toolCalls: [...toolCallsByIndex.entries()].sort(([a], [b]) => a - b).map(([index, acc]) => {
|
|
922
|
+
let args = {};
|
|
923
|
+
let parseError;
|
|
924
|
+
try {
|
|
925
|
+
args = JSON.parse(acc.arguments || "{}");
|
|
926
|
+
} catch (e) {
|
|
927
|
+
parseError = e instanceof Error ? e.message : String(e);
|
|
928
|
+
}
|
|
929
|
+
return {
|
|
930
|
+
id: acc.id || `call-${index}`,
|
|
931
|
+
name: acc.name,
|
|
932
|
+
arguments: args,
|
|
933
|
+
...parseError ? { argumentsParseError: parseError } : {}
|
|
934
|
+
};
|
|
935
|
+
})
|
|
936
|
+
};
|
|
937
|
+
yield {
|
|
938
|
+
type: "done",
|
|
939
|
+
...usage ? { usage } : {}
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
async #postChat(input, stream) {
|
|
943
|
+
const { baseUrl, apiKey, model } = this.#requireConfig();
|
|
944
|
+
validateLlmMessages(input.messages);
|
|
945
|
+
const body = {
|
|
946
|
+
model: input.model ?? model,
|
|
947
|
+
messages: input.messages.map(toOpenAiMessage)
|
|
948
|
+
};
|
|
949
|
+
if (input.tools?.length) {
|
|
950
|
+
body["tools"] = toOpenAiTools(input.tools);
|
|
951
|
+
body["tool_choice"] = "auto";
|
|
952
|
+
}
|
|
953
|
+
if (input.temperature !== void 0) body["temperature"] = input.temperature;
|
|
954
|
+
if (stream) {
|
|
955
|
+
body["stream"] = true;
|
|
956
|
+
body["stream_options"] = { include_usage: true };
|
|
957
|
+
}
|
|
958
|
+
let res;
|
|
959
|
+
try {
|
|
960
|
+
res = await this.#fetch(`${baseUrl}/chat/completions`, {
|
|
961
|
+
method: "POST",
|
|
962
|
+
headers: {
|
|
963
|
+
"content-type": "application/json",
|
|
964
|
+
authorization: `Bearer ${apiKey}`
|
|
965
|
+
},
|
|
966
|
+
body: JSON.stringify(body),
|
|
967
|
+
signal: input.signal ?? (this.#config.requestTimeoutMs ? AbortSignal.timeout(this.#config.requestTimeoutMs) : null)
|
|
968
|
+
});
|
|
969
|
+
} catch (e) {
|
|
970
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `LLM request failed: ${errorMessage$2(e)}`, e);
|
|
971
|
+
}
|
|
972
|
+
if (!res.ok) {
|
|
973
|
+
const detail = await res.text().catch(() => "");
|
|
974
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `LLM request failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`, { status: res.status });
|
|
975
|
+
}
|
|
976
|
+
return res;
|
|
977
|
+
}
|
|
978
|
+
/** 轻量探测(GET /models),集成测试据此决定 skip */
|
|
979
|
+
async checkAvailability() {
|
|
980
|
+
try {
|
|
981
|
+
const { baseUrl, apiKey } = this.#requireConfig();
|
|
982
|
+
return (await this.#fetch(`${baseUrl}/models`, { headers: { authorization: `Bearer ${apiKey}` } })).ok;
|
|
983
|
+
} catch {
|
|
984
|
+
return false;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
#parseResponse(data) {
|
|
988
|
+
try {
|
|
989
|
+
const choice = data.choices?.[0]?.message;
|
|
990
|
+
if (!choice) throw new Error("response has no choices[0].message");
|
|
991
|
+
const toolCalls = choice["tool_calls"]?.map((tc) => {
|
|
992
|
+
let args = {};
|
|
993
|
+
let parseError;
|
|
994
|
+
try {
|
|
995
|
+
args = JSON.parse(tc.function?.arguments ?? "{}");
|
|
996
|
+
} catch (e) {
|
|
997
|
+
parseError = e instanceof Error ? e.message : String(e);
|
|
998
|
+
}
|
|
999
|
+
return {
|
|
1000
|
+
id: tc.id,
|
|
1001
|
+
name: tc.function?.name ?? "",
|
|
1002
|
+
arguments: args,
|
|
1003
|
+
...parseError ? { argumentsParseError: parseError } : {}
|
|
1004
|
+
};
|
|
1005
|
+
});
|
|
1006
|
+
const raw = choice["content"];
|
|
1007
|
+
const split = typeof raw === "string" ? splitThinkTags(raw) : {
|
|
1008
|
+
text: "",
|
|
1009
|
+
thinking: void 0
|
|
1010
|
+
};
|
|
1011
|
+
const content = split.text;
|
|
1012
|
+
const declared = choice["reasoning_content"];
|
|
1013
|
+
const thinking = typeof declared === "string" && declared !== "" ? declared : split.thinking;
|
|
1014
|
+
const usageRaw = data.usage;
|
|
1015
|
+
const usage = usageRaw && (typeof usageRaw["prompt_tokens"] === "number" || typeof usageRaw["completion_tokens"] === "number") ? {
|
|
1016
|
+
inputTokens: typeof usageRaw["prompt_tokens"] === "number" ? usageRaw["prompt_tokens"] : 0,
|
|
1017
|
+
outputTokens: typeof usageRaw["completion_tokens"] === "number" ? usageRaw["completion_tokens"] : 0
|
|
1018
|
+
} : void 0;
|
|
1019
|
+
return {
|
|
1020
|
+
content: content !== "" ? textParts(content) : void 0,
|
|
1021
|
+
toolCalls: toolCalls?.length ? toolCalls : void 0,
|
|
1022
|
+
...typeof thinking === "string" && thinking !== "" ? { thinking } : {},
|
|
1023
|
+
...usage ? { usage } : {},
|
|
1024
|
+
raw: data
|
|
1025
|
+
};
|
|
1026
|
+
} catch (e) {
|
|
1027
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to interpret LLM response: ${errorMessage$2(e)}`, data);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
};
|
|
1031
|
+
|
|
1032
|
+
//#endregion
|
|
1033
|
+
//#region ../runtime/src/llm/anthropicClient.ts
|
|
1034
|
+
const ANTHROPIC_VERSION = "2023-06-01";
|
|
1035
|
+
const errorMessage$1 = (e) => e instanceof Error ? e.message : String(e);
|
|
1036
|
+
/** parts → Anthropic content blocks(image/document 均为 base64 source) */
|
|
1037
|
+
const toAnthropicBlocks = (parts, where) => parts.map((part) => {
|
|
1038
|
+
switch (part.type) {
|
|
1039
|
+
case "text": return {
|
|
1040
|
+
type: "text",
|
|
1041
|
+
text: part.text
|
|
1042
|
+
};
|
|
1043
|
+
case "image": return {
|
|
1044
|
+
type: "image",
|
|
1045
|
+
source: {
|
|
1046
|
+
type: "base64",
|
|
1047
|
+
media_type: part.mimeType,
|
|
1048
|
+
data: part.data
|
|
1049
|
+
}
|
|
1050
|
+
};
|
|
1051
|
+
case "file": return {
|
|
1052
|
+
type: "document",
|
|
1053
|
+
source: {
|
|
1054
|
+
type: "base64",
|
|
1055
|
+
media_type: part.mimeType,
|
|
1056
|
+
data: part.data
|
|
1057
|
+
}
|
|
1058
|
+
};
|
|
1059
|
+
default: return rejectUnsupportedPart(part, "Anthropic", where);
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1062
|
+
/** system 是独立的字符串参数,无多模态形态 */
|
|
1063
|
+
const toAnthropicSystemText = (parts) => {
|
|
1064
|
+
const nonText = parts.find((p) => p.type !== "text");
|
|
1065
|
+
if (nonText) rejectUnsupportedPart(nonText, "Anthropic", "system");
|
|
1066
|
+
return partsToText(parts);
|
|
1067
|
+
};
|
|
1068
|
+
/** LlmMessage 序列 → system 独立参数 + user/assistant 消息(tool 结果合并进 user 消息 tool_result blocks) */
|
|
1069
|
+
function toAnthropicMessages(messages) {
|
|
1070
|
+
const system = messages.filter((m) => m.role === "system").map((m) => toAnthropicSystemText(m.content)).join("\n");
|
|
1071
|
+
const out = [];
|
|
1072
|
+
for (const msg of messages) {
|
|
1073
|
+
if (msg.role === "system") continue;
|
|
1074
|
+
if (msg.role === "tool") {
|
|
1075
|
+
const block = {
|
|
1076
|
+
type: "tool_result",
|
|
1077
|
+
tool_use_id: msg.toolCallId ?? "",
|
|
1078
|
+
content: toAnthropicBlocks(msg.content, "tool")
|
|
1079
|
+
};
|
|
1080
|
+
const last = out.at(-1);
|
|
1081
|
+
if (last && last.role === "user" && Array.isArray(last.content)) last.content.push(block);
|
|
1082
|
+
else out.push({
|
|
1083
|
+
role: "user",
|
|
1084
|
+
content: [block]
|
|
1085
|
+
});
|
|
1086
|
+
continue;
|
|
1087
|
+
}
|
|
1088
|
+
if (msg.role === "assistant" && msg.toolCalls?.length) {
|
|
1089
|
+
const content = toAnthropicBlocks(msg.content, "assistant");
|
|
1090
|
+
for (const call of msg.toolCalls) content.push({
|
|
1091
|
+
type: "tool_use",
|
|
1092
|
+
id: call.id,
|
|
1093
|
+
name: call.name,
|
|
1094
|
+
input: call.arguments
|
|
1095
|
+
});
|
|
1096
|
+
out.push({
|
|
1097
|
+
role: "assistant",
|
|
1098
|
+
content
|
|
1099
|
+
});
|
|
1100
|
+
continue;
|
|
1101
|
+
}
|
|
1102
|
+
out.push({
|
|
1103
|
+
role: msg.role,
|
|
1104
|
+
content: toAnthropicBlocks(msg.content, msg.role)
|
|
1105
|
+
});
|
|
1106
|
+
}
|
|
1107
|
+
return system === "" ? { messages: out } : {
|
|
1108
|
+
system,
|
|
1109
|
+
messages: out
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
const toAnthropicTools = (tools) => tools.map((tool) => ({
|
|
1113
|
+
name: tool.name,
|
|
1114
|
+
...tool.description ? { description: tool.description } : {},
|
|
1115
|
+
input_schema: sanitizeForVendor(toPortableToolSchema(stripModelUnfillableParams(tool.inputSchema)), ANTHROPIC_SCHEMA_PROFILE, tool.name)
|
|
1116
|
+
}));
|
|
1117
|
+
/** Anthropic Messages API 客户端(零依赖 fetch;Node/浏览器通用) */
|
|
1118
|
+
var AnthropicClient = class {
|
|
1119
|
+
#config;
|
|
1120
|
+
#fetch;
|
|
1121
|
+
constructor(config) {
|
|
1122
|
+
this.#config = config;
|
|
1123
|
+
this.#fetch = config.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1124
|
+
}
|
|
1125
|
+
#baseUrl() {
|
|
1126
|
+
return (this.#config.baseUrl ?? "https://api.anthropic.com").replace(/\/+$/, "");
|
|
1127
|
+
}
|
|
1128
|
+
#headers() {
|
|
1129
|
+
return {
|
|
1130
|
+
"content-type": "application/json",
|
|
1131
|
+
"x-api-key": this.#config.apiKey,
|
|
1132
|
+
"anthropic-version": ANTHROPIC_VERSION,
|
|
1133
|
+
...this.#config.dangerouslyAllowDirectBrowserAccess ? { "anthropic-dangerous-direct-browser-access": "true" } : {}
|
|
1134
|
+
};
|
|
1135
|
+
}
|
|
1136
|
+
async complete(input) {
|
|
1137
|
+
const res = await this.#post(input, false);
|
|
1138
|
+
let data;
|
|
1139
|
+
try {
|
|
1140
|
+
data = await res.json();
|
|
1141
|
+
} catch (e) {
|
|
1142
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to parse LLM response: ${errorMessage$1(e)}`, e);
|
|
1143
|
+
}
|
|
1144
|
+
return this.#parseResponse(data);
|
|
1145
|
+
}
|
|
1146
|
+
/** SSE 流式:text_delta → text-delta;input_json_delta 按 block index 聚合 → tool-calls */
|
|
1147
|
+
async *stream(input) {
|
|
1148
|
+
const res = await this.#post(input, true);
|
|
1149
|
+
if (!res.body) throw new WebSkillError("LLM_REQUEST_FAILED", "LLM streaming response has no body");
|
|
1150
|
+
const toolCallsByIndex = /* @__PURE__ */ new Map();
|
|
1151
|
+
const decoder = new TextDecoder();
|
|
1152
|
+
const reader = res.body.getReader();
|
|
1153
|
+
const frames = createSseFrameReader();
|
|
1154
|
+
let inputTokens;
|
|
1155
|
+
let outputTokens;
|
|
1156
|
+
const handleFrame = function* (data) {
|
|
1157
|
+
let chunk;
|
|
1158
|
+
try {
|
|
1159
|
+
chunk = JSON.parse(data);
|
|
1160
|
+
} catch (e) {
|
|
1161
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to parse LLM stream frame: ${errorMessage$1(e)}`, data);
|
|
1162
|
+
}
|
|
1163
|
+
const type = chunk["type"];
|
|
1164
|
+
if (type === "error") throw new WebSkillError("LLM_REQUEST_FAILED", `LLM stream error: ${chunk["error"]?.message ?? data}`);
|
|
1165
|
+
if (type === "message_start") {
|
|
1166
|
+
const usage = chunk["message"]?.["usage"];
|
|
1167
|
+
if (typeof usage?.["input_tokens"] === "number") inputTokens = usage["input_tokens"];
|
|
1168
|
+
if (typeof usage?.["output_tokens"] === "number") outputTokens = usage["output_tokens"];
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
if (type === "message_delta") {
|
|
1172
|
+
const usage = chunk["usage"];
|
|
1173
|
+
if (typeof usage?.["output_tokens"] === "number") outputTokens = usage["output_tokens"];
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
if (type === "content_block_start") {
|
|
1177
|
+
const block = chunk["content_block"];
|
|
1178
|
+
if (block?.["type"] === "tool_use") {
|
|
1179
|
+
const index = typeof chunk["index"] === "number" ? chunk["index"] : 0;
|
|
1180
|
+
toolCallsByIndex.set(index, {
|
|
1181
|
+
id: typeof block["id"] === "string" ? block["id"] : "",
|
|
1182
|
+
name: typeof block["name"] === "string" ? block["name"] : "",
|
|
1183
|
+
arguments: ""
|
|
1184
|
+
});
|
|
1185
|
+
}
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
if (type === "content_block_delta") {
|
|
1189
|
+
const index = typeof chunk["index"] === "number" ? chunk["index"] : 0;
|
|
1190
|
+
const delta = chunk["delta"];
|
|
1191
|
+
if (delta?.["type"] === "text_delta" && typeof delta["text"] === "string" && delta["text"] !== "") {
|
|
1192
|
+
yield {
|
|
1193
|
+
type: "text-delta",
|
|
1194
|
+
delta: delta["text"]
|
|
1195
|
+
};
|
|
1196
|
+
return;
|
|
1197
|
+
}
|
|
1198
|
+
if (delta?.["type"] === "thinking_delta" && typeof delta["thinking"] === "string" && delta["thinking"] !== "") {
|
|
1199
|
+
yield {
|
|
1200
|
+
type: "thinking-delta",
|
|
1201
|
+
delta: delta["thinking"]
|
|
1202
|
+
};
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
if (delta?.["type"] === "input_json_delta" && typeof delta["partial_json"] === "string") {
|
|
1206
|
+
const acc = toolCallsByIndex.get(index) ?? {
|
|
1207
|
+
id: "",
|
|
1208
|
+
name: "",
|
|
1209
|
+
arguments: ""
|
|
1210
|
+
};
|
|
1211
|
+
acc.arguments += delta["partial_json"];
|
|
1212
|
+
toolCallsByIndex.set(index, acc);
|
|
1213
|
+
}
|
|
1214
|
+
return;
|
|
1215
|
+
}
|
|
1216
|
+
};
|
|
1217
|
+
try {
|
|
1218
|
+
for (;;) {
|
|
1219
|
+
const { value, done: readerDone } = await reader.read();
|
|
1220
|
+
const payloads = readerDone ? frames.flush() : frames.push(decoder.decode(value, { stream: true }));
|
|
1221
|
+
for (const payload of payloads) yield* handleFrame(payload);
|
|
1222
|
+
if (readerDone) break;
|
|
1223
|
+
}
|
|
1224
|
+
} finally {
|
|
1225
|
+
reader.releaseLock();
|
|
1226
|
+
}
|
|
1227
|
+
if (toolCallsByIndex.size > 0) yield {
|
|
1228
|
+
type: "tool-calls",
|
|
1229
|
+
toolCalls: [...toolCallsByIndex.entries()].sort(([a], [b]) => a - b).map(([index, acc]) => {
|
|
1230
|
+
let args = {};
|
|
1231
|
+
let parseError;
|
|
1232
|
+
try {
|
|
1233
|
+
args = JSON.parse(acc.arguments || "{}");
|
|
1234
|
+
} catch (e) {
|
|
1235
|
+
parseError = e instanceof Error ? e.message : String(e);
|
|
1236
|
+
}
|
|
1237
|
+
return {
|
|
1238
|
+
id: acc.id || `call-${index}`,
|
|
1239
|
+
name: acc.name,
|
|
1240
|
+
arguments: args,
|
|
1241
|
+
...parseError ? { argumentsParseError: parseError } : {}
|
|
1242
|
+
};
|
|
1243
|
+
})
|
|
1244
|
+
};
|
|
1245
|
+
const usage = inputTokens !== void 0 || outputTokens !== void 0 ? {
|
|
1246
|
+
inputTokens: inputTokens ?? 0,
|
|
1247
|
+
outputTokens: outputTokens ?? 0
|
|
1248
|
+
} : void 0;
|
|
1249
|
+
yield {
|
|
1250
|
+
type: "done",
|
|
1251
|
+
...usage ? { usage } : {}
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
async #post(input, stream) {
|
|
1255
|
+
validateLlmMessages(input.messages);
|
|
1256
|
+
const { system, messages } = toAnthropicMessages(input.messages);
|
|
1257
|
+
const body = {
|
|
1258
|
+
model: input.model ?? this.#config.model,
|
|
1259
|
+
max_tokens: this.#config.maxTokens ?? 4096,
|
|
1260
|
+
messages
|
|
1261
|
+
};
|
|
1262
|
+
if (system !== void 0) body["system"] = system;
|
|
1263
|
+
if (input.tools?.length) body["tools"] = toAnthropicTools(input.tools);
|
|
1264
|
+
const thinkingBudget = this.#config.thinkingBudgetTokens;
|
|
1265
|
+
if (thinkingBudget !== void 0 && Number.isInteger(thinkingBudget) && thinkingBudget > 0) body["thinking"] = {
|
|
1266
|
+
type: "enabled",
|
|
1267
|
+
budget_tokens: thinkingBudget
|
|
1268
|
+
};
|
|
1269
|
+
else if (input.temperature !== void 0) body["temperature"] = input.temperature;
|
|
1270
|
+
if (stream) body["stream"] = true;
|
|
1271
|
+
let res;
|
|
1272
|
+
try {
|
|
1273
|
+
res = await this.#fetch(`${this.#baseUrl()}/v1/messages`, {
|
|
1274
|
+
method: "POST",
|
|
1275
|
+
headers: this.#headers(),
|
|
1276
|
+
body: JSON.stringify(body),
|
|
1277
|
+
signal: input.signal ?? (this.#config.requestTimeoutMs ? AbortSignal.timeout(this.#config.requestTimeoutMs) : null)
|
|
1278
|
+
});
|
|
1279
|
+
} catch (e) {
|
|
1280
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `LLM request failed: ${errorMessage$1(e)}`, e);
|
|
1281
|
+
}
|
|
1282
|
+
if (!res.ok) {
|
|
1283
|
+
const detail = await res.text().catch(() => "");
|
|
1284
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `LLM request failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`, { status: res.status });
|
|
1285
|
+
}
|
|
1286
|
+
return res;
|
|
1287
|
+
}
|
|
1288
|
+
/**
|
|
1289
|
+
* 真实调用路径探测(FR-27.1):`POST /v1/messages` 最小请求(max_tokens=1),200 即可达。
|
|
1290
|
+
* Anthropic 兼容端点(如 LM Studio 的 /anthropic 层)不实现 `GET /v1/models`,
|
|
1291
|
+
* 用 /models 探测会把「能对话」误判为「不可达」——checkAvailability 的语义从
|
|
1292
|
+
* 「/models 可达」改为「能完成一次真实调用」。
|
|
1293
|
+
*/
|
|
1294
|
+
async checkAvailability() {
|
|
1295
|
+
try {
|
|
1296
|
+
return (await this.#fetch(`${this.#baseUrl()}/v1/messages`, {
|
|
1297
|
+
method: "POST",
|
|
1298
|
+
headers: this.#headers(),
|
|
1299
|
+
body: JSON.stringify({
|
|
1300
|
+
model: this.#config.model,
|
|
1301
|
+
max_tokens: 1,
|
|
1302
|
+
messages: [{
|
|
1303
|
+
role: "user",
|
|
1304
|
+
content: "ping"
|
|
1305
|
+
}]
|
|
1306
|
+
})
|
|
1307
|
+
})).ok;
|
|
1308
|
+
} catch {
|
|
1309
|
+
return false;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
#parseResponse(data) {
|
|
1313
|
+
try {
|
|
1314
|
+
const blocks = data.content;
|
|
1315
|
+
if (!Array.isArray(blocks)) throw new Error("response has no content blocks");
|
|
1316
|
+
const text = blocks.filter((b) => b["type"] === "text").map((b) => String(b["text"] ?? "")).join("");
|
|
1317
|
+
const thinking = blocks.filter((b) => b["type"] === "thinking").map((b) => String(b["thinking"] ?? "")).join("");
|
|
1318
|
+
const toolCalls = blocks.filter((b) => b["type"] === "tool_use").map((b) => ({
|
|
1319
|
+
id: typeof b["id"] === "string" ? b["id"] : "",
|
|
1320
|
+
name: typeof b["name"] === "string" ? b["name"] : "",
|
|
1321
|
+
arguments: typeof b["input"] === "object" && b["input"] !== null ? b["input"] : {}
|
|
1322
|
+
}));
|
|
1323
|
+
const usageRaw = data.usage;
|
|
1324
|
+
const usage = typeof usageRaw?.["input_tokens"] === "number" || typeof usageRaw?.["output_tokens"] === "number" ? {
|
|
1325
|
+
inputTokens: typeof usageRaw["input_tokens"] === "number" ? usageRaw["input_tokens"] : 0,
|
|
1326
|
+
outputTokens: typeof usageRaw["output_tokens"] === "number" ? usageRaw["output_tokens"] : 0
|
|
1327
|
+
} : void 0;
|
|
1328
|
+
return {
|
|
1329
|
+
content: text === "" ? void 0 : textParts(text),
|
|
1330
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
1331
|
+
...thinking === "" ? {} : { thinking },
|
|
1332
|
+
...usage ? { usage } : {},
|
|
1333
|
+
raw: data
|
|
1334
|
+
};
|
|
1335
|
+
} catch (e) {
|
|
1336
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to interpret LLM response: ${errorMessage$1(e)}`, data);
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
};
|
|
1340
|
+
|
|
1341
|
+
//#endregion
|
|
1342
|
+
//#region ../runtime/src/llm/googleGenAiClient.ts
|
|
1343
|
+
const errorMessage = (e) => e instanceof Error ? e.message : String(e);
|
|
1344
|
+
/** usageMetadata → LlmTokenUsage(promptTokenCount / candidatesTokenCount;无字段返回 undefined) */
|
|
1345
|
+
function parseGenAiUsage(data) {
|
|
1346
|
+
const usage = data?.usageMetadata;
|
|
1347
|
+
if (!usage) return void 0;
|
|
1348
|
+
const input = usage["promptTokenCount"];
|
|
1349
|
+
const output = usage["candidatesTokenCount"];
|
|
1350
|
+
if (typeof input !== "number" && typeof output !== "number") return void 0;
|
|
1351
|
+
return {
|
|
1352
|
+
inputTokens: typeof input === "number" ? input : 0,
|
|
1353
|
+
outputTokens: typeof output === "number" ? output : 0
|
|
1354
|
+
};
|
|
1355
|
+
}
|
|
1356
|
+
/** parts → GenAI parts(二进制统一走 inlineData) */
|
|
1357
|
+
const toGenAiParts = (parts, where) => parts.map((part) => {
|
|
1358
|
+
switch (part.type) {
|
|
1359
|
+
case "text": return { text: part.text };
|
|
1360
|
+
case "image":
|
|
1361
|
+
case "file": return { inlineData: {
|
|
1362
|
+
mimeType: part.mimeType,
|
|
1363
|
+
data: part.data
|
|
1364
|
+
} };
|
|
1365
|
+
default: return rejectUnsupportedPart(part, "Google GenAI", where);
|
|
1366
|
+
}
|
|
1367
|
+
});
|
|
1368
|
+
/** systemInstruction 与 functionResponse 只接受文本 */
|
|
1369
|
+
const toGenAiText = (parts, where) => {
|
|
1370
|
+
const nonText = parts.find((p) => p.type !== "text");
|
|
1371
|
+
if (nonText) rejectUnsupportedPart(nonText, "Google GenAI", where);
|
|
1372
|
+
return partsToText(parts);
|
|
1373
|
+
};
|
|
1374
|
+
/** tool 结果文本包装为 functionResponse.response 对象 */
|
|
1375
|
+
function responseObject(content) {
|
|
1376
|
+
try {
|
|
1377
|
+
const parsed = JSON.parse(content);
|
|
1378
|
+
if (typeof parsed === "object" && parsed !== null) return parsed;
|
|
1379
|
+
} catch {}
|
|
1380
|
+
return { result: content };
|
|
1381
|
+
}
|
|
1382
|
+
/** LlmMessage 序列 → systemInstruction + contents(tool 结果合并进 user 消息 functionResponse parts) */
|
|
1383
|
+
function toGenAiContents(messages) {
|
|
1384
|
+
const system = messages.filter((m) => m.role === "system").map((m) => toGenAiText(m.content, "system")).join("\n");
|
|
1385
|
+
const nameByCallId = /* @__PURE__ */ new Map();
|
|
1386
|
+
for (const msg of messages) if (msg.role === "assistant") for (const call of msg.toolCalls ?? []) nameByCallId.set(call.id, call.name);
|
|
1387
|
+
const out = [];
|
|
1388
|
+
for (const msg of messages) {
|
|
1389
|
+
if (msg.role === "system") continue;
|
|
1390
|
+
if (msg.role === "tool") {
|
|
1391
|
+
const callId = msg.toolCallId ?? "";
|
|
1392
|
+
const part = { functionResponse: {
|
|
1393
|
+
name: nameByCallId.get(callId) ?? callId,
|
|
1394
|
+
response: responseObject(toGenAiText(msg.content, "tool"))
|
|
1395
|
+
} };
|
|
1396
|
+
const last = out.at(-1);
|
|
1397
|
+
if (last && last.role === "user") last.parts.push(part);
|
|
1398
|
+
else out.push({
|
|
1399
|
+
role: "user",
|
|
1400
|
+
parts: [part]
|
|
1401
|
+
});
|
|
1402
|
+
continue;
|
|
1403
|
+
}
|
|
1404
|
+
if (msg.role === "assistant") {
|
|
1405
|
+
const parts = toGenAiParts(msg.content, "assistant");
|
|
1406
|
+
for (const call of msg.toolCalls ?? []) {
|
|
1407
|
+
const signature = call.vendor?.["thoughtSignature"];
|
|
1408
|
+
parts.push({
|
|
1409
|
+
functionCall: {
|
|
1410
|
+
name: call.name,
|
|
1411
|
+
args: call.arguments
|
|
1412
|
+
},
|
|
1413
|
+
...typeof signature === "string" ? { thoughtSignature: signature } : {}
|
|
1414
|
+
});
|
|
1415
|
+
}
|
|
1416
|
+
out.push({
|
|
1417
|
+
role: "model",
|
|
1418
|
+
parts: parts.length > 0 ? parts : [{ text: "" }]
|
|
1419
|
+
});
|
|
1420
|
+
continue;
|
|
1421
|
+
}
|
|
1422
|
+
out.push({
|
|
1423
|
+
role: "user",
|
|
1424
|
+
parts: toGenAiParts(msg.content, msg.role)
|
|
1425
|
+
});
|
|
1426
|
+
}
|
|
1427
|
+
return system === "" ? { contents: out } : {
|
|
1428
|
+
systemInstruction: { parts: [{ text: system }] },
|
|
1429
|
+
contents: out
|
|
1430
|
+
};
|
|
1431
|
+
}
|
|
1432
|
+
const toGenAiTools = (tools) => [{ functionDeclarations: tools.map((tool) => {
|
|
1433
|
+
const portable = toPortableToolSchema(stripModelUnfillableParams(tool.inputSchema));
|
|
1434
|
+
return {
|
|
1435
|
+
name: tool.name,
|
|
1436
|
+
...tool.description ? { description: tool.description } : {},
|
|
1437
|
+
...isExpressibleForVendor(portable, GOOGLE_SCHEMA_PROFILE) ? { parameters: sanitizeForVendor(portable, GOOGLE_SCHEMA_PROFILE, tool.name) } : { parametersJsonSchema: relaxRefLoops(portable) }
|
|
1438
|
+
};
|
|
1439
|
+
}) }];
|
|
1440
|
+
/** Google GenAI(generateContent / streamGenerateContent)客户端(零依赖 fetch) */
|
|
1441
|
+
var GoogleGenAiClient = class {
|
|
1442
|
+
#config;
|
|
1443
|
+
#fetch;
|
|
1444
|
+
constructor(config) {
|
|
1445
|
+
this.#config = config;
|
|
1446
|
+
this.#fetch = config.fetchImpl ?? ((input, init) => fetch(input, init));
|
|
1447
|
+
}
|
|
1448
|
+
#baseUrl() {
|
|
1449
|
+
return (this.#config.baseUrl ?? "https://generativelanguage.googleapis.com").replace(/\/+$/, "");
|
|
1450
|
+
}
|
|
1451
|
+
async complete(input) {
|
|
1452
|
+
const res = await this.#post(input, false);
|
|
1453
|
+
let data;
|
|
1454
|
+
try {
|
|
1455
|
+
data = await res.json();
|
|
1456
|
+
} catch (e) {
|
|
1457
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to parse LLM response: ${errorMessage(e)}`, e);
|
|
1458
|
+
}
|
|
1459
|
+
const parts = this.#responseParts(data);
|
|
1460
|
+
const text = parts.filter((p) => typeof p["text"] === "string").map((p) => String(p["text"])).join("");
|
|
1461
|
+
const toolCalls = this.#functionCalls(parts);
|
|
1462
|
+
const usage = parseGenAiUsage(data);
|
|
1463
|
+
return {
|
|
1464
|
+
content: text === "" ? void 0 : textParts(text),
|
|
1465
|
+
toolCalls: toolCalls.length > 0 ? toolCalls : void 0,
|
|
1466
|
+
...usage ? { usage } : {},
|
|
1467
|
+
raw: data
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
/** SSE 流式:chunk.parts text → text-delta;functionCall 聚合 → tool-calls */
|
|
1471
|
+
async *stream(input) {
|
|
1472
|
+
const res = await this.#post(input, true);
|
|
1473
|
+
if (!res.body) throw new WebSkillError("LLM_REQUEST_FAILED", "LLM streaming response has no body");
|
|
1474
|
+
const toolCalls = [];
|
|
1475
|
+
const decoder = new TextDecoder();
|
|
1476
|
+
const reader = res.body.getReader();
|
|
1477
|
+
const frames = createSseFrameReader();
|
|
1478
|
+
let usage;
|
|
1479
|
+
const handleFrame = function* (data) {
|
|
1480
|
+
let chunk;
|
|
1481
|
+
try {
|
|
1482
|
+
chunk = JSON.parse(data);
|
|
1483
|
+
} catch (e) {
|
|
1484
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to parse LLM stream frame: ${errorMessage(e)}`, data);
|
|
1485
|
+
}
|
|
1486
|
+
usage = parseGenAiUsage(chunk) ?? usage;
|
|
1487
|
+
const parts = chunk.candidates?.[0]?.content?.parts;
|
|
1488
|
+
for (const part of parts ?? []) {
|
|
1489
|
+
if (typeof part["text"] === "string" && part["text"] !== "") {
|
|
1490
|
+
yield {
|
|
1491
|
+
type: "text-delta",
|
|
1492
|
+
delta: part["text"]
|
|
1493
|
+
};
|
|
1494
|
+
continue;
|
|
1495
|
+
}
|
|
1496
|
+
const call = part["functionCall"];
|
|
1497
|
+
if (call) {
|
|
1498
|
+
const signature = part["thoughtSignature"];
|
|
1499
|
+
toolCalls.push({
|
|
1500
|
+
id: `call-${toolCalls.length}`,
|
|
1501
|
+
name: call.name ?? "",
|
|
1502
|
+
arguments: call.args ?? {},
|
|
1503
|
+
...typeof signature === "string" ? { vendor: { thoughtSignature: signature } } : {}
|
|
1504
|
+
});
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
};
|
|
1508
|
+
try {
|
|
1509
|
+
for (;;) {
|
|
1510
|
+
const { value, done: readerDone } = await reader.read();
|
|
1511
|
+
const payloads = readerDone ? frames.flush() : frames.push(decoder.decode(value, { stream: true }));
|
|
1512
|
+
for (const payload of payloads) yield* handleFrame(payload);
|
|
1513
|
+
if (readerDone) break;
|
|
1514
|
+
}
|
|
1515
|
+
} finally {
|
|
1516
|
+
reader.releaseLock();
|
|
1517
|
+
}
|
|
1518
|
+
if (toolCalls.length > 0) yield {
|
|
1519
|
+
type: "tool-calls",
|
|
1520
|
+
toolCalls
|
|
1521
|
+
};
|
|
1522
|
+
yield {
|
|
1523
|
+
type: "done",
|
|
1524
|
+
...usage ? { usage } : {}
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
async #post(input, stream) {
|
|
1528
|
+
const model = input.model ?? this.#config.model;
|
|
1529
|
+
const action = stream ? ":streamGenerateContent?alt=sse" : ":generateContent";
|
|
1530
|
+
validateLlmMessages(input.messages);
|
|
1531
|
+
const { systemInstruction, contents } = toGenAiContents(input.messages);
|
|
1532
|
+
const body = { contents };
|
|
1533
|
+
if (systemInstruction) body["systemInstruction"] = systemInstruction;
|
|
1534
|
+
if (input.tools?.length) body["tools"] = toGenAiTools(input.tools);
|
|
1535
|
+
if (input.temperature !== void 0) body["generationConfig"] = { temperature: input.temperature };
|
|
1536
|
+
let res;
|
|
1537
|
+
try {
|
|
1538
|
+
res = await this.#fetch(`${this.#baseUrl()}/v1beta/models/${encodeURIComponent(model)}${action}`, {
|
|
1539
|
+
method: "POST",
|
|
1540
|
+
headers: {
|
|
1541
|
+
"content-type": "application/json",
|
|
1542
|
+
"x-goog-api-key": this.#config.apiKey
|
|
1543
|
+
},
|
|
1544
|
+
body: JSON.stringify(body),
|
|
1545
|
+
signal: input.signal ?? (this.#config.requestTimeoutMs ? AbortSignal.timeout(this.#config.requestTimeoutMs) : null)
|
|
1546
|
+
});
|
|
1547
|
+
} catch (e) {
|
|
1548
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `LLM request failed: ${errorMessage(e)}`, e);
|
|
1549
|
+
}
|
|
1550
|
+
if (!res.ok) {
|
|
1551
|
+
const detail = await res.text().catch(() => "");
|
|
1552
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `LLM request failed with HTTP ${res.status}${detail ? `: ${detail.slice(0, 500)}` : ""}`, { status: res.status });
|
|
1553
|
+
}
|
|
1554
|
+
return res;
|
|
1555
|
+
}
|
|
1556
|
+
/** 轻量探测(GET /v1beta/models),集成测试据此决定 skip */
|
|
1557
|
+
async checkAvailability() {
|
|
1558
|
+
try {
|
|
1559
|
+
return (await this.#fetch(`${this.#baseUrl()}/v1beta/models`, { headers: { "x-goog-api-key": this.#config.apiKey } })).ok;
|
|
1560
|
+
} catch {
|
|
1561
|
+
return false;
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
#responseParts(data) {
|
|
1565
|
+
try {
|
|
1566
|
+
const parts = data.candidates?.[0]?.content?.parts;
|
|
1567
|
+
if (!Array.isArray(parts)) throw new Error("response has no candidates[0].content.parts");
|
|
1568
|
+
return parts;
|
|
1569
|
+
} catch (e) {
|
|
1570
|
+
throw new WebSkillError("LLM_REQUEST_FAILED", `Failed to interpret LLM response: ${errorMessage(e)}`, data);
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
#functionCalls(parts) {
|
|
1574
|
+
const out = [];
|
|
1575
|
+
for (const part of parts) {
|
|
1576
|
+
const call = part["functionCall"];
|
|
1577
|
+
if (call) {
|
|
1578
|
+
const signature = part["thoughtSignature"];
|
|
1579
|
+
out.push({
|
|
1580
|
+
id: `call-${out.length}`,
|
|
1581
|
+
name: call.name ?? "",
|
|
1582
|
+
arguments: call.args ?? {},
|
|
1583
|
+
...typeof signature === "string" ? { vendor: { thoughtSignature: signature } } : {}
|
|
1584
|
+
});
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
return out;
|
|
1588
|
+
}
|
|
1589
|
+
};
|
|
1590
|
+
|
|
1591
|
+
//#endregion
|
|
1592
|
+
//#region ../runtime/src/llm/vercelAiAdapter.ts
|
|
1593
|
+
/** v5 { inputTokens, outputTokens } 与 v4 { promptTokens, completionTokens } 两种形状都接 */
|
|
1594
|
+
function fromVercelUsage(raw) {
|
|
1595
|
+
const u = raw ?? {};
|
|
1596
|
+
const input = u["inputTokens"] ?? u["promptTokens"];
|
|
1597
|
+
const output = u["outputTokens"] ?? u["completionTokens"];
|
|
1598
|
+
if (typeof input !== "number" && typeof output !== "number") return void 0;
|
|
1599
|
+
return {
|
|
1600
|
+
inputTokens: typeof input === "number" ? input : 0,
|
|
1601
|
+
outputTokens: typeof output === "number" ? output : 0
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
function toVercelToolSpecs(tools) {
|
|
1605
|
+
const out = {};
|
|
1606
|
+
for (const tool of tools) out[tool.name] = {
|
|
1607
|
+
...tool.description ? { description: tool.description } : {},
|
|
1608
|
+
inputSchema: tool.inputSchema
|
|
1609
|
+
};
|
|
1610
|
+
return out;
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* 归一化供应商思考内容:v5 的 `reasoningText`(字符串)与 v4 的 `reasoning`
|
|
1614
|
+
* (Array<{ text; type }>)都能落到同一条 `LlmResponse.thinking`。
|
|
1615
|
+
*/
|
|
1616
|
+
function normalizeThinking(value) {
|
|
1617
|
+
if (typeof value === "string") return value !== "" ? value : void 0;
|
|
1618
|
+
if (!Array.isArray(value)) return void 0;
|
|
1619
|
+
const text = value.map((part) => typeof part?.text === "string" ? part.text : "").join("");
|
|
1620
|
+
return text !== "" ? text : void 0;
|
|
1621
|
+
}
|
|
1622
|
+
/** 接受 generateText 的返回值形状:{ text?, toolCalls?: [{ toolCallId, toolName, input|args }] } */
|
|
1623
|
+
function fromVercelResult(result) {
|
|
1624
|
+
const r = result;
|
|
1625
|
+
const toolCalls = (r.toolCalls ?? []).map((tc, i) => {
|
|
1626
|
+
const args = tc.input ?? tc.args ?? {};
|
|
1627
|
+
return {
|
|
1628
|
+
id: typeof tc.toolCallId === "string" ? tc.toolCallId : `call-${i}`,
|
|
1629
|
+
name: typeof tc.toolName === "string" ? tc.toolName : "",
|
|
1630
|
+
arguments: typeof args === "object" && args !== null ? args : {}
|
|
1631
|
+
};
|
|
1632
|
+
});
|
|
1633
|
+
const thinking = normalizeThinking(r.reasoningText ?? r.reasoning);
|
|
1634
|
+
const usage = fromVercelUsage(r.usage);
|
|
1635
|
+
return {
|
|
1636
|
+
content: typeof r.text === "string" && r.text !== "" ? textParts(r.text) : void 0,
|
|
1637
|
+
toolCalls: toolCalls.length ? toolCalls : void 0,
|
|
1638
|
+
...thinking !== void 0 ? { thinking } : {},
|
|
1639
|
+
...usage ? { usage } : {},
|
|
1640
|
+
raw: result
|
|
1641
|
+
};
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Vercel streamText fullStream 片段 → LlmStreamEvent 映射。
|
|
1645
|
+
* 未知片段类型返回 undefined(调用方跳过)。
|
|
1646
|
+
*/
|
|
1647
|
+
function fromVercelStreamPart(part) {
|
|
1648
|
+
const p = part ?? {};
|
|
1649
|
+
switch (p["type"]) {
|
|
1650
|
+
case "text-delta": {
|
|
1651
|
+
const delta = p["text"] ?? p["delta"];
|
|
1652
|
+
return typeof delta === "string" && delta !== "" ? {
|
|
1653
|
+
type: "text-delta",
|
|
1654
|
+
delta
|
|
1655
|
+
} : void 0;
|
|
1656
|
+
}
|
|
1657
|
+
case "reasoning-delta": {
|
|
1658
|
+
const delta = p["text"] ?? p["delta"];
|
|
1659
|
+
return typeof delta === "string" && delta !== "" ? {
|
|
1660
|
+
type: "thinking-delta",
|
|
1661
|
+
delta
|
|
1662
|
+
} : void 0;
|
|
1663
|
+
}
|
|
1664
|
+
case "tool-call": return {
|
|
1665
|
+
type: "tool-calls",
|
|
1666
|
+
toolCalls: [{
|
|
1667
|
+
id: typeof p["toolCallId"] === "string" ? p["toolCallId"] : "call-0",
|
|
1668
|
+
name: typeof p["toolName"] === "string" ? p["toolName"] : "",
|
|
1669
|
+
arguments: typeof p["input"] === "object" && p["input"] !== null ? p["input"] : {}
|
|
1670
|
+
}]
|
|
1671
|
+
};
|
|
1672
|
+
case "finish": {
|
|
1673
|
+
const usage = fromVercelUsage(p["totalUsage"] ?? p["usage"]);
|
|
1674
|
+
return {
|
|
1675
|
+
type: "done",
|
|
1676
|
+
...usage ? { usage } : {}
|
|
1677
|
+
};
|
|
1678
|
+
}
|
|
1679
|
+
default: return;
|
|
1680
|
+
}
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
//#endregion
|
|
1684
|
+
//#region ../runtime/src/llm/mockLlmClient.ts
|
|
1685
|
+
/**
|
|
1686
|
+
* 预设响应序列的确定性 LlmClient;handler 形式可按会话状态动态应答。
|
|
1687
|
+
* 默认不含 stream(存量行为);构造传 { streaming: true } 启用流式:
|
|
1688
|
+
* 队列项 { stream: [...] } 按预设 delta 序列产出,LlmResponse 项自动合成增量。
|
|
1689
|
+
*/
|
|
1690
|
+
var MockLlmClient = class {
|
|
1691
|
+
calls = [];
|
|
1692
|
+
stream;
|
|
1693
|
+
#queue;
|
|
1694
|
+
constructor(responses, options) {
|
|
1695
|
+
this.#queue = [...responses];
|
|
1696
|
+
if (options?.streaming) this.stream = (input) => this.#streamImpl(input);
|
|
1697
|
+
}
|
|
1698
|
+
async complete(input) {
|
|
1699
|
+
this.calls.push(input);
|
|
1700
|
+
const next = this.#queue.shift();
|
|
1701
|
+
if (!next) throw new WebSkillError("LLM_REQUEST_FAILED", "MockLlmClient: response queue exhausted");
|
|
1702
|
+
if (typeof next === "function") return next(input);
|
|
1703
|
+
if ("stream" in next) {
|
|
1704
|
+
const events = typeof next.stream === "function" ? next.stream(input) : next.stream;
|
|
1705
|
+
let content = "";
|
|
1706
|
+
let thinking = "";
|
|
1707
|
+
let toolCalls;
|
|
1708
|
+
let usage;
|
|
1709
|
+
for (const event of events) if (event.type === "text-delta") content += event.delta;
|
|
1710
|
+
else if (event.type === "thinking-delta") thinking += event.delta;
|
|
1711
|
+
else if (event.type === "tool-calls") toolCalls = event.toolCalls;
|
|
1712
|
+
else if (event.type === "done") {
|
|
1713
|
+
if (event.content !== void 0) content = event.content;
|
|
1714
|
+
if (event.usage !== void 0) usage = event.usage;
|
|
1715
|
+
}
|
|
1716
|
+
return {
|
|
1717
|
+
content: content === "" ? void 0 : textParts(content),
|
|
1718
|
+
toolCalls,
|
|
1719
|
+
...thinking === "" ? {} : { thinking },
|
|
1720
|
+
...usage ? { usage } : {}
|
|
1721
|
+
};
|
|
1722
|
+
}
|
|
1723
|
+
return next;
|
|
1724
|
+
}
|
|
1725
|
+
async *#streamImpl(input) {
|
|
1726
|
+
this.calls.push(input);
|
|
1727
|
+
const next = this.#queue.shift();
|
|
1728
|
+
if (!next) throw new WebSkillError("LLM_REQUEST_FAILED", "MockLlmClient: response queue exhausted");
|
|
1729
|
+
if (typeof next !== "function" && "stream" in next) {
|
|
1730
|
+
const events = typeof next.stream === "function" ? next.stream(input) : next.stream;
|
|
1731
|
+
for (const event of events) yield event;
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
const response = typeof next === "function" ? await next(input) : next;
|
|
1735
|
+
if (response.thinking !== void 0 && response.thinking !== "") {
|
|
1736
|
+
const half = Math.ceil(response.thinking.length / 2);
|
|
1737
|
+
yield {
|
|
1738
|
+
type: "thinking-delta",
|
|
1739
|
+
delta: response.thinking.slice(0, half)
|
|
1740
|
+
};
|
|
1741
|
+
const rest = response.thinking.slice(half);
|
|
1742
|
+
if (rest !== "") yield {
|
|
1743
|
+
type: "thinking-delta",
|
|
1744
|
+
delta: rest
|
|
1745
|
+
};
|
|
1746
|
+
}
|
|
1747
|
+
const text = partsToText(response.content);
|
|
1748
|
+
if (text !== "") {
|
|
1749
|
+
const chunkSize = Math.max(1, Math.ceil(text.length / 3));
|
|
1750
|
+
for (let i = 0; i < text.length; i += chunkSize) yield {
|
|
1751
|
+
type: "text-delta",
|
|
1752
|
+
delta: text.slice(i, i + chunkSize)
|
|
1753
|
+
};
|
|
1754
|
+
}
|
|
1755
|
+
if (response.toolCalls?.length) yield {
|
|
1756
|
+
type: "tool-calls",
|
|
1757
|
+
toolCalls: response.toolCalls
|
|
1758
|
+
};
|
|
1759
|
+
yield {
|
|
1760
|
+
type: "done",
|
|
1761
|
+
...response.usage ? { usage: response.usage } : {}
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
|
|
1766
|
+
//#endregion
|
|
1767
|
+
export { GoogleGenAiClient as a, findUnpairedToolCalls as c, partsToText as d, promptText as f, toVercelToolSpecs as i, interruptedToolResult as l, fromVercelResult as n, AnthropicClient as o, textParts as p, fromVercelStreamPart as r, OpenAiCompatibleClient as s, MockLlmClient as t, sealToolCallPairs as u };
|