@webskill/chatbot 0.15.0 → 0.17.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.
@@ -0,0 +1,511 @@
1
+ //#region ../ui-kit/src/i18n/localizedText.ts
2
+ /**
3
+ * 键的声明顺序即回退顺序(0.13.0 分册 21 FR-21.2)。
4
+ * 写成 `Record<Locale, …>` 而不是 `['zh','en'] as const satisfies readonly Locale[]`:
5
+ * 后者只校验元素合法、不校验取值穷尽,新增语种时会安静地少一个。
6
+ */
7
+ const FALLBACK_ORDER = {
8
+ zh: null,
9
+ en: null
10
+ };
11
+ /**
12
+ * SDK 支持的语种,声明顺序同时是缺语种时的回退顺序与设置界面里的展示顺序。
13
+ * @experimental
14
+ */
15
+ const SUPPORTED_LOCALES = Object.keys(FALLBACK_ORDER);
16
+ const trimmed = (value) => {
17
+ if (typeof value !== "string") return void 0;
18
+ const text = value.trim();
19
+ return text === "" ? void 0 : text;
20
+ };
21
+ /**
22
+ * 按当前语种解析多语种文案(0.13.0 分册 21 FR-21.2):
23
+ * 当前语种非空 → 用它;否则按 {@link SUPPORTED_LOCALES} 顺序取第一个非空的;
24
+ * 全空返回 `undefined`——调用方据此整条丢弃,返回空串会一路流到渲染层变成空白卡片。
25
+ * @experimental
26
+ */
27
+ function resolveLocalizedText(text, locale) {
28
+ if (typeof text === "string") return trimmed(text);
29
+ if (typeof text !== "object" || text === null) return void 0;
30
+ const preferred = trimmed(text[locale]);
31
+ if (preferred !== void 0) return preferred;
32
+ for (const candidate of SUPPORTED_LOCALES) {
33
+ const value = trimmed(text[candidate]);
34
+ if (value !== void 0) return value;
35
+ }
36
+ }
37
+ /** 字符串简写填满全部语种:「这条不分语种,就这一句」。空白串得到空对象。 */
38
+ function normalizeLocalizedText(value) {
39
+ if (typeof value === "string") {
40
+ const text = trimmed(value);
41
+ if (text === void 0) return {};
42
+ return Object.fromEntries(SUPPORTED_LOCALES.map((locale) => [locale, text]));
43
+ }
44
+ if (typeof value !== "object" || value === null) return {};
45
+ const out = {};
46
+ for (const locale of SUPPORTED_LOCALES) {
47
+ const text = trimmed(value[locale]);
48
+ if (text !== void 0) out[locale] = text;
49
+ }
50
+ return out;
51
+ }
52
+ /** 是否有任何语种填了内容;全空的条目一律丢弃(FR-21.2 规则 3) */
53
+ function hasLocalizedText(text) {
54
+ return SUPPORTED_LOCALES.some((locale) => resolveLocalizedText(text, locale) !== void 0);
55
+ }
56
+
57
+ //#endregion
58
+ //#region ../runtime/src/tools/types.ts
59
+ /**
60
+ * `file` 分片的上限(FR-23.4),量的是**实际上线的 base64 长度**而不是解码后的字节——
61
+ * provider 的限额算的是请求载荷。
62
+ *
63
+ * 2026-08-14 查证的三家上限:
64
+ * | provider | 上限 | 出处 |
65
+ * | --------- | --------------------------------------- | ----------------------- |
66
+ * | Anthropic | **32 MB**(**整个请求载荷**)、600 页 | PDF support / 请求大小 |
67
+ * | OpenAI | 单文件 50 MB,全部文件合计 50 MB | File inputs / 使用须知 |
68
+ * | Gemini | 50 MB 或 1000 页(内联与 Files API 同) | 文档理解 / 技术详情 |
69
+ *
70
+ * 取最小值 Anthropic 的 32 MB。它是**整个请求**的额度,不是文档单独的额度,
71
+ * 所以再留出余量给系统提示词、catalog(约 34 KB)、历史消息与其余分片。
72
+ * 25 000 KB base64 ≈ 18.75 MB 原始 PDF。取 1024 的整数倍:设置界面按 KB 展示,
73
+ * 十进制的 24 000 000 会显示成 23437.5 这种读不出来的数。
74
+ */
75
+ const DEFAULT_MAX_DOCUMENT_BYTES = 256e5;
76
+ /**
77
+ * `document-text` 分片的上限(FR-23.4)。
78
+ *
79
+ * 约束来自**上下文窗口**而不是请求大小:抽出来的文本要整段进上下文。
80
+ * 按英文约 4 字节/token 折算,500 KB ≈ 128K token,占 200K 窗口的多半,
81
+ * 给历史消息与模型的回答留下其余。
82
+ */
83
+ const DEFAULT_MAX_DOCUMENT_TEXT_BYTES = 512e3;
84
+ /**
85
+ * 单次 fetchData 结果的字节上限(分册 16,FR-16.6):1000 KB。
86
+ * 结构化数据不是文档,量级差一个数量级;超出**只拒不截**——截断的 JSON 解不出来。
87
+ */
88
+ const DEFAULT_MAX_DATA_SOURCE_BYTES = 1024e3;
89
+ /**
90
+ * 单个上传文件交给脚本的字节上限(0.15.0 分册 17,FR-17.5):20 MB。
91
+ * 取得比模型图片预算宽:字节不进模型上下文,只进脚本,护的是内存不是 token。
92
+ */
93
+ const DEFAULT_MAX_UPLOAD_FILE_BYTES = 20 * 1024 * 1024;
94
+
95
+ //#endregion
96
+ //#region ../runtime/src/engine/limits.ts
97
+ /**
98
+ * Agent loop 的运行上限默认值。**全仓唯一来源**(S8)。
99
+ *
100
+ * 在 0.7.0 之前这三个数字在 `AgentLoop` 的构造与 ui-kit 的 `defaultRuntimeConfig()`
101
+ * 里各写了一遍。两份值今天恰好相等,所以不出事;一旦其中一处被改,
102
+ * 「恢复默认值」会恢复到 agent loop 根本不用的值——验收绿、行为错。
103
+ * @stable
104
+ */
105
+ const DEFAULT_LOOP_LIMITS = {
106
+ maxTurns: 1e3,
107
+ totalTimeoutMs: 36e5,
108
+ toolTimeoutMs: 6e5,
109
+ maxHistoryMessages: 1e3
110
+ };
111
+
112
+ //#endregion
113
+ //#region ../ui-kit/src/runtime-config/quickPrompts.ts
114
+ /**
115
+ * 受控枚举的全部取值(console 的图标下拉数据源)。
116
+ *
117
+ * 0.20.0:写成字面量而不是 `Object.keys(ICONS)`——后者会让「只要名字」的
118
+ * 调用方(`@webskill/chatbot/config`)在运行时依赖图标表,把 lucide-react 连同 React 一起拖进产物。
119
+ * 与 `QuickPromptIconName` 的一致性由 `enums.ts` 里的 `RuntimeConfigEnumsAreExhaustive` 双向锁定;
120
+ * 与图标表的一致性由 `./quickPromptIcons` 的 `Record<QuickPromptIconName, LucideIcon>` 锁定。
121
+ * @experimental
122
+ */
123
+ const QUICK_PROMPT_ICON_NAMES = [
124
+ "chart",
125
+ "document",
126
+ "report",
127
+ "search",
128
+ "list",
129
+ "bug",
130
+ "test",
131
+ "metric",
132
+ "compare",
133
+ "page",
134
+ "run",
135
+ "warn",
136
+ "sparkles",
137
+ "settings"
138
+ ];
139
+ /**
140
+ * 图标表与 `resolveQuickPromptIcon` 拆在 `./quickPromptIcons`:本文件被
141
+ * `@webskill/chatbot/config` 依赖,不能沾 React。
142
+ */
143
+ /** 空态网格是两列,8 条即四行(FR-17.4 / D-17-6);用户可在 console 里上调 */
144
+ const DEFAULT_QUICK_PROMPT_LIMIT = 8;
145
+ /** 可调范围的天花板:20 条已经把欢迎区撑成十行,再多就满屏都是按钮 */
146
+ const MAX_QUICK_PROMPT_LIMIT = 20;
147
+ /** 统一成对象形态:宿主可以只给字符串,该字符串填满全部语种(FR-21.1) */
148
+ const normalize = (entry) => typeof entry === "string" ? { text: normalizeLocalizedText(entry) } : entry;
149
+ /**
150
+ * 拼接动态与静态两批快捷指令(FR-17.4)。
151
+ *
152
+ * 动态在前——它是上下文相关的,更容易点到的位置留给它。
153
+ * 去重**只发生在跨界处**:静态清单里与某条动态指令同文案的会让位;
154
+ * 静态清单**内部**的重复原样保留(0.12.0 AC-21.6:宿主自定义时文案本就无唯一性约束,
155
+ * 混排 `['A', {text:'A', icon:'bug'}]` 是合法的两张卡)。
156
+ *
157
+ * 0.13.0 分册 21 FR-21.4:去重口径是**按 `locale` 解析后的文案**,
158
+ * 因此同一对条目在一个语种下会合并、在另一个语种下可能各自保留。
159
+ * 按 `id` 去重不可行:动态清单传的是 `QuickPrompt`,本来就没有 `id`。
160
+ *
161
+ * 结果按 `limit` 截断(默认 {@link DEFAULT_QUICK_PROMPT_LIMIT})。
162
+ */
163
+ function mergeQuickPrompts(dynamic, statics, locale, limit = 8) {
164
+ const resolve = (entry) => {
165
+ const prompt = normalize(entry);
166
+ const text = resolveLocalizedText(prompt.text, locale);
167
+ if (text === void 0) return void 0;
168
+ return {
169
+ text,
170
+ ...prompt.icon !== void 0 ? { icon: prompt.icon } : {}
171
+ };
172
+ };
173
+ const head = [];
174
+ const claimed = /* @__PURE__ */ new Set();
175
+ for (const entry of dynamic ?? []) {
176
+ const prompt = resolve(entry);
177
+ if (prompt === void 0 || claimed.has(prompt.text)) continue;
178
+ claimed.add(prompt.text);
179
+ head.push(prompt);
180
+ }
181
+ const tail = [];
182
+ for (const entry of statics) {
183
+ const prompt = resolve(entry);
184
+ if (prompt === void 0 || claimed.has(prompt.text)) continue;
185
+ tail.push(prompt);
186
+ }
187
+ return [...head, ...tail].slice(0, clampQuickPromptLimit(limit));
188
+ }
189
+ /** 存储里的脏值不能让空态变成空白或一堵墙;非正整数一律回默认 */
190
+ function clampQuickPromptLimit(value) {
191
+ if (typeof value !== "number" || !Number.isFinite(value)) return 8;
192
+ const floored = Math.floor(value);
193
+ if (floored < 1) return 8;
194
+ return Math.min(floored, 20);
195
+ }
196
+
197
+ //#endregion
198
+ //#region ../ui-kit/src/runtime-config/types.ts
199
+ /**
200
+ * 生成式 UI 场景预设的默认开放范围。
201
+ * ui-kit 不依赖 `@webskill/ui`,因此这里只能写字面量;
202
+ * 与目录实际导出的 `UI_PRESET_NAMES` 是否一致由 console 的测试守护。
203
+ */
204
+ const DEFAULT_UI_PRESETS = [
205
+ "charts",
206
+ "cards",
207
+ "dashboards",
208
+ "slides",
209
+ "reports"
210
+ ];
211
+ /** 渲染器档位的枚举值;下拉选项与导入校验共用同一份,避免两处各写一个列表 */
212
+ const RUNTIME_RENDERER_IDS = [
213
+ "native",
214
+ "a2ui",
215
+ "openui",
216
+ "vercel"
217
+ ];
218
+ /** SDK 默认值(三个运行上限取自 `DEFAULT_LOOP_LIMITS`;interaction user/required/300s、deny-all 等) */
219
+ function defaultRuntimeConfig() {
220
+ return {
221
+ loop: {
222
+ ...DEFAULT_LOOP_LIMITS,
223
+ toolResultMaxBytes: 256e3,
224
+ maxDocumentBytes: DEFAULT_MAX_DOCUMENT_BYTES,
225
+ maxDocumentTextBytes: DEFAULT_MAX_DOCUMENT_TEXT_BYTES
226
+ },
227
+ interaction: {
228
+ missingParams: "user",
229
+ confirmations: "required",
230
+ interactionTimeoutMs: 3e5,
231
+ approvalScope: "once-per-run"
232
+ },
233
+ router: { strategy: "progressive" },
234
+ hooks: {
235
+ timeoutMs: 5e3,
236
+ failOnHookError: false
237
+ },
238
+ streaming: true,
239
+ renderResult: true,
240
+ sandbox: {
241
+ executor: "auto",
242
+ networkPolicy: "deny-all",
243
+ capabilities: {
244
+ readReference: true,
245
+ readAsset: true,
246
+ writeArtifact: true,
247
+ confirm: true,
248
+ fetchData: false
249
+ },
250
+ maxDataSourceBytes: DEFAULT_MAX_DATA_SOURCE_BYTES,
251
+ dataSources: [],
252
+ remoteUrl: {
253
+ allowHttp: false,
254
+ allowPrivateHosts: false
255
+ },
256
+ typescript: { enabled: false },
257
+ downloadedFiles: false,
258
+ uploadFiles: false,
259
+ maxUploadFileBytes: DEFAULT_MAX_UPLOAD_FILE_BYTES
260
+ },
261
+ llm: { entries: [] },
262
+ agentCapabilities: {
263
+ todo: true,
264
+ skillGeneration: false,
265
+ generativeUi: false,
266
+ delegation: false,
267
+ uiPresets: [...DEFAULT_UI_PRESETS]
268
+ },
269
+ security: { unsignedSkills: "warn" },
270
+ appearance: {
271
+ theme: "dark",
272
+ locale: "zh",
273
+ renderer: "native",
274
+ dictationLang: ""
275
+ },
276
+ multimodal: {
277
+ imageAttachments: false,
278
+ pageImageCapture: false,
279
+ maxImageBytes: 10 * 1024 * 1024,
280
+ maxImagesPerMessage: 50,
281
+ minImageArea: 1024
282
+ },
283
+ documentSurface: { enabled: false },
284
+ userProfile: {
285
+ enabled: false,
286
+ injectMaxBytes: 4096,
287
+ recordLimit: 500,
288
+ encrypted: true
289
+ },
290
+ skillState: { quarantineThreshold: 5 },
291
+ quickPrompts: [],
292
+ quickPromptsSeeded: false,
293
+ quickPromptLimit: 8,
294
+ dismissedAutoEntries: []
295
+ };
296
+ }
297
+ /** 旧的单对象配置升格成列表时使用的固定 id:每次 load 都重算,随机 id 会让会话里存的归属失效 */
298
+ const MIGRATED_ENTRY_ID = "llm-1";
299
+ function isLlmEntry(value) {
300
+ const e = value;
301
+ return typeof e?.id === "string" && e.id !== "" && typeof e.provider === "string";
302
+ }
303
+ /**
304
+ * `llm` 段的迁移(AC-4.2):旧的单对象升格为单元素列表,原字段一个不丢(含 `apiKey`)。
305
+ * 全空的旧配置等价于「还没配过模型」,不生成占位条目。
306
+ */
307
+ function mergeLlmSelection(raw) {
308
+ const value = typeof raw === "object" && raw !== null ? raw : {};
309
+ const entries = Array.isArray(value["entries"]) ? value["entries"].filter(isLlmEntry).map((entry) => ({
310
+ ...entry,
311
+ label: entry.label ?? entry.model ?? entry.id
312
+ })) : upgradeLegacyLlm(value);
313
+ if (entries.length === 0) return { entries: [] };
314
+ const requested = typeof value["defaultId"] === "string" ? value["defaultId"] : void 0;
315
+ const defaultId = entries.some((entry) => entry.id === requested) ? requested : entries[0]?.id;
316
+ return defaultId !== void 0 ? {
317
+ entries,
318
+ defaultId
319
+ } : { entries };
320
+ }
321
+ function upgradeLegacyLlm(legacy) {
322
+ const model = typeof legacy.model === "string" ? legacy.model.trim() : "";
323
+ const baseUrl = typeof legacy.baseUrl === "string" ? legacy.baseUrl.trim() : "";
324
+ const apiKey = typeof legacy.apiKey === "string" ? legacy.apiKey.trim() : "";
325
+ if (model === "" && baseUrl === "" && apiKey === "") return [];
326
+ const provider = typeof legacy.provider === "string" ? legacy.provider : "openai-compatible";
327
+ return [{
328
+ id: MIGRATED_ENTRY_ID,
329
+ label: model !== "" ? model : provider,
330
+ provider,
331
+ model,
332
+ ...baseUrl !== "" ? { baseUrl } : {},
333
+ ...apiKey !== "" ? { apiKey } : {},
334
+ ...typeof legacy.requestTimeoutMs === "number" ? { requestTimeoutMs: legacy.requestTimeoutMs } : {}
335
+ }];
336
+ }
337
+ /** 存储读出的部分配置按默认值补齐(老版本存储缺字段时平滑迁移) */
338
+ function mergeRuntimeConfigDefaults(partial) {
339
+ const d = defaultRuntimeConfig();
340
+ const p = typeof partial === "object" && partial !== null ? partial : {};
341
+ const sub = (key, base) => ({
342
+ ...base,
343
+ ...p[key] ?? {}
344
+ });
345
+ return {
346
+ loop: sub("loop", d.loop),
347
+ interaction: sub("interaction", d.interaction),
348
+ router: sub("router", d.router),
349
+ hooks: sub("hooks", d.hooks),
350
+ streaming: typeof p["streaming"] === "boolean" ? p["streaming"] : d.streaming,
351
+ renderResult: typeof p["renderResult"] === "boolean" ? p["renderResult"] : d.renderResult,
352
+ sandbox: {
353
+ ...sub("sandbox", d.sandbox),
354
+ capabilities: {
355
+ ...d.sandbox.capabilities,
356
+ ...p["sandbox"]?.["capabilities"] ?? {}
357
+ },
358
+ remoteUrl: {
359
+ ...d.sandbox.remoteUrl,
360
+ ...p["sandbox"]?.["remoteUrl"] ?? {}
361
+ },
362
+ typescript: {
363
+ ...d.sandbox.typescript,
364
+ ...p["sandbox"]?.["typescript"] ?? {}
365
+ },
366
+ dataSources: readDataSourceEntries(p["sandbox"]?.["dataSources"])
367
+ },
368
+ llm: mergeLlmSelection(p["llm"]),
369
+ agentCapabilities: {
370
+ ...sub("agentCapabilities", d.agentCapabilities),
371
+ uiPresets: readStringList(p["agentCapabilities"]?.["uiPresets"], d.agentCapabilities.uiPresets)
372
+ },
373
+ security: sub("security", d.security),
374
+ appearance: mergeAppearance(p["appearance"], d.appearance),
375
+ multimodal: mergeMultimodal(p["multimodal"], d.multimodal),
376
+ documentSurface: mergeDocumentSurface(p["documentSurface"], d.documentSurface),
377
+ userProfile: mergeUserProfile(p["userProfile"], d.userProfile),
378
+ skillState: mergeSkillState(p["skillState"], d.skillState),
379
+ quickPrompts: readQuickPrompts(p["quickPrompts"]),
380
+ quickPromptsSeeded: p["quickPromptsSeeded"] === true,
381
+ quickPromptLimit: clampQuickPromptLimit(p["quickPromptLimit"]),
382
+ dismissedAutoEntries: readStringList(p["dismissedAutoEntries"], [])
383
+ };
384
+ }
385
+ const RENDERER_IDS = RUNTIME_RENDERER_IDS;
386
+ /**
387
+ * `appearance` 逐字段校验而不是整段展开:这三个字段都是字面量联合,
388
+ * 存储里的脏值(旧版本、手改的 localStorage)展开后会变成类型上不存在的档位。
389
+ */
390
+ function mergeAppearance(raw, d) {
391
+ const p = typeof raw === "object" && raw !== null ? raw : {};
392
+ const stored = p["dictationLang"];
393
+ return {
394
+ theme: p["theme"] === "light" || p["theme"] === "dark" ? p["theme"] : d.theme,
395
+ locale: p["locale"] === "zh" || p["locale"] === "en" ? p["locale"] : d.locale,
396
+ renderer: RENDERER_IDS.includes(p["renderer"]) ? p["renderer"] : d.renderer,
397
+ dictationLang: typeof stored === "string" ? stored.trim() : d.dictationLang
398
+ };
399
+ }
400
+ function readStringList(raw, fallback) {
401
+ if (!Array.isArray(raw)) return [...fallback];
402
+ return raw.filter((item) => typeof item === "string");
403
+ }
404
+ /**
405
+ * 快捷指令逐条校验(FR-17.2):缺 `id` 或文案全语种皆空的条目**单条丢弃**,
406
+ * 不因为一条脏数据把整段回退——用户其余的配置不该被连坐。
407
+ * `icon` 取值不在枚举内时抹掉该字段(渲染侧按无图标处理)。
408
+ *
409
+ * 0.13.0 分册 25 FR-25.3:`text: string` 的存量条目一并丢弃,不再升级为语种对象——
410
+ * 它们在 console 的快捷指令页里没有身份可言,留着就是「看得见、删不掉」。
411
+ */
412
+ function readQuickPrompts(raw) {
413
+ if (!Array.isArray(raw)) return [];
414
+ const out = [];
415
+ for (const item of raw) {
416
+ if (typeof item !== "object" || item === null) continue;
417
+ const entry = item;
418
+ const id = typeof entry["id"] === "string" ? entry["id"].trim() : "";
419
+ const rawText = entry["text"];
420
+ if (id === "" || typeof rawText !== "object" || rawText === null) continue;
421
+ const text = normalizeLocalizedText(rawText);
422
+ if (!hasLocalizedText(text)) continue;
423
+ const icon = entry["icon"];
424
+ const valid = typeof icon === "string" && QUICK_PROMPT_ICON_NAMES.includes(icon) ? icon : void 0;
425
+ out.push({
426
+ id,
427
+ text,
428
+ ...valid !== void 0 ? { icon: valid } : {}
429
+ });
430
+ }
431
+ return out;
432
+ }
433
+ /**
434
+ * 逐条校验用户配的数据源:`id` / `url` 缺失或非字符串即整条丢弃。
435
+ * 不整段回退,一条脏数据不该连坐其余几条。
436
+ */
437
+ function readDataSourceEntries(raw) {
438
+ if (!Array.isArray(raw)) return [];
439
+ const out = [];
440
+ const seen = /* @__PURE__ */ new Set();
441
+ for (const item of raw) {
442
+ if (typeof item !== "object" || item === null) continue;
443
+ const entry = item;
444
+ const id = typeof entry["id"] === "string" ? entry["id"].trim() : "";
445
+ const url = typeof entry["url"] === "string" ? entry["url"].trim() : "";
446
+ if (id === "" || url === "" || seen.has(id)) continue;
447
+ seen.add(id);
448
+ out.push({
449
+ id,
450
+ url,
451
+ description: typeof entry["description"] === "string" ? entry["description"] : ""
452
+ });
453
+ }
454
+ return out;
455
+ }
456
+ /** 上限字段非正数时回退默认值:0 或负数会把整条通道变成永远拒收 */
457
+ function mergeMultimodal(raw, d) {
458
+ const p = typeof raw === "object" && raw !== null ? raw : {};
459
+ const positive = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
460
+ return {
461
+ imageAttachments: typeof p["imageAttachments"] === "boolean" ? p["imageAttachments"] : d.imageAttachments,
462
+ pageImageCapture: typeof p["pageImageCapture"] === "boolean" ? p["pageImageCapture"] : d.pageImageCapture,
463
+ maxImageBytes: positive(p["maxImageBytes"], d.maxImageBytes),
464
+ maxImagesPerMessage: positive(p["maxImagesPerMessage"], d.maxImagesPerMessage),
465
+ minImageArea: nonNegative(p["minImageArea"], d.minImageArea)
466
+ };
467
+ }
468
+ /** 非有限数 / 负数回退默认值,`0` 原样保留 */
469
+ function nonNegative(value, fallback) {
470
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? Math.floor(value) : fallback;
471
+ }
472
+ /**
473
+ * CSP 白名单只收字符串数组;存储被污染时回退默认值(空),
474
+ * **不能**把非法值原样带进 CSP —— `viewerCspHeader` 会抛,等于整条投放面瘫掉。
475
+ */
476
+ function mergeDocumentSurface(raw, d) {
477
+ const p = typeof raw === "object" && raw !== null ? raw : {};
478
+ return { enabled: typeof p["enabled"] === "boolean" ? p["enabled"] : d.enabled };
479
+ }
480
+ /** 与 multimodal 同一套判据:非正数上限会让记录/注入通道恒空,回退默认值 */
481
+ function mergeUserProfile(raw, d) {
482
+ const p = typeof raw === "object" && raw !== null ? raw : {};
483
+ const positive = (value, fallback) => typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
484
+ return {
485
+ enabled: typeof p["enabled"] === "boolean" ? p["enabled"] : d.enabled,
486
+ injectMaxBytes: positive(p["injectMaxBytes"], d.injectMaxBytes),
487
+ recordLimit: positive(p["recordLimit"], d.recordLimit),
488
+ encrypted: typeof p["encrypted"] === "boolean" ? p["encrypted"] : d.encrypted
489
+ };
490
+ }
491
+ /** 与 multimodal 同一套判据:阈值非正整数会让隔离永不触发或立即触发,回退默认值 */
492
+ function mergeSkillState(raw, d) {
493
+ const value = (typeof raw === "object" && raw !== null ? raw : {})["quarantineThreshold"];
494
+ return { quarantineThreshold: typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : d.quarantineThreshold };
495
+ }
496
+ /**
497
+ * Wraps a store whose load() may return partial/legacy data, normalizing every read
498
+ * through mergeRuntimeConfigDefaults so all consumers share one defaults-filling
499
+ * behavior (0.2.7 B3). save/reset pass through unchanged.
500
+ */
501
+ function withRuntimeConfigDefaults(store) {
502
+ return {
503
+ load: async () => mergeRuntimeConfigDefaults(await store.load()),
504
+ save: (config) => store.save(config),
505
+ reset: () => store.reset(),
506
+ ...store.subscribe ? { subscribe: (listener) => store.subscribe(listener) } : {}
507
+ };
508
+ }
509
+
510
+ //#endregion
511
+ export { QUICK_PROMPT_ICON_NAMES as a, withRuntimeConfigDefaults as i, defaultRuntimeConfig as n, mergeQuickPrompts as o, mergeRuntimeConfigDefaults as r, normalizeLocalizedText as s, RUNTIME_RENDERER_IDS as t };
package/dist/vite.d.ts ADDED
@@ -0,0 +1,33 @@
1
+ import { n as ParseWebSkillConfigOptions, r as ParsedWebSkillConfig } from "./parse-Cc8nFxir.js";
2
+ import { Plugin } from "vite";
3
+ //#region src/vite.d.ts
4
+ /**
5
+ * `'bake'` 时打印的警告。
6
+ *
7
+ * 是导出常量而不是内联串:AC-12.2 对它做断言,而措辞本身正是那条 AC 要守的东西。
8
+ * @experimental
9
+ */
10
+ declare const WEBSKILL_SECRETS_WARNING: string;
11
+ /**
12
+ * 凭据三态。`'omit'` 是**不生成**解密代码,而不是生成了但走不到。
13
+ * @experimental
14
+ */
15
+ type WebSkillSecretsMode = 'omit' | 'bake' | 'dev-only';
16
+ /** 插件选项。`hostSections` 等解析选项从 `ParseWebSkillConfigOptions` 继承 @experimental */
17
+ interface WebSkillConfigPluginOptions extends ParseWebSkillConfigOptions {
18
+ /** 配置文件路径。文件不存在时按「没有宿主缺省值」处理,不报错 */
19
+ readonly file: string;
20
+ /** 缺省 `'omit'`:不猜这个构建是不是私有部署,猜错的代价是把凭据发上公网 */
21
+ readonly secrets?: WebSkillSecretsMode;
22
+ /** 解析成功后回调,宿主用来读 `host` 分节(如浏览器扩展的 manifest 覆写) */
23
+ onParsed?(parsed: ParsedWebSkillConfig): void;
24
+ }
25
+ /**
26
+ * 构建期读 `config.json`,把结果以虚拟模块 `virtual:webskill-config` 交给应用代码。
27
+ *
28
+ * 校验失败一律**中断构建**(带字段完整路径);文件不存在则按「无宿主缺省值」继续。
29
+ * @experimental
30
+ */
31
+ declare function webskillConfig(options: WebSkillConfigPluginOptions): Plugin;
32
+ //#endregion
33
+ export { WEBSKILL_SECRETS_WARNING, WebSkillConfigPluginOptions, WebSkillSecretsMode, webskillConfig };
package/dist/vite.js ADDED
@@ -0,0 +1,132 @@
1
+ import { n as parseWebSkillConfig } from "./parse-79VbXLl4.js";
2
+ import { readFileSync } from "node:fs";
3
+ import { webcrypto } from "node:crypto";
4
+
5
+ //#region src/vite.ts
6
+ /**
7
+ * `@webskill/chatbot/vite` —— 把配置文件接进构建。
8
+ *
9
+ * 只有这个入口碰 `node:*`;配置契约本身在 `@webskill/chatbot/config`,
10
+ * 收的是已解析的 JSON 值,浏览器侧与构建脚本共用。
11
+ */
12
+ const VIRTUAL_ID = "virtual:webskill-config";
13
+ const RESOLVED_ID = `\0${VIRTUAL_ID}`;
14
+ /**
15
+ * `'bake'` 时打印的警告。
16
+ *
17
+ * 是导出常量而不是内联串:AC-12.2 对它做断言,而措辞本身正是那条 AC 要守的东西。
18
+ * @experimental
19
+ */
20
+ const WEBSKILL_SECRETS_WARNING = "WARNING: WebSkill is baking API keys into the build output. The decryption key travels in the same bundle as the ciphertext, so this is obfuscation, not encryption — anyone holding the build output can recover the plaintext. Only ship it to a distribution surface you control.";
21
+ const EMPTY_PARSE = {
22
+ defaults: {},
23
+ secrets: {},
24
+ host: {}
25
+ };
26
+ const toBase64 = (bytes) => Buffer.from(bytes).toString("base64");
27
+ /**
28
+ * `'omit'` 分支生成的源码里**没有任何 crypto 调用**。
29
+ * 留一段走不到的 WebCrypto 分支等于给审计者一个「这里可能有密钥」的假信号。
30
+ */
31
+ const NO_SECRETS_SOURCE = "export const loadWebSkillSecrets = async () => ({});\n";
32
+ /** 渲染函数里禁止出现反引号注释:它会静默终止模板字符串,残骸在运行时才炸 */
33
+ function renderSecretLoader(key, entries) {
34
+ return `const SECRET_KEY = ${JSON.stringify(key)};
35
+ const SECRET_ENTRIES = ${JSON.stringify(entries, null, 2)};
36
+
37
+ function fromBase64(value) {
38
+ const binary = atob(value);
39
+ const buffer = new ArrayBuffer(binary.length);
40
+ const bytes = new Uint8Array(buffer);
41
+ for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
42
+ return buffer;
43
+ }
44
+
45
+ let cache;
46
+
47
+ export async function loadWebSkillSecrets() {
48
+ if (cache !== undefined) return cache;
49
+ const out = {};
50
+ try {
51
+ const key = await crypto.subtle.importKey('raw', fromBase64(SECRET_KEY), 'AES-GCM', false, ['decrypt']);
52
+ for (const id of Object.keys(SECRET_ENTRIES)) {
53
+ const secret = SECRET_ENTRIES[id];
54
+ const plain = await crypto.subtle.decrypt(
55
+ { name: 'AES-GCM', iv: fromBase64(secret.iv) },
56
+ key,
57
+ fromBase64(secret.data)
58
+ );
59
+ out[id] = new TextDecoder().decode(plain);
60
+ }
61
+ } catch {
62
+ // 产物被改坏时按「没有烘焙凭据」处理:用户仍可自己填,不该整页失败
63
+ for (const id of Object.keys(out)) delete out[id];
64
+ }
65
+ cache = out;
66
+ return cache;
67
+ }
68
+ `;
69
+ }
70
+ async function renderSecrets(secrets) {
71
+ const ids = Object.keys(secrets);
72
+ if (ids.length === 0) return NO_SECRETS_SOURCE;
73
+ const rawKey = webcrypto.getRandomValues(/* @__PURE__ */ new Uint8Array(32));
74
+ const cryptoKey = await webcrypto.subtle.importKey("raw", rawKey, "AES-GCM", false, ["encrypt"]);
75
+ const entries = {};
76
+ for (const id of ids) {
77
+ const iv = webcrypto.getRandomValues(/* @__PURE__ */ new Uint8Array(12));
78
+ const data = await webcrypto.subtle.encrypt({
79
+ name: "AES-GCM",
80
+ iv
81
+ }, cryptoKey, new TextEncoder().encode(secrets[id]));
82
+ entries[id] = {
83
+ iv: toBase64(iv),
84
+ data: toBase64(new Uint8Array(data))
85
+ };
86
+ }
87
+ return renderSecretLoader(toBase64(rawKey), entries);
88
+ }
89
+ function readConfigFile(file) {
90
+ let text;
91
+ try {
92
+ text = readFileSync(file, "utf8");
93
+ } catch {
94
+ return;
95
+ }
96
+ return JSON.parse(text);
97
+ }
98
+ /**
99
+ * 构建期读 `config.json`,把结果以虚拟模块 `virtual:webskill-config` 交给应用代码。
100
+ *
101
+ * 校验失败一律**中断构建**(带字段完整路径);文件不存在则按「无宿主缺省值」继续。
102
+ * @experimental
103
+ */
104
+ function webskillConfig(options) {
105
+ const secretsMode = options.secrets ?? "omit";
106
+ let parsed = EMPTY_PARSE;
107
+ let bakeSecrets = false;
108
+ return {
109
+ name: "webskill:config",
110
+ configResolved(config) {
111
+ const raw = readConfigFile(options.file);
112
+ parsed = raw === void 0 ? EMPTY_PARSE : parseWebSkillConfig(raw, { hostSections: options.hostSections });
113
+ bakeSecrets = secretsMode === "bake" || secretsMode === "dev-only" && config.mode === "development";
114
+ if (bakeSecrets && Object.keys(parsed.secrets).length > 0) config.logger.warn(WEBSKILL_SECRETS_WARNING);
115
+ options.onParsed?.(parsed);
116
+ },
117
+ resolveId(id) {
118
+ return id === VIRTUAL_ID ? RESOLVED_ID : void 0;
119
+ },
120
+ async load(id) {
121
+ if (id !== RESOLVED_ID) return void 0;
122
+ const secretsSource = bakeSecrets ? await renderSecrets(parsed.secrets) : NO_SECRETS_SOURCE;
123
+ return `/* Generated by @webskill/chatbot/vite. Do not edit. */
124
+ export const WEBSKILL_CONFIG = ${JSON.stringify(parsed.defaults, null, 2)};
125
+
126
+ ${secretsSource}`;
127
+ }
128
+ };
129
+ }
130
+
131
+ //#endregion
132
+ export { WEBSKILL_SECRETS_WARNING, webskillConfig };